@co0ontty/wand 4.83.1 → 4.84.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 (188) hide show
  1. package/README.md +4 -0
  2. package/dist/agent-dispatch.js +5 -1
  3. package/dist/ai-team-availability.d.ts +1 -1
  4. package/dist/ai-team-prompts.js +13 -4
  5. package/dist/ai-team-runner.d.ts +80 -5
  6. package/dist/ai-team-runner.js +332 -55
  7. package/dist/ai-team-types.d.ts +92 -5
  8. package/dist/ai-team-types.js +9 -2
  9. package/dist/build-info.json +3 -3
  10. package/dist/cli-api.d.ts +1 -0
  11. package/dist/cli-api.js +3 -0
  12. package/dist/cli.js +18 -1
  13. package/dist/commit-task-archive.d.ts +3 -1
  14. package/dist/commit-task-archive.js +21 -10
  15. package/dist/config.d.ts +1 -1
  16. package/dist/config.js +73 -0
  17. package/dist/conversation-mentions.d.ts +16 -0
  18. package/dist/conversation-mentions.js +32 -0
  19. package/dist/conversation-service.d.ts +90 -0
  20. package/dist/conversation-service.js +759 -0
  21. package/dist/conversation-session-preview.d.ts +9 -0
  22. package/dist/conversation-session-preview.js +47 -0
  23. package/dist/conversation-task-preview.d.ts +5 -0
  24. package/dist/conversation-task-preview.js +8 -0
  25. package/dist/conversation-types.d.ts +80 -0
  26. package/dist/conversation-types.js +7 -0
  27. package/dist/core-extension-host.d.ts +27 -0
  28. package/dist/core-extension-host.js +138 -0
  29. package/dist/core-runner.d.ts +86 -0
  30. package/dist/core-runner.js +757 -0
  31. package/dist/core-status-cli.d.ts +9 -0
  32. package/dist/core-status-cli.js +280 -0
  33. package/dist/core-turn-tracker.d.ts +12 -0
  34. package/dist/core-turn-tracker.js +36 -0
  35. package/dist/decision-runner.d.ts +6 -0
  36. package/dist/decision-runner.js +3 -1
  37. package/dist/decision-tool.d.ts +2 -0
  38. package/dist/decision-tool.js +49 -22
  39. package/dist/env-utils.d.ts +7 -3
  40. package/dist/env-utils.js +30 -5
  41. package/dist/git-quick-commit.d.ts +5 -0
  42. package/dist/git-quick-commit.js +9 -3
  43. package/dist/git-utils.js +2 -1
  44. package/dist/harness-engine.d.ts +110 -0
  45. package/dist/harness-engine.js +331 -0
  46. package/dist/mission-diff.js +2 -1
  47. package/dist/model-auto-assign.d.ts +50 -0
  48. package/dist/model-auto-assign.js +163 -0
  49. package/dist/model-group-runner.d.ts +4 -0
  50. package/dist/model-group-runner.js +72 -0
  51. package/dist/model-groups.d.ts +38 -0
  52. package/dist/model-groups.js +138 -0
  53. package/dist/models.d.ts +12 -0
  54. package/dist/models.js +102 -8
  55. package/dist/npm-update-utils.js +2 -1
  56. package/dist/openrouter-free-cli.d.ts +25 -0
  57. package/dist/openrouter-free-cli.js +109 -0
  58. package/dist/openrouter-free-models.d.ts +85 -0
  59. package/dist/openrouter-free-models.js +458 -0
  60. package/dist/openrouter-free-selection.d.ts +5 -0
  61. package/dist/openrouter-free-selection.js +7 -0
  62. package/dist/path-repair.d.ts +10 -0
  63. package/dist/path-repair.js +50 -5
  64. package/dist/pi-auto-resources.d.ts +12 -0
  65. package/dist/pi-auto-resources.js +151 -0
  66. package/dist/pi-execution-types.d.ts +48 -0
  67. package/dist/pi-execution-types.js +1 -0
  68. package/dist/pi-execution.d.ts +9 -0
  69. package/dist/pi-execution.js +187 -0
  70. package/dist/pi-extension-resources.d.ts +13 -0
  71. package/dist/pi-extension-resources.js +71 -0
  72. package/dist/pi-openrouter-free-bridge.d.ts +2 -0
  73. package/dist/pi-openrouter-free-bridge.js +80 -0
  74. package/dist/pi-resource-bridge.d.ts +2 -0
  75. package/dist/pi-resource-bridge.js +73 -0
  76. package/dist/pi-resource-catalog.d.ts +18 -0
  77. package/dist/pi-resource-catalog.js +82 -0
  78. package/dist/pi-resource-recommendation.d.ts +29 -0
  79. package/dist/pi-resource-recommendation.js +169 -0
  80. package/dist/pi-resource-run.d.ts +8 -0
  81. package/dist/pi-resource-run.js +28 -0
  82. package/dist/pi-session-settings.d.ts +157 -0
  83. package/dist/pi-session-settings.js +166 -0
  84. package/dist/platform-action-catalog.d.ts +24 -0
  85. package/dist/platform-action-catalog.js +141 -0
  86. package/dist/platform-action-service.d.ts +37 -0
  87. package/dist/platform-action-service.js +138 -0
  88. package/dist/platform-action-types.d.ts +125 -0
  89. package/dist/platform-action-types.js +7 -0
  90. package/dist/process-manager.d.ts +21 -1
  91. package/dist/process-manager.js +168 -15
  92. package/dist/provider-catalog.js +1 -1
  93. package/dist/provider-cli-updater.js +29 -8
  94. package/dist/provider-history-scanner.d.ts +9 -0
  95. package/dist/provider-history-scanner.js +107 -1
  96. package/dist/retention.d.ts +19 -7
  97. package/dist/retention.js +171 -28
  98. package/dist/server-ai-team-routes.js +6 -2
  99. package/dist/server-conversation-routes.d.ts +5 -0
  100. package/dist/server-conversation-routes.js +91 -0
  101. package/dist/server-employee-routes.d.ts +2 -0
  102. package/dist/server-employee-routes.js +85 -11
  103. package/dist/server-pi-recommendation-routes.d.ts +13 -0
  104. package/dist/server-pi-recommendation-routes.js +67 -0
  105. package/dist/server-resume-routes.js +1 -1
  106. package/dist/server-session-routes.d.ts +4 -3
  107. package/dist/server-session-routes.js +182 -37
  108. package/dist/server-settings-routes.d.ts +5 -0
  109. package/dist/server-settings-routes.js +46 -0
  110. package/dist/server-task-routes.js +49 -4
  111. package/dist/server-team-dispatch-routes.d.ts +19 -0
  112. package/dist/server-team-dispatch-routes.js +71 -0
  113. package/dist/server-update-routes.d.ts +1 -1
  114. package/dist/server-update-routes.js +10 -5
  115. package/dist/server-workspace-routes.js +44 -24
  116. package/dist/server.d.ts +1 -0
  117. package/dist/server.js +148 -30
  118. package/dist/session-ai-context.d.ts +5 -1
  119. package/dist/session-ai-context.js +9 -4
  120. package/dist/session-registry.d.ts +4 -0
  121. package/dist/session-registry.js +47 -13
  122. package/dist/session-transport.js +8 -1
  123. package/dist/settings-web-access.d.ts +15 -0
  124. package/dist/settings-web-access.js +37 -0
  125. package/dist/silicon-employee-defaults.d.ts +29 -0
  126. package/dist/silicon-employee-defaults.js +41 -0
  127. package/dist/silicon-employee-dispatch.d.ts +5 -1
  128. package/dist/silicon-employee-dispatch.js +10 -4
  129. package/dist/silicon-employee-draft.d.ts +21 -4
  130. package/dist/silicon-employee-draft.js +103 -16
  131. package/dist/storage.d.ts +73 -2
  132. package/dist/storage.js +362 -26
  133. package/dist/structured-claude-protocol.js +1 -1
  134. package/dist/structured-client-protocol.js +11 -33
  135. package/dist/structured-content.d.ts +23 -0
  136. package/dist/structured-content.js +72 -0
  137. package/dist/structured-failure.d.ts +16 -0
  138. package/dist/structured-failure.js +36 -0
  139. package/dist/structured-pi-adapter.d.ts +8 -3
  140. package/dist/structured-pi-adapter.js +131 -30
  141. package/dist/structured-provider-common.d.ts +6 -0
  142. package/dist/structured-provider-common.js +23 -1
  143. package/dist/structured-runner.d.ts +45 -0
  144. package/dist/structured-session-manager.d.ts +78 -4
  145. package/dist/structured-session-manager.js +513 -129
  146. package/dist/structured-thinking.d.ts +15 -0
  147. package/dist/structured-thinking.js +46 -0
  148. package/dist/subagent-dispatch.d.ts +56 -0
  149. package/dist/subagent-dispatch.js +102 -0
  150. package/dist/system-employee.d.ts +4 -3
  151. package/dist/system-employee.js +7 -4
  152. package/dist/task-retention.d.ts +23 -0
  153. package/dist/task-retention.js +69 -0
  154. package/dist/task-types.d.ts +4 -0
  155. package/dist/task-types.js +3 -0
  156. package/dist/team-dispatch.d.ts +101 -0
  157. package/dist/team-dispatch.js +269 -0
  158. package/dist/tool-preview.js +7 -1
  159. package/dist/tui/commands.js +50 -1
  160. package/dist/tui/ipc-protocol.d.ts +1 -1
  161. package/dist/tui/ipc-server.d.ts +4 -0
  162. package/dist/tui/ipc-server.js +23 -0
  163. package/dist/turn-heartbeat.d.ts +35 -0
  164. package/dist/turn-heartbeat.js +64 -0
  165. package/dist/types.d.ts +147 -1
  166. package/dist/user-profile.d.ts +33 -0
  167. package/dist/user-profile.js +70 -0
  168. package/dist/web-ui/chat-message-meta.d.ts +11 -0
  169. package/dist/web-ui/chat-message-meta.js +47 -0
  170. package/dist/web-ui/content/ai-teams.js +13 -13
  171. package/dist/web-ui/content/scripts.js +795 -95
  172. package/dist/web-ui/content/styles.css +1 -1
  173. package/dist/web-ui/content/tailwind.css +1 -6808
  174. package/dist/web-ui/content/vendor/qrcode/qrcode.bundle.js +1 -1
  175. package/dist/web-ui/embedded-assets.d.ts +1 -1
  176. package/dist/web-ui/embedded-assets.js +5 -5
  177. package/dist/web-ui/index.d.ts +1 -1
  178. package/dist/web-ui/index.js +3 -3
  179. package/dist/web-ui/markdown.d.ts +2 -0
  180. package/dist/web-ui/markdown.js +111 -0
  181. package/dist/web-ui/page.d.ts +4 -0
  182. package/dist/web-ui/page.js +8 -0
  183. package/dist/web-ui/react/local-preview/controller.d.ts +30 -0
  184. package/dist/web-ui/react/local-preview/controller.js +159 -0
  185. package/dist/web-ui/running-activity.d.ts +63 -0
  186. package/dist/web-ui/running-activity.js +134 -0
  187. package/dist/web-ui/styles.js +2 -11
  188. package/package.json +7 -2
