@harness-mix/cli 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +4 -4
- package/docs/harness-management.md +1 -1
- package/docs/multi-agent-collaboration.md +3 -1
- package/docs/native-acp.md +138 -127
- package/output/native-build/renderer-extension.js +149 -19
- package/package.json +4 -2
- package/scripts/acp-image-test.cjs +69 -0
- package/scripts/adapters-test.cjs +25 -0
- package/scripts/codex-accounts-test.cjs +34 -0
- package/scripts/codex-adapter-test.cjs +77 -1
- package/scripts/collaboration-test.cjs +304 -1
- package/scripts/collaboration-ui-smoke.cjs +25 -3
- package/scripts/e2e-delegate.cjs +1 -1
- package/scripts/e2e-hermes-image.cjs +60 -0
- package/scripts/openclaw-adapter-test.cjs +121 -3
- package/scripts/openclaw-mcp-probe.cjs +100 -0
- package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
- package/scripts/openclaw-thinking-probe.cjs +73 -0
- package/scripts/storage-verification-test.cjs +11 -0
- package/src/main/adapters/claude.js +15 -7
- package/src/main/adapters/codex-app-server.js +29 -11
- package/src/main/adapters/codex.js +39 -5
- package/src/main/adapters/omp.js +7 -2
- package/src/main/adapters/openclaw.js +534 -343
- package/src/main/adapters/pi-family.js +35 -8
- package/src/main/adapters/zcode.js +12 -8
- package/src/main/host/collaboration-tools.js +1 -1
- package/src/main/host/collaboration.js +285 -36
- package/src/main/host/runtime.js +9 -3
- package/src/main/host/verification-gates.js +14 -2
- package/src/main/native/codex-accounts.js +15 -4
- package/src/main/native/protocol.js +6 -0
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +20 -7
- package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
- package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
|
@@ -52,19 +52,45 @@ function emitAll(emitEvent, mapped) {
|
|
|
52
52
|
for (const item of Array.isArray(mapped) ? mapped : [mapped]) if (item) emitEvent(item);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** Pi
|
|
55
|
+
/** Pi 家族工厂的权限模型按家族成员原生面注入;两者的启动旗标不同,不得共用目录。
|
|
56
|
+
* Pi(pi-coding-agent)的权限模型 = 项目信任(project trust):启动时用 --approve / --no-approve 覆盖一次。
|
|
57
|
+
* 实测校准(本机 pi 0.84.2 `pi --help`):--approve/-a「Trust project-local files for this run」、
|
|
58
|
+
* --no-approve/-na「Ignore project-local files for this run」;default 即不传旗标(走 defaultProjectTrust 全局设置)。 */
|
|
56
59
|
const PI_PERMISSION_MODES = [
|
|
57
60
|
{ id: "default", label: "默认", description: "按全局设置处理项目级设置与扩展(defaultProjectTrust)" },
|
|
58
61
|
{ id: "approve", label: "信任项目", description: "本次信任该项目的设置与扩展(--approve)" },
|
|
59
62
|
{ id: "no-approve", label: "忽略项目资源", description: "本次不加载项目级设置与扩展(--no-approve)" },
|
|
60
63
|
];
|
|
61
64
|
|
|
65
|
+
/** OMP(Pi 的 fork)权限模型已与 Pi 分叉:启动旗标为 --approval-mode always-ask|write|yolo
|
|
66
|
+
* (覆盖 tools.approvalMode,仅本次运行不持久化;--auto-approve/--yolo 是 yolo 捷径),
|
|
67
|
+
* 无 --approve/--no-approve。实测本机 @oh-my-pi/pi-coding-agent 18.1.19:
|
|
68
|
+
* src/cli/flag-tables.ts(--approval-mode 合法值)、src/cli/args.ts(--auto-approve/--yolo 解析)、
|
|
69
|
+
* src/config/settings-schema.ts(三档语义,描述为上游原文的中文转写)。 */
|
|
70
|
+
const OMP_APPROVAL_MODES = [
|
|
71
|
+
{ id: "always-ask", label: "Always ask", description: "自动放行只读工具;写入与执行类工具需确认。" },
|
|
72
|
+
{ id: "write", label: "Write", description: "自动放行只读与写入工具;执行类工具(bash、浏览器、任务等)需确认。" },
|
|
73
|
+
{ id: "yolo", label: "Yolo", description: "自动放行全部层级工具;用户策略仍可要求确认或拦截。", dangerous: true },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Pi 的权限模式 → 启动旗标(default 不传旗标) */
|
|
77
|
+
function piPermissionLaunchArgs(mode) {
|
|
78
|
+
if (mode === "approve") return ["--approve"];
|
|
79
|
+
if (mode === "no-approve") return ["--no-approve"];
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** OMP 的权限模式 → 启动旗标(--approval-mode 逐值下发,不持久化) */
|
|
84
|
+
function ompPermissionLaunchArgs(mode) {
|
|
85
|
+
return OMP_APPROVAL_MODES.some((entry) => entry.id === mode) ? ["--approval-mode", mode] : [];
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/**
|
|
63
89
|
* Pi 家族 Adapter 工厂:Pi 与 Oh My Pi(OMP,Pi 的 fork)共用 --mode rpc 协议。
|
|
64
90
|
* 会话/模型/工具/权限都由原生程序维护,这里只做事件投影。
|
|
65
91
|
* @param {{ id: string, name: string, icon: string, bin: string, packageHint: string, aliases?: string[] }} options
|
|
66
92
|
*/
|
|
67
|
-
function piFamily({ id, name, icon, bin, packageHint, aliases }) {
|
|
93
|
+
function piFamily({ id, name, icon, bin, packageHint, aliases, permissionModes = PI_PERMISSION_MODES, permissionLaunchArgs = piPermissionLaunchArgs }) {
|
|
68
94
|
const manifest = {
|
|
69
95
|
id,
|
|
70
96
|
name,
|
|
@@ -117,8 +143,8 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
|
|
|
117
143
|
const args = thread.restore && thread.nativeSessionFile
|
|
118
144
|
? ['--session', thread.nativeSessionFile]
|
|
119
145
|
: ["--session-id", thread.nativeSessionId, "--name", sanitizeName(thread.title)];
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
// 权限模式只在该原生 CLI 真正支持的旗标集内映射(Pi 项目信任 vs OMP --approval-mode)
|
|
147
|
+
args.push(...permissionLaunchArgs(thread.options?.permissionMode));
|
|
122
148
|
const process = spawnProcess(args, thread.cwd, {
|
|
123
149
|
onEvent: event => forwardEvent(process, event, emitEvent),
|
|
124
150
|
onDiagnostic: (message) => diagnostic(message),
|
|
@@ -239,7 +265,7 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
|
|
|
239
265
|
return {
|
|
240
266
|
models: (modelsData?.models ?? []).map((m) => ({ id: m.id, name: m.name || m.id, provider: m.provider, contextWindow: m.contextWindow })),
|
|
241
267
|
thinkingLevels: (levelsData?.levels ?? []).map((l) => ({ id: l, label: l })),
|
|
242
|
-
permissionModes
|
|
268
|
+
permissionModes,
|
|
243
269
|
};
|
|
244
270
|
} finally {
|
|
245
271
|
process.stop();
|
|
@@ -251,7 +277,7 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
|
|
|
251
277
|
this.listModelsFor(session),
|
|
252
278
|
session.process.command({ type: 'get_available_thinking_levels' }),
|
|
253
279
|
]);
|
|
254
|
-
return { models, thinkingLevels: (levels?.levels ?? []).map(id => ({ id, label: id })), permissionModes
|
|
280
|
+
return { models, thinkingLevels: (levels?.levels ?? []).map(id => ({ id, label: id })), permissionModes };
|
|
255
281
|
},
|
|
256
282
|
|
|
257
283
|
/** 任务级 Fork:另起进程,用 CLI --fork 从源会话分叉出全新原生会话 */
|
|
@@ -369,7 +395,8 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
|
|
|
369
395
|
}
|
|
370
396
|
|
|
371
397
|
// forwardEvent 暴露给回放/单测(runtime 经由 create().open() 闭包使用同一份实现)
|
|
372
|
-
|
|
398
|
+
// permissionModes 暴露注入的本成员权限目录(测试/契约侧可校验,不必拉起原生进程)
|
|
399
|
+
return { manifest, create, project, forwardEvent, permissionModes };
|
|
373
400
|
}
|
|
374
401
|
|
|
375
|
-
module.exports = { piFamily, PI_PERMISSION_MODES };
|
|
402
|
+
module.exports = { piFamily, PI_PERMISSION_MODES, OMP_APPROVAL_MODES, piPermissionLaunchArgs, ompPermissionLaunchArgs };
|
|
@@ -81,9 +81,19 @@ const PERMISSION_MODES = [
|
|
|
81
81
|
// Headless entry resolution. The desktop-bundled zcode.cjs is the primary
|
|
82
82
|
// source; a standalone CLI on PATH and explicit env overrides also work.
|
|
83
83
|
// The desktop app executable itself is NOT a headless CLI — never guess it
|
|
84
|
-
// (the Qoder IDE-launcher lesson).
|
|
84
|
+
// (the Qoder IDE-launcher lesson). The ZCode installer offers per-user
|
|
85
|
+
// (%LOCALAPPDATA%\Programs) and per-machine (Program Files) layouts; probe
|
|
86
|
+
// both, per-user first so existing installs keep their original path.
|
|
85
87
|
function bundledCli() {
|
|
86
|
-
if (process.platform === 'win32')
|
|
88
|
+
if (process.platform === 'win32') {
|
|
89
|
+
const candidates = [
|
|
90
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs'),
|
|
91
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'ZCode', 'resources', 'glm', 'zcode.cjs'),
|
|
92
|
+
];
|
|
93
|
+
return candidates.find((file) => {
|
|
94
|
+
try { return fs.existsSync(file) && fs.statSync(file).isFile(); } catch { return false; }
|
|
95
|
+
}) || null;
|
|
96
|
+
}
|
|
87
97
|
if (process.platform === 'darwin') return path.join(process.env.HOME || '', 'Applications', 'ZCode.app', 'Contents', 'Resources', 'glm', 'zcode.cjs');
|
|
88
98
|
return null;
|
|
89
99
|
}
|
|
@@ -128,12 +138,6 @@ function workspaceIdentity(cwd) {
|
|
|
128
138
|
// (createNodeProviderRuntimePathEnv); the agent requires BOTH paths and
|
|
129
139
|
// without the builtin file the provider registry stays empty ("Select a
|
|
130
140
|
// model before continuing").
|
|
131
|
-
function bundledCli() {
|
|
132
|
-
if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs');
|
|
133
|
-
if (process.platform === 'darwin') return path.join(process.env.HOME || '', 'Applications', 'ZCode.app', 'Contents', 'Resources', 'glm', 'zcode.cjs');
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
141
|
function providerConfigPaths() {
|
|
138
142
|
const desktopDir = bundledCli() ? path.dirname(path.dirname(bundledCli())) : null;
|
|
139
143
|
const builtin = process.env.HARNESS_MIX_ZCODE_BUILTIN_CONFIG
|
|
@@ -7,7 +7,7 @@ const tools = [
|
|
|
7
7
|
{ name: 'list_agents', description: 'List native Harnesses available for delegation and their IDs.', inputSchema: object({}) },
|
|
8
8
|
{ name: 'delegate_to_agent', description: 'Start an independent native Harness session asynchronously. For Agent Team work, pass team_id, member_id and team_task_id so the native session becomes that durable teammate and can use the shared task graph and mailbox. An @Agent mention explicitly assigns that agent its associated work. Include all necessary context. By default workers share the lead working directory, so later reviewers see earlier edits; whenever another session is actively running in that directory, new workers start isolated automatically instead. Wait for development before starting dependent review; do not edit overlapping files concurrently. Use isolation=worktree for independent isolated experiments (auto also isolates Git projects). Returns task_id; collect results before finishing.', inputSchema: object({ agent_type: taskId, task, isolation: { type: 'string', enum: ['auto', 'worktree', 'shared'] }, team_id: teamId, member_id: memberId, team_task_id: taskId }, ['agent_type', 'task']) },
|
|
9
9
|
{ name: 'create_agent_team', description: 'Create a durable multi-Harness Agent Team with one lead and up to six named members with explicit roles. This creates shared team state, not worker sessions; assign tasks and delegate them to members next.', inputSchema: object({ name: { type: 'string', minLength: 1, maxLength: 120 }, goal: task, members: { type: 'array', minItems: 1, maxItems: 6, items: object({ name: { type: 'string', minLength: 1, maxLength: 80 }, role: { type: 'string', minLength: 1, maxLength: 240 }, agent_type: taskId }, ['name', 'role', 'agent_type']) } }, ['name', 'goal', 'members']) },
|
|
10
|
-
{ name: 'assign_team_task', description: 'Add a task to the shared team task graph. Dependencies must already exist. The assignee is a team member id or unique member name.', inputSchema: object({ team_id: teamId, title: { type: 'string', minLength: 1, maxLength: 160 }, description: task, assignee: memberId, depends_on: { type: 'array', maxItems: 16, items: taskId } }, ['team_id', 'title', 'description', 'assignee']) },
|
|
10
|
+
{ name: 'assign_team_task', description: 'Add a task to the shared team task graph. Dependencies must already exist. The assignee is a team member id or unique member name. Optional retry {max 1..3}: on a failed attempt the Host automatically re-dispatches the same member with the failure reason attached (session and workspace reused); without it a failure simply marks the task failed and notifies the lead mailbox.', inputSchema: object({ team_id: teamId, title: { type: 'string', minLength: 1, maxLength: 160 }, description: task, assignee: memberId, depends_on: { type: 'array', maxItems: 16, items: taskId }, retry: object({ max: { type: 'integer', minimum: 1, maximum: 3 } }, ['max']) }, ['team_id', 'title', 'description', 'assignee']) },
|
|
11
11
|
{ name: 'get_team_state', description: 'Read the current team roster, shared task graph and recent mailbox messages. Available to the lead and native teammate sessions. The projection includes a coarse phase (forming/running/waiting/completed), per-status progress counters, and each task\'s dependency depth so parallel lanes and pipeline stages are visible without walking depends_on by hand.', inputSchema: object({ team_id: teamId }, ['team_id']) },
|
|
12
12
|
{ name: 'update_team_task', description: 'Update task status/result in the shared graph. Teammates may update only tasks assigned to themselves; the lead may update any task.', inputSchema: object({ team_id: teamId, task_id: taskId, status: { type: 'string', enum: ['pending', 'blocked', 'in_progress', 'completed', 'failed'] }, result: { type: 'string', maxLength: 48000 } }, ['team_id', 'task_id', 'status']) },
|
|
13
13
|
{ name: 'send_team_message', description: 'Send a durable teammate-to-teammate mailbox message. Use a member id/name, lead, or * for broadcast. An idle native recipient is notified directly in its own session. Set kind to classify the hand-off: handoff (work passes to another member), review-request, or review-result; pair it with task_id so the recipient can locate the shared task. Defaults to text.', inputSchema: object({ team_id: teamId, to: { type: 'string', minLength: 1 }, message: task, task_id: taskId, kind: { type: 'string', enum: ['text', 'handoff', 'review-request', 'review-result'] } }, ['team_id', 'to', 'message']) },
|
|
@@ -215,6 +215,17 @@ class Collaboration {
|
|
|
215
215
|
return matches[0];
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// 成员未读数:发给该成员(定向或广播)且尚未经原生会话送达的消息条数,
|
|
219
|
+
// 让轮询 get_team_state 的成员一眼看到“有 N 条未读”,无需遍历邮箱。
|
|
220
|
+
memberUnread(team, member) {
|
|
221
|
+
return team.messages.filter(message => {
|
|
222
|
+
if (message.from === member.id) return false;
|
|
223
|
+
if (message.to !== member.id && message.to !== '*') return false;
|
|
224
|
+
const state = message.deliveryBy?.[member.id] ?? (message.to === member.id ? message.delivery : 'mailbox');
|
|
225
|
+
return state !== 'native_session';
|
|
226
|
+
}).length;
|
|
227
|
+
}
|
|
228
|
+
|
|
218
229
|
teamView(team) {
|
|
219
230
|
const leadThread = this.runtime.threads.find(thread => thread.id === team.owner);
|
|
220
231
|
const leadAgent = leadThread?.harnessId ?? 'codex';
|
|
@@ -224,7 +235,7 @@ class Collaboration {
|
|
|
224
235
|
team_id: team.id, name: team.name, goal: team.goal, status: team.status, lead_thread_id: team.owner,
|
|
225
236
|
phase: teamPhase(team), progress: teamProgress(team.tasks),
|
|
226
237
|
lead: { id: 'lead', name: 'Team Lead', role: `${leadName} · 协调与验收`, agent: leadAgent, display_status: this.runtime.execution.isRunning(team.owner) ? 'working' : 'ready' },
|
|
227
|
-
members: team.members.map(member => ({ ...member, display_status: member.childId && this.runtime.execution.isRunning(member.childId) ? 'working' : member.status })),
|
|
238
|
+
members: team.members.map(member => ({ ...member, display_status: member.childId && this.runtime.execution.isRunning(member.childId) ? 'working' : member.status, unread: this.memberUnread(team, member) })),
|
|
228
239
|
tasks: team.tasks.map(task => ({ ...task, depth: depths.get(task.id) ?? 0 })), messages: team.messages.slice(-40).map(message => ({ ...message })),
|
|
229
240
|
updated_at: team.updatedAt,
|
|
230
241
|
};
|
|
@@ -258,11 +269,19 @@ class Collaboration {
|
|
|
258
269
|
if (!parent || this.runtime.threads.some(t => t.id !== job.owner && t.id !== job.childId
|
|
259
270
|
&& (this.runtime.execution.isRunning(t.id) || t.reviewPending)
|
|
260
271
|
&& [parent.cwd, job.workspace?.cwd].some(cwd => String(cwd).toLowerCase() === String(t.cwd).toLowerCase()))) throw new Error('其他任务正在同一目录运行或待审查,请等待其结算后再应用');
|
|
261
|
-
|
|
272
|
+
// 子线程已删除时无从查询其门禁策略:review+digest+用户显式授权仍是硬前置,这里跳过
|
|
273
|
+
const childThread = job.childId ? this.runtime.threads.find(t => t.id === job.childId) : null;
|
|
274
|
+
if (childThread) this.runtime.verificationGates.assertSatisfied(childThread, '应用子任务改动');
|
|
262
275
|
job.applying = true;
|
|
263
276
|
try {
|
|
264
277
|
const result = await applyWorkspace(job.workspace, digest);
|
|
265
278
|
job.appliedDigest = result.digest;
|
|
279
|
+
// off 策略下的零配置安全网:apply 结果附一次 advisory 验证(不阻断、不改门禁语义)
|
|
280
|
+
const childForVerify = job.childId ? this.runtime.threads.find(t => t.id === job.childId) : null;
|
|
281
|
+
if (childForVerify) {
|
|
282
|
+
const report = await this.runtime.verificationGates.advisory(childForVerify).catch(() => null);
|
|
283
|
+
if (report) { job.verification = { mode: 'advisory', status: report.status, checks: report.checks }; result.verification = job.verification; }
|
|
284
|
+
}
|
|
266
285
|
await this.save();
|
|
267
286
|
return result;
|
|
268
287
|
} finally { delete job.applying; }
|
|
@@ -349,7 +368,7 @@ class Collaboration {
|
|
|
349
368
|
const assignee = this.resolveMember(team, args.assignee);
|
|
350
369
|
const dependencies = [...new Set(args.depends_on ?? [])];
|
|
351
370
|
if (dependencies.some(id => !team.tasks.some(task => task.id === id))) throw new Error('Unknown dependency task');
|
|
352
|
-
const taskEntry = { id: randomUUID(), title: args.title.trim(), description: args.description, assignee: assignee.id, dependsOn: dependencies, status: dependencies.length ? 'blocked' : 'pending', createdAt: Date.now(), updatedAt: Date.now() };
|
|
371
|
+
const taskEntry = { id: randomUUID(), title: args.title.trim(), description: args.description, assignee: assignee.id, dependsOn: dependencies, status: dependencies.length ? 'blocked' : 'pending', createdAt: Date.now(), updatedAt: Date.now(), ...(args.retry ? { retry: { max: args.retry.max, used: 0 } } : {}) };
|
|
353
372
|
team.tasks.push(taskEntry);
|
|
354
373
|
this.refreshTeamStatus(team);
|
|
355
374
|
await this.publishTeam(team, 'task_assigned');
|
|
@@ -375,19 +394,18 @@ class Collaboration {
|
|
|
375
394
|
if (name === 'send_team_message') {
|
|
376
395
|
const target = args.to === '*' || args.to.toLowerCase() === 'lead' ? args.to.toLowerCase() : this.resolveMember(team, args.to).id;
|
|
377
396
|
if (args.task_id && !team.tasks.some(task => task.id === args.task_id)) throw new Error('Unknown team task');
|
|
378
|
-
const message = { id: randomUUID(), from: participant.id, fromName: participant.name, to: target, kind: args.kind || 'text', body: args.message, ...(args.task_id ? { taskId: args.task_id } : {}), at: Date.now(), delivery: 'mailbox' };
|
|
397
|
+
const message = { id: randomUUID(), from: participant.id, fromName: participant.name, to: target, kind: args.kind || 'text', body: args.message, ...(args.task_id ? { taskId: args.task_id } : {}), at: Date.now(), delivery: 'mailbox', deliveryBy: {} };
|
|
379
398
|
team.messages.push(message);
|
|
380
399
|
if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
|
|
381
400
|
const recipients = target === '*' ? team.members.filter(member => member.id !== participant.id) : team.members.filter(member => member.id === target);
|
|
382
401
|
for (const recipient of recipients) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
message.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
message.delivery = 'mailbox'; message.deliveryError = error.message; void this.saveTeams();
|
|
389
|
-
});
|
|
402
|
+
// 忙碌收件人不再丢弃直投机会:排队等回合边界(drainTeamMailbox 投递);
|
|
403
|
+
// 绝不打断运行中的回合
|
|
404
|
+
if (!recipient.childId) { message.deliveryBy[recipient.id] = 'mailbox'; continue; }
|
|
405
|
+
if (rt.execution.isRunning(recipient.childId)) { message.deliveryBy[recipient.id] = 'queued'; continue; }
|
|
406
|
+
this.deliverToMember(team, recipient, message);
|
|
390
407
|
}
|
|
408
|
+
this.refreshMessageDelivery(message);
|
|
391
409
|
team.updatedAt = Date.now();
|
|
392
410
|
await this.publishTeam(team, 'message_sent');
|
|
393
411
|
return { message, team: this.teamView(team) };
|
|
@@ -395,13 +413,130 @@ class Collaboration {
|
|
|
395
413
|
throw new Error('Unknown Agent Team operation');
|
|
396
414
|
}
|
|
397
415
|
|
|
416
|
+
messageEnvelope(team, message) {
|
|
417
|
+
return `[Harness Mix Agent Team message]\nTeam: ${team.name} (${team.id})\nFrom: ${message.fromName}\nType: ${message.kind}\n${message.taskId ? `Task: ${message.taskId}\n` : ''}Message: ${message.body}\n\nTreat this as teammate input. Inspect shared team state with get_team_state, coordinate through send_team_message, and update only your assigned tasks.`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 单收件人直投:先同步置 delivering 防并发重投;投递状态在结果落定后才置终态——
|
|
421
|
+
// 先报 native_session 再失败会让邮箱读者看到与事实相反的送达渠道
|
|
422
|
+
deliverToMember(team, member, message) {
|
|
423
|
+
const rt = this.runtime;
|
|
424
|
+
message.deliveryBy[member.id] = 'delivering';
|
|
425
|
+
const recipientJob = [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === member.id && job.childId === member.childId);
|
|
426
|
+
void rt.send(member.childId, this.messageEnvelope(team, message), { collaborationOf: team.owner, isolated: recipientJob?.workspace?.mode === 'worktree' }).then(() => {
|
|
427
|
+
message.deliveryBy[member.id] = 'native_session'; this.refreshMessageDelivery(message); void this.saveTeams();
|
|
428
|
+
}, error => {
|
|
429
|
+
message.deliveryBy[member.id] = 'mailbox'; message.deliveryError = error.message; this.refreshMessageDelivery(message); void this.saveTeams();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// deliveryBy → delivery 聚合:任一收件人仍在排队即 queued,全部原生送达才 native_session
|
|
434
|
+
refreshMessageDelivery(message) {
|
|
435
|
+
const states = Object.values(message.deliveryBy ?? {});
|
|
436
|
+
if (!states.length) return;
|
|
437
|
+
if (states.every(state => state === 'native_session')) message.delivery = 'native_session';
|
|
438
|
+
else if (states.includes('queued') || states.includes('delivering')) message.delivery = 'queued';
|
|
439
|
+
else message.delivery = 'mailbox';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 回合边界投递泵:把排队消息投给已空闲的收件人。由作业轮询循环与各结算路径
|
|
443
|
+
// 触发;delivering 标记同步置位,天然防并发重投。lead 已空闲时 rt.send 拒绝,
|
|
444
|
+
// 消息按既有语义降级回邮箱并记录 deliveryError。
|
|
445
|
+
drainTeamMailbox(team) {
|
|
446
|
+
if (!team || this.closing) return;
|
|
447
|
+
const rt = this.runtime;
|
|
448
|
+
for (const message of team.messages) {
|
|
449
|
+
for (const [memberId, state] of Object.entries(message.deliveryBy ?? {})) {
|
|
450
|
+
if (state !== 'queued') continue;
|
|
451
|
+
const member = team.members.find(entry => entry.id === memberId);
|
|
452
|
+
if (!member?.childId || rt.execution.isRunning(member.childId)) continue;
|
|
453
|
+
this.deliverToMember(team, member, message);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 用户在团队看板/协作卡上的操作入口。principal 是用户:权限高于 lead 模型,
|
|
459
|
+
// 因此允许 lead-only 语义(改派/以 lead 身份发消息)。安全边界不变——改派目标
|
|
460
|
+
// 只能是团队既有成员(创建时已过 # 提及门控),派发类操作以「向 lead 线程注入
|
|
461
|
+
// 指令回合」实现:run() 把 worker 作业监管在运行中的 lead 回合上,绕过 lead
|
|
462
|
+
// 直接派发会被立刻结算为 cancelled。指令文本自带目标成员的 #提及,走与用户
|
|
463
|
+
// 手打提及完全相同的授权路径(activeMentions 按回合重算,见 runtime #send)。
|
|
464
|
+
async userAction(threadId, action, args = {}) {
|
|
465
|
+
await this.initialize();
|
|
466
|
+
if (this.closing) throw new Error('Host is closing');
|
|
467
|
+
if (!this.prefs.collaboration) throw new Error('多 Agent 协作已在设置中停用(设置 → 协作)。');
|
|
468
|
+
const rt = this.runtime;
|
|
469
|
+
const thread = rt.threads.find(t => t.id === threadId);
|
|
470
|
+
if (!thread || thread.parentThreadId) throw new Error('团队操作仅限主导者线程');
|
|
471
|
+
const dispatch = text => {
|
|
472
|
+
// 不等待回合完成(可能长达整个协作周期);回合级失败由线程自身呈现
|
|
473
|
+
void rt.send(threadId, text, {}).catch(() => {});
|
|
474
|
+
};
|
|
475
|
+
if (action === 'continue') {
|
|
476
|
+
const interrupted = this.list(threadId).filter(job => job.status === 'interrupted');
|
|
477
|
+
if (!interrupted.length) throw new Error('没有可恢复的中断委派');
|
|
478
|
+
if (args.taskId && this.owned(threadId, args.taskId).status !== 'interrupted') throw new Error('仅中断的委派可以恢复');
|
|
479
|
+
if (rt.execution.isRunning(threadId)) throw new Error('主导者回合进行中,请在回合结束后继续协作');
|
|
480
|
+
// list() 返回 view 投影:agent 字段名是 agent_type
|
|
481
|
+
const mentions = [...new Set(interrupted.map(job => job.agent_type))].map(agent => `#${agent}`).join(' ');
|
|
482
|
+
dispatch(args.taskId
|
|
483
|
+
? `[Harness Mix collaboration · 用户操作]\n用户要求恢复中断的委派 ${args.taskId}(${mentions})。请调用 list_delegations 确认状态后,用 resume_delegation 恢复该任务;不要重放已完成的写入或外部副作用。`
|
|
484
|
+
: `[Harness Mix collaboration · 用户操作]\n用户要求继续之前中断的协作(涉及 ${mentions})。请先调用 list_delegations 查看全部中断项,逐项判断能否安全继续:用户明确要求继续的用 resume_delegation 恢复,其余报告 task_id 与不恢复的原因;不要重放已完成的写入或外部副作用。`);
|
|
485
|
+
// list() 已返回 view 投影,不可再包一层 this.view(字段名会错位)
|
|
486
|
+
return { dispatched: true, interrupted };
|
|
487
|
+
}
|
|
488
|
+
const participant = this.teamFor(threadId, args.teamId);
|
|
489
|
+
if (participant.kind !== 'lead') throw new Error('团队操作仅限主导者线程');
|
|
490
|
+
const { team } = participant;
|
|
491
|
+
if (action === 'task/cancel') {
|
|
492
|
+
const task = team.tasks.find(entry => entry.id === args.taskId);
|
|
493
|
+
if (!task) throw new Error('未知的团队任务');
|
|
494
|
+
const job = [...this.jobs.values()].find(entry => entry.teamId === team.id && entry.teamTaskId === task.id && entry.status === 'running');
|
|
495
|
+
if (job) await this.cancel(job);
|
|
496
|
+
else if (task.status === 'in_progress') {
|
|
497
|
+
// 防御:in_progress 但无运行作业(状态簿记损坏)——直接归位并广播,
|
|
498
|
+
// 不让任务永久卡在进行中
|
|
499
|
+
task.status = 'pending'; task.updatedAt = Date.now();
|
|
500
|
+
const member = team.members.find(entry => entry.id === task.assignee);
|
|
501
|
+
if (member?.status === 'working') member.status = 'ready';
|
|
502
|
+
this.refreshTeamStatus(team);
|
|
503
|
+
await this.publishTeam(team, 'task_cancelled');
|
|
504
|
+
} else throw new Error('仅进行中的任务可以取消');
|
|
505
|
+
return this.teamView(team);
|
|
506
|
+
}
|
|
507
|
+
if (action === 'task/reassign') {
|
|
508
|
+
const task = team.tasks.find(entry => entry.id === args.taskId);
|
|
509
|
+
if (!task) throw new Error('未知的团队任务');
|
|
510
|
+
if (!['failed', 'interrupted', 'pending'].includes(task.status)) throw new Error('运行中或已完成的任务不能改派;请先取消或等待其结算');
|
|
511
|
+
const target = this.resolveMember(team, args.memberId);
|
|
512
|
+
if ([...this.jobs.values()].some(entry => entry.teamId === team.id && entry.memberId === target.id && entry.status === 'running')) throw new Error(`成员 ${target.name} 正在执行其他任务,不能改派`);
|
|
513
|
+
if (rt.execution.isRunning(threadId)) throw new Error('主导者回合进行中,请在回合结束后改派');
|
|
514
|
+
if (target.id !== task.assignee) { task.reassignedFrom = task.assignee; task.assignee = target.id; }
|
|
515
|
+
task.status = task.dependsOn.some(id => team.tasks.find(entry => entry.id === id)?.status !== 'completed') ? 'blocked' : 'pending';
|
|
516
|
+
task.result = undefined; task.updatedAt = Date.now();
|
|
517
|
+
this.refreshTeamStatus(team);
|
|
518
|
+
await this.publishTeam(team, 'task_reassigned');
|
|
519
|
+
dispatch(`[Harness Mix collaboration · 用户改派]\n用户在团队看板上将任务「${task.title}」改派给成员 ${target.name}(Harness: #${target.agent})。该任务已重置为待开始。请立即调用 delegate_to_agent 派发它:team_id=${team.id}、member_id=${target.id}、team_task_id=${task.id}、agent_type=${target.agent},任务描述写明目标${args.note ? `,并纳入用户备注:${args.note}` : ''}。${task.reassignedFrom ? '任务此前已部分执行过,派发时说明不要重复已完成的步骤。' : ''}`);
|
|
520
|
+
return this.teamView(team);
|
|
521
|
+
}
|
|
522
|
+
if (action === 'message/send') {
|
|
523
|
+
if (typeof args.message !== 'string' || !args.message.trim()) throw new Error('消息内容不能为空');
|
|
524
|
+
if (args.kind && !['text', 'handoff', 'review-request', 'review-result'].includes(args.kind)) throw new Error('未知的消息类型');
|
|
525
|
+
const to = args.to ?? '*';
|
|
526
|
+
if (to !== '*') this.resolveMember(team, to);
|
|
527
|
+
// 以 lead 身份发出:teamCall 以 team.owner 为 principal 解析为 lead
|
|
528
|
+
return this.teamCall(team.owner, 'send_team_message', { team_id: team.id, to, message: args.message, ...(args.kind ? { kind: args.kind } : {}) });
|
|
529
|
+
}
|
|
530
|
+
throw new Error('未知的用户操作');
|
|
531
|
+
}
|
|
532
|
+
|
|
398
533
|
view(job) {
|
|
399
534
|
const pending = job.childId ? this.runtime.core.interactions?.pending(job.childId)?.[0] : null;
|
|
400
535
|
return { task_id: job.id, parent_thread_id: job.owner, child_thread_id: job.childId, agent_type: job.agent, status: job.status,
|
|
401
536
|
team_id: job.teamId, member_id: job.memberId, team_task_id: job.teamTaskId,
|
|
402
537
|
display_status: pending ? 'waiting_approval' : job.status, attention: pending ? { type: pending.type, title: pending.title, message: pending.message } : undefined,
|
|
403
538
|
task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error,
|
|
404
|
-
diff: job.diff, digest: job.digest, branch: job.workspace?.branch };
|
|
539
|
+
diff: job.diff, digest: job.digest, branch: job.workspace?.branch, verification: job.verification };
|
|
405
540
|
}
|
|
406
541
|
|
|
407
542
|
async call(principal, name, args) {
|
|
@@ -480,7 +615,7 @@ class Collaboration {
|
|
|
480
615
|
await this.publishTeam(team, 'task_started');
|
|
481
616
|
}
|
|
482
617
|
await this.save();
|
|
483
|
-
const teamPrompt = team ?
|
|
618
|
+
const teamPrompt = team ? this.teamEnvelope(team, member, teamTask, args.task) : args.task;
|
|
484
619
|
job.done = this.run(parent, job, teamPrompt);
|
|
485
620
|
return this.view(job);
|
|
486
621
|
}
|
|
@@ -519,7 +654,7 @@ class Collaboration {
|
|
|
519
654
|
return this.view(job);
|
|
520
655
|
}
|
|
521
656
|
|
|
522
|
-
async run(parent, job, task) {
|
|
657
|
+
async run(parent, job, task) {
|
|
523
658
|
const rt = this.runtime;
|
|
524
659
|
const turnId = job.turnId;
|
|
525
660
|
const title = `Agent 协作 · ${rt.adapters.get(job.agent).manifest.name}`;
|
|
@@ -541,7 +676,9 @@ class Collaboration {
|
|
|
541
676
|
}
|
|
542
677
|
const workerPermMode = defaultWorkerPermissionMode(job.agent);
|
|
543
678
|
if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
|
|
544
|
-
|
|
679
|
+
// resume/follow-up 时既有子会话可能已被删除:回落新建替代会话(同一 Harness、
|
|
680
|
+
// 同一工作区),而不是永久报错把该作业废弃
|
|
681
|
+
const child = (job.childId && rt.threads.find(t => t.id === job.childId)) || await rt.createThread({
|
|
545
682
|
harnessId: job.agent, cwd: job.workspace.cwd, title: `${parent.title} › ${task.slice(0, 40)}`, parentThreadId: parent.id,
|
|
546
683
|
options: { ...(workerPermMode ? { permissionMode: workerPermMode } : {}) },
|
|
547
684
|
onCreated: async thread => {
|
|
@@ -552,7 +689,6 @@ class Collaboration {
|
|
|
552
689
|
await this.save();
|
|
553
690
|
}
|
|
554
691
|
});
|
|
555
|
-
if (!child) throw new Error('Native child history is missing; no replacement session was created');
|
|
556
692
|
job.childId = child.id;
|
|
557
693
|
await this.save();
|
|
558
694
|
if (!spawnSettled) {
|
|
@@ -560,28 +696,70 @@ class Collaboration {
|
|
|
560
696
|
spawnSettled = true;
|
|
561
697
|
}
|
|
562
698
|
emit({ kind: 'tool', toolCallId: workCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
|
|
563
|
-
|
|
699
|
+
// 终态保持:cancel()/close() 已写入的 cancelled/interrupted 不得在此被覆写——
|
|
700
|
+
// 关机竞态下覆写成 cancelled 会让重启后的 resume_delegation 拒绝恢复该作业
|
|
701
|
+
if (job.status !== 'running' || this.closing || !rt.execution.isRunning(parent.id)) {
|
|
702
|
+
if (job.status === 'running') {
|
|
703
|
+
job.status = this.closing ? 'interrupted' : 'cancelled';
|
|
704
|
+
await this.settleStoppedJob(job);
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
564
708
|
// Child native file events remain visible; only the lead snapshots the shared workspace.
|
|
565
|
-
|
|
566
|
-
|
|
709
|
+
// 成员会话可能被并发占用(邮箱投递泵、用户追问、上一回合结算尾部,或 isRunning
|
|
710
|
+
// 已清而 sending 锁未释放的结算窗口):busy 拒绝等空闲后重试(≤10s),而不是把
|
|
711
|
+
// 「任务正在执行」误判为任务失败(改派/重派紧跟失败结算时尤其容易触发)
|
|
712
|
+
let sendDone = false, sendError, sendRetrying = false;
|
|
713
|
+
const dispatchInput = async () => {
|
|
714
|
+
for (let attempt = 0; ; attempt++) {
|
|
715
|
+
try {
|
|
716
|
+
return await rt.send(child.id, task, { collaborationOf: parent.id, isolated: job.workspace.mode === 'worktree' });
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (!/任务正在执行/.test(String(error?.message ?? error)) || attempt >= 100) throw error;
|
|
719
|
+
sendRetrying = true;
|
|
720
|
+
await delay(100);
|
|
721
|
+
sendRetrying = false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
const sending = dispatchInput();
|
|
567
726
|
void sending.then(() => { sendDone = true; }, error => { sendDone = true; sendError = error; });
|
|
568
|
-
const
|
|
727
|
+
const timeoutMs = rt.delegationTimeoutMs ?? 30 * 60 * 1000;
|
|
728
|
+
const until = Date.now() + timeoutMs;
|
|
569
729
|
let displayedStatus = 'running';
|
|
570
730
|
let turnInactiveSince = null;
|
|
571
731
|
while (job.status === 'running' && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)) {
|
|
572
|
-
|
|
732
|
+
// 已删除的 worker 线程:core 回合可能仍呈 running 态(removeThread 不结算回合),
|
|
733
|
+
// 不得据此继续等待,否则作业空转到超时
|
|
734
|
+
const childRunning = rt.threads.some(t => t.id === child.id) && (rt.execution.isRunning(child.id) || child.reviewPending);
|
|
573
735
|
if (!childRunning) {
|
|
574
736
|
if (!turnInactiveSince) turnInactiveSince = Date.now();
|
|
575
|
-
|
|
737
|
+
// sendRetrying 期间不按「子回合静默」提前结算:投递还在等成员空闲
|
|
738
|
+
if (sendDone || (!sendRetrying && Date.now() - turnInactiveSince > 2000)) break;
|
|
576
739
|
} else {
|
|
577
740
|
turnInactiveSince = null;
|
|
578
741
|
}
|
|
579
742
|
const current = this.view(job).display_status;
|
|
580
743
|
if (current !== displayedStatus) { displayedStatus = current; emit({ kind: 'tool', toolCallId: workCallId, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId); }
|
|
581
|
-
if (Date.now() > until) {
|
|
744
|
+
if (Date.now() > until) {
|
|
745
|
+
// 先落失败再取消子线程:runtime.cancel 会把 running 作业标记为 cancelled,
|
|
746
|
+
// 若先取消后抛错,catch 的记录分支(仅认 running)会吞掉超时错误并误报已取消
|
|
747
|
+
job.status = 'failed';
|
|
748
|
+
job.error = `Subtask timed out after ${Math.round(timeoutMs / 60000)} minutes`;
|
|
749
|
+
await rt.cancel(child.id);
|
|
750
|
+
throw new Error(job.error);
|
|
751
|
+
}
|
|
752
|
+
// 顺带泵送排队消息:其他成员可能已空闲(不打断任何人运行中的回合)
|
|
753
|
+
if (job.teamId) this.drainTeamMailbox(this.teams.get(job.teamId));
|
|
582
754
|
await delay(100);
|
|
583
755
|
}
|
|
584
756
|
if (job.status !== 'running') return;
|
|
757
|
+
if (!rt.threads.some(t => t.id === child.id)) {
|
|
758
|
+
// 子线程在执行中被删除:按停止结算,而不是把未完成的 core 回合误判为 completed
|
|
759
|
+
job.status = 'cancelled';
|
|
760
|
+
await this.settleStoppedJob(job);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
585
763
|
if (sendError) throw sendError;
|
|
586
764
|
const turn = rt.execution.lastTurn(child.id);
|
|
587
765
|
if (!turn || turn.status === 'error') throw new Error(turn?.error || child.error || 'Subtask failed');
|
|
@@ -602,7 +780,7 @@ class Collaboration {
|
|
|
602
780
|
if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(entry => entry.id === id)?.status === 'completed')) candidate.status = 'pending';
|
|
603
781
|
}
|
|
604
782
|
}
|
|
605
|
-
if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_settled'); }
|
|
783
|
+
if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_settled'); this.drainTeamMailbox(team); }
|
|
606
784
|
}
|
|
607
785
|
if (job.workspace?.mode === 'worktree' && job.status === 'completed') {
|
|
608
786
|
try {
|
|
@@ -617,9 +795,14 @@ class Collaboration {
|
|
|
617
795
|
const team = this.teams.get(job.teamId);
|
|
618
796
|
const member = team?.members.find(entry => entry.id === job.memberId);
|
|
619
797
|
const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
|
|
620
|
-
if (member) member.status = 'ready';
|
|
621
|
-
|
|
622
|
-
if (
|
|
798
|
+
if (member && member.status !== 'interrupted') member.status = 'ready';
|
|
799
|
+
let retried = false;
|
|
800
|
+
if (teamTask && teamTask.status === 'in_progress') {
|
|
801
|
+
// 失败必达:system 通知先进 lead 邮箱,再决定自动重派或落 failed
|
|
802
|
+
retried = await this.retryTeamTask(parent, job, team, member, teamTask, error);
|
|
803
|
+
}
|
|
804
|
+
if (teamTask && !retried) { teamTask.status = 'failed'; teamTask.result = error.message; teamTask.updatedAt = Date.now(); }
|
|
805
|
+
if (team && !retried) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_failed'); this.drainTeamMailbox(team); }
|
|
623
806
|
}
|
|
624
807
|
}
|
|
625
808
|
finally {
|
|
@@ -630,6 +813,68 @@ class Collaboration {
|
|
|
630
813
|
}
|
|
631
814
|
}
|
|
632
815
|
|
|
816
|
+
// 团队委派的任务信封:成员身份 + 共享图约定(call() 的首次派发与失败重派共用)
|
|
817
|
+
teamEnvelope(team, member, teamTask, baseTask) {
|
|
818
|
+
return `${baseTask}\n\n[Harness Mix Agent Team]\nTeam: ${team.name} (${team.id})\nShared goal: ${team.goal}\nYou are ${member.name}. Role: ${member.role}\nAssigned task: ${teamTask.title} (${teamTask.id})\nYou are a persistent teammate, not a one-shot subagent. Read shared state with get_team_state, update your assigned task with update_team_task, and coordinate directly with teammates through send_team_message. Do not create or assign team members.`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// system 伪参与者通知:不进 roster、不投递,只进邮箱与团队动态,供 lead 免轮询看到失败
|
|
822
|
+
pushSystemNotice(team, body, taskId) {
|
|
823
|
+
team.messages.push({ id: randomUUID(), from: 'system', fromName: 'Harness Mix', to: 'lead', kind: 'text', body, ...(taskId ? { taskId } : {}), at: Date.now(), delivery: 'mailbox' });
|
|
824
|
+
if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 失败结算的统一入口:先投 system 通知,再按 retry 预算决定自动重派(同一成员,
|
|
828
|
+
// 复用其会话与工作区,附上次失败原因)或落 failed。返回 true 表示已重新派发。
|
|
829
|
+
// 重派仍处于同一 lead 回合内(run() 的监管前提),预算只在真正重新派发时消耗。
|
|
830
|
+
async retryTeamTask(parent, failedJob, team, member, teamTask, error) {
|
|
831
|
+
if (!team || !teamTask || failedJob.status !== 'failed') return false;
|
|
832
|
+
const rt = this.runtime;
|
|
833
|
+
const reason = String(error?.message ?? error ?? 'unknown failure');
|
|
834
|
+
const budget = teamTask.retry;
|
|
835
|
+
const canRetry = !!budget && budget.used < budget.max && member
|
|
836
|
+
&& !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)
|
|
837
|
+
&& rt.status[member.agent]?.available !== false
|
|
838
|
+
&& [...this.jobs.values()].filter(j => j.owner === parent.id && j.status === 'running').length < MAX_CONCURRENT_SUBTASKS;
|
|
839
|
+
this.pushSystemNotice(team, `任务「${teamTask.title}」失败:${reason}${canRetry ? `;将自动重试(第 ${budget.used + 1}/${budget.max} 次)` : budget ? ';重试预算已耗尽' : ''}`, teamTask.id);
|
|
840
|
+
if (!canRetry) return false;
|
|
841
|
+
budget.used += 1;
|
|
842
|
+
const retryJob = { id: randomUUID(), owner: parent.id, agent: failedJob.agent, turnId: rt.execution.lastTurn(parent.id)?.id,
|
|
843
|
+
status: 'running', task: failedJob.task, isolation: failedJob.isolation,
|
|
844
|
+
...(failedJob.workspace ? { workspace: failedJob.workspace } : {}),
|
|
845
|
+
teamId: team.id, memberId: failedJob.memberId, teamTaskId: teamTask.id,
|
|
846
|
+
...(failedJob.childId && rt.threads.some(t => t.id === failedJob.childId) ? { childId: failedJob.childId } : {}) };
|
|
847
|
+
this.jobs.set(retryJob.id, retryJob);
|
|
848
|
+
teamTask.status = 'in_progress'; teamTask.jobId = retryJob.id; teamTask.updatedAt = Date.now();
|
|
849
|
+
if (member) member.status = 'working';
|
|
850
|
+
this.refreshTeamStatus(team);
|
|
851
|
+
await this.publishTeam(team, 'task_retry');
|
|
852
|
+
await this.save();
|
|
853
|
+
retryJob.done = this.run(parent, retryJob, this.teamEnvelope(team, member, teamTask,
|
|
854
|
+
`Previous attempt failed: ${reason}\nInspect what was already done; do not repeat completed side effects and avoid the failure path.\n\nOriginal task:\n${retryJob.task}`));
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 所有「作业在运行中被外力终止」的路径(取消工具、lead 停止级联、用户直接停止/
|
|
859
|
+
// 删除 worker 线程、宿主关机)共用的收尾:团队图里不得残留 in_progress 的任务——
|
|
860
|
+
// 否则该成员永远无法被再次委派(delegate_to_agent 会以 already running 拒绝)。
|
|
861
|
+
async settleStoppedJob(job) {
|
|
862
|
+
if (job.teamId) {
|
|
863
|
+
const team = this.teams.get(job.teamId);
|
|
864
|
+
const member = team?.members.find(entry => entry.id === job.memberId);
|
|
865
|
+
const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
|
|
866
|
+
const interrupted = job.status === 'interrupted';
|
|
867
|
+
const memberStatus = interrupted ? 'interrupted' : 'ready';
|
|
868
|
+
let changed = false;
|
|
869
|
+
if (member && member.status !== memberStatus) { member.status = memberStatus; changed = true; }
|
|
870
|
+
if (teamTask?.status === 'in_progress') { teamTask.status = interrupted ? 'interrupted' : 'pending'; teamTask.updatedAt = Date.now(); changed = true; }
|
|
871
|
+
if (team && changed) { this.refreshTeamStatus(team); await this.publishTeam(team, interrupted ? 'task_interrupted' : 'task_cancelled'); }
|
|
872
|
+
// 取消/中断 unwind 后成员空闲:泵送排队消息(lead 已停则按语义降级回邮箱)
|
|
873
|
+
if (team) this.drainTeamMailbox(team);
|
|
874
|
+
}
|
|
875
|
+
await this.save();
|
|
876
|
+
}
|
|
877
|
+
|
|
633
878
|
async cancel(job) {
|
|
634
879
|
if (job.status !== 'running') return;
|
|
635
880
|
job.status = this.closing ? 'interrupted' : 'cancelled';
|
|
@@ -643,15 +888,7 @@ class Collaboration {
|
|
|
643
888
|
}
|
|
644
889
|
} finally {
|
|
645
890
|
job.cancelling = false;
|
|
646
|
-
|
|
647
|
-
const team = this.teams.get(job.teamId);
|
|
648
|
-
const member = team?.members.find(entry => entry.id === job.memberId);
|
|
649
|
-
const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
|
|
650
|
-
if (member) member.status = this.closing ? 'interrupted' : 'ready';
|
|
651
|
-
if (teamTask?.status === 'in_progress') { teamTask.status = this.closing ? 'interrupted' : 'pending'; teamTask.updatedAt = Date.now(); }
|
|
652
|
-
if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, this.closing ? 'task_interrupted' : 'task_cancelled'); }
|
|
653
|
-
}
|
|
654
|
-
await this.save();
|
|
891
|
+
await this.settleStoppedJob(job);
|
|
655
892
|
}
|
|
656
893
|
}
|
|
657
894
|
|
|
@@ -667,6 +904,18 @@ class Collaboration {
|
|
|
667
904
|
} finally { this.cancelling.delete(owner); }
|
|
668
905
|
}
|
|
669
906
|
isParticipant(thread, owner) { return thread.id === owner || [...this.jobs.values()].some(j => j.owner === owner && j.childId === thread.id); }
|
|
907
|
+
|
|
908
|
+
// 线程删除后不得残留悬空的成员会话引用,否则团队视图会永远显示一个
|
|
909
|
+
// 已不存在的 working 成员、消息投递也会反复打到死 id 上
|
|
910
|
+
async forgetThread(threadId) {
|
|
911
|
+
let changed = false;
|
|
912
|
+
for (const team of this.teams.values()) {
|
|
913
|
+
for (const member of team.members) {
|
|
914
|
+
if (member.childId === threadId) { delete member.childId; changed = true; }
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (changed) await this.saveTeams();
|
|
918
|
+
}
|
|
670
919
|
async close() {
|
|
671
920
|
await this.initialize();
|
|
672
921
|
this.closing = true;
|