Compare commits
4 Commits
d28afb2df1
...
27ab594921
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ab594921 | ||
|
|
d04eca4703 | ||
|
|
9de4fb7d40 | ||
|
|
6f2206a438 |
@@ -0,0 +1,859 @@
|
||||
# 设备 GUI+CLI 双能力接入与并行冲突控制 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Boss 支持同一台 Mac/Windows 设备同时接入 Codex GUI 与 CLI,并在同项目 GUI/CLI 并行写入风险出现时默认阻断,由用户在当前异常项目/文件夹级别选择禁止、允许本次或永久放行。
|
||||
|
||||
**Architecture:** 继续沿用现有单设备模型,不拆成两个虚拟设备。设备层新增 `gui/cli` 双能力状态和默认执行模式;项目/文件夹层新增并行冲突状态与放行策略;执行链在进入 CLI 写入任务前先做规则层冲突检测,命中后返回结构化风险卡,等待用户确认。
|
||||
|
||||
**Tech Stack:** Next.js App Router、TypeScript、文件型状态存储 `data/boss-state.json`、现有 `local-agent` heartbeat 链路、Android 原生客户端、Node test runner、Gradle unit tests
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 状态模型与执行冲突检测
|
||||
|
||||
- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-execution-conflict.test.ts`
|
||||
|
||||
职责:
|
||||
- 给 `Device` 增加 `gui/cli` 能力状态
|
||||
- 给项目/文件夹增加并行冲突策略与状态
|
||||
- 在进入 CLI 写入型任务前做规则层冲突检测
|
||||
- 把 `禁止 / 允许本次 / 永久放行` 限定在当前异常项目/文件夹
|
||||
|
||||
### 设备详情与设备 API
|
||||
|
||||
- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts`
|
||||
- Modify: `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts`
|
||||
|
||||
职责:
|
||||
- 把设备的 GUI/CLI 双能力与默认执行模式暴露给 Web/Android
|
||||
- 允许设备详情页更新 `preferredExecutionMode`
|
||||
|
||||
### 本地 agent 心跳能力上报
|
||||
|
||||
- Modify: `/Users/kris/code/boss/local-agent/server.mjs`
|
||||
- Modify: `/Users/kris/code/boss/local-agent/config.example.json`
|
||||
- Test: `/Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs`
|
||||
|
||||
职责:
|
||||
- heartbeat 上报当前设备的 `gui/cli` 能力状态
|
||||
- 不改变现有 `local-agent -> codex exec resume` 主链
|
||||
|
||||
### Android 设备详情与冲突提示
|
||||
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java`
|
||||
- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java`
|
||||
|
||||
职责:
|
||||
- 在设备详情页显示 `GUI / CLI` 状态
|
||||
- 增加默认执行模式切换
|
||||
- 展示当前项目/文件夹级冲突状态和放行策略
|
||||
|
||||
### Web 设备详情与冲突提示
|
||||
|
||||
- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx`
|
||||
- Modify: `/Users/kris/code/boss/src/app/devices/page.tsx`
|
||||
- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts`
|
||||
|
||||
职责:
|
||||
- Web 端设备详情显示双能力与默认执行模式
|
||||
- 当前异常项目/文件夹的冲突卡三动作可操作
|
||||
|
||||
### 文档与回归
|
||||
|
||||
- Modify: `/Users/kris/code/boss/README.md`
|
||||
- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
|
||||
职责:
|
||||
- 记录 GUI/CLI 双能力设备模型
|
||||
- 记录项目/文件夹级冲突控制行为
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 先定义设备双能力与项目级冲突状态
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住设备双能力默认值与局部冲突策略**
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readState, writeStateForTests } from "@/lib/boss-data";
|
||||
|
||||
test("device stores gui and cli capabilities without splitting the physical device", async () => {
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio");
|
||||
assert.ok(device);
|
||||
assert.equal(device.capabilities?.gui?.connected, true);
|
||||
assert.equal(device.capabilities?.cli?.connected, true);
|
||||
assert.equal(device.preferredExecutionMode, "cli");
|
||||
});
|
||||
|
||||
test("conflict policy is scoped to the active folder instead of the whole device", async () => {
|
||||
const state = await readState();
|
||||
state.projectExecutionPolicies = [
|
||||
{
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
allowPolicy: "allow_always",
|
||||
conflictState: "warning",
|
||||
updatedAt: "2026-04-06T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
await writeStateForTests(state);
|
||||
|
||||
const nextState = await readState();
|
||||
const bossPolicy = nextState.projectExecutionPolicies.find((item) => item.folderKey === "mac-studio:boss");
|
||||
const otherPolicy = nextState.projectExecutionPolicies.find((item) => item.folderKey === "mac-studio:talking");
|
||||
assert.equal(bossPolicy?.allowPolicy, "allow_always");
|
||||
assert.equal(otherPolicy, undefined);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL,提示 `Device` 没有 `capabilities` 或 `preferredExecutionMode`
|
||||
|
||||
- [ ] **Step 3: 在状态模型里增加设备能力与冲突策略类型**
|
||||
|
||||
在 `/Users/kris/code/boss/src/lib/boss-data.ts` 增加最小实现:
|
||||
|
||||
```ts
|
||||
export type DeviceExecutionMode = "gui" | "cli";
|
||||
export type ProjectConflictAllowPolicy = "forbid" | "allow_once" | "allow_always";
|
||||
export type ProjectConflictState = "none" | "warning" | "blocked";
|
||||
|
||||
export interface DeviceCapabilityState {
|
||||
connected: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
}
|
||||
|
||||
export interface DeviceCapabilities {
|
||||
gui: DeviceCapabilityState;
|
||||
cli: DeviceCapabilityState;
|
||||
}
|
||||
|
||||
export interface ProjectExecutionPolicy {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
allowPolicy: ProjectConflictAllowPolicy;
|
||||
conflictState: ProjectConflictState;
|
||||
activeCliExecution?: boolean;
|
||||
recentExternalActivityAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
account: string;
|
||||
source: DeviceSource;
|
||||
status: DeviceStatus;
|
||||
projects: string[];
|
||||
quota5h: number;
|
||||
quota7d: number;
|
||||
lastSeenAt: string;
|
||||
endpoint?: string;
|
||||
token?: string;
|
||||
note?: string;
|
||||
capabilities?: DeviceCapabilities;
|
||||
preferredExecutionMode?: DeviceExecutionMode;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 给初始种子设备补默认值**
|
||||
|
||||
在 `/Users/kris/code/boss/src/lib/boss-data.ts` 的种子设备里补:
|
||||
|
||||
```ts
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: "2026-04-06T09:00:00+08:00", lastActiveProjectId: "master-agent" },
|
||||
cli: { connected: true, lastSeenAt: "2026-04-06T09:00:00+08:00", lastActiveProjectId: "master-agent" },
|
||||
},
|
||||
preferredExecutionMode: "cli",
|
||||
```
|
||||
|
||||
Windows demo 设备补:
|
||||
|
||||
```ts
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: "2026-04-06T08:50:00+08:00", lastActiveProjectId: "audit-collab" },
|
||||
cli: { connected: false, lastSeenAt: "2026-04-06T08:40:00+08:00", lastActiveProjectId: "" },
|
||||
},
|
||||
preferredExecutionMode: "gui",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 给状态恢复逻辑补兼容默认值**
|
||||
|
||||
在 `readState()` 的 normalize 阶段补:
|
||||
|
||||
```ts
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: Boolean(device.capabilities?.gui?.connected),
|
||||
lastSeenAt: device.capabilities?.gui?.lastSeenAt || device.lastSeenAt,
|
||||
lastActiveProjectId: device.capabilities?.gui?.lastActiveProjectId || "",
|
||||
},
|
||||
cli: {
|
||||
connected: Boolean(device.capabilities?.cli?.connected),
|
||||
lastSeenAt: device.capabilities?.cli?.lastSeenAt || device.lastSeenAt,
|
||||
lastActiveProjectId: device.capabilities?.cli?.lastActiveProjectId || "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode:
|
||||
device.preferredExecutionMode === "gui" || device.preferredExecutionMode === "cli"
|
||||
? device.preferredExecutionMode
|
||||
: "cli",
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/src/lib/boss-data.ts /Users/kris/code/boss/tests/device-gui-cli-capabilities.test.ts
|
||||
git commit -m "feat: add gui cli device capability state"
|
||||
```
|
||||
|
||||
### Task 2: 在规则层实现项目/文件夹级并行冲突检测
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/src/lib/boss-data.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-execution-conflict.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住默认阻断、允许本次、永久放行都只作用于当前 folder**
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyProjectConflictDecision,
|
||||
detectProjectExecutionConflict,
|
||||
readState,
|
||||
writeStateForTests,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
test("detectProjectExecutionConflict blocks cli execution when the same folder has new external activity", async () => {
|
||||
const state = await readState();
|
||||
state.projectExecutionPolicies = [];
|
||||
await writeStateForTests(state);
|
||||
|
||||
const result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:05:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:04:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.policy.allowPolicy, "forbid");
|
||||
});
|
||||
|
||||
test("allow_once only clears the active folder conflict after a single execution", async () => {
|
||||
await applyProjectConflictDecision({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
decision: "allow_once",
|
||||
});
|
||||
|
||||
let result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:10:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:09:00.000Z",
|
||||
});
|
||||
assert.equal(result.blocked, false);
|
||||
|
||||
result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:20:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:19:00.000Z",
|
||||
});
|
||||
assert.equal(result.blocked, true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-execution-conflict.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL,提示函数不存在或策略未生效
|
||||
|
||||
- [ ] **Step 3: 增加冲突检测与决策函数**
|
||||
|
||||
在 `/Users/kris/code/boss/src/lib/boss-data.ts` 增加:
|
||||
|
||||
```ts
|
||||
export async function detectProjectExecutionConflict(input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
executionMode: DeviceExecutionMode;
|
||||
activityAt: string;
|
||||
externalActivityAt?: string;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const policy = matchProjectExecutionPolicy(state, input);
|
||||
const hasConflict =
|
||||
input.executionMode === "cli" &&
|
||||
Boolean(input.externalActivityAt) &&
|
||||
input.externalActivityAt! <= input.activityAt;
|
||||
|
||||
if (!hasConflict) {
|
||||
return { blocked: false, policy };
|
||||
}
|
||||
|
||||
if (policy?.allowPolicy === "allow_always") {
|
||||
return { blocked: false, policy };
|
||||
}
|
||||
|
||||
if (policy?.allowPolicy === "allow_once") {
|
||||
await clearAllowOncePolicy(input);
|
||||
return { blocked: false, policy };
|
||||
}
|
||||
|
||||
const nextPolicy = await upsertProjectExecutionPolicy({
|
||||
deviceId: input.deviceId,
|
||||
folderKey: input.folderKey,
|
||||
projectId: input.projectId,
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
activeCliExecution: true,
|
||||
recentExternalActivityAt: input.externalActivityAt,
|
||||
});
|
||||
return { blocked: true, policy: nextPolicy };
|
||||
}
|
||||
|
||||
export async function applyProjectConflictDecision(input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
decision: ProjectConflictAllowPolicy;
|
||||
}) {
|
||||
return upsertProjectExecutionPolicy({
|
||||
deviceId: input.deviceId,
|
||||
folderKey: input.folderKey,
|
||||
projectId: input.projectId,
|
||||
allowPolicy: input.decision,
|
||||
conflictState: input.decision === "forbid" ? "blocked" : "warning",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 确保匹配范围只落在当前异常项目/文件夹**
|
||||
|
||||
匹配函数必须先按:
|
||||
|
||||
```ts
|
||||
deviceId + folderKey
|
||||
```
|
||||
|
||||
再退化到:
|
||||
|
||||
```ts
|
||||
deviceId + projectId
|
||||
```
|
||||
|
||||
不得在设备级匹配整个设备的默认策略。
|
||||
|
||||
- [ ] **Step 5: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-execution-conflict.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/src/lib/boss-data.ts /Users/kris/code/boss/tests/device-execution-conflict.test.ts
|
||||
git commit -m "feat: add folder scoped gui cli conflict guard"
|
||||
```
|
||||
|
||||
### Task 3: 让设备详情 API 与投影视图暴露 GUI/CLI 双能力
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts`
|
||||
- Modify: `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts`
|
||||
- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住设备详情返回 GUI/CLI 能力与默认执行模式**
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { GET, PATCH } from "@/app/api/v1/devices/[deviceId]/route";
|
||||
|
||||
test("device detail exposes gui cli capabilities and preferred mode", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/v1/devices/mac-studio"), {
|
||||
params: Promise.resolve({ deviceId: "mac-studio" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
const device = data.workspace.selectedDevice;
|
||||
assert.equal(device.capabilities.gui.connected, true);
|
||||
assert.equal(device.capabilities.cli.connected, true);
|
||||
assert.equal(device.preferredExecutionMode, "cli");
|
||||
});
|
||||
|
||||
test("device detail patch updates preferred execution mode only", async () => {
|
||||
const response = await PATCH(
|
||||
new Request("http://localhost/api/v1/devices/mac-studio", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ preferredExecutionMode: "gui" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
||||
);
|
||||
const data = await response.json();
|
||||
assert.equal(data.device.preferredExecutionMode, "gui");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL,提示返回字段缺失
|
||||
|
||||
- [ ] **Step 3: 在投影视图里暴露 GUI/CLI 状态**
|
||||
|
||||
在 `/Users/kris/code/boss/src/lib/boss-projections.ts` 的 `DeviceWorkspaceView` 构造里把:
|
||||
|
||||
```ts
|
||||
selectedDevice: {
|
||||
...device,
|
||||
capabilities: device.capabilities,
|
||||
preferredExecutionMode: device.preferredExecutionMode ?? "cli",
|
||||
},
|
||||
```
|
||||
|
||||
稳定输出。
|
||||
|
||||
- [ ] **Step 4: 在设备 PATCH 路由里支持只更新默认执行模式**
|
||||
|
||||
在 `/Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts` 收窄允许字段:
|
||||
|
||||
```ts
|
||||
const body = await request.json();
|
||||
const payload = {
|
||||
preferredExecutionMode:
|
||||
body.preferredExecutionMode === "gui" || body.preferredExecutionMode === "cli"
|
||||
? body.preferredExecutionMode
|
||||
: undefined,
|
||||
};
|
||||
const device = await updateDevice(deviceId, payload);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/src/lib/boss-projections.ts /Users/kris/code/boss/src/app/api/v1/devices/[deviceId]/route.ts /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
git commit -m "feat: expose gui cli device capability details"
|
||||
```
|
||||
|
||||
### Task 4: 让 local-agent heartbeat 上报 CLI 能力
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/local-agent/server.mjs`
|
||||
- Modify: `/Users/kris/code/boss/local-agent/config.example.json`
|
||||
- Test: `/Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住 heartbeat 载荷包含 `capabilities.cli`**
|
||||
|
||||
```js
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildHeartbeatPayload } from "../local-agent/server.mjs";
|
||||
|
||||
test("heartbeat payload includes cli capability state", async () => {
|
||||
const payload = await buildHeartbeatPayload({
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
cliConnected: true,
|
||||
guiConnected: false,
|
||||
});
|
||||
|
||||
assert.equal(payload.device.capabilities.cli.connected, true);
|
||||
assert.equal(payload.device.capabilities.gui.connected, false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL,提示 heartbeat payload 未包含该字段
|
||||
|
||||
- [ ] **Step 3: 在 heartbeat 载荷里新增双能力状态**
|
||||
|
||||
在 `/Users/kris/code/boss/local-agent/server.mjs` 的 heartbeat payload 里补:
|
||||
|
||||
```js
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: Boolean(config.guiConnected),
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
cli: {
|
||||
connected: true,
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode: config.preferredExecutionMode || "cli",
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在示例配置中补默认模式**
|
||||
|
||||
在 `/Users/kris/code/boss/local-agent/config.example.json` 补:
|
||||
|
||||
```json
|
||||
"preferredExecutionMode": "cli",
|
||||
"guiConnected": false
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/local-agent/server.mjs /Users/kris/code/boss/local-agent/config.example.json /Users/kris/code/boss/tests/local-agent-heartbeat-capabilities.test.mjs
|
||||
git commit -m "feat: report gui cli capabilities in heartbeat"
|
||||
```
|
||||
|
||||
### Task 5: Android 设备详情页显示 GUI/CLI 双能力并支持默认模式切换
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java`
|
||||
- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住设备详情显示 GUI/CLI 状态与默认模式**
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void renderDeviceShowsGuiCliCapabilitiesAndPreferredMode() {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(TestDeviceDetailActivity.class, new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "GUI"));
|
||||
assertTrue(viewTreeContainsText(content, "CLI"));
|
||||
assertTrue(viewTreeContainsText(content, "默认执行模式"));
|
||||
assertTrue(viewTreeContainsText(content, "CLI"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.DeviceDetailActivityTest --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL,提示页面文本不存在
|
||||
|
||||
- [ ] **Step 3: 给 Android client 增加更新默认模式接口**
|
||||
|
||||
在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java` 加:
|
||||
|
||||
```java
|
||||
public ApiResponse updateDeviceExecutionMode(String deviceId, String mode) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("preferredExecutionMode", mode);
|
||||
return patch("/api/v1/devices/" + encodePathSegment(deviceId), payload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在设备详情页新增能力状态与模式切换**
|
||||
|
||||
在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java` 的 `renderDevice(...)` 中追加:
|
||||
|
||||
```java
|
||||
JSONObject capabilities = device.optJSONObject("capabilities");
|
||||
JSONObject gui = capabilities == null ? null : capabilities.optJSONObject("gui");
|
||||
JSONObject cli = capabilities == null ? null : capabilities.optJSONObject("cli");
|
||||
String preferredExecutionMode = device.optString("preferredExecutionMode", "cli");
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"GUI",
|
||||
gui != null && gui.optBoolean("connected", false) ? "已连接" : "未连接",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"CLI",
|
||||
cli != null && cli.optBoolean("connected", false) ? "已连接" : "未连接",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildMenuRow(
|
||||
this,
|
||||
"默认执行模式",
|
||||
"当前:" + ("gui".equals(preferredExecutionMode) ? "GUI" : "CLI"),
|
||||
null,
|
||||
v -> openExecutionModeDialog(preferredExecutionMode)
|
||||
));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现切换弹窗和保存**
|
||||
|
||||
在 `openExecutionModeDialog(...)` 里:
|
||||
|
||||
```java
|
||||
String[] items = new String[] { "GUI", "CLI" };
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("默认执行模式")
|
||||
.setSingleChoiceItems(items, "gui".equals(currentMode) ? 0 : 1, null)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveExecutionMode(selectedMode))
|
||||
.show();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.DeviceDetailActivityTest --tests com.hyzq.boss.BossApiClientDeviceModeTest --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/BossApiClientDeviceModeTest.java
|
||||
git commit -m "feat: surface gui cli capabilities on android devices"
|
||||
```
|
||||
|
||||
### Task 6: Web 设备详情页显示双能力并支持冲突策略
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx`
|
||||
- Modify: `/Users/kris/code/boss/src/app/devices/page.tsx`
|
||||
- Test: `/Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁住设备详情展示 GUI/CLI 与默认模式**
|
||||
|
||||
```ts
|
||||
test("device page renders gui cli capability badges and preferred mode", async () => {
|
||||
const view = getDeviceWorkspaceView(await readState(), "mac-studio");
|
||||
assert.equal(view.selectedDevice?.capabilities?.gui.connected, true);
|
||||
assert.equal(view.selectedDevice?.capabilities?.cli.connected, true);
|
||||
assert.equal(view.selectedDevice?.preferredExecutionMode, "cli");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- FAIL
|
||||
|
||||
- [ ] **Step 3: 在 Web 设备详情区域渲染能力与默认模式**
|
||||
|
||||
在 `/Users/kris/code/boss/src/components/app-ui.tsx` 的设备详情块加入:
|
||||
|
||||
```tsx
|
||||
<div className="text-xs text-zinc-500">
|
||||
GUI:{device.capabilities?.gui?.connected ? "已连接" : "未连接"} · CLI:
|
||||
{device.capabilities?.cli?.connected ? "已连接" : "未连接"}
|
||||
</div>
|
||||
<button onClick={() => openDeviceExecutionMode(device.id)}>
|
||||
默认执行模式:{device.preferredExecutionMode === "gui" ? "GUI" : "CLI"}
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 为冲突卡预留当前项目/文件夹级入口**
|
||||
|
||||
在同一区块加入:
|
||||
|
||||
```tsx
|
||||
{policy ? (
|
||||
<div>
|
||||
<div>当前项目并行策略:{policy.allowPolicy}</div>
|
||||
<button>禁止</button>
|
||||
<button>允许本次</button>
|
||||
<button>永久放行</button>
|
||||
</div>
|
||||
) : null}
|
||||
```
|
||||
|
||||
先把数据通道和入口铺上,不在这一任务里完成所有交互。
|
||||
|
||||
- [ ] **Step 5: 重跑测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx --yes tsx --test /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/src/components/app-ui.tsx /Users/kris/code/boss/src/app/devices/page.tsx /Users/kris/code/boss/tests/device-detail-capabilities-route.test.ts
|
||||
git commit -m "feat: show gui cli capability state on web devices"
|
||||
```
|
||||
|
||||
### Task 7: 完整验证并同步文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/kris/code/boss/README.md`
|
||||
- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
|
||||
- [ ] **Step 1: 跑服务端与 Android 关键回归**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss && npx --yes tsx --test tests/device-gui-cli-capabilities.test.ts tests/device-execution-conflict.test.ts tests/device-detail-capabilities-route.test.ts
|
||||
cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.DeviceDetailActivityTest --tests com.hyzq.boss.BossApiClientDeviceModeTest --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- PASS
|
||||
|
||||
- [ ] **Step 2: 跑 lint / build / release**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss && npm run lint
|
||||
cd /Users/kris/code/boss && npm run build
|
||||
cd /Users/kris/code/boss/android && ./gradlew assembleRelease --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 全部 PASS
|
||||
|
||||
- [ ] **Step 3: 更新文档**
|
||||
|
||||
在 `/Users/kris/code/boss/README.md` 和 `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` 增加:
|
||||
|
||||
```md
|
||||
- 当前设备模型已支持同一台设备同时接入 Codex GUI 与 CLI
|
||||
- 设备详情页可查看 GUI / CLI 状态,并切换默认执行模式
|
||||
- 同项目 GUI/CLI 并行写入风险默认阻断,用户可在当前异常项目/文件夹级别选择 禁止 / 允许本次 / 永久放行
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交文档与收口代码**
|
||||
|
||||
```bash
|
||||
git add /Users/kris/code/boss/README.md /Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md
|
||||
git commit -m "docs: describe gui cli device capability workflow"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 推分支并部署**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss && git push gitea codex/wechat-native-ui-rollback
|
||||
cd /Users/kris/code/boss && ./scripts/deploy-server.sh
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 分支推送成功
|
||||
- 服务器健康检查正常
|
||||
@@ -0,0 +1,492 @@
|
||||
# Mac/Windows 设备 GUI+CLI 双能力接入与并行冲突控制设计
|
||||
|
||||
日期:`2026-04-06`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Boss 当前的设备执行主链已经稳定在:
|
||||
|
||||
- 设备 heartbeat 上报
|
||||
- `local-agent -> codex exec resume`
|
||||
- Web / Android 会话与设备控制面
|
||||
|
||||
但现状仍有两个结构性缺口:
|
||||
|
||||
1. **设备模型还没有把 Codex GUI 与 Codex CLI 当成同一台设备下的两种能力。**
|
||||
- 用户可能只开 GUI
|
||||
- 也可能只开 CLI
|
||||
- 也可能同一台 Mac / Windows 同时开着 GUI 和 CLI
|
||||
- 当前 Boss 还没有显式表达这种“双能力设备”
|
||||
|
||||
2. **同一台设备的 GUI 与 CLI 同时推进同一项目时,缺少并行冲突控制。**
|
||||
- 主 Agent 可能通过 CLI 正在推进某个项目
|
||||
- 用户本人也可能在 GUI 中继续操作同一项目
|
||||
- 如果两边都对同一 worktree 写入,很容易出现覆盖、上下文漂移、重复修改、误判进度
|
||||
|
||||
本轮设计要解决的是:
|
||||
|
||||
- 让 Boss 正式支持 `Mac / Windows` 的 `Codex CLI` 接入
|
||||
- 让同一台设备同时支持 `GUI + CLI`
|
||||
- 让 GUI / CLI 可以切换默认执行模式
|
||||
- 在同项目并行写入风险出现时,默认阻断并弹出风险确认
|
||||
|
||||
## 2. 调研结论
|
||||
|
||||
### 2.1 官方产品结论
|
||||
|
||||
基于官方公开说明,Codex app 与 CLI 的关系更像:
|
||||
|
||||
- 同一用户工作空间下的多个入口
|
||||
- 共享 `session history`
|
||||
- 共享 `configuration`
|
||||
- 共享 `skills`
|
||||
- 额度与使用量也统一计入同一个产品账户体系
|
||||
|
||||
因此,Boss 不应把 GUI 与 CLI 理解成两个完全独立系统。
|
||||
|
||||
### 2.2 本地实测结论
|
||||
|
||||
在当前 macOS 开发机上,Codex 的核心状态位于:
|
||||
|
||||
- `~/.codex/state_5.sqlite`
|
||||
- `~/.codex/logs_1.sqlite`
|
||||
- `~/.codex/session_index.jsonl`
|
||||
- `~/.codex/.codex-global-state.json`
|
||||
|
||||
同时 GUI 还有自己的应用壳目录:
|
||||
|
||||
- `~/Library/Application Support/Codex`
|
||||
|
||||
这说明:
|
||||
|
||||
- `~/.codex` 更像主状态库
|
||||
- `Application Support/Codex` 更像 GUI 壳层缓存/偏好/会话容器
|
||||
|
||||
### 2.3 Windows 实测结论
|
||||
|
||||
在局域网 Windows 设备 `192.168.31.18` 上实测:
|
||||
|
||||
- `C:\Users\kris\.codex` 存在
|
||||
- 且真实包含:
|
||||
- `state_5.sqlite`
|
||||
- `logs_1.sqlite`
|
||||
- `session_index.jsonl`
|
||||
- `.codex-global-state.json`
|
||||
- `auth.json`
|
||||
- `config.toml`
|
||||
|
||||
这说明 Windows 上也存在同样的用户级 Codex 核心状态目录。
|
||||
|
||||
### 2.4 设计结论
|
||||
|
||||
因此在 Boss 中,GUI 与 CLI 应建模为:
|
||||
|
||||
- **同一台物理设备下的两种能力**
|
||||
- 不是两台设备
|
||||
- 不是两套独立项目空间
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
1. 支持 `Mac / Windows` 的 Codex CLI 接入。
|
||||
2. 同一台设备可同时在线:
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
3. 设备级支持默认执行模式切换:
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
4. 主 Agent 和用户都可以在 GUI / CLI 间切换执行入口。
|
||||
5. 检测同设备、同项目、同 folder/worktree 的并行写入风险。
|
||||
6. 当发生并行写入风险时:
|
||||
- 默认阻断继续执行
|
||||
- 弹出风险警告
|
||||
- 提供三个动作:
|
||||
- `禁止`
|
||||
- `允许本次`
|
||||
- `永久放行`
|
||||
7. 这三个动作都必须是**当前异常项目文件夹/线程级**,不是全局开关。
|
||||
8. 整套冲突检测应尽量基于规则层完成,避免持续消耗 token。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
1. 本轮不把 GUI 与 CLI 拆成两台虚拟设备。
|
||||
2. 本轮不做 OS 级窗口焦点监测或键鼠监控。
|
||||
3. 本轮不重写现有 `local-agent -> codex exec resume` 主链。
|
||||
4. 本轮不要求 GUI 与 CLI 对同项目绝对互斥。
|
||||
5. 本轮不做真正的多 worktree 自动编排,只做冲突识别与风险控制。
|
||||
|
||||
## 4. 用户体验结论
|
||||
|
||||
### 4.1 设备模型
|
||||
|
||||
一台物理设备在 Boss 中保持单条记录,例如:
|
||||
|
||||
- `Mac Studio`
|
||||
- `Windows GPU`
|
||||
|
||||
但设备详情里要新增两类能力状态:
|
||||
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
|
||||
每种能力分别展示:
|
||||
|
||||
- 是否已连接
|
||||
- 最近在线时间
|
||||
- 最近活跃项目
|
||||
|
||||
### 4.2 默认执行模式
|
||||
|
||||
设备级新增 `默认执行模式`:
|
||||
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
|
||||
说明:
|
||||
|
||||
- 这只是默认值
|
||||
- 不会关闭另一种能力
|
||||
- 同一台设备允许 `GUI + CLI` 同时在线
|
||||
|
||||
### 4.3 前台切换语义
|
||||
|
||||
用户层的真实语义是:
|
||||
|
||||
- 可以只用 GUI
|
||||
- 可以只用 CLI
|
||||
- 也可以同一台设备同时使用 GUI 和 CLI
|
||||
|
||||
Boss 必须支持三种状态共存,而不是互斥二选一。
|
||||
|
||||
## 5. 数据模型设计
|
||||
|
||||
### 5.1 Device 新增能力字段
|
||||
|
||||
为设备增加能力层字段:
|
||||
|
||||
- `capabilities.gui.connected`
|
||||
- `capabilities.gui.lastSeenAt`
|
||||
- `capabilities.gui.lastActiveProjectId`
|
||||
- `capabilities.cli.connected`
|
||||
- `capabilities.cli.lastSeenAt`
|
||||
- `capabilities.cli.lastActiveProjectId`
|
||||
- `preferredExecutionMode`
|
||||
|
||||
约束:
|
||||
|
||||
- `preferredExecutionMode` 仅允许:
|
||||
- `gui`
|
||||
- `cli`
|
||||
|
||||
### 5.2 Project / Folder 级并行风险状态
|
||||
|
||||
新增项目级或文件夹级并行状态:
|
||||
|
||||
- `deviceId`
|
||||
- `folderKey`
|
||||
- `projectId`
|
||||
- `activeCliExecution`
|
||||
- `recentExternalActivityAt`
|
||||
- `conflictState`
|
||||
- `allowPolicy`
|
||||
|
||||
其中:
|
||||
|
||||
- `conflictState`:
|
||||
- `none`
|
||||
- `warning`
|
||||
- `blocked`
|
||||
- `allowPolicy`:
|
||||
- `forbid`
|
||||
- `allow_once`
|
||||
- `allow_always`
|
||||
|
||||
### 5.3 冲突动作生效粒度
|
||||
|
||||
这次风险警告上的所有动作都必须是**局部策略**,不是全局策略。
|
||||
|
||||
推荐主键:
|
||||
|
||||
- `deviceId + folderKey`
|
||||
|
||||
如果没有稳定 `folderKey`,退化为:
|
||||
|
||||
- `deviceId + projectId`
|
||||
|
||||
对于单线程项目,本质上仍然是该线程所在 folder/project 级别策略,而不是全账号或全设备策略。
|
||||
|
||||
也就是说:
|
||||
|
||||
- `禁止` 只表示当前这个异常项目/文件夹继续维持阻断
|
||||
- `允许本次` 只放行当前这个异常项目/文件夹的一次执行
|
||||
- `永久放行` 只放行当前这个异常项目/文件夹后续同类冲突
|
||||
|
||||
## 6. 并行冲突检测
|
||||
|
||||
## 6.1 冲突定义
|
||||
|
||||
满足以下条件时,视为并行冲突风险:
|
||||
|
||||
1. 同一台设备
|
||||
2. 主 Agent 正通过 `CLI` 执行某项目的写入型任务
|
||||
3. 同一项目 / folder / worktree 又出现新的外部活动
|
||||
4. 该外部活动并非当前 Boss 发起的同一 CLI 任务的正常进展
|
||||
|
||||
这里的“外部活动”不强行区分一定来自 GUI 还是外部 CLI 手工操作。产品语义统一视为:
|
||||
|
||||
- **非当前 Boss 任务所控制的人工/外部活动**
|
||||
|
||||
如果设备同时具备 `GUI connected = true`,前台提示可优先表述为 GUI 并行操作;否则用更中性的“外部活动”提示。
|
||||
|
||||
### 6.2 检测方式
|
||||
|
||||
冲突检测应尽量走规则层,而不是 LLM 判断。
|
||||
|
||||
规则层输入:
|
||||
|
||||
- 当前 CLI 执行任务是否在跑
|
||||
- 目标 deviceId / projectId / folderKey
|
||||
- 线程 heartbeat / reply / activity 上报
|
||||
- 最近外部活动时间
|
||||
- 当前任务自己的 trace / taskId / targetThreadRef
|
||||
|
||||
输出:
|
||||
|
||||
- 是否冲突
|
||||
- 冲突归属到哪个 folder/project
|
||||
|
||||
### 6.3 Token 策略
|
||||
|
||||
本设计要求:
|
||||
|
||||
- 冲突检测本身不调用 LLM
|
||||
- 冲突提示文案模板化
|
||||
- 只有用户主动要求“解释为什么冲突”或“请主 Agent 给建议”时,才允许主 Agent 读取线程状态文档与最近进展事件
|
||||
|
||||
因此,这套机制本身不应成为高 token 消耗来源。
|
||||
|
||||
## 7. 冲突拦截与用户确认
|
||||
|
||||
### 7.1 默认行为
|
||||
|
||||
发生并行冲突风险时:
|
||||
|
||||
- **默认不继续**
|
||||
- 先弹风险警告
|
||||
- 不直接自动放行
|
||||
|
||||
### 7.2 风险警告文案
|
||||
|
||||
默认模板:
|
||||
|
||||
- `检测到该项目正在同一设备上发生并行操作。`
|
||||
- `主 Agent 当前正通过 CLI 推进,而你也在同一项目中产生了新的人工活动。`
|
||||
- `继续执行可能导致文件覆盖、上下文偏移或重复修改。`
|
||||
|
||||
### 7.3 用户动作
|
||||
|
||||
警告卡必须提供三个动作:
|
||||
|
||||
1. `禁止`
|
||||
- 当前这个异常项目/文件夹本次不继续
|
||||
- 当前这个异常项目/文件夹保持默认阻断
|
||||
- 不影响其他项目/文件夹
|
||||
|
||||
2. `允许本次`
|
||||
- 只放行当前这个异常项目/文件夹的当前这一次执行
|
||||
- 不改变当前项目/文件夹后续默认策略
|
||||
- 不影响其他项目/文件夹
|
||||
|
||||
3. `永久放行`
|
||||
- 仅对当前异常项目/文件夹生效
|
||||
- 后续同一 `deviceId + folderKey` 再发生同类冲突时,允许继续
|
||||
- 不影响其他项目/文件夹
|
||||
|
||||
### 7.4 永久放行撤销
|
||||
|
||||
`永久放行` 不是不可逆。
|
||||
|
||||
需要提供撤销入口,建议放在:
|
||||
|
||||
- 项目文件夹页 / 线程会话信息页
|
||||
- 或设备详情中该项目的执行策略区
|
||||
|
||||
文案示例:
|
||||
|
||||
- `该项目已允许 GUI/CLI 并行继续`
|
||||
- `关闭永久放行`
|
||||
|
||||
## 8. 执行路由设计
|
||||
|
||||
### 8.1 默认执行模式
|
||||
|
||||
主 Agent 与普通线程调度时:
|
||||
|
||||
- 先读设备的 `preferredExecutionMode`
|
||||
- 优先走对应能力
|
||||
|
||||
### 8.2 执行能力选择
|
||||
|
||||
推荐行为:
|
||||
|
||||
- 写入型自动执行:优先 `CLI`
|
||||
- 人工观察 / 人工接管 / 人工窗口操作:优先 `GUI`
|
||||
|
||||
### 8.3 同时在线
|
||||
|
||||
当 `GUI + CLI` 同时在线时:
|
||||
|
||||
- 不自动断开其中一边
|
||||
- 也不自动把设备拆成两个逻辑设备
|
||||
- 只在同项目并行写入风险时触发冲突控制
|
||||
|
||||
## 9. 前台设计
|
||||
|
||||
### 9.1 设备详情页
|
||||
|
||||
新增两个可见区:
|
||||
|
||||
1. `连接能力`
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
|
||||
2. `默认执行模式`
|
||||
- 单选:
|
||||
- `GUI`
|
||||
- `CLI`
|
||||
|
||||
### 9.2 项目文件夹 / 线程会话
|
||||
|
||||
当项目存在永久放行策略时,当前页可见:
|
||||
|
||||
- `GUI/CLI 并行已允许`
|
||||
|
||||
当项目发生实时冲突时,当前页显示风险卡:
|
||||
|
||||
- 风险说明
|
||||
- `禁止`
|
||||
- `允许本次`
|
||||
- `永久放行`
|
||||
|
||||
### 9.3 主 Agent 交互
|
||||
|
||||
主 Agent 遇到此类冲突时:
|
||||
|
||||
- 默认停止继续下发
|
||||
- 生成风险卡
|
||||
- 等用户明确动作
|
||||
|
||||
注意:
|
||||
|
||||
- 这不代表主 Agent 抢占线程控制权
|
||||
- 主 Agent 只是协同与调度者
|
||||
- 用户仍可继续直接控制线程
|
||||
|
||||
## 10. API 与状态变更建议
|
||||
|
||||
### 10.1 设备能力状态
|
||||
|
||||
新增或扩展设备详情接口字段:
|
||||
|
||||
- `capabilities.gui`
|
||||
- `capabilities.cli`
|
||||
- `preferredExecutionMode`
|
||||
|
||||
### 10.2 项目并行策略
|
||||
|
||||
新增项目/文件夹级策略字段:
|
||||
|
||||
- `parallelConflictPolicy`
|
||||
- `parallelConflictState`
|
||||
|
||||
并提供更新入口:
|
||||
|
||||
- `forbid`
|
||||
- `allow_once`
|
||||
- `allow_always`
|
||||
|
||||
`allow_once` 应在当前任务完成或取消后自动清空。
|
||||
|
||||
这些策略更新都只允许作用在当前冲突对象上:
|
||||
|
||||
- 同一 `deviceId + folderKey`
|
||||
- 或退化到同一 `deviceId + projectId`
|
||||
|
||||
不得写成设备级或全局级默认行为。
|
||||
|
||||
### 10.3 冲突事件
|
||||
|
||||
建议在 SSE / APP 状态流里补一类事件:
|
||||
|
||||
- `project.parallel_conflict.detected`
|
||||
- `project.parallel_conflict.cleared`
|
||||
- `project.parallel_conflict.policy_updated`
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
### 11.1 服务端
|
||||
|
||||
覆盖这些场景:
|
||||
|
||||
1. 同设备 GUI + CLI 同时在线
|
||||
2. CLI 正在执行,出现外部人工活动
|
||||
3. 默认阻断
|
||||
4. `允许本次` 只放行一次
|
||||
5. `永久放行` 只对当前 folder/project 生效
|
||||
6. 不同 folder 不受影响
|
||||
7. `allow_once` 自动清空
|
||||
|
||||
### 11.2 Android / Web
|
||||
|
||||
覆盖这些场景:
|
||||
|
||||
1. 设备详情页能显示 GUI/CLI 双能力
|
||||
2. 默认执行模式切换
|
||||
3. 冲突卡三按钮行为
|
||||
4. 永久放行状态展示
|
||||
5. 撤销永久放行
|
||||
|
||||
## 12. 风险与边界
|
||||
|
||||
### 12.1 无法精确知道“是 GUI 还是手工 CLI”
|
||||
|
||||
本轮不做 OS 级窗口焦点和输入行为追踪,因此“人工活动”只能基于 Boss 可观察到的项目活动来判定。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 有时只能判断为“外部人工/非 Boss 活动”
|
||||
- 不能 100% 精确证明一定是 GUI
|
||||
|
||||
这在产品上是可以接受的,因为风险本质是:
|
||||
|
||||
- **同项目正在被外部人工推进**
|
||||
|
||||
### 12.2 worktree 级精度
|
||||
|
||||
如果未来要支持同项目多 worktree 并行,本模型还需再细化到:
|
||||
|
||||
- `deviceId + folderKey + worktreeRef`
|
||||
|
||||
本轮先按:
|
||||
|
||||
- `deviceId + folderKey`
|
||||
|
||||
收口。
|
||||
|
||||
## 13. 最终结论
|
||||
|
||||
本轮正式采用以下设计:
|
||||
|
||||
1. **GUI 和 CLI 是同一设备下的双能力,不拆成两个设备。**
|
||||
2. **Mac / Windows 都按用户级 `.codex` 主状态库模型接入。**
|
||||
3. **默认执行模式按设备级切换。**
|
||||
4. **同项目 GUI/CLI 并行写入风险由规则层检测。**
|
||||
5. **默认阻断,不自动继续。**
|
||||
6. **用户必须在当前异常项目/文件夹级别做出选择:**
|
||||
- `禁止`
|
||||
- `允许本次`
|
||||
- `永久放行`
|
||||
7. **这三个动作都只对当前项目文件夹/线程生效,不做全局开关。**
|
||||
|
||||
这套方案既符合 Codex GUI/CLI 的真实关系,也能最大程度避免“主 Agent 与人工开发同时写同一项目”带来的实际风险。
|
||||
@@ -70,6 +70,11 @@ async function resolveHeartbeatProjects(config, runtime) {
|
||||
}
|
||||
|
||||
async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
const now = new Date().toISOString();
|
||||
const preferredExecutionMode =
|
||||
config.preferredExecutionMode === "gui" || config.preferredExecutionMode === "cli"
|
||||
? config.preferredExecutionMode
|
||||
: undefined;
|
||||
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -83,6 +88,19 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
status: config.status,
|
||||
quota5h: config.quota5h,
|
||||
quota7d: config.quota7d,
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: Boolean(config.guiConnected),
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
cli: {
|
||||
connected: true,
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode,
|
||||
projects: heartbeatProjects.projects,
|
||||
projectCandidates: heartbeatProjects.projectCandidates,
|
||||
endpoint: config.endpoint,
|
||||
|
||||
@@ -131,6 +131,9 @@ export type AuditDecision = "pass" | "fail" | "warning" | "inconclusive";
|
||||
export type CapabilityLeaseMode = "exclusive" | "shared_read" | "shared_stream";
|
||||
export type AuthRole = "member" | "admin" | "highest_admin";
|
||||
export type LoginMethod = "password" | "code";
|
||||
export type DeviceExecutionMode = "gui" | "cli";
|
||||
export type ProjectConflictAllowPolicy = "forbid" | "allow_once" | "allow_always";
|
||||
export type ProjectConflictState = "none" | "warning" | "blocked";
|
||||
export type OtaUpdateStatus = "available" | "scheduled" | "applied" | "skipped";
|
||||
export type OtaLogStatus = "checked" | "applied" | "skipped";
|
||||
export type AppLogLevel = "info" | "warn" | "error";
|
||||
@@ -196,6 +199,8 @@ export interface Device {
|
||||
endpoint?: string;
|
||||
token?: string;
|
||||
note?: string;
|
||||
capabilities?: DeviceCapabilities;
|
||||
preferredExecutionMode?: DeviceExecutionMode;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -409,6 +414,37 @@ export interface ProjectAgentControls {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DeviceCapabilityState {
|
||||
connected: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
}
|
||||
|
||||
export interface DeviceCapabilities {
|
||||
gui: DeviceCapabilityState;
|
||||
cli: DeviceCapabilityState;
|
||||
}
|
||||
|
||||
export interface DeviceCapabilitiesInput {
|
||||
gui?: Partial<DeviceCapabilityState>;
|
||||
cli?: Partial<DeviceCapabilityState>;
|
||||
}
|
||||
|
||||
export interface DeviceUpdatePayload extends Partial<Omit<Device, "capabilities">> {
|
||||
capabilities?: DeviceCapabilitiesInput;
|
||||
}
|
||||
|
||||
export interface ProjectExecutionPolicy {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
activeCliExecution?: boolean;
|
||||
recentExternalActivityAt?: string;
|
||||
conflictState: ProjectConflictState;
|
||||
allowPolicy: ProjectConflictAllowPolicy;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectOrchestrationBackendChoice {
|
||||
backendId: OrchestrationBackendId;
|
||||
label: string;
|
||||
@@ -1002,6 +1038,7 @@ export interface BossState {
|
||||
auditRequests: AuditTaskRequest[];
|
||||
auditResults: AuditTaskResult[];
|
||||
capabilities: Capability[];
|
||||
projectExecutionPolicies: ProjectExecutionPolicy[];
|
||||
}
|
||||
|
||||
function detectRuntimeRoot(startDir: string) {
|
||||
@@ -1097,6 +1134,19 @@ const initialState: BossState = {
|
||||
endpoint: "mac://kris.local",
|
||||
token: "boss-mac-studio-token",
|
||||
note: "本机 Codex 主节点 · 17600003315 已绑定",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-03-25T11:52:00+08:00",
|
||||
lastActiveProjectId: "master-agent",
|
||||
},
|
||||
cli: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-03-25T11:52:00+08:00",
|
||||
lastActiveProjectId: "master-agent",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode: "cli",
|
||||
},
|
||||
{
|
||||
id: "win-gpu-01",
|
||||
@@ -1112,6 +1162,19 @@ const initialState: BossState = {
|
||||
endpoint: "win://gpu.local",
|
||||
token: "boss-win-gpu-token",
|
||||
note: "摄像头证据通道偶发抖动",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-06T08:50:00+08:00",
|
||||
lastActiveProjectId: "audit-collab",
|
||||
},
|
||||
cli: {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-06T08:40:00+08:00",
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode: "gui",
|
||||
},
|
||||
{
|
||||
id: "cloud-backup",
|
||||
@@ -1127,6 +1190,19 @@ const initialState: BossState = {
|
||||
endpoint: "cloud://standby",
|
||||
token: "boss-cloud-backup-token",
|
||||
note: "standby 节点",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-06T08:15:00+08:00",
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
cli: {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-06T08:15:00+08:00",
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode: "cli",
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
@@ -1584,6 +1660,7 @@ const initialState: BossState = {
|
||||
evidenceModes: ["serial_log"],
|
||||
},
|
||||
],
|
||||
projectExecutionPolicies: [],
|
||||
threadStatusDocuments: [],
|
||||
threadProgressEvents: [],
|
||||
};
|
||||
@@ -1644,6 +1721,110 @@ function trimToDefined(value?: string) {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeDeviceCapabilityState(
|
||||
raw: Partial<DeviceCapabilityState> | undefined,
|
||||
fallbackLastSeenAt: string,
|
||||
): DeviceCapabilityState {
|
||||
return {
|
||||
connected: Boolean(raw?.connected),
|
||||
lastSeenAt: trimToDefined(raw?.lastSeenAt) ?? fallbackLastSeenAt,
|
||||
lastActiveProjectId: trimToDefined(raw?.lastActiveProjectId) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeviceCapabilities(
|
||||
raw: DeviceCapabilitiesInput | undefined,
|
||||
fallbackLastSeenAt: string,
|
||||
): DeviceCapabilities {
|
||||
return {
|
||||
gui: normalizeDeviceCapabilityState(raw?.gui, fallbackLastSeenAt),
|
||||
cli: normalizeDeviceCapabilityState(raw?.cli, fallbackLastSeenAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePreferredExecutionMode(value: unknown): DeviceExecutionMode {
|
||||
return value === "gui" ? "gui" : "cli";
|
||||
}
|
||||
|
||||
function normalizeProjectExecutionPolicy(
|
||||
raw: Partial<ProjectExecutionPolicy>,
|
||||
): ProjectExecutionPolicy {
|
||||
return {
|
||||
deviceId: trimToDefined(raw.deviceId) ?? "",
|
||||
folderKey: trimToDefined(raw.folderKey),
|
||||
projectId: trimToDefined(raw.projectId) ?? "",
|
||||
activeCliExecution: Boolean(raw.activeCliExecution),
|
||||
recentExternalActivityAt: trimToDefined(raw.recentExternalActivityAt),
|
||||
conflictState:
|
||||
raw.conflictState === "warning" || raw.conflictState === "blocked"
|
||||
? raw.conflictState
|
||||
: "none",
|
||||
allowPolicy:
|
||||
raw.allowPolicy === "allow_once" || raw.allowPolicy === "allow_always"
|
||||
? raw.allowPolicy
|
||||
: "forbid",
|
||||
updatedAt: raw.updatedAt ?? nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectExecutionPolicyScope(input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return {
|
||||
deviceId: trimToDefined(input.deviceId) ?? "",
|
||||
folderKey: trimToDefined(input.folderKey),
|
||||
projectId: trimToDefined(input.projectId) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function findProjectExecutionPolicyInState(
|
||||
state: BossState,
|
||||
input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
},
|
||||
) {
|
||||
const scope = normalizeProjectExecutionPolicyScope(input);
|
||||
if (scope.folderKey) {
|
||||
const folderMatch = state.projectExecutionPolicies.find(
|
||||
(policy) => policy.deviceId === scope.deviceId && policy.folderKey === scope.folderKey,
|
||||
);
|
||||
if (folderMatch) {
|
||||
return folderMatch;
|
||||
}
|
||||
}
|
||||
return state.projectExecutionPolicies.find(
|
||||
(policy) => policy.deviceId === scope.deviceId && policy.projectId === scope.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
function upsertProjectExecutionPolicyInState(
|
||||
state: BossState,
|
||||
input: Partial<ProjectExecutionPolicy> & {
|
||||
deviceId: string;
|
||||
projectId: string;
|
||||
},
|
||||
) {
|
||||
const scope = normalizeProjectExecutionPolicyScope(input);
|
||||
const existing = findProjectExecutionPolicyInState(state, scope);
|
||||
const next = normalizeProjectExecutionPolicy({
|
||||
...existing,
|
||||
...input,
|
||||
...scope,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
Object.assign(existing, next);
|
||||
return existing;
|
||||
}
|
||||
|
||||
state.projectExecutionPolicies.unshift(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseControlTextOverride(value: unknown) {
|
||||
if (value === undefined || value === null) {
|
||||
return { kind: "clear" as const };
|
||||
@@ -2921,13 +3102,24 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
...(raw.user?.settings ?? {}),
|
||||
},
|
||||
},
|
||||
devices: ensureArray(raw.devices, base.devices).map((device, index) => ({
|
||||
...base.devices[index % base.devices.length],
|
||||
...device,
|
||||
source:
|
||||
device.source ??
|
||||
(device.id === PRIMARY_CODEX_NODE_ID ? "production" : "demo"),
|
||||
})),
|
||||
devices: ensureArray(raw.devices, base.devices).map((device, index) => {
|
||||
const fallback =
|
||||
(device.id ? base.devices.find((item) => item.id === device.id) : undefined) ??
|
||||
base.devices[index % base.devices.length];
|
||||
const lastSeenAt = trimToDefined(device.lastSeenAt) ?? fallback.lastSeenAt;
|
||||
return {
|
||||
...fallback,
|
||||
...device,
|
||||
source:
|
||||
device.source ??
|
||||
(device.id === PRIMARY_CODEX_NODE_ID ? "production" : "demo"),
|
||||
lastSeenAt,
|
||||
capabilities: normalizeDeviceCapabilities(device.capabilities ?? fallback.capabilities, lastSeenAt),
|
||||
preferredExecutionMode: normalizePreferredExecutionMode(
|
||||
device.preferredExecutionMode ?? fallback.preferredExecutionMode,
|
||||
),
|
||||
};
|
||||
}),
|
||||
projects: ensureArray(raw.projects, base.projects).map((project, index) =>
|
||||
normalizeProject(project, base.projects[index % base.projects.length]),
|
||||
),
|
||||
@@ -3202,6 +3394,10 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
supportedActions: ensureArray(item.supportedActions, []),
|
||||
evidenceModes: ensureArray(item.evidenceModes, []),
|
||||
})),
|
||||
projectExecutionPolicies: ensureArray(
|
||||
raw.projectExecutionPolicies,
|
||||
base.projectExecutionPolicies,
|
||||
).map((policy) => normalizeProjectExecutionPolicy(policy)),
|
||||
};
|
||||
|
||||
if (!state.projects.some((project) => project.id === "master-agent")) {
|
||||
@@ -3698,6 +3894,20 @@ function syncDerivedState(input: BossState) {
|
||||
(item) => visibleDeviceIds.has(item.deviceId) && visibleImportDraftIds.has(item.draftId),
|
||||
);
|
||||
const visibleProjectIds = new Set(state.projects.map((project) => project.id));
|
||||
const seenProjectExecutionPolicies = new Set<string>();
|
||||
state.projectExecutionPolicies = state.projectExecutionPolicies
|
||||
.map((policy) => normalizeProjectExecutionPolicy(policy))
|
||||
.filter((policy) => visibleDeviceIds.has(policy.deviceId))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.filter((policy) => {
|
||||
const key = `${policy.deviceId}:${policy.folderKey ?? policy.projectId}`;
|
||||
if (seenProjectExecutionPolicies.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seenProjectExecutionPolicies.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 200);
|
||||
const threadStatusDocumentByThread = new Map<string, ThreadStatusDocument>();
|
||||
const normalizedThreadStatusDocuments = state.threadStatusDocuments.map((document) =>
|
||||
normalizeThreadStatusDocument(document),
|
||||
@@ -6253,7 +6463,89 @@ export async function reassignMasterAgentTaskExecution(payload: {
|
||||
return task;
|
||||
}
|
||||
|
||||
function isCliWriteTask(task: MasterAgentTask) {
|
||||
if (task.taskType === "dispatch_execution") {
|
||||
return true;
|
||||
}
|
||||
if (task.taskType !== "conversation_reply") {
|
||||
return false;
|
||||
}
|
||||
if (task.projectId === "master-agent") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveProjectConflictScopeForTask(
|
||||
state: BossState,
|
||||
task: MasterAgentTask,
|
||||
): { project?: Project; projectId: string; folderKey?: string; deviceId: string } | null {
|
||||
const projectId = task.targetProjectId ?? task.projectId;
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (project?.isGroup) {
|
||||
return null;
|
||||
}
|
||||
const folderRef =
|
||||
trimToDefined(task.targetCodexFolderRef) ??
|
||||
trimToDefined(project?.threadMeta.codexFolderRef ?? project?.threadMeta.folderName);
|
||||
const folderKey = project
|
||||
? buildProjectFolderKey(project)
|
||||
: task.deviceId && folderRef
|
||||
? `${task.deviceId}:${folderRef.toLowerCase()}`
|
||||
: undefined;
|
||||
if (!project && !folderKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
project,
|
||||
projectId: project?.id ?? projectId,
|
||||
folderKey,
|
||||
deviceId: task.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
const snapshot = await readState();
|
||||
const queued = snapshot.masterAgentTasks.find(
|
||||
(item) => item.deviceId === deviceId && item.status === "queued",
|
||||
);
|
||||
if (!queued) {
|
||||
return null;
|
||||
}
|
||||
const device = snapshot.devices.find((item) => item.id === deviceId);
|
||||
if (device?.preferredExecutionMode === "gui" && isCliWriteTask(queued)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCliWriteTask(queued)) {
|
||||
const scope = resolveProjectConflictScopeForTask(snapshot, queued);
|
||||
const externalActivityAt = scope?.project?.threadMeta.lastObservedCodexActivityAt;
|
||||
if (scope) {
|
||||
const existingPolicy = findProjectExecutionPolicyInState(snapshot, scope);
|
||||
const fallbackPolicy =
|
||||
existingPolicy ??
|
||||
snapshot.projectExecutionPolicies.find(
|
||||
(policy) => policy.deviceId === deviceId && policy.projectId === scope.projectId,
|
||||
);
|
||||
if (fallbackPolicy?.conflictState === "blocked" && fallbackPolicy.allowPolicy === "forbid") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (scope && externalActivityAt) {
|
||||
const conflict = await detectProjectExecutionConflict({
|
||||
deviceId,
|
||||
folderKey: scope.folderKey,
|
||||
projectId: scope.projectId,
|
||||
executionMode: "cli",
|
||||
activityAt: nowIso(),
|
||||
externalActivityAt,
|
||||
});
|
||||
if (conflict.blocked) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let attachmentProjectId: string | undefined;
|
||||
let dispatchExecutionProjectId: string | undefined;
|
||||
const task = await mutateState((state) => {
|
||||
@@ -6282,6 +6574,20 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
dispatchExecutionProjectId = execution.groupProjectId;
|
||||
}
|
||||
}
|
||||
if (isCliWriteTask(next)) {
|
||||
const scope = resolveProjectConflictScopeForTask(state, next);
|
||||
if (scope) {
|
||||
const existing = findProjectExecutionPolicyInState(state, scope);
|
||||
upsertProjectExecutionPolicyInState(state, {
|
||||
...existing,
|
||||
...scope,
|
||||
allowPolicy: existing?.allowPolicy ?? "forbid",
|
||||
conflictState: existing?.conflictState ?? "none",
|
||||
activeCliExecution: true,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...next };
|
||||
});
|
||||
if (task) {
|
||||
@@ -6564,6 +6870,21 @@ export async function completeMasterAgentTask(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
if (isCliWriteTask(task)) {
|
||||
const scope = resolveProjectConflictScopeForTask(state, task);
|
||||
if (scope) {
|
||||
const policy = findProjectExecutionPolicyInState(state, scope);
|
||||
if (policy) {
|
||||
if (policy.allowPolicy === "allow_once") {
|
||||
policy.allowPolicy = "forbid";
|
||||
policy.conflictState = "blocked";
|
||||
}
|
||||
policy.activeCliExecution = false;
|
||||
policy.updatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,
|
||||
@@ -6948,7 +7269,7 @@ export async function createDeviceEnrollment(payload: {
|
||||
return { device, enrollment };
|
||||
}
|
||||
|
||||
export async function updateDevice(deviceId: string, payload: Partial<Device>) {
|
||||
export async function updateDevice(deviceId: string, payload: DeviceUpdatePayload) {
|
||||
const device = await mutateState((state) => {
|
||||
const nextDevice = state.devices.find((item) => item.id === deviceId);
|
||||
if (!nextDevice) throw new Error("DEVICE_NOT_FOUND");
|
||||
@@ -6962,6 +7283,20 @@ export async function updateDevice(deviceId: string, payload: Partial<Device>) {
|
||||
if (payload.projects) {
|
||||
nextDevice.projects = payload.projects.filter(Boolean);
|
||||
}
|
||||
if (payload.capabilities) {
|
||||
nextDevice.capabilities = normalizeDeviceCapabilities(
|
||||
{
|
||||
gui: payload.capabilities.gui ?? nextDevice.capabilities?.gui,
|
||||
cli: payload.capabilities.cli ?? nextDevice.capabilities?.cli,
|
||||
},
|
||||
trimToDefined(payload.capabilities.gui?.lastSeenAt) ??
|
||||
trimToDefined(payload.capabilities.cli?.lastSeenAt) ??
|
||||
nextDevice.lastSeenAt,
|
||||
);
|
||||
}
|
||||
if (payload.preferredExecutionMode !== undefined) {
|
||||
nextDevice.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode);
|
||||
}
|
||||
nextDevice.lastSeenAt = nowIso();
|
||||
return nextDevice;
|
||||
});
|
||||
@@ -6969,6 +7304,105 @@ export async function updateDevice(deviceId: string, payload: Partial<Device>) {
|
||||
return device;
|
||||
}
|
||||
|
||||
export async function applyProjectConflictDecision(input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
decision: ProjectConflictAllowPolicy;
|
||||
}) {
|
||||
return mutateState((state) => {
|
||||
const policy = upsertProjectExecutionPolicyInState(state, {
|
||||
deviceId: input.deviceId,
|
||||
folderKey: input.folderKey,
|
||||
projectId: input.projectId,
|
||||
allowPolicy: input.decision,
|
||||
conflictState: input.decision === "forbid" ? "blocked" : "warning",
|
||||
activeCliExecution: false,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
return normalizeProjectExecutionPolicy(policy);
|
||||
});
|
||||
}
|
||||
|
||||
export async function detectProjectExecutionConflict(input: {
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
executionMode: DeviceExecutionMode;
|
||||
activityAt: string;
|
||||
externalActivityAt?: string;
|
||||
}) {
|
||||
let result!: {
|
||||
blocked: boolean;
|
||||
policy: ProjectExecutionPolicy;
|
||||
};
|
||||
|
||||
await mutateState((state) => {
|
||||
const scope = normalizeProjectExecutionPolicyScope(input);
|
||||
const existingPolicy = findProjectExecutionPolicyInState(state, scope);
|
||||
const hasConflict =
|
||||
input.executionMode === "cli" &&
|
||||
Boolean(input.externalActivityAt) &&
|
||||
input.externalActivityAt! <= input.activityAt;
|
||||
|
||||
if (!hasConflict) {
|
||||
result = {
|
||||
blocked: false,
|
||||
policy: normalizeProjectExecutionPolicy({
|
||||
...existingPolicy,
|
||||
...scope,
|
||||
allowPolicy: existingPolicy?.allowPolicy ?? "forbid",
|
||||
conflictState: existingPolicy?.conflictState ?? "none",
|
||||
activeCliExecution: false,
|
||||
updatedAt: existingPolicy?.updatedAt ?? nowIso(),
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingPolicy?.allowPolicy === "allow_always") {
|
||||
const policy = upsertProjectExecutionPolicyInState(state, {
|
||||
...existingPolicy,
|
||||
...scope,
|
||||
allowPolicy: "allow_always",
|
||||
conflictState: "warning",
|
||||
activeCliExecution: true,
|
||||
recentExternalActivityAt: input.externalActivityAt,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
result = { blocked: false, policy: normalizeProjectExecutionPolicy(policy) };
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingPolicy?.allowPolicy === "allow_once") {
|
||||
const policy = upsertProjectExecutionPolicyInState(state, {
|
||||
...existingPolicy,
|
||||
...scope,
|
||||
allowPolicy: "allow_once",
|
||||
conflictState: "warning",
|
||||
activeCliExecution: true,
|
||||
recentExternalActivityAt: input.externalActivityAt,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
result = { blocked: false, policy: normalizeProjectExecutionPolicy(policy) };
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedPolicy = upsertProjectExecutionPolicyInState(state, {
|
||||
...existingPolicy,
|
||||
...scope,
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
activeCliExecution: true,
|
||||
recentExternalActivityAt: input.externalActivityAt,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
result = { blocked: true, policy: normalizeProjectExecutionPolicy(blockedPolicy) };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function claimEnrollment(
|
||||
state: BossState,
|
||||
deviceId: string,
|
||||
@@ -7133,6 +7567,8 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
quota5h: number;
|
||||
quota7d: number;
|
||||
projects: string[];
|
||||
capabilities?: DeviceCapabilitiesInput;
|
||||
preferredExecutionMode?: DeviceExecutionMode;
|
||||
endpoint?: string;
|
||||
projectCandidates?: Array<{
|
||||
folderName: string;
|
||||
@@ -7191,6 +7627,8 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
endpoint: payload.endpoint,
|
||||
token: claimedEnrollment?.token ?? payload.token ?? randomToken("boss"),
|
||||
note: claimedEnrollment?.note,
|
||||
capabilities: normalizeDeviceCapabilities(payload.capabilities, nowIso()),
|
||||
preferredExecutionMode: normalizePreferredExecutionMode(payload.preferredExecutionMode),
|
||||
};
|
||||
state.devices.push(device);
|
||||
} else {
|
||||
@@ -7208,6 +7646,13 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
device.lastSeenAt = nowIso();
|
||||
device.endpoint = payload.endpoint ?? device.endpoint;
|
||||
device.token = claimedEnrollment?.token ?? payload.token ?? device.token;
|
||||
device.capabilities = normalizeDeviceCapabilities(
|
||||
payload.capabilities ?? device.capabilities,
|
||||
device.lastSeenAt,
|
||||
);
|
||||
if (device.preferredExecutionMode === undefined && payload.preferredExecutionMode !== undefined) {
|
||||
device.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAutoImportLegacyProjects) {
|
||||
@@ -7286,6 +7731,48 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
createdAt: candidate.lastActiveAt,
|
||||
sourceTaskId: `heartbeat-${candidate.candidateId}`,
|
||||
});
|
||||
const folderKey = buildProjectFolderKey(matchingProject);
|
||||
const activePolicy = folderKey
|
||||
? findProjectExecutionPolicyInState(state, {
|
||||
deviceId: payload.deviceId,
|
||||
folderKey,
|
||||
projectId: matchingProject.id,
|
||||
})
|
||||
: undefined;
|
||||
const hasRunningCliTask = state.masterAgentTasks.some((task) => {
|
||||
if (task.deviceId !== payload.deviceId || task.status !== "running") {
|
||||
return false;
|
||||
}
|
||||
if (!isCliWriteTask(task)) {
|
||||
return false;
|
||||
}
|
||||
const taskScope = resolveProjectConflictScopeForTask(state, task);
|
||||
if (!taskScope) {
|
||||
return false;
|
||||
}
|
||||
if (taskScope.projectId !== matchingProject.id) {
|
||||
return false;
|
||||
}
|
||||
if (!folderKey) {
|
||||
return true;
|
||||
}
|
||||
return taskScope.folderKey === folderKey;
|
||||
});
|
||||
if (activePolicy?.activeCliExecution || hasRunningCliTask) {
|
||||
const allowPolicy = activePolicy?.allowPolicy ?? "forbid";
|
||||
const conflictState = allowPolicy === "allow_always" ? "warning" : "blocked";
|
||||
upsertProjectExecutionPolicyInState(state, {
|
||||
...activePolicy,
|
||||
deviceId: payload.deviceId,
|
||||
folderKey,
|
||||
projectId: matchingProject.id,
|
||||
allowPolicy,
|
||||
conflictState,
|
||||
activeCliExecution: true,
|
||||
recentExternalActivityAt: candidate.lastActiveAt,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state, "heartbeat_activity")) {
|
||||
projectUnderstandingSyncRequests.push({
|
||||
|
||||
316
tests/device-execution-conflict.test.ts
Normal file
316
tests/device-execution-conflict.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let detectProjectExecutionConflict: (typeof import("../src/lib/boss-data"))["detectProjectExecutionConflict"];
|
||||
let applyProjectConflictDecision: (typeof import("../src/lib/boss-data"))["applyProjectConflictDecision"];
|
||||
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
||||
let claimNextMasterAgentTask: (typeof import("../src/lib/boss-data"))["claimNextMasterAgentTask"];
|
||||
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
||||
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
|
||||
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-conflict-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
detectProjectExecutionConflict = data.detectProjectExecutionConflict;
|
||||
applyProjectConflictDecision = data.applyProjectConflictDecision;
|
||||
queueMasterAgentTask = data.queueMasterAgentTask;
|
||||
claimNextMasterAgentTask = data.claimNextMasterAgentTask;
|
||||
completeMasterAgentTask = data.completeMasterAgentTask;
|
||||
updateDevice = data.updateDevice;
|
||||
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildProjectFolderKey(project: Awaited<ReturnType<typeof readState>>["projects"][number]) {
|
||||
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
|
||||
return `${project.deviceIds[0]}:${folderRef}`;
|
||||
}
|
||||
|
||||
async function getCliProject() {
|
||||
const state = await readState();
|
||||
let project = state.projects.find(
|
||||
(item) => !item.isGroup && item.id !== "master-agent" && item.deviceIds.includes("mac-studio"),
|
||||
);
|
||||
if (!project) {
|
||||
project = {
|
||||
id: "thread-ui",
|
||||
name: "Boss UI",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "线程执行中",
|
||||
updatedAt: "2026-04-06T10:00:00.000Z",
|
||||
lastMessageAt: "2026-04-06T10:00:00.000Z",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "thread-ui",
|
||||
threadId: "thread-ui-main",
|
||||
threadDisplayName: "Boss UI 主线程",
|
||||
folderName: "boss",
|
||||
activityIconCount: 1,
|
||||
updatedAt: "2026-04-06T10:00:00.000Z",
|
||||
codexThreadRef: "thread-ui-main",
|
||||
codexFolderRef: "boss",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "medium",
|
||||
contextBudgetPct: 64,
|
||||
contextBudgetLabel: "64%",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
state.projects.push(project);
|
||||
await writeState(state);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
test("detectProjectExecutionConflict blocks cli execution when the same folder has new external activity", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
state.projectExecutionPolicies = [];
|
||||
await writeState(state);
|
||||
|
||||
const result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:05:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:04:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.policy.allowPolicy, "forbid");
|
||||
assert.equal(result.policy.conflictState, "blocked");
|
||||
});
|
||||
|
||||
test("allow_once only clears the active folder conflict after a single execution", async () => {
|
||||
await setup();
|
||||
|
||||
await applyProjectConflictDecision({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
decision: "allow_once",
|
||||
});
|
||||
|
||||
let result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:10:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:09:00.000Z",
|
||||
});
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.policy.allowPolicy, "allow_once");
|
||||
|
||||
result = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:20:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:19:00.000Z",
|
||||
});
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.policy.allowPolicy, "allow_once");
|
||||
});
|
||||
|
||||
test("allow_always applies only to the active folder and does not unlock other folders on the same device", async () => {
|
||||
await setup();
|
||||
|
||||
await applyProjectConflictDecision({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
decision: "allow_always",
|
||||
});
|
||||
|
||||
const allowed = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:30:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:29:00.000Z",
|
||||
});
|
||||
assert.equal(allowed.blocked, false);
|
||||
assert.equal(allowed.policy.allowPolicy, "allow_always");
|
||||
|
||||
const blocked = await detectProjectExecutionConflict({
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:talking",
|
||||
projectId: "thread-talking",
|
||||
executionMode: "cli",
|
||||
activityAt: "2026-04-06T10:31:00.000Z",
|
||||
externalActivityAt: "2026-04-06T10:30:00.000Z",
|
||||
});
|
||||
assert.equal(blocked.blocked, true);
|
||||
assert.equal(blocked.policy.allowPolicy, "forbid");
|
||||
});
|
||||
|
||||
test("claimNextMasterAgentTask keeps conversation replies queued when the device prefers gui mode", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
await updateDevice("mac-studio", {
|
||||
preferredExecutionMode: "gui",
|
||||
});
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-preferred-gui",
|
||||
requestText: "继续推进当前线程任务",
|
||||
executionPrompt: "请继续推进当前线程任务",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
|
||||
const claimed = await claimNextMasterAgentTask("mac-studio");
|
||||
|
||||
assert.equal(claimed, null);
|
||||
const state = await readState();
|
||||
const queued = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(queued?.status, "queued");
|
||||
});
|
||||
|
||||
test("heartbeat external activity on an active cli folder blocks the next claim until the user explicitly allows it", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
const folderKey = buildProjectFolderKey(project);
|
||||
|
||||
const firstTask = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-first",
|
||||
requestText: "先推进一轮",
|
||||
executionPrompt: "请先推进一轮",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
const claimedFirst = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(claimedFirst?.taskId, firstTask.taskId);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
projects: [project.threadMeta.folderName],
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: project.threadMeta.folderName,
|
||||
folderRef: project.threadMeta.codexFolderRef,
|
||||
threadId: project.threadMeta.threadId,
|
||||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||||
codexFolderRef: project.threadMeta.codexFolderRef,
|
||||
codexThreadRef: project.threadMeta.codexThreadRef,
|
||||
lastActiveAt: "2026-04-06T11:05:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let state = await readState();
|
||||
let policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
||||
assert.ok(policy, "expected heartbeat to persist a scoped conflict policy");
|
||||
assert.equal(policy?.activeCliExecution, true);
|
||||
assert.equal(policy?.conflictState, "blocked");
|
||||
assert.equal(policy?.recentExternalActivityAt, "2026-04-06T11:05:00.000Z");
|
||||
|
||||
const secondTask = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-second",
|
||||
requestText: "继续推进第二轮",
|
||||
executionPrompt: "请继续推进第二轮",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
|
||||
const blockedClaim = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(blockedClaim, null);
|
||||
|
||||
await applyProjectConflictDecision({
|
||||
deviceId: "mac-studio",
|
||||
folderKey,
|
||||
projectId: project.id,
|
||||
decision: "allow_once",
|
||||
});
|
||||
|
||||
const allowedClaim = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(allowedClaim?.taskId, secondTask.taskId);
|
||||
|
||||
state = await readState();
|
||||
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
||||
assert.equal(policy?.allowPolicy, "allow_once");
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: secondTask.taskId,
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: "第二轮已完成",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
});
|
||||
|
||||
state = await readState();
|
||||
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
||||
assert.ok(policy, "expected scoped policy to remain after consuming allow_once");
|
||||
assert.equal(policy?.allowPolicy, "forbid");
|
||||
assert.equal(policy?.activeCliExecution, false);
|
||||
assert.equal(policy?.conflictState, "blocked");
|
||||
});
|
||||
242
tests/device-gui-cli-capabilities.test.ts
Normal file
242
tests/device-gui-cli-capabilities.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
||||
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-capabilities-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
||||
updateDevice = data.updateDevice;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("device stores gui and cli capabilities without splitting the physical device", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio") as
|
||||
| ({
|
||||
capabilities?: {
|
||||
gui?: { connected?: boolean };
|
||||
cli?: { connected?: boolean };
|
||||
};
|
||||
preferredExecutionMode?: string;
|
||||
} & (typeof state.devices)[number])
|
||||
| undefined;
|
||||
|
||||
assert.ok(device);
|
||||
assert.equal(device.capabilities?.gui?.connected, true);
|
||||
assert.equal(device.capabilities?.cli?.connected, true);
|
||||
assert.equal(device.preferredExecutionMode, "cli");
|
||||
});
|
||||
|
||||
test("conflict policy is scoped to the active folder instead of the whole device", async () => {
|
||||
await setup();
|
||||
|
||||
const state = (await readState()) as typeof readState extends () => Promise<infer T>
|
||||
? T & {
|
||||
projectExecutionPolicies?: Array<{
|
||||
deviceId: string;
|
||||
folderKey?: string;
|
||||
projectId: string;
|
||||
allowPolicy: string;
|
||||
conflictState: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
: never;
|
||||
|
||||
state.projectExecutionPolicies = [
|
||||
{
|
||||
deviceId: "mac-studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
projectId: "thread-ui",
|
||||
allowPolicy: "allow_always",
|
||||
conflictState: "warning",
|
||||
updatedAt: "2026-04-06T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const nextState = (await readState()) as typeof state;
|
||||
const bossPolicy = nextState.projectExecutionPolicies?.find(
|
||||
(item) => item.folderKey === "mac-studio:boss",
|
||||
);
|
||||
const otherPolicy = nextState.projectExecutionPolicies?.find(
|
||||
(item) => item.folderKey === "mac-studio:talking",
|
||||
);
|
||||
|
||||
assert.equal(bossPolicy?.allowPolicy, "allow_always");
|
||||
assert.equal(otherPolicy, undefined);
|
||||
});
|
||||
|
||||
test("legacy device state without capabilities is normalized with seeded defaults", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const legacyState = {
|
||||
...state,
|
||||
devices: state.devices.map((device) =>
|
||||
device.id === "mac-studio"
|
||||
? {
|
||||
...device,
|
||||
capabilities: undefined,
|
||||
preferredExecutionMode: undefined,
|
||||
}
|
||||
: device,
|
||||
),
|
||||
};
|
||||
|
||||
await writeFile(process.env.BOSS_STATE_FILE!, JSON.stringify(legacyState, null, 2), "utf8");
|
||||
|
||||
const normalized = await readState();
|
||||
const device = normalized.devices.find((item) => item.id === "mac-studio");
|
||||
|
||||
assert.ok(device);
|
||||
assert.equal(device.capabilities?.gui.connected, true);
|
||||
assert.equal(device.capabilities?.cli.connected, true);
|
||||
assert.equal(device.preferredExecutionMode, "cli");
|
||||
});
|
||||
|
||||
test("legacy device normalization matches seeded defaults by device id instead of array position", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const reorderedLegacyDevices = [
|
||||
{
|
||||
id: "win-gpu-01",
|
||||
source: "production",
|
||||
capabilities: undefined,
|
||||
preferredExecutionMode: undefined,
|
||||
},
|
||||
{
|
||||
id: "mac-studio",
|
||||
source: "production",
|
||||
capabilities: undefined,
|
||||
preferredExecutionMode: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
await writeFile(
|
||||
process.env.BOSS_STATE_FILE!,
|
||||
JSON.stringify(
|
||||
{
|
||||
...state,
|
||||
devices: reorderedLegacyDevices,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const normalized = await readState();
|
||||
const windowsDevice = normalized.devices.find((item) => item.id === "win-gpu-01");
|
||||
const macDevice = normalized.devices.find((item) => item.id === "mac-studio");
|
||||
|
||||
assert.ok(windowsDevice);
|
||||
assert.ok(macDevice);
|
||||
assert.equal(windowsDevice.capabilities?.gui.connected, true);
|
||||
assert.equal(windowsDevice.capabilities?.cli.connected, false);
|
||||
assert.equal(windowsDevice.preferredExecutionMode, "gui");
|
||||
assert.equal(macDevice.capabilities?.gui.connected, true);
|
||||
assert.equal(macDevice.capabilities?.cli.connected, true);
|
||||
assert.equal(macDevice.preferredExecutionMode, "cli");
|
||||
});
|
||||
|
||||
test("device heartbeat persists gui cli capability state on the same physical device", async () => {
|
||||
await setup();
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
preferredExecutionMode: "gui",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-06T09:30:00.000Z",
|
||||
lastActiveProjectId: "audit-collab",
|
||||
},
|
||||
cli: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-06T09:31:00.000Z",
|
||||
lastActiveProjectId: "master-agent",
|
||||
},
|
||||
},
|
||||
projects: ["硬件审计协作"],
|
||||
endpoint: "mac://kris.local",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio");
|
||||
|
||||
assert.ok(device);
|
||||
assert.equal(device.preferredExecutionMode, "cli");
|
||||
assert.equal(device.capabilities?.gui.connected, true);
|
||||
assert.equal(device.capabilities?.gui.lastActiveProjectId, "audit-collab");
|
||||
assert.equal(device.capabilities?.cli.connected, true);
|
||||
assert.equal(device.capabilities?.cli.lastActiveProjectId, "master-agent");
|
||||
});
|
||||
|
||||
test("device heartbeat does not overwrite the preferred execution mode chosen in Boss for an existing device", async () => {
|
||||
await setup();
|
||||
|
||||
await updateDevice("mac-studio", {
|
||||
preferredExecutionMode: "gui",
|
||||
});
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
preferredExecutionMode: "cli",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-06T09:35:00.000Z",
|
||||
lastActiveProjectId: "master-agent",
|
||||
},
|
||||
cli: {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-06T09:36:00.000Z",
|
||||
lastActiveProjectId: "audit-collab",
|
||||
},
|
||||
},
|
||||
projects: ["硬件审计协作"],
|
||||
endpoint: "mac://kris.local",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio");
|
||||
assert.ok(device);
|
||||
assert.equal(device.preferredExecutionMode, "gui");
|
||||
});
|
||||
120
tests/local-agent-heartbeat-capabilities.test.mjs
Normal file
120
tests/local-agent-heartbeat-capabilities.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function startMockControlPlane() {
|
||||
let resolveHeartbeat;
|
||||
const heartbeatReceived = new Promise((resolve) => {
|
||||
resolveHeartbeat = resolve;
|
||||
});
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const bodyText = Buffer.concat(chunks).toString("utf8");
|
||||
if (request.method === "POST" && request.url === "/api/device-heartbeat") {
|
||||
resolveHeartbeat({
|
||||
headers: request.headers,
|
||||
bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("failed to bind mock control plane");
|
||||
}
|
||||
|
||||
return {
|
||||
server,
|
||||
port: address.port,
|
||||
heartbeatReceived,
|
||||
};
|
||||
}
|
||||
|
||||
test("local-agent heartbeat reports gui and cli capability state", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-heartbeat-capabilities-"));
|
||||
const skillsDir = path.join(runtimeRoot, "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
|
||||
const mockControlPlane = await startMockControlPlane();
|
||||
const exampleConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
|
||||
);
|
||||
const configPath = path.join(runtimeRoot, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...exampleConfig,
|
||||
bindHost: "127.0.0.1",
|
||||
port: 0,
|
||||
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
masterAgentPollIntervalMs: 60_000,
|
||||
masterAgentEnabled: false,
|
||||
codexSessionDiscoveryEnabled: false,
|
||||
projects: [],
|
||||
projectCandidates: [],
|
||||
skillsDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
try {
|
||||
const heartbeatRequest = await Promise.race([
|
||||
mockControlPlane.heartbeatReceived,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
|
||||
}, 8000);
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(heartbeatRequest.bodyText);
|
||||
|
||||
assert.ok(payload.capabilities, "heartbeat payload should include device capabilities");
|
||||
assert.equal(payload.capabilities.gui.connected, false);
|
||||
assert.equal(payload.capabilities.cli.connected, true);
|
||||
assert.equal(payload.preferredExecutionMode, "cli");
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
child.once("close", resolve);
|
||||
}).catch(() => null);
|
||||
await new Promise((resolve) => {
|
||||
mockControlPlane.server.close(resolve);
|
||||
});
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user