@@ -8,7 +8,7 @@ import { buildAiTeamDeliverySummary } from "./ai-team-delivery.js";
8
8
  import { dispatchAgentForTask, resolveTaskDispatchTarget, sendToAgentSession, stopAgentSession, } from "./agent-dispatch.js";
9
9
  import { AI_TEAM_REPORT_DIR, aiTeamChatHistoryPath, aiTeamHandoffPath, aiTeamReportPath, buildLeaderFollowupPrompt, buildLeaderFormatRetryPrompt, buildAiTeamObjective, buildLeaderKickoffPrompt, buildMemberPrompt, leaderOf, parseLeaderDecision, renderChatHistoryFile, renderHandoffFile, } from "./ai-team-prompts.js";
10
10
  import { AI_TEAM_STARTUP_WINDOW_MS, classifyCandidateFailure, classifyStartupFailure, isDegradableWorkFailure, isModelUnknownBeforeDispatch, } from "./ai-team-availability.js";
11
- import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, AI_TEAM_TERMINAL_RUN_STATUSES, agentKey, aiTeamChatTitle, memberAgents, } from "./ai-team-types.js";
11
+ import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, AI_TEAM_SILENCE_HEARTBEAT_MS, AI_TEAM_TERMINAL_RUN_STATUSES, agentKey, aiTeamChatTitle, memberAgents, } from "./ai-team-types.js";
12
12
  import { renderLiveStepText } from "./ai-team-live.js";
13
13
  import { teamReportPreview } from "./team-report-preview.js";
14
14
  import { linkedTeamReportFile } from "./team-report-file-link.js";
@@ -32,6 +32,13 @@ const STOP_REPLY = /^(停止|停下|stop)[。.!!]*$/i;
32
32
  /** 步数用完后,纯「继续」保留尚未执行的计划;带新要求的回复交回负责人安排。 */
33
33
  const CONTINUE_REPLY = /^(继续|让团队继续|继续执行|接着做|接着干|continue)[。.!!]*$/i;
34
34
  const STEP_LIMIT_DETAIL = "已达到步数上限";
35
+ /** ISO 文本 → 毫秒;脏值 / 缺省返回 0,调用方按「没有这条活动」处理,不参与最大值比较。 */
36
+ function toMs(value) {
37
+ if (!value)
38
+ return 0;
39
+ const ms = Date.parse(value);
40
+ return Number.isFinite(ms) ? ms : 0;
41
+ }
35
42
  /** 用户可见的冲突错误(路由转 409)。 */
36
43
  export class AiTeamConflictError extends Error {
37
44
  }
@@ -46,6 +53,7 @@ export function createAiTeamSessionOps(deps) {
46
53
  snapshot: (sessionId) => deps.sessions.getLatest(sessionId),
47
54
  ownerOf: (sessionId) => deps.sessions.ownerOf(sessionId),
48
55
  consumeUnacceptedStartup: (sessionId, requestId) => deps.structured?.consumeUnacceptedTeamStartup(sessionId, requestId) ?? false,
56
+ consumeUnacceptedFailure: (sessionId, requestId) => deps.structured?.consumeUnacceptedTeamFailure(sessionId, requestId) ?? null,
49
57
  };
50
58
  }
51
59
  export function createAiTeamChatOps(deps) {
@@ -81,10 +89,12 @@ export function createAiTeamRunner(deps) {
81
89
  resolveCwd: (task) => resolveTaskDispatchTarget(deps, task).cwd,
82
90
  ops: createAiTeamSessionOps(deps),
83
91
  chat: createAiTeamChatOps(deps),
92
+ chatHistoryTurns: deps.chatHistoryTurns,
84
93
  notify: deps.notify,
85
94
  notifyLive: deps.notifyLive,
86
95
  models: deps.models,
87
96
  defaultModelOf: deps.defaultModelOf,
97
+ selfAuthor: deps.selfAuthor,
88
98
  });
89
99
  deps.structured?.registerRelay(AI_TEAM_CHAT_PREFIX, (sessionId, text) => runner.chatInput(sessionId, text));
90
100
  return runner;
@@ -335,11 +345,13 @@ export class AiTeamRunner {
335
345
  storage;
336
346
  ops;
337
347
  chat;
348
+ chatHistoryTurns;
338
349
  resolveCwd;
339
350
  notifyListener;
340
351
  notifyLiveListener;
341
352
  modelsOf;
342
353
  defaultModelOf;
354
+ selfAuthorOf;
343
355
  now;
344
356
  chains = new Map();
345
357
  outputTimers = new Map();
@@ -347,6 +359,22 @@ export class AiTeamRunner {
347
359
  liveTimers = new Map();
348
360
  /** 上次推出去的 live 列表指纹(§4.9):stepId + text + omittedChars + state 全同就不重复推。 */
349
361
  lastLiveKey = new Map();
362
+ /** 上次真正推出 live 列表的时刻(毫秒),静默心跳的起算点。 */
363
+ lastLivePushAt = new Map();
364
+ /** 上次 `ai-team-run` 通知发出的时刻(毫秒);心跳据此判断 run 通道是否也该重推。 */
365
+ lastRunNotifyAt = new Map();
366
+ /**
367
+ * 静默心跳计时器,按 runId 计:**每个 run 至多一个**,run 离开 running(终态 / 等批准 / 等回复)
368
+ * 或 dispose 时立即 cancel,不留悬挂计时器。
369
+ */
370
+ heartbeatTimers = new Map();
371
+ /**
372
+ * 每个运行中步骤最近一次「可观测活动」的签名与时刻(毫秒)。纯内存观测记录,不入库:
373
+ * 服务重启后退回步骤自己的 `startedAt` 重新起算,第一次观察不算活动。
374
+ * 步骤落地(done / failed / skipped)时在 `saveStep` 里删掉,容量随并发步数有界。
375
+ */
376
+ stepActivity = new Map();
377
+ heartbeatMs;
350
378
  recheckTimers = new Map();
351
379
  /** 已经确定「不降级、按既有路径记 failed」的步骤,防止 finishStep 再判一次形成回环。 */
352
380
  noDegrade = new Set();
@@ -358,12 +386,15 @@ export class AiTeamRunner {
358
386
  this.storage = options.storage;
359
387
  this.ops = options.ops;
360
388
  this.chat = options.chat;
389
+ this.chatHistoryTurns = options.chatHistoryTurns;
361
390
  this.resolveCwd = options.resolveCwd;
362
391
  this.notifyListener = options.notify;
363
392
  this.notifyLiveListener = options.notifyLive;
364
393
  this.modelsOf = options.models;
365
394
  this.defaultModelOf = options.defaultModelOf;
395
+ this.selfAuthorOf = options.selfAuthor;
366
396
  this.now = options.now ?? Date.now;
397
+ this.heartbeatMs = Math.max(1, options.heartbeatMs ?? AI_TEAM_SILENCE_HEARTBEAT_MS);
367
398
  }
368
399
  /** 提示词里的模型名解析器:`default` 哨兵交给服务端配置的默认模型。 */
369
400
  defaultModelName = (provider) => this.defaultModelOf?.(provider)?.trim() ?? "";
@@ -391,7 +422,7 @@ export class AiTeamRunner {
391
422
  }
392
423
  // ── 公开操作 ──
393
424
  async start(input) {
394
- const definition = this.storage.getAiTeam(input.teamId);
425
+ const definition = input.team ?? this.storage.getAiTeam(input.teamId ?? "");
395
426
  if (!definition)
396
427
  throw new Error("团队不存在。");
397
428
  const team = freezeTeamEmployees(definition, this.storage);
@@ -405,7 +436,11 @@ export class AiTeamRunner {
405
436
  const objective = buildAiTeamObjective(task, input.note);
406
437
  const createdAt = this.iso();
407
438
  const run = {
408
- id: `run_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
439
+ id: input.runId ?? `run_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
440
+ ...(input.conversationId ? {
441
+ conversationId: input.conversationId, memberVersion: input.memberVersion,
442
+ roundNumber: this.storage.listAiTeamRuns({ taskId: task.id }).length + 1,
443
+ } : {}),
409
444
  teamId: team.id,
410
445
  team,
411
446
  taskId: task.id,
@@ -429,6 +464,7 @@ export class AiTeamRunner {
429
464
  const chatHistory = input.chatSessionId ? this.writeChatHistory(run) : null;
430
465
  this.openChat(run, task, shown, chatHistory);
431
466
  this.storage.saveAiTeamRun(run);
467
+ input.onAccepted?.(run);
432
468
  if (task.status === "todo")
433
469
  this.storage.updateWandTask(task.id, { status: "doing" });
434
470
  excludeReportDir(cwd);
@@ -580,6 +616,31 @@ export class AiTeamRunner {
580
616
  }
581
617
  }
582
618
  }
619
+ /** Explicit task target; ordinary group talk never implicitly approves/stops/starts a run. */
620
+ async inputForRun(runId, text) {
621
+ const note = text.trim();
622
+ if (!note)
623
+ throw new Error("消息不能为空。");
624
+ const run = this.storage.getAiTeamRun(runId);
625
+ if (!run)
626
+ throw new Error("团队运行不存在。");
627
+ if (run.status === "awaiting_approval")
628
+ return this.reject(runId, note);
629
+ if (run.status === "waiting_user") {
630
+ if (run.statusDetail === STEP_LIMIT_DETAIL)
631
+ throw new AiTeamConflictError("请显式增加步数,不用普通消息扩预算。");
632
+ return this.reply(runId, note);
633
+ }
634
+ if (run.status !== "running")
635
+ throw new AiTeamConflictError("本轮已结束,请切回群内沟通或显式继续此任务。");
636
+ return this.act(runId, async (current) => {
637
+ this.requireStatus(current, "running", "本轮状态已变化,请核对发送目标。");
638
+ this.postUser(current, note);
639
+ current.pendingNotes = [...current.pendingNotes, note];
640
+ this.postNotice(current, "已记下,负责人安排下一步时会看到。");
641
+ this.saveRun(current);
642
+ });
643
+ }
583
644
  detail(runId) {
584
645
  const run = this.storage.getAiTeamRun(runId);
585
646
  if (!run)
@@ -594,16 +655,20 @@ export class AiTeamRunner {
594
655
  memberStates[step.sessionId] = activityState(snapshot);
595
656
  }
596
657
  // 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
597
- const messages = run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [];
658
+ // 有对话归属时 ConversationService 已投影用户署名;旧运行只回放 relay 快照,在这里补同一口径。
659
+ const self = this.selfAuthorOf;
660
+ const messages = (run.conversationId && this.chatHistoryTurns ? this.chatHistoryTurns(run.conversationId)
661
+ : run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [])
662
+ .map((turn) => (turn.role === "user" && !turn.author && self ? { ...turn, author: self() } : turn));
598
663
  const currentTeam = this.storage.getAiTeam(run.teamId);
599
- const binding = run.chatSessionId ? this.storage.getSessionWorkspace(run.chatSessionId) : null;
600
- const task = (binding?.workspaceTaskId
601
- ? this.storage.getWandTaskByWorkspaceTaskId(binding.workspaceTaskId) : null)
602
- ?? this.storage.getWandTask(run.taskId);
664
+ // A relay belongs to a conversation, not its newest task. Always use the run's task.
665
+ const task = this.storage.getWandTask(run.taskId);
603
666
  const displayTeam = projectTeamEmployees(displayAiTeam(run.team, currentTeam), this.storage);
604
667
  return {
605
668
  run,
606
669
  delivery: buildAiTeamDeliverySummary({ run, steps, displayTeam, relayTurns: messages, memberStates }),
670
+ // 活动时间投影(派生、不入库):静默期界面上要能给出「本轮已跑多久 + 最近一次活动的时刻」。
671
+ activity: this.activityOf(run, steps),
607
672
  chatTitle: aiTeamChatTitle(task?.title),
608
673
  chatTitleUpdatedAt: task?.updatedAt ?? "",
609
674
  steps,
@@ -621,14 +686,45 @@ export class AiTeamRunner {
621
686
  * 快照拿不到的步骤跳过而不抛。run 不存在时与 detail() 同口径抛错。
622
687
  */
623
688
  live(runId) {
689
+ return this.liveSnapshot(runId).steps;
690
+ }
691
+ /**
692
+ * 轮询端(`GET /api/ai-team-runs/:id/live`)的完整投影:live 步骤 + run 级活动时间。
693
+ * 一次读数算出两者,移动端不必为了「还在跑吗」再拉一次 detail。run 不存在时与 detail() 同口径抛错。
694
+ */
695
+ liveSnapshot(runId) {
624
696
  const run = this.storage.getAiTeamRun(runId);
625
697
  if (!run)
626
698
  throw new Error("团队运行不存在。");
627
- return this.liveSteps(run);
699
+ const all = this.storage.listAiTeamSteps(runId);
700
+ const steps = this.liveSteps(run, all);
701
+ return { runId, status: run.status, activity: this.activityOf(run, all), steps };
628
702
  }
629
- liveSteps(run) {
703
+ /**
704
+ * run 级活动时间投影(派生、不入库)。`steps` 为该 run 的全部步骤,缺省时只在 run 还在跑
705
+ * 才去查表:终态 / 等批准 / 等用户时 `run.updatedAt` 就是最后一次状态写入,而逐步骤的内存观察
706
+ * 也已随步骤落地清掉,再查几十条历史记录没有意义。
707
+ */
708
+ activityOf(run, steps) {
709
+ const startedMs = toMs(run.createdAt);
710
+ let lastMs = Math.max(startedMs, toMs(run.updatedAt));
711
+ const known = steps ?? (run.status === "running" ? this.storage.listAiTeamSteps(run.id) : []);
712
+ for (const step of known) {
713
+ lastMs = Math.max(lastMs, toMs(step.startedAt), toMs(step.endedAt));
714
+ const observed = this.stepActivity.get(step.id);
715
+ if (observed !== undefined)
716
+ lastMs = Math.max(lastMs, observed.at);
717
+ }
718
+ // 时钟只前进不后退:观测到的活动不可能早于本轮开始。
719
+ return {
720
+ startedAt: new Date(startedMs).toISOString(),
721
+ lastActivityAt: new Date(Math.max(lastMs, startedMs)).toISOString(),
722
+ observedAt: this.iso(),
723
+ };
724
+ }
725
+ liveSteps(run, allSteps) {
630
726
  const steps = [];
631
- for (const step of this.storage.listAiTeamSteps(run.id)) {
727
+ for (const step of allSteps ?? this.storage.listAiTeamSteps(run.id)) {
632
728
  if (step.status !== "running" || !step.sessionId)
633
729
  continue;
634
730
  const snapshot = this.ops.snapshot(step.sessionId);
@@ -650,6 +746,7 @@ export class AiTeamRunner {
650
746
  preferOutput: this.ops.ownerOf(step.sessionId) === "pty"
651
747
  && !(provider === "claude" && snapshot.providerCliActive === true),
652
748
  });
749
+ const state = activityState(snapshot);
653
750
  return {
654
751
  stepId: step.id,
655
752
  seq: step.seq,
@@ -659,12 +756,38 @@ export class AiTeamRunner {
659
756
  model: agent?.model?.trim() || undefined,
660
757
  thinkingEffort: agent?.thinkingEffort || undefined,
661
758
  sessionId: step.sessionId,
662
- state: activityState(snapshot),
759
+ state,
663
760
  text: rendered.text,
664
761
  omittedChars: rendered.omittedChars,
762
+ startedAt: step.startedAt,
763
+ lastActivityAt: this.isoOf(this.noteStepActivity(step, state, rendered.text, snapshot)),
764
+ // updatedAt 是「本次快照生成时刻」,每次推送/轮询都会前进;判断有没有新进展看 lastActivityAt。
665
765
  updatedAt: this.iso(),
666
766
  };
667
767
  }
768
+ /**
769
+ * 记一步「最近一次可观测活动」的时刻并返回。签名 = 活动状态 + live 文本长度 + 原始输出与回合
770
+ * 规模 + 是否在本轮里 + 待授权请求 id;任一项变了就算活动发生在此刻。
771
+ * 与 live 推送指纹刻意不同:指纹只管「要不要推」,这里的信号要能把「文本没变但输出仍在涨」
772
+ * (一条长命令、一段被 ANSI 包住的进度)也认成活动。首次观察不记成活动,以 `startedAt` 为基线,
773
+ * 免得服务重启后第一次读快照就谎报「刚刚有活动」。
774
+ */
775
+ noteStepActivity(step, state, text, snapshot) {
776
+ const signal = [
777
+ state,
778
+ text.length,
779
+ snapshot.output?.length ?? 0,
780
+ snapshot.messages?.length ?? 0,
781
+ snapshot.structuredState?.inFlight === true ? 1 : 0,
782
+ snapshot.pendingEscalation?.requestId ?? "",
783
+ ].join("\u0000");
784
+ const seen = this.stepActivity.get(step.id);
785
+ if (seen && seen.signal === signal)
786
+ return seen.at;
787
+ const at = seen ? this.now() : Math.max(toMs(step.startedAt), toMs(step.endedAt));
788
+ this.stepActivity.set(step.id, { signal, at });
789
+ return at;
790
+ }
668
791
  /** 挂一次 run 级 live 去抖:同一 run 每 500ms 至多算一遍、推一次,与 evaluate 的计时器互不干扰。 */
669
792
  scheduleLivePush(runId) {
670
793
  if (!this.notifyLiveListener || this.liveTimers.has(runId))
@@ -676,33 +799,106 @@ export class AiTeamRunner {
676
799
  timer.unref?.();
677
800
  this.liveTimers.set(runId, timer);
678
801
  }
679
- /** 推一次 live 列表(§4.9);指纹(stepId + text + omittedChars + state)没变就不重复推,推送失败只忽略。 */
680
- pushLive(runId) {
802
+ /**
803
+ * 推一次 live 列表(§4.9);指纹(stepId + text + omittedChars + state)没变就不重复推,
804
+ * 推送失败只忽略。例外是静默心跳:`heartbeat` 为 true 时按心跳计时器的裁决放行,内容可以逐字相同,
805
+ * 但新的 `activity.observedAt` 与 heartbeat 标记让前端知道「这个 run 还在被跟踪」。
806
+ * 放行不松开去抖:推完照样记指纹并重排心跳,所以静默期最多每 heartbeatMs 一次,不是无界重推。
807
+ */
808
+ pushLive(runId, heartbeat = false) {
681
809
  if (!this.notifyLiveListener)
682
810
  return;
683
811
  const run = this.storage.getAiTeamRun(runId);
684
812
  if (!run)
685
813
  return;
686
- const steps = this.liveSteps(run);
814
+ const terminal = AI_TEAM_TERMINAL_RUN_STATUSES.includes(run.status);
815
+ const all = this.storage.listAiTeamSteps(runId);
816
+ const steps = this.liveSteps(run, all);
687
817
  const key = steps
688
818
  .map((step) => `${step.stepId}\u0000${step.text}\u0000${step.omittedChars}\u0000${step.state}`)
689
819
  .join("\u0001");
690
- if (this.lastLiveKey.get(runId) === key)
691
- return;
692
- if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(run.status)) {
693
- // 终态的收尾推送之后不再留指纹:留着就没人清,长跑服务会按 run 数攒字符串(§4.9)。
820
+ const unchanged = this.lastLiveKey.get(runId) === key;
821
+ if (terminal) {
822
+ // 终态:指纹和心跳基线一律清掉(留着就没人清,长跑服务会按 run 数攒字符串,§4.9);
823
+ // 没有新内容就什么都不发,此后不会再有 heartbeat 推送。
694
824
  this.lastLiveKey.delete(runId);
825
+ this.cancelHeartbeat(runId);
826
+ if (unchanged)
827
+ return;
828
+ }
829
+ else if (unchanged) {
830
+ if (!heartbeat) {
831
+ // 内容没变:不重复推,但把「静默到点重推一次」排上,去抖不能把后面的心跳吃掉。
832
+ this.scheduleHeartbeat(runId);
833
+ return;
834
+ }
695
835
  }
696
- else {
836
+ if (!terminal) {
697
837
  this.lastLiveKey.set(runId, key);
838
+ this.lastLivePushAt.set(runId, this.now());
839
+ // 只有 running 才有心跳;等批准 / 等用户是「等人」而不是「在干活」,状态芯片本身已经说明。
840
+ if (run.status === "running")
841
+ this.scheduleHeartbeat(runId);
842
+ else
843
+ this.cancelHeartbeat(runId);
698
844
  }
699
845
  try {
700
- this.notifyLiveListener({ runId, taskId: run.taskId, steps });
846
+ this.notifyLiveListener({
847
+ runId, taskId: run.taskId, status: run.status,
848
+ activity: this.activityOf(run, all), heartbeat, steps,
849
+ });
701
850
  }
702
851
  catch {
703
852
  // 通知失败不影响调度;客户端靠下一次事件或轮询端点补齐。
704
853
  }
705
854
  }
855
+ /**
856
+ * 排一次静默心跳:距上次 live 推送或 run 通知还差多久到阈值,就多久后再看一次。
857
+ * 同一 run 至多一个计时器;run 不在 running、被删除或 dispose 时 cancel,不泄漏。
858
+ */
859
+ scheduleHeartbeat(runId) {
860
+ if (this.heartbeatTimers.has(runId))
861
+ return;
862
+ if (!this.notifyLiveListener && !this.notifyListener)
863
+ return;
864
+ const last = Math.max(this.lastLivePushAt.get(runId) ?? 0, this.lastRunNotifyAt.get(runId) ?? 0);
865
+ if (last <= 0)
866
+ return;
867
+ const delay = Math.max(0, last + this.heartbeatMs - this.now());
868
+ const timer = setTimeout(() => {
869
+ this.heartbeatTimers.delete(runId);
870
+ this.beat(runId);
871
+ }, delay);
872
+ timer.unref?.();
873
+ this.heartbeatTimers.set(runId, timer);
874
+ }
875
+ /** 心跳到点:仍在跑且仍在静默就把 live 快照和 run 状态各重推一次,然后按新基线续排。 */
876
+ beat(runId) {
877
+ const run = this.storage.getAiTeamRun(runId);
878
+ if (!run)
879
+ return; // run 已被删除:不再续排,这条心跳到此为止
880
+ if (run.status !== "running") {
881
+ this.cancelHeartbeat(runId);
882
+ return;
883
+ }
884
+ const now = this.now();
885
+ if (now - (this.lastLivePushAt.get(runId) ?? now) >= this.heartbeatMs)
886
+ this.pushLive(runId, true);
887
+ // run 通道同样允许静默重推:客户端收到 ai-team-run 会重拉 detail,进而拿到新的活动时间。
888
+ // 用刚从存储读出的 run(已带 conversationId)通知,importRun 会早退,心跳不产生额外写入。
889
+ if (now - (this.lastRunNotifyAt.get(runId) ?? now) >= this.heartbeatMs)
890
+ this.notify(run);
891
+ this.scheduleHeartbeat(runId);
892
+ }
893
+ /** run 离开 running(终态、等批准、等用户)、被删除或 dispose:取消心跳并清掉基线。 */
894
+ cancelHeartbeat(runId) {
895
+ const timer = this.heartbeatTimers.get(runId);
896
+ if (timer)
897
+ clearTimeout(timer);
898
+ this.heartbeatTimers.delete(runId);
899
+ this.lastLivePushAt.delete(runId);
900
+ this.lastRunNotifyAt.delete(runId);
901
+ }
706
902
  /** 团队页的运行记录;teamId 为空时列出所有团队(侧边栏计数用)。 */
707
903
  listRuns(filter) {
708
904
  const runs = this.storage.listAiTeamRuns({
@@ -712,7 +908,13 @@ export class AiTeamRunner {
712
908
  });
713
909
  return runs.map((run) => {
714
910
  const task = this.storage.getWandTask(run.taskId);
715
- return { ...run, taskTitle: task?.title ?? "(任务已删除)", taskIdentifier: task?.identifier ?? "" };
911
+ // activity 是派生投影(不入库):列表页靠它显示「已运行 N 分钟」,终态 run 不再查步骤表。
912
+ return {
913
+ ...run,
914
+ taskTitle: task?.title ?? "(任务已删除)",
915
+ taskIdentifier: task?.identifier ?? "",
916
+ activity: this.activityOf(run),
917
+ };
716
918
  });
717
919
  }
718
920
  // ── 事件驱动 ──
@@ -791,9 +993,15 @@ export class AiTeamRunner {
791
993
  clearTimeout(timer);
792
994
  for (const timer of this.liveTimers.values())
793
995
  clearTimeout(timer);
996
+ for (const timer of this.heartbeatTimers.values())
997
+ clearTimeout(timer);
794
998
  this.outputTimers.clear();
795
999
  this.recheckTimers.clear();
796
1000
  this.liveTimers.clear();
1001
+ this.heartbeatTimers.clear();
1002
+ this.lastLivePushAt.clear();
1003
+ this.lastRunNotifyAt.clear();
1004
+ this.stepActivity.clear();
797
1005
  this.lastLiveKey.clear();
798
1006
  this.noDegrade.clear();
799
1007
  this.teamRequestIds.clear();
@@ -929,10 +1137,15 @@ export class AiTeamRunner {
929
1137
  && !this.noDegrade.has(step.id)) {
930
1138
  const failure = this.asyncStartupFailure(run, step, outcome.text);
931
1139
  if (failure) {
932
- await this.degradeWorkStep(run, step, failure, proceed);
1140
+ await this.degradeStep(run, step, failure, proceed);
933
1141
  return;
934
1142
  }
935
1143
  }
1144
+ if (step.kind === "leader" && outcome.kind === "failed" && outcome.sessionError
1145
+ && !this.noDegrade.has(step.id)) {
1146
+ await this.degradeStep(run, step, { kind: "runtime-failure", reason: outcome.text }, proceed);
1147
+ return;
1148
+ }
936
1149
  run.stepsUsed += 1;
937
1150
  if (step.kind === "work") {
938
1151
  const report = outcome.text.trim() || (outcome.kind === "done" ? "(成员没有写报告)" : "失败");
@@ -957,11 +1170,10 @@ export class AiTeamRunner {
957
1170
  await this.advance(run);
958
1171
  return;
959
1172
  }
960
- // 负责人的模型报错时停下来等用户(换模型或稍后回复即可接着来),不当成格式错误空转重试。
961
- // 按真实角色身份发出正常对话气泡并展示完整报错,而不是次级 notice 小字。
1173
+ // 负责人候选耗尽后才等用户,不把模型调用失败当作格式错误重试。
962
1174
  const leader = leaderOf(run.team);
963
1175
  if (outcome.kind === "failed" && outcome.sessionError) {
964
- // Leaders keep their existing wait-for-user rule, not worker candidate fallback.
1176
+ // Candidate failures have already traversed the configured chain.
965
1177
  const requestId = this.teamRequestIds.get(step.id);
966
1178
  if (step.sessionId && requestId)
967
1179
  this.ops.consumeUnacceptedStartup?.(step.sessionId, requestId);
@@ -988,7 +1200,7 @@ export class AiTeamRunner {
988
1200
  run.formatRetries += 1;
989
1201
  this.postNotice(run, `负责人的回复没能读懂,让他重答(${run.formatRetries}/${MAX_FORMAT_RETRIES})`, step.sessionId);
990
1202
  this.saveRun(run);
991
- const retry = this.createLeaderStep(run, "重新回复");
1203
+ const retry = this.createLeaderStep(run, "重新回复", this.infoOf(step).usedCandidate);
992
1204
  await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run), this.defaultModelName)));
993
1205
  return;
994
1206
  }
@@ -1100,7 +1312,12 @@ export class AiTeamRunner {
1100
1312
  const finished = steps
1101
1313
  .filter((step) => step.kind === "work" && step.seq > lastLeaderSeq && (step.status === "done" || step.status === "failed"))
1102
1314
  .map((step) => ({ step, memberName: this.member(run.team, step.memberId)?.name ?? step.memberId }));
1103
- const step = this.createLeaderStep(run, userNote ? "回应用户" : "安排下一步");
1315
+ const previousLeader = [...steps].reverse().find((item) => item.kind === "leader" && item.status === "done");
1316
+ const leader = leaderOf(run.team);
1317
+ const previousCandidate = previousLeader ? this.infoOf(previousLeader).usedCandidate : 0;
1318
+ const agent = memberAgents(leader)[previousCandidate];
1319
+ const candidate = agent && this.liveSessionFor(run, leader, "", agent, "leader") ? previousCandidate : 0;
1320
+ const step = this.createLeaderStep(run, userNote ? "回应用户" : "安排下一步", candidate);
1104
1321
  const handoffPath = this.writeHandoff(run, step.seq, "leader", finished.map(({ step: item, memberName }) => this.handoffEntry(run, item, memberName)));
1105
1322
  await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run), this.defaultModelName)));
1106
1323
  }
@@ -1180,7 +1397,8 @@ export class AiTeamRunner {
1180
1397
  const previous = this.storage.listAiTeamRuns({ taskId: run.taskId })
1181
1398
  .filter((item) => item.id !== run.id && item.chatSessionId === chatSessionId)
1182
1399
  .reverse();
1183
- const turns = this.ops.snapshot(chatSessionId)?.messages ?? [];
1400
+ const turns = run.conversationId && this.chatHistoryTurns ? this.chatHistoryTurns(run.conversationId)
1401
+ : this.ops.snapshot(chatSessionId)?.messages ?? [];
1184
1402
  if (previous.length === 0 && turns.length === 0)
1185
1403
  return null;
1186
1404
  const runs = previous.map((item) => ({
@@ -1235,7 +1453,7 @@ export class AiTeamRunner {
1235
1453
  this.setStatus(run, "waiting_user", STEP_LIMIT_DETAIL);
1236
1454
  return false;
1237
1455
  }
1238
- createLeaderStep(run, title) {
1456
+ createLeaderStep(run, title, usedCandidate = 0) {
1239
1457
  const seq = this.nextSeq(run.id);
1240
1458
  const leader = leaderOf(run.team);
1241
1459
  const step = {
@@ -1251,6 +1469,7 @@ export class AiTeamRunner {
1251
1469
  report: "",
1252
1470
  reportPath: aiTeamReportPath(run.id, seq, "leader", leader.id),
1253
1471
  dependsOn: [],
1472
+ dispatchInfo: { usedCandidate, skipped: [] },
1254
1473
  startedAt: null,
1255
1474
  endedAt: null,
1256
1475
  };
@@ -1267,8 +1486,8 @@ export class AiTeamRunner {
1267
1486
  const info = this.infoOf(step);
1268
1487
  const agent = candidates[info.usedCandidate];
1269
1488
  // §3.4 触发点 1 的事前分支:当前候选已被拉黑、候选列表被改到没有这一档,或快照就绪且模型
1270
- // 明确不在清单里。三者都走 degradeWorkStep 留痕,不在这里静默换候选。
1271
- if (member && step.kind === "work") {
1489
+ // 明确不在清单里。三者都走 degradeStep 留痕,不在这里静默换候选。
1490
+ if (member) {
1272
1491
  const blocked = agent
1273
1492
  ? this.blockedCandidate(run.id, agent)
1274
1493
  : { kind: "runtime-failure", reason: `候选列表已变更,没有候选 ${info.usedCandidate + 1}` };
@@ -1278,11 +1497,11 @@ export class AiTeamRunner {
1278
1497
  : null;
1279
1498
  const pre = blocked ?? unknown;
1280
1499
  if (pre) {
1281
- await this.degradeWorkStep(run, step, pre, proceed);
1500
+ await this.degradeStep(run, step, pre, proceed, step.kind === "leader" ? buildPrompt : undefined);
1282
1501
  return;
1283
1502
  }
1284
1503
  }
1285
- const reusable = member ? this.liveSessionFor(run, member, step.id, agent) : null;
1504
+ const reusable = member ? this.liveSessionFor(run, member, step.id, agent, step.kind) : null;
1286
1505
  const prompt = buildPrompt(step.reportPath, reusable === null);
1287
1506
  const startedAt = this.iso();
1288
1507
  let running = { ...step, instructions: step.kind === "leader" ? prompt.message : step.instructions, status: "running", startedAt, sessionId: reusable };
@@ -1337,13 +1556,22 @@ export class AiTeamRunner {
1337
1556
  // 尝试,也不能当成「不可降级」直接吞掉——记进黑名单后交给既有失败出口。
1338
1557
  this.disableHostKind(run.id, agent.kind);
1339
1558
  }
1340
- else if (agent && step.kind === "work" && kind === "spawn-missing"
1559
+ if (step.kind === "leader" && !this.noDegrade.has(step.id)) {
1560
+ const delivered = this.stepOutcome(run, running);
1561
+ if (delivered.kind === "done") {
1562
+ await this.finishStep(run, running, delivered, proceed);
1563
+ return;
1564
+ }
1565
+ await this.degradeStep(run, running, { kind, reason: message }, proceed, buildPrompt);
1566
+ return;
1567
+ }
1568
+ if (agent && step.kind === "work" && kind === "spawn-missing"
1341
1569
  && (!member?.employeeId || reusable === null)
1342
1570
  && !existsSync(path.join(run.cwd, step.reportPath)) && !this.noDegrade.has(step.id)) {
1343
1571
  // §3.4 触发点 1:会话根本没起来,ENOENT 对 structured / pty 同样可靠(修正 B4 排除的是
1344
1572
  // PTY 的异步分类)。传 `running` 而不是入参 `step`:复用会话时 sessionId 只在 running 上。
1345
- // 候选边界与黑名单由 degradeWorkStep 自己判,耗尽时它走 failed 出口。
1346
- await this.degradeWorkStep(run, running, { kind, reason: message }, proceed);
1573
+ // 候选边界与黑名单由 degradeStep 自己判,耗尽时它走 failed 出口。
1574
+ await this.degradeStep(run, running, { kind, reason: message }, proceed);
1347
1575
  return;
1348
1576
  }
1349
1577
  await this.finishStep(run, running, { kind: "failed", text: `派发失败:${this.redact(message)}` }, proceed);
@@ -1354,7 +1582,7 @@ export class AiTeamRunner {
1354
1582
  * 只直接派出 A′,**不整轮 advance**(N1:advance 的 queued/byId/busyMembers 是进循环时的快照,
1355
1583
  * 整轮调整会在同一栈里对同一步二次派发)。`proceed` 原样透传给 A′ 的 dispatchStep。
1356
1584
  */
1357
- async degradeWorkStep(run, step, failure, proceed) {
1585
+ async degradeStep(run, step, failure, proceed, buildPrompt) {
1358
1586
  const info = this.infoOf(step);
1359
1587
  const member = this.member(run.team, step.memberId);
1360
1588
  const candidates = member ? memberAgents(member) : [];
@@ -1368,16 +1596,17 @@ export class AiTeamRunner {
1368
1596
  ];
1369
1597
  const next = info.usedCandidate + 1;
1370
1598
  const tail = candidates.slice(next);
1371
- // 级联深度天然以候选上限(4)为界:每降一级 usedCandidate +1。耗尽时退回既有 failed 出口,
1372
- // 交回 Leader 决定 ask/finish,不另设 waiting_user 捷径、不循环。
1599
+ // 候选下标单调增加;耗尽时负责人等待用户,成员失败交回负责人。
1600
+ // 内部候选重试不扣业务步骤预算,也不循环回首选。
1373
1601
  if (!member || !agent || tail.length === 0 || tail.every((item) => this.blockedCandidate(run.id, item))) {
1374
1602
  const detail = skipped.map((item) => `候选 ${item.candidate + 1}(${item.reason})`).join("、");
1375
1603
  this.noDegrade.add(step.id);
1376
1604
  try {
1377
- await this.finishStep(run, step, {
1605
+ await this.finishStep(run, { ...step, dispatchInfo: { ...info, skipped } }, {
1378
1606
  kind: "failed",
1379
1607
  sessionError: true,
1380
- text: `${member?.name ?? "该成员"}的所有候选均不可用:${detail || reason}`,
1608
+ text: step.kind === "leader" && candidates.length === 1 ? reason
1609
+ : `${member?.name ?? "该成员"}的所有候选均不可用:${detail || reason}`,
1381
1610
  }, proceed);
1382
1611
  }
1383
1612
  finally {
@@ -1391,10 +1620,23 @@ export class AiTeamRunner {
1391
1620
  if (previous && !isSessionGone(previous)) {
1392
1621
  try {
1393
1622
  this.ops.stop(step.sessionId);
1623
+ if (step.kind === "leader" && !isSessionGone(this.ops.snapshot(step.sessionId) ?? previous)) {
1624
+ throw new Error("旧负责人会话尚未停止");
1625
+ }
1394
1626
  }
1395
1627
  catch (error) {
1396
- // stop 抛错不做兜底会让旧步卡在 running,这里只记日志继续记账。
1397
1628
  console.error(`[AiTeam] stop degraded session ${step.sessionId} failed:`, getErrorMessage(error));
1629
+ if (step.kind === "leader") {
1630
+ this.noDegrade.add(step.id);
1631
+ try {
1632
+ await this.finishStep(run, step, { kind: "failed", sessionError: true,
1633
+ text: `无法停止旧负责人会话,未启动备用:${this.cleanError(getErrorMessage(error))}` }, proceed);
1634
+ }
1635
+ finally {
1636
+ this.noDegrade.delete(step.id);
1637
+ }
1638
+ return;
1639
+ }
1398
1640
  }
1399
1641
  }
1400
1642
  }
@@ -1414,14 +1656,14 @@ export class AiTeamRunner {
1414
1656
  id: randomUUID(),
1415
1657
  runId: run.id,
1416
1658
  seq,
1417
- kind: "work",
1659
+ kind: step.kind,
1418
1660
  memberId: step.memberId,
1419
1661
  title: step.title,
1420
1662
  instructions: step.instructions,
1421
1663
  sessionId: null,
1422
1664
  status: "queued",
1423
1665
  report: "",
1424
- reportPath: aiTeamReportPath(run.id, seq, "work", step.memberId),
1666
+ reportPath: aiTeamReportPath(run.id, seq, step.kind, step.memberId),
1425
1667
  dependsOn: step.dependsOn,
1426
1668
  startedAt: null,
1427
1669
  endedAt: null,
@@ -1429,8 +1671,8 @@ export class AiTeamRunner {
1429
1671
  };
1430
1672
  this.saveStep(replacement);
1431
1673
  // 群聊降级行(§5.3/B15):一次降级一行,署名用切过去的新候选;事前 model-unknown 跳过
1432
- // 同样走 degradeWorkStep,所以这里也覆盖它。reason 已经过脱敏 + 头尾截断(§4.5)。
1433
- this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]);
1674
+ // 同样走 degradeStep,所以这里也覆盖它。reason 已经过脱敏 + 头尾截断(§4.5)。
1675
+ this.postNotice(run, `⚠️ ${member.name} 的${info.usedCandidate === 0 ? "首选配置" : `候选 ${info.usedCandidate + 1}`}不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]);
1434
1676
  // ④ 依赖重指向(R1):排队步骤等的仍是「这件事做完」,只是换了承载候选的步骤。
1435
1677
  // running / done 的历史步骤不改写。
1436
1678
  for (const queued of this.storage.listAiTeamSteps(run.id)) {
@@ -1444,6 +1686,17 @@ export class AiTeamRunner {
1444
1686
  // ⑤ 黑名单记账(立即写 run_state_json),然后只直接派 A′。
1445
1687
  this.accountCandidateFailure(run.id, agent, failure.kind);
1446
1688
  await this.dispatchStep(run, replacement, (reportPath, fresh) => {
1689
+ if (step.kind === "leader") {
1690
+ const original = buildPrompt ? buildPrompt(reportPath, true).message
1691
+ : step.instructions.replaceAll(step.reportPath, reportPath);
1692
+ // New candidates need completed reports and the exact failed-round request.
1693
+ const finished = this.storage.listAiTeamSteps(run.id)
1694
+ .filter((item) => item.kind === "work" && (item.status === "done" || item.status === "failed"))
1695
+ .map((item) => ({ step: item, memberName: this.member(run.team, item.memberId)?.name ?? item.memberId }));
1696
+ const history = this.writeChatHistory(run) ?? this.chatHistoryFor(run);
1697
+ const handoff = this.writeHandoff(run, replacement.seq, "leader", finished.map(({ step: item, memberName }) => this.handoffEntry(run, item, memberName)));
1698
+ return buildLeaderFollowupPrompt(run, finished, reportPath, `这是同一轮负责人判断的候选重试;继续下面的原始要求,已完成的工作不要重做。\n${original}`, fresh, handoff, history, this.defaultModelName);
1699
+ }
1447
1700
  const target = this.member(run.team, replacement.memberId) ?? member;
1448
1701
  return buildMemberPrompt(run, replacement, target, fresh, this.memberUpstream(run, replacement), this.chatHistoryFor(run));
1449
1702
  }, proceed);
@@ -1457,13 +1710,16 @@ export class AiTeamRunner {
1457
1710
  if (!member)
1458
1711
  return null;
1459
1712
  const requestId = this.teamRequestIds.get(step.id);
1460
- const unaccepted = !!(step.sessionId && requestId
1461
- && this.ops.consumeUnacceptedStartup?.(step.sessionId, requestId));
1462
- // Bound employees may retry only this request's explicit unaccepted fact, never a text/time guess.
1463
- if (member.employeeId) {
1713
+ const refusal = step.sessionId && requestId && this.ops.consumeUnacceptedFailure
1714
+ ? this.ops.consumeUnacceptedFailure(step.sessionId, requestId) : null;
1715
+ const unaccepted = this.ops.consumeUnacceptedFailure ? refusal?.retryable === true
1716
+ : !!(step.sessionId && requestId && this.ops.consumeUnacceptedStartup?.(step.sessionId, requestId));
1717
+ // Production dispatch uses the shared current-request fact; never infer a replay from a time window.
1718
+ if (member.employeeId || this.ops.consumeUnacceptedFailure) {
1464
1719
  if (!unaccepted || existsSync(path.join(run.cwd, step.reportPath)))
1465
1720
  return null;
1466
- return { kind: "spawn-missing", reason: outcomeText };
1721
+ // A quota rejection must not blacklist the whole provider and suppress its other channels.
1722
+ return { kind: !refusal || refusal.kind === "spawn" ? "spawn-missing" : "input-rejected", reason: outcomeText };
1467
1723
  }
1468
1724
  const info = this.infoOf(step);
1469
1725
  const candidates = memberAgents(member);
@@ -1575,7 +1831,7 @@ export class AiTeamRunner {
1575
1831
  * 独立 CLI 团队沿用旧刷新规则;带员工链接的运行全程保留新开工时的执行快照。
1576
1832
  */
1577
1833
  refreshTeam(run) {
1578
- if (run.team.members.some((member) => member.employeeId))
1834
+ if (run.conversationId || run.team.members.some((member) => member.employeeId))
1579
1835
  return;
1580
1836
  const latest = this.storage.getAiTeam(run.teamId);
1581
1837
  if (!latest || !latest.members.some((member) => member.isLeader))
@@ -1591,12 +1847,15 @@ export class AiTeamRunner {
1591
1847
  * 同一成员复用还活着的会话。比对基准是该成员**最近一步实际使用的候选**(§3.6),
1592
1848
  * 降级之后不会因为「和首选不一致」而每步都另开会话;候选换了五元组就不复用。
1593
1849
  */
1594
- liveSessionFor(run, member, excludeStepId, agent) {
1850
+ liveSessionFor(run, member, excludeStepId, agent, kind) {
1595
1851
  const steps = this.storage.listAiTeamSteps(run.id);
1596
1852
  for (let index = steps.length - 1; index >= 0; index -= 1) {
1597
1853
  const step = steps[index];
1598
1854
  if (step.id === excludeStepId || step.memberId !== member.id || !step.sessionId)
1599
1855
  continue;
1856
+ // One identity, two compatible stage contexts: a JSON planner is not the work executor.
1857
+ if (kind && step.kind !== kind)
1858
+ continue;
1600
1859
  const snapshot = this.ops.snapshot(step.sessionId);
1601
1860
  if (!snapshot || isSessionGone(snapshot))
1602
1861
  return null;
@@ -1674,7 +1933,10 @@ export class AiTeamRunner {
1674
1933
  if (!this.chat || !run.chatSessionId)
1675
1934
  return;
1676
1935
  try {
1677
- this.chat.post(run.chatSessionId, [turn]);
1936
+ this.chat.post(run.chatSessionId, [{ ...turn,
1937
+ ...(run.conversationId ? { messageId: randomUUID(), conversationId: run.conversationId,
1938
+ conversationTarget: { taskId: run.taskId, runId: run.id } } : {}),
1939
+ }]);
1678
1940
  }
1679
1941
  catch (error) {
1680
1942
  console.error(`[AiTeam] post to chat ${run.chatSessionId} failed:`, getErrorMessage(error));
@@ -1754,6 +2016,9 @@ export class AiTeamRunner {
1754
2016
  // 进终态就不可能再有 live 变化,清掉该 run 的推送指纹:不然长跑的服务会按 run 数线性攒字符串。
1755
2017
  if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(status))
1756
2018
  this.lastLiveKey.delete(run.id);
2019
+ // 不再干活(终态或等人)就撤掉心跳:留着计时器会继续推「仍在运行」,那才是误判。
2020
+ if (status !== "running")
2021
+ this.cancelHeartbeat(run.id);
1757
2022
  }
1758
2023
  saveRun(run) {
1759
2024
  run.updatedAt = this.iso();
@@ -1766,10 +2031,19 @@ export class AiTeamRunner {
1766
2031
  if (step.sessionId && requestId)
1767
2032
  this.ops.consumeUnacceptedStartup?.(step.sessionId, requestId);
1768
2033
  this.teamRequestIds.delete(step.id);
2034
+ // 步骤落地:逐步骤的活动观察随之失效(live 列表里已经没有这一步了),不留内存条目。
2035
+ this.stepActivity.delete(step.id);
1769
2036
  }
1770
2037
  this.storage.saveAiTeamStep(step);
1771
2038
  }
1772
2039
  notify(run) {
2040
+ // 记下 run 通道的活动基线并排上静默心跳:长任务只等一次状态写入是不够的,
2041
+ // 用户在十几分钟没有新消息时需要「仍在运行」的证据,见 beat()。
2042
+ this.lastRunNotifyAt.set(run.id, this.now());
2043
+ if (run.status === "running")
2044
+ this.scheduleHeartbeat(run.id);
2045
+ else
2046
+ this.cancelHeartbeat(run.id);
1773
2047
  try {
1774
2048
  this.notifyListener?.(run);
1775
2049
  }
@@ -1780,4 +2054,7 @@ export class AiTeamRunner {
1780
2054
  iso() {
1781
2055
  return new Date(this.now()).toISOString();
1782
2056
  }
2057
+ isoOf(ms) {
2058
+ return new Date(ms).toISOString();
2059
+ }
1783
2060
  }