@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +4 -4
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/renderer-extension.js +149 -19
  7. package/package.json +4 -2
  8. package/scripts/acp-image-test.cjs +69 -0
  9. package/scripts/adapters-test.cjs +25 -0
  10. package/scripts/codex-accounts-test.cjs +34 -0
  11. package/scripts/codex-adapter-test.cjs +77 -1
  12. package/scripts/collaboration-test.cjs +304 -1
  13. package/scripts/collaboration-ui-smoke.cjs +25 -3
  14. package/scripts/e2e-delegate.cjs +1 -1
  15. package/scripts/e2e-hermes-image.cjs +60 -0
  16. package/scripts/openclaw-adapter-test.cjs +121 -3
  17. package/scripts/openclaw-mcp-probe.cjs +100 -0
  18. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  19. package/scripts/openclaw-thinking-probe.cjs +73 -0
  20. package/scripts/storage-verification-test.cjs +11 -0
  21. package/src/main/adapters/claude.js +15 -7
  22. package/src/main/adapters/codex-app-server.js +29 -11
  23. package/src/main/adapters/codex.js +39 -5
  24. package/src/main/adapters/omp.js +7 -2
  25. package/src/main/adapters/openclaw.js +534 -343
  26. package/src/main/adapters/pi-family.js +35 -8
  27. package/src/main/adapters/zcode.js +12 -8
  28. package/src/main/host/collaboration-tools.js +1 -1
  29. package/src/main/host/collaboration.js +285 -36
  30. package/src/main/host/runtime.js +9 -3
  31. package/src/main/host/verification-gates.js +14 -2
  32. package/src/main/native/codex-accounts.js +15 -4
  33. package/src/main/native/protocol.js +6 -0
  34. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  35. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  36. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  37. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  38. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  39. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  40. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  41. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  42. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  43. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  44. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  45. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +20 -7
  46. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  47. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  48. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  49. 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 家族(Pi / Oh My Pi)的权限模型 = 项目信任(project trust):启动时用 --approve / --no-approve 覆盖一次 */
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
- if (thread.options?.permissionMode === "approve") args.push("--approve");
121
- if (thread.options?.permissionMode === "no-approve") args.push("--no-approve");
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: PI_PERMISSION_MODES,
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: PI_PERMISSION_MODES };
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
- return { manifest, create, project, forwardEvent };
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') return path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs');
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
- if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '应用子任务改动');
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
- if (!recipient.childId || rt.execution.isRunning(recipient.childId)) continue;
384
- const envelope = `[Harness Mix Agent Team message]\nTeam: ${team.name} (${team.id})\nFrom: ${participant.name}\nType: ${message.kind}\n${args.task_id ? `Task: ${args.task_id}\n` : ''}Message: ${args.message}\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.`;
385
- message.delivery = 'native_session';
386
- const recipientJob = [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === recipient.id && job.childId === recipient.childId);
387
- void rt.send(recipient.childId, envelope, { collaborationOf: team.owner, isolated: recipientJob?.workspace?.mode === 'worktree' }).catch(error => {
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 ? `${args.task}\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.` : args.task;
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
- const child = job.childId ? rt.threads.find(t => t.id === job.childId) : await rt.createThread({
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
- if (job.status !== 'running' || this.closing || !rt.execution.isRunning(parent.id)) { job.status = 'cancelled'; return; }
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
- const sending = rt.send(child.id, task, { collaborationOf: parent.id, isolated: job.workspace.mode === 'worktree' });
566
- let sendDone = false, sendError;
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 until = Date.now() + 30 * 60 * 1000;
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
- const childRunning = rt.execution.isRunning(child.id) || child.reviewPending;
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
- if (sendDone || Date.now() - turnInactiveSince > 2000) break;
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) { await rt.cancel(child.id); throw new Error('Subtask timed out after 30 minutes'); }
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
- if (teamTask) { teamTask.status = 'failed'; teamTask.result = error.message; teamTask.updatedAt = Date.now(); }
622
- if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_failed'); }
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
- if (job.teamId) {
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;