baxian 2.0.2 → 2.0.4

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 (232) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +3 -3
  3. package/dist/agent/bootstrap.js +4 -4
  4. package/dist/agent/branch.d.ts.map +1 -1
  5. package/dist/agent/branch.js +0 -7
  6. package/dist/agent/branch.js.map +1 -1
  7. package/dist/agent/detect/region.d.ts.map +1 -1
  8. package/dist/agent/detect/region.js +0 -3
  9. package/dist/agent/detect/region.js.map +1 -1
  10. package/dist/agent/dispatch-reconciler.d.ts +2 -0
  11. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  12. package/dist/agent/dispatch-reconciler.js +48 -55
  13. package/dist/agent/dispatch-reconciler.js.map +1 -1
  14. package/dist/agent/lineage.d.ts.map +1 -1
  15. package/dist/agent/lineage.js +0 -9
  16. package/dist/agent/lineage.js.map +1 -1
  17. package/dist/agent/manager.d.ts +42 -8
  18. package/dist/agent/manager.d.ts.map +1 -1
  19. package/dist/agent/manager.js +717 -375
  20. package/dist/agent/manager.js.map +1 -1
  21. package/dist/agent/net-exec.d.ts.map +1 -1
  22. package/dist/agent/net-exec.js +0 -5
  23. package/dist/agent/net-exec.js.map +1 -1
  24. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  25. package/dist/agent/pane-streamer-manager.js +0 -36
  26. package/dist/agent/pane-streamer-manager.js.map +1 -1
  27. package/dist/agent/pane-streamer.d.ts +6 -0
  28. package/dist/agent/pane-streamer.d.ts.map +1 -1
  29. package/dist/agent/pane-streamer.js +126 -83
  30. package/dist/agent/pane-streamer.js.map +1 -1
  31. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  32. package/dist/agent/phase-signal-watcher.js +2 -77
  33. package/dist/agent/phase-signal-watcher.js.map +1 -1
  34. package/dist/agent/phase-signal.d.ts +0 -1
  35. package/dist/agent/phase-signal.d.ts.map +1 -1
  36. package/dist/agent/phase-signal.js +0 -17
  37. package/dist/agent/phase-signal.js.map +1 -1
  38. package/dist/agent/preflight.d.ts.map +1 -1
  39. package/dist/agent/preflight.js +0 -18
  40. package/dist/agent/preflight.js.map +1 -1
  41. package/dist/agent/prompt.d.ts.map +1 -1
  42. package/dist/agent/prompt.js +0 -1
  43. package/dist/agent/prompt.js.map +1 -1
  44. package/dist/agent/repo-store.d.ts.map +1 -1
  45. package/dist/agent/repo-store.js +0 -38
  46. package/dist/agent/repo-store.js.map +1 -1
  47. package/dist/agent/runner.d.ts.map +1 -1
  48. package/dist/agent/runner.js +0 -2
  49. package/dist/agent/runner.js.map +1 -1
  50. package/dist/agent/tmux-install.js +0 -1
  51. package/dist/agent/tmux-install.js.map +1 -1
  52. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  53. package/dist/agent/tmux-probe-poller.js +4 -11
  54. package/dist/agent/tmux-probe-poller.js.map +1 -1
  55. package/dist/agent/tmux.d.ts.map +1 -1
  56. package/dist/agent/tmux.js +0 -86
  57. package/dist/agent/tmux.js.map +1 -1
  58. package/dist/agent/vt-visible-text.d.ts +46 -1
  59. package/dist/agent/vt-visible-text.d.ts.map +1 -1
  60. package/dist/agent/vt-visible-text.js +639 -63
  61. package/dist/agent/vt-visible-text.js.map +1 -1
  62. package/dist/api/agents.d.ts.map +1 -1
  63. package/dist/api/agents.js +3 -49
  64. package/dist/api/agents.js.map +1 -1
  65. package/dist/api/config.d.ts.map +1 -1
  66. package/dist/api/config.js +10 -59
  67. package/dist/api/config.js.map +1 -1
  68. package/dist/api/hosts.d.ts.map +1 -1
  69. package/dist/api/hosts.js +8 -63
  70. package/dist/api/hosts.js.map +1 -1
  71. package/dist/api/platform-guard.d.ts.map +1 -1
  72. package/dist/api/platform-guard.js +2 -3
  73. package/dist/api/platform-guard.js.map +1 -1
  74. package/dist/api/probe.d.ts.map +1 -1
  75. package/dist/api/probe.js +0 -1
  76. package/dist/api/probe.js.map +1 -1
  77. package/dist/api/projects.d.ts.map +1 -1
  78. package/dist/api/projects.js +15 -28
  79. package/dist/api/projects.js.map +1 -1
  80. package/dist/api/tasks.d.ts.map +1 -1
  81. package/dist/api/tasks.js +94 -58
  82. package/dist/api/tasks.js.map +1 -1
  83. package/dist/app.d.ts +2 -2
  84. package/dist/app.d.ts.map +1 -1
  85. package/dist/app.js +0 -6
  86. package/dist/app.js.map +1 -1
  87. package/dist/cli.d.ts.map +1 -1
  88. package/dist/cli.js +6 -31
  89. package/dist/cli.js.map +1 -1
  90. package/dist/config/hot-reload.d.ts.map +1 -1
  91. package/dist/config/hot-reload.js +0 -4
  92. package/dist/config/hot-reload.js.map +1 -1
  93. package/dist/config/loader.d.ts.map +1 -1
  94. package/dist/config/loader.js +6 -29
  95. package/dist/config/loader.js.map +1 -1
  96. package/dist/config/normalizer.d.ts.map +1 -1
  97. package/dist/config/normalizer.js +0 -19
  98. package/dist/config/normalizer.js.map +1 -1
  99. package/dist/config/validator.d.ts.map +1 -1
  100. package/dist/config/validator.js +31 -229
  101. package/dist/config/validator.js.map +1 -1
  102. package/dist/event/handlers.d.ts.map +1 -1
  103. package/dist/event/handlers.js +3 -11
  104. package/dist/event/handlers.js.map +1 -1
  105. package/dist/event/log.js +0 -2
  106. package/dist/event/log.js.map +1 -1
  107. package/dist/event/ws.d.ts.map +1 -1
  108. package/dist/event/ws.js +3 -3
  109. package/dist/event/ws.js.map +1 -1
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +5 -46
  112. package/dist/index.js.map +1 -1
  113. package/dist/platform/body-digest.d.ts.map +1 -1
  114. package/dist/platform/body-digest.js +0 -4
  115. package/dist/platform/body-digest.js.map +1 -1
  116. package/dist/platform/command-renderer.d.ts.map +1 -1
  117. package/dist/platform/command-renderer.js +0 -3
  118. package/dist/platform/command-renderer.js.map +1 -1
  119. package/dist/platform/comment-cursor.d.ts.map +1 -1
  120. package/dist/platform/comment-cursor.js +0 -21
  121. package/dist/platform/comment-cursor.js.map +1 -1
  122. package/dist/platform/driver-spec.d.ts.map +1 -1
  123. package/dist/platform/driver-spec.js +1 -42
  124. package/dist/platform/driver-spec.js.map +1 -1
  125. package/dist/platform/feedback.d.ts +5 -1
  126. package/dist/platform/feedback.d.ts.map +1 -1
  127. package/dist/platform/feedback.js +7 -1
  128. package/dist/platform/feedback.js.map +1 -1
  129. package/dist/platform/field-mapper.d.ts.map +1 -1
  130. package/dist/platform/field-mapper.js +3 -11
  131. package/dist/platform/field-mapper.js.map +1 -1
  132. package/dist/platform/git-driver.d.ts.map +1 -1
  133. package/dist/platform/git-driver.js +0 -20
  134. package/dist/platform/git-driver.js.map +1 -1
  135. package/dist/platform/manifest.d.ts.map +1 -1
  136. package/dist/platform/manifest.js +0 -3
  137. package/dist/platform/manifest.js.map +1 -1
  138. package/dist/platform/markers.d.ts.map +1 -1
  139. package/dist/platform/markers.js +0 -19
  140. package/dist/platform/markers.js.map +1 -1
  141. package/dist/platform/platform-poller.d.ts +5 -1
  142. package/dist/platform/platform-poller.d.ts.map +1 -1
  143. package/dist/platform/platform-poller.js +8 -49
  144. package/dist/platform/platform-poller.js.map +1 -1
  145. package/dist/platform/plugin-registry.d.ts.map +1 -1
  146. package/dist/platform/plugin-registry.js +1 -12
  147. package/dist/platform/plugin-registry.js.map +1 -1
  148. package/dist/platform/pr-conversation-cache.d.ts +12 -7
  149. package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
  150. package/dist/platform/pr-conversation-cache.js +60 -40
  151. package/dist/platform/pr-conversation-cache.js.map +1 -1
  152. package/dist/platform/preflight-exec.d.ts.map +1 -1
  153. package/dist/platform/preflight-exec.js +0 -9
  154. package/dist/platform/preflight-exec.js.map +1 -1
  155. package/dist/platform/response-parser.d.ts.map +1 -1
  156. package/dist/platform/response-parser.js +0 -4
  157. package/dist/platform/response-parser.js.map +1 -1
  158. package/dist/platform/review-timeline.d.ts +17 -2
  159. package/dist/platform/review-timeline.d.ts.map +1 -1
  160. package/dist/platform/review-timeline.js +93 -106
  161. package/dist/platform/review-timeline.js.map +1 -1
  162. package/dist/platform/row-schema.d.ts.map +1 -1
  163. package/dist/platform/row-schema.js +0 -11
  164. package/dist/platform/row-schema.js.map +1 -1
  165. package/dist/platform/startup.d.ts.map +1 -1
  166. package/dist/platform/startup.js +1 -12
  167. package/dist/platform/startup.js.map +1 -1
  168. package/dist/platform/types.d.ts.map +1 -1
  169. package/dist/platform/types.js +0 -5
  170. package/dist/platform/types.js.map +1 -1
  171. package/dist/platform/verdict-engine.d.ts.map +1 -1
  172. package/dist/platform/verdict-engine.js +0 -15
  173. package/dist/platform/verdict-engine.js.map +1 -1
  174. package/dist/shared/concurrency.d.ts.map +1 -1
  175. package/dist/shared/concurrency.js +0 -2
  176. package/dist/shared/concurrency.js.map +1 -1
  177. package/dist/shared/constants.d.ts +0 -2
  178. package/dist/shared/constants.d.ts.map +1 -1
  179. package/dist/shared/constants.js +0 -3
  180. package/dist/shared/constants.js.map +1 -1
  181. package/dist/shared/git-url.d.ts.map +1 -1
  182. package/dist/shared/git-url.js +0 -9
  183. package/dist/shared/git-url.js.map +1 -1
  184. package/dist/shared/types.d.ts +31 -12
  185. package/dist/shared/types.d.ts.map +1 -1
  186. package/dist/shared/types.js +14 -0
  187. package/dist/shared/types.js.map +1 -1
  188. package/dist/skill/registry.d.ts.map +1 -1
  189. package/dist/skill/registry.js +0 -5
  190. package/dist/skill/registry.js.map +1 -1
  191. package/dist/skills/baxian-signals/SKILL.md +0 -6
  192. package/dist/state/agent-store.d.ts.map +1 -1
  193. package/dist/state/agent-store.js +0 -6
  194. package/dist/state/agent-store.js.map +1 -1
  195. package/dist/state/error-record-store.d.ts.map +1 -1
  196. package/dist/state/error-record-store.js +0 -5
  197. package/dist/state/error-record-store.js.map +1 -1
  198. package/dist/state/init.d.ts.map +1 -1
  199. package/dist/state/init.js +0 -1
  200. package/dist/state/init.js.map +1 -1
  201. package/dist/state/lock.d.ts.map +1 -1
  202. package/dist/state/lock.js +0 -1
  203. package/dist/state/lock.js.map +1 -1
  204. package/dist/state/snapshot.d.ts +0 -1
  205. package/dist/state/snapshot.d.ts.map +1 -1
  206. package/dist/state/snapshot.js +0 -13
  207. package/dist/state/snapshot.js.map +1 -1
  208. package/dist/state/task-store.d.ts.map +1 -1
  209. package/dist/state/task-store.js +96 -11
  210. package/dist/state/task-store.js.map +1 -1
  211. package/dist/terminal/attach.d.ts.map +1 -1
  212. package/dist/terminal/attach.js +0 -4
  213. package/dist/terminal/attach.js.map +1 -1
  214. package/dist/terminal/stream-ws.js +0 -5
  215. package/dist/terminal/stream-ws.js.map +1 -1
  216. package/dist/web/assets/index-CzVzThuT.js +15 -0
  217. package/dist/web/assets/{index-BS2adAfI.css → index-Rmp4wT_5.css} +1 -1
  218. package/dist/web/index.html +2 -2
  219. package/package.json +1 -1
  220. package/dist/agent/root-agent-runtime.d.ts +0 -80
  221. package/dist/agent/root-agent-runtime.d.ts.map +0 -1
  222. package/dist/agent/root-agent-runtime.js +0 -637
  223. package/dist/agent/root-agent-runtime.js.map +0 -1
  224. package/dist/agent/root-recovery-coordinator.d.ts +0 -124
  225. package/dist/agent/root-recovery-coordinator.d.ts.map +0 -1
  226. package/dist/agent/root-recovery-coordinator.js +0 -1557
  227. package/dist/agent/root-recovery-coordinator.js.map +0 -1
  228. package/dist/state/root-recovery-store.d.ts +0 -107
  229. package/dist/state/root-recovery-store.d.ts.map +0 -1
  230. package/dist/state/root-recovery-store.js +0 -400
  231. package/dist/state/root-recovery-store.js.map +0 -1
  232. package/dist/web/assets/index-BKiM0MSM.js +0 -15
@@ -1,15 +1,18 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { join, normalize } from 'node:path';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
6
7
  import { tmuxInstallHint } from './preflight.js';
7
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
8
+ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskAttentionGeneration, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
8
9
  import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
10
  import { DriverOpError, buildDriverRunContext, GitDriver, safeDriverErrorText } from '../platform/git-driver.js';
11
+ import { versionTimeOf } from '../platform/row-schema.js';
10
12
  import { checkOpenPrBinding, checkPrBinding } from '../platform/pr-binding.js';
11
13
  import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
- import { recheckPassProvenance } from '../platform/verdict-engine.js';
14
+ import { prReviewCacheRevision } from '../platform/pr-conversation-cache.js';
15
+ import { deadTokens, recheckPassProvenance } from '../platform/verdict-engine.js';
13
16
  import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../platform/startup.js';
14
17
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
15
18
  import { SkillRegistry } from '../skill/registry.js';
@@ -27,7 +30,7 @@ import { resolveProjectTool } from '../config/validator.js';
27
30
  import { SHA_HEX_SOURCE } from '../platform/types.js';
28
31
  import { bodyDigest } from '../platform/body-digest.js';
29
32
  import { validateCommentBody } from '../platform/command-renderer.js';
30
- // 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
33
+ import { buildReviewTokenLine, extractReviewTokens, rowTokens } from '../platform/markers.js';
31
34
  const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
32
35
  export function isBenignDispatchConflict(err) {
33
36
  return err instanceof ApiError
@@ -40,6 +43,16 @@ function requireTaskQaAgentId(task, operation) {
40
43
  throw new Error(`${operation}: task ${task.id} has no QA participant`);
41
44
  return task.qaAgentId;
42
45
  }
46
+ function samePassProvenance(left, right) {
47
+ return left !== undefined
48
+ && right !== undefined
49
+ && left.sourceKey === right.sourceKey
50
+ && left.id === right.id
51
+ && left.bodyDigest === right.bodyDigest
52
+ && left.token === right.token
53
+ && left.failToken === right.failToken
54
+ && left.anchorSha === right.anchorSha;
55
+ }
43
56
  export class EnsureSessionError extends Error {
44
57
  partial;
45
58
  constructor(partial, message) {
@@ -81,10 +94,6 @@ export function buildLaunchCommand(agent) {
81
94
  segments.push('--dangerously-bypass-approvals-and-sandbox');
82
95
  break;
83
96
  case 'opencode':
84
- // opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
85
- // Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
86
- // the .claude/skills scan — that closes the last collision source: a claude-code agent's
87
- // .claude/skills/baxian-* in a shared base repo.
88
97
  segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
89
98
  if (yolo)
90
99
  segments.push('--auto');
@@ -98,7 +107,6 @@ export function buildLaunchCommand(agent) {
98
107
  if (agent.model) {
99
108
  segments.push(`--model ${shellQuote(agent.model)}`);
100
109
  }
101
- // opencode has no --add-dir; extra roots go through its permission model instead (validator rejects the pairing).
102
110
  if (agent.addDirs && agent.addDirs.length > 0 && agent.runtime !== 'opencode') {
103
111
  for (const dir of agent.addDirs) {
104
112
  segments.push(`--add-dir ${shellQuote(dir)}`);
@@ -106,15 +114,12 @@ export function buildLaunchCommand(agent) {
106
114
  }
107
115
  return segments.join(' ');
108
116
  }
109
- // cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
110
117
  export function launchCommandIn(dir, agent) {
111
118
  return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
112
119
  }
113
120
  function agentRuntimeKindFor(agent) {
114
121
  return agent.runtime;
115
122
  }
116
- // restart-repl drives the REPL back to a shell before relaunch. Each runtime exits
117
- // via its own slash command (a bare "exit" would be sent to the model as chat text).
118
123
  const REPL_EXIT_COMMAND = {
119
124
  'claude-code': '/exit',
120
125
  codex: '/quit',
@@ -146,11 +151,6 @@ export function skillSubdirFor(runtime) {
146
151
  switch (runtime) {
147
152
  case 'codex':
148
153
  case 'opencode':
149
- // opencode has no way to disable its .agents/skills compatibility scan (only .claude via
150
- // env), and when it shares a base repo with a codex agent it loads the codex copy of a
151
- // same-named baxian-* skill (verified: the .agents copy wins over .opencode). So opencode
152
- // materializes into .agents/skills too — both runtimes share one runtime-agnostic copy
153
- // (serialized by the same skill-dir lock) instead of colliding on duplicate names.
154
154
  return '.agents/skills';
155
155
  case 'qodercli':
156
156
  return '.qoder/skills';
@@ -161,8 +161,6 @@ export function skillSubdirFor(runtime) {
161
161
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
162
162
  const DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS = 5_000;
163
163
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
164
- // DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
165
- // probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
166
164
  const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
167
165
  const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
168
166
  function createSignalTokenExcluding(...excluded) {
@@ -186,7 +184,6 @@ const RUNTIME_LIVENESS_SAMPLES = 3;
186
184
  export function canDispatchWithBinding(binding) {
187
185
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
188
186
  }
189
- // 派发期 hold:prompt 从未注入、pane 无在途回合,重派安全;ack_unknown 一类不在此列
190
187
  export const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
191
188
  'checkout-preparation-failed',
192
189
  'dirty-workdir',
@@ -220,8 +217,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
220
217
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
221
218
  return false;
222
219
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
223
- // 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
224
- // (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
225
220
  const staleCodeHandoff = state.awaitingPhase != null
226
221
  && CHECKOUT_HOLD_PHASES.has(state.awaitingPhase)
227
222
  && !!boundTask
@@ -245,6 +240,7 @@ export class AgentManager {
245
240
  paneStreamerManager;
246
241
  pluginRegistry;
247
242
  platformDefaultBranchOf;
243
+ prConversationCache;
248
244
  platformDriverExec;
249
245
  phaseSignalWatcher;
250
246
  errorRecordStore;
@@ -253,6 +249,7 @@ export class AgentManager {
253
249
  dispatchSettleTimeoutMs;
254
250
  dispatchAckResendIntervalMs = 3_000;
255
251
  taskMutationQueue = Promise.resolve();
252
+ taskMutationScope = new AsyncLocalStorage();
256
253
  agentOperationQueues = new Map();
257
254
  agentIndex;
258
255
  platformRunner;
@@ -273,10 +270,10 @@ export class AgentManager {
273
270
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
274
271
  markCompleteInFlight = new Set();
275
272
  specVerdictInFlight = new Set();
276
- // 引用计数:cancelTask startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
273
+ codeVerdictInFlight = new Set();
274
+ retryTaskInFlight = new Set();
277
275
  cancelCleanupInFlight = new Map();
278
276
  deletionInFlight = new Set();
279
- // Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
280
277
  divergedAgents = new Set();
281
278
  deletionGeneration = new Map();
282
279
  compactInFlight = new Set();
@@ -294,6 +291,7 @@ export class AgentManager {
294
291
  this.paneStreamerManager = deps.paneStreamerManager;
295
292
  this.pluginRegistry = deps.pluginRegistry;
296
293
  this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
294
+ this.prConversationCache = deps.prConversationCache;
297
295
  this.errorRecordStore = deps.errorRecordStore;
298
296
  this.phaseSignalWatcher = deps.phaseSignalWatcher
299
297
  ?? (deps.paneStreamerManager
@@ -320,10 +318,26 @@ export class AgentManager {
320
318
  };
321
319
  }
322
320
  withTaskLock(fn) {
323
- const next = this.taskMutationQueue.then(fn);
321
+ const inherited = this.taskMutationScope.getStore();
322
+ if (inherited?.active)
323
+ return fn();
324
+ const next = this.taskMutationQueue.then(() => {
325
+ const scope = { active: true };
326
+ return this.taskMutationScope.run(scope, async () => {
327
+ try {
328
+ return await fn();
329
+ }
330
+ finally {
331
+ scope.active = false;
332
+ }
333
+ });
334
+ });
324
335
  this.taskMutationQueue = next.catch(() => undefined);
325
336
  return next;
326
337
  }
338
+ outsideTaskMutationScope(fn) {
339
+ return this.taskMutationScope.exit(fn);
340
+ }
327
341
  withAgentOperationLease(agentId, fn) {
328
342
  const previous = this.agentOperationQueues.get(agentId) ?? Promise.resolve();
329
343
  const next = previous.then(fn);
@@ -386,7 +400,7 @@ export class AgentManager {
386
400
  return true;
387
401
  }
388
402
  catch (err) {
389
- console.warn(`[AgentManager] safeEmit ${event.type} failed (audit log loss; state machine unaffected):`, err);
403
+ console.warn(`[AgentManager] safeEmit ${event.type} failed; persistence or handler delivery may be incomplete:`, err);
390
404
  return false;
391
405
  }
392
406
  }
@@ -401,7 +415,6 @@ export class AgentManager {
401
415
  data,
402
416
  });
403
417
  }
404
- // 配置层 intervention(无 agent/task 上下文):如 repo-conflict——离线绕过锁的同 repo 冲突项目。
405
418
  async emitConfigIntervention(projectId, data) {
406
419
  const key = this.configInterventionKey(projectId, data);
407
420
  if (this.configInterventionKeys.has(key))
@@ -487,7 +500,6 @@ export class AgentManager {
487
500
  this.deletionInFlight.add(id);
488
501
  return null;
489
502
  }
490
- // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an open-task scan that a concurrent commit then orphans.
491
503
  async scanOpenThenClaimDeletion(targets) {
492
504
  return this.withTaskLock(async () => {
493
505
  for (const id of targets) {
@@ -519,7 +531,6 @@ export class AgentManager {
519
531
  for (const id of agentIds)
520
532
  this.deletionInFlight.delete(id);
521
533
  }
522
- // strict 读取:守卫扫描不能把读失败折叠成「无活跃任务」放行提交
523
534
  async listActiveParticipantSeats() {
524
535
  const tasks = await this.taskStore.listStrict();
525
536
  return tasks
@@ -541,21 +552,18 @@ export class AgentManager {
541
552
  return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
542
553
  });
543
554
  }
544
- // A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
545
555
  bumpDeletionGeneration(agentId) {
546
556
  this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
547
557
  }
548
558
  deletionGenerationOf(agentId) {
549
559
  return this.deletionGeneration.get(agentId) ?? 0;
550
560
  }
551
- // Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
552
561
  deletionGateOpen(agentId, genAtEntry) {
553
562
  return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
554
563
  }
555
564
  isDeletionInFlight(agentId) {
556
565
  return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
557
566
  }
558
- // replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
559
567
  retainTombstoneForDivergence(agentId) {
560
568
  this.divergedAgents.add(agentId);
561
569
  }
@@ -574,7 +582,6 @@ export class AgentManager {
574
582
  }
575
583
  const runner = this.createRunnerFor(agent);
576
584
  const tmux = new TmuxManager(runner);
577
- // Side-effects run inside the lifecycle section (serialized with DELETE cleanup) behind a re-checked generation gate; the entry gate is only a fast-fail.
578
585
  return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
579
586
  }
580
587
  async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
@@ -592,7 +599,6 @@ export class AgentManager {
592
599
  catch (err) {
593
600
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
594
601
  }
595
- // Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
596
602
  assertGenerationOpen();
597
603
  await this.agentStore.update(agentId, (existing) => {
598
604
  if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
@@ -615,7 +621,6 @@ export class AgentManager {
615
621
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
616
622
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
617
623
  }
618
- // Re-gate after the probe await: a DELETE may have tombstoned mid-probe, and build/adopt would else create a session cleanup can only reap later.
619
624
  assertGenerationOpen();
620
625
  if (!snapshot) {
621
626
  return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
@@ -651,11 +656,6 @@ export class AgentManager {
651
656
  const subdir = skillSubdirFor(agent.runtime);
652
657
  const destRoot = `${workdir}/${subdir}`;
653
658
  await this.excludeInjectedSkills(runner, workdir, subdir);
654
- // opencode's skills are model-invoked, not slash commands; the dispatched /<skill> sigil
655
- // reaches the model as message text and it loads the skill from .opencode/skills. We do NOT
656
- // wrap skills as .opencode/commands: opencode expands $ARGUMENTS in a command template and
657
- // would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
658
- // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
659
659
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
660
660
  await this.ensureSkillDirSafe(runner, workdir, subdir, [
661
661
  ...this.skillRegistry.names(),
@@ -664,7 +664,6 @@ export class AgentManager {
664
664
  await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
665
665
  });
666
666
  }
667
- // A rollback only kills if no adoption bumped the generation since its create.
668
667
  sessionLifecycleChain = new Map();
669
668
  adoptGeneration = new Map();
670
669
  runUnderSessionLifecycle(agentId, fn) {
@@ -678,7 +677,6 @@ export class AgentManager {
678
677
  });
679
678
  return cur;
680
679
  }
681
- // Inside the lifecycle lock, a generation bump since create means handoff — stand down.
682
680
  async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
683
681
  await this.runUnderSessionLifecycle(agentId, async () => {
684
682
  try {
@@ -715,7 +713,6 @@ export class AgentManager {
715
713
  return;
716
714
  }
717
715
  const runner = this.createRunnerFor(cfg);
718
- // Rollback may run before or after the claim write, so accept empty-or-ours.
719
716
  const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
720
717
  if (outcome === 'refused') {
721
718
  console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
@@ -766,7 +763,7 @@ export class AgentManager {
766
763
  const top = subdir.split('/')[0];
767
764
  const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
768
765
  const inner =
769
- // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
766
+ // Keep the canonical Workdir check and find/rm in one command to fail closed on a rebound ancestor.
770
767
  `${canonicalSelfGuard(workdir)} && ` +
771
768
  `cd ${shellQuote(workdir)} && ` +
772
769
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
@@ -795,9 +792,7 @@ export class AgentManager {
795
792
  return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
796
793
  }
797
794
  async excludeInjectedSkills(runner, workdir, subdir) {
798
- const inner =
799
- // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
800
- `${canonicalSelfGuard(workdir)} && ` +
795
+ const inner = `${canonicalSelfGuard(workdir)} && ` +
801
796
  `cd ${shellQuote(workdir)} && ` +
802
797
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
803
798
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
@@ -828,15 +823,12 @@ export class AgentManager {
828
823
  ['mouse', 'on'],
829
824
  ];
830
825
  }
831
- // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
832
- // every option write goes through the identity-checked by-ref batch.
833
826
  async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
834
827
  const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
835
828
  if (outcome === 'gone') {
836
829
  throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
837
830
  }
838
831
  }
839
- // Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
840
832
  async resolveClaimedPane(tmux, agentId, expectedPaneId) {
841
833
  const snapshot = await tmux.getSessionSnapshot(agentId);
842
834
  if (!snapshot)
@@ -861,7 +853,6 @@ export class AgentManager {
861
853
  try {
862
854
  createdRef = await tmux.createSession(agentId, sessionDir);
863
855
  createdSession = true;
864
- // First batch writes the claim itself, so it must prove the session is still unclaimed.
865
856
  await this.setSessionOptions(tmux, agentId, createdRef, [
866
857
  ['@baxian-agent-id', agentId],
867
858
  ['@baxian-runtime', agent.runtime],
@@ -955,7 +946,7 @@ export class AgentManager {
955
946
  }
956
947
  }
957
948
  await this.markDialogPending(agentId, creationToken);
958
- void this.slowPollDialogPending(agentId, creationToken).catch((pollErr) => {
949
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, creationToken)).catch((pollErr) => {
959
950
  console.warn(`[bootstrap] slowPoll for ${agentId} crashed:`, pollErr);
960
951
  });
961
952
  return;
@@ -1038,7 +1029,6 @@ export class AgentManager {
1038
1029
  const agent = this.getAgentConfig(agentId);
1039
1030
  if (!agent)
1040
1031
  return false;
1041
- // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
1042
1032
  const genAtEntry = this.deletionGenerationOf(agentId);
1043
1033
  const state = await this.agentStore.get(agentId);
1044
1034
  if (state?.awaitingPhase !== 'greeting_failed')
@@ -1170,7 +1160,6 @@ export class AgentManager {
1170
1160
  if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
1171
1161
  return false;
1172
1162
  }
1173
- // The error belongs to the caller's incarnation; gate every side-effect so a DELETE→recreate reusing %0 can't write the stale dialog/fail onto the new agent.
1174
1163
  const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
1175
1164
  let state = await this.agentStore.get(agentId);
1176
1165
  if (!state || !gateOpen())
@@ -1230,10 +1219,10 @@ export class AgentManager {
1230
1219
  err.partial.handled = true;
1231
1220
  const snapshotPaneId = state.paneId;
1232
1221
  const snapshotTaskId = state.taskId;
1233
- void this.slowPollDialogPending(agentId, state.creationToken, {
1222
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, state.creationToken, {
1234
1223
  ...(snapshotPaneId !== undefined ? { expectedPaneId: snapshotPaneId } : {}),
1235
1224
  expectedTaskId: snapshotTaskId,
1236
- }).catch((pollErr) => {
1225
+ })).catch((pollErr) => {
1237
1226
  console.warn(`[runtime] slowPoll for ${agentId} crashed:`, pollErr);
1238
1227
  });
1239
1228
  return true;
@@ -1301,6 +1290,7 @@ export class AgentManager {
1301
1290
  return;
1302
1291
  const now = new Date().toISOString();
1303
1292
  let projectIdForEmit = '';
1293
+ let taskIdForEmit;
1304
1294
  let wrote = false;
1305
1295
  const isBootstrapPath = creationToken !== undefined;
1306
1296
  if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
@@ -1316,6 +1306,7 @@ export class AgentManager {
1316
1306
  return AGENT_STORE_NOOP;
1317
1307
  }
1318
1308
  projectIdForEmit = fresh.projectId;
1309
+ taskIdForEmit = fresh.taskId;
1319
1310
  wrote = true;
1320
1311
  if (isBootstrapPath) {
1321
1312
  return {
@@ -1329,11 +1320,15 @@ export class AgentManager {
1329
1320
  updatedAt: now,
1330
1321
  };
1331
1322
  }
1323
+ const awaitingReason = fresh.taskId
1324
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${fresh.taskId}: cancel it if it is still active, ` +
1325
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1326
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.';
1332
1327
  return {
1333
1328
  ...fresh,
1334
1329
  paneId,
1335
1330
  awaitingPhase: 'agent_dialog_resolved_runtime',
1336
- awaitingReason: 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.',
1331
+ awaitingReason,
1337
1332
  updatedAt: now,
1338
1333
  };
1339
1334
  });
@@ -1350,9 +1345,13 @@ export class AgentManager {
1350
1345
  });
1351
1346
  }
1352
1347
  else {
1353
- await this.emitIntervention(projectIdForEmit, agentId, undefined, {
1348
+ const note = taskIdForEmit
1349
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${taskIdForEmit}: cancel it if it is still active, ` +
1350
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1351
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.';
1352
+ await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
1354
1353
  phase: 'agent_dialog_resolved_runtime',
1355
- note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
1354
+ note,
1356
1355
  });
1357
1356
  }
1358
1357
  return;
@@ -1363,7 +1362,7 @@ export class AgentManager {
1363
1362
  return;
1364
1363
  const controller = new AbortController();
1365
1364
  this.runtimeMenuWatchers.set(agentId, controller);
1366
- void this.runtimeMenuWatchLoop(agentId, controller.signal)
1365
+ void this.outsideTaskMutationScope(() => this.runtimeMenuWatchLoop(agentId, controller.signal))
1367
1366
  .catch((err) => {
1368
1367
  console.warn(`[runtimeMenuWatch] ${agentId} loop crashed:`, err);
1369
1368
  })
@@ -1424,6 +1423,10 @@ export class AgentManager {
1424
1423
  }
1425
1424
  else if (!onMenu && pendingMenu) {
1426
1425
  pendingMenu = false;
1426
+ await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
1427
+ phase: 'agent_runtime_menu_resolved',
1428
+ previousPhase: 'agent_runtime_menu_pending',
1429
+ });
1427
1430
  }
1428
1431
  }
1429
1432
  }
@@ -1440,7 +1443,6 @@ export class AgentManager {
1440
1443
  signal.addEventListener('abort', onAbort, { once: true });
1441
1444
  });
1442
1445
  }
1443
- // Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
1444
1446
  async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
1445
1447
  const sessionRef = snapshot.ref;
1446
1448
  this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
@@ -1582,8 +1584,6 @@ export class AgentManager {
1582
1584
  throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
1583
1585
  }
1584
1586
  }
1585
- // Frees a bootstrap blocked on its predecessor's half-created session (nonce
1586
- // env present but claim never written); anything else is left alone.
1587
1587
  async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
1588
1588
  let snapshot;
1589
1589
  try {
@@ -1607,7 +1607,6 @@ export class AgentManager {
1607
1607
  return false;
1608
1608
  let outcome;
1609
1609
  try {
1610
- // claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
1611
1610
  outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
1612
1611
  }
1613
1612
  catch (err) {
@@ -1642,14 +1641,10 @@ export class AgentManager {
1642
1641
  console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1643
1642
  }
1644
1643
  }
1645
- // Create-on-null only under this predicate (evaluated inside the store's serialized commit): closes the delete→recreate ABA yet keeps lazy first-allocation for config-only agents.
1646
1644
  bindingInitPredicate(agentId, genAtEntry) {
1647
1645
  return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
1648
1646
  }
1649
- async acquireAgentForTask(agentId, taskId, phase,
1650
- // 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
1651
- // release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
1652
- opts = {}) {
1647
+ async acquireAgentForTask(agentId, taskId, phase, opts = {}) {
1653
1648
  const genAtEntry = this.deletionGenerationOf(agentId);
1654
1649
  if (this.isDeletionInFlight(agentId))
1655
1650
  return false;
@@ -1769,15 +1764,12 @@ export class AgentManager {
1769
1764
  return false;
1770
1765
  }
1771
1766
  }
1772
- // expectedHold 是 hold 世代快照的严格 CAS(全 undefined 表示“期望无 hold”):
1773
- // hold 消失(被并发 Resume 清掉)或换代同样意味着接管,不得继续释放
1774
1767
  const matchesExpectedHold = (binding) => {
1775
1768
  const held = binding.status === 'awaiting_human';
1776
1769
  return (held ? binding.awaitingPhase : undefined) === opts.expectedHold.phase
1777
1770
  && (held ? binding.awaitingSince : undefined) === opts.expectedHold.since
1778
1771
  && (held ? binding.awaitingNonce : undefined) === opts.expectedHold.nonce;
1779
1772
  };
1780
- // 世代门前置:hold 换代时任何 pane/workdir 副作用都不得发生(末尾 CAS 只兜最后窗口)
1781
1773
  if (opts.expectedHold && !matchesExpectedHold(state)) {
1782
1774
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed or cleared before release; refusing`);
1783
1775
  return false;
@@ -1801,7 +1793,6 @@ export class AgentManager {
1801
1793
  if (!latest)
1802
1794
  return AGENT_STORE_NOOP;
1803
1795
  if (opts.clearAwaitingHuman && latest.status === 'awaiting_human') {
1804
- // The hold-generation CAS lives inside the atomic update: a hold written after any pre-read survives.
1805
1796
  if (opts.expectedHold
1806
1797
  && (latest.awaitingPhase !== opts.expectedHold.phase
1807
1798
  || latest.awaitingSince !== opts.expectedHold.since
@@ -1819,8 +1810,6 @@ export class AgentManager {
1819
1810
  ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1820
1811
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1821
1812
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1822
- // Release voids the open question: strip the watermark, bump the epoch so
1823
- // any in-flight/queued commit of the old generation fences server-side.
1824
1813
  needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
1825
1814
  };
1826
1815
  return cleared;
@@ -1852,7 +1841,6 @@ export class AgentManager {
1852
1841
  || !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
1853
1842
  return false;
1854
1843
  }
1855
- // 释放中被写入的新 hold(如 ack_unknown)比 cleanup 失败信息更关键,不得覆盖
1856
1844
  if (!holdMatchesExpected(latest)) {
1857
1845
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; not overwriting with branch-cleanup-pending`);
1858
1846
  return false;
@@ -1918,7 +1906,6 @@ export class AgentManager {
1918
1906
  sessionConfirmedAbsent = true;
1919
1907
  }
1920
1908
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1921
- // 仍重验持久化世代,防护当前进程租约之外的状态写入。
1922
1909
  if (opts.expectedHold) {
1923
1910
  const beforeBranchOps = await this.agentStore.get(agentId);
1924
1911
  if (!beforeBranchOps || !matchesExpectedHold(beforeBranchOps)) {
@@ -1990,9 +1977,6 @@ export class AgentManager {
1990
1977
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1991
1978
  }
1992
1979
  catch (err) {
1993
- // REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
1994
- // branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
1995
- // 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
1996
1980
  if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
1997
1981
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
1998
1982
  `(the pass can be re-queued): ${err.message}`);
@@ -2019,7 +2003,6 @@ export class AgentManager {
2019
2003
  || (state.workdir !== undefined && existing.workdir !== state.workdir)) {
2020
2004
  return AGENT_STORE_NOOP;
2021
2005
  }
2022
- // 世代 CAS 在原子写入内判定:预读后被重写或清除的 hold 都不得被这次释放清掉
2023
2006
  if (opts.expectedHold && !matchesExpectedHold(existing)) {
2024
2007
  holdGenerationChanged = true;
2025
2008
  return AGENT_STORE_NOOP;
@@ -2125,7 +2108,6 @@ export class AgentManager {
2125
2108
  if (candidateTasks.length === 0)
2126
2109
  continue;
2127
2110
  const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
2128
- // Pin the incarnation before the first state/workdir/refs snapshot so a DELETE→same-id recreate (possibly a different workdir) in the read→gate window is rejected.
2129
2111
  const genAtEntry = this.deletionGenerationOf(cfg.id);
2130
2112
  const state = await this.agentStore.get(cfg.id);
2131
2113
  if (!state || !canDispatchWithBinding(state))
@@ -2224,20 +2206,11 @@ export class AgentManager {
2224
2206
  }
2225
2207
  }
2226
2208
  }
2227
- // ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
2228
2209
  needInputRetry = new Map();
2229
2210
  needInputRetryInFlight = new Set();
2230
2211
  gitReviewRecoveryAlerted = new Set();
2231
- // Watermark writes currently in the store chain. A restore bump snapshots these along
2232
- // with the queue: an answer whose write has not settled yet would otherwise be invisible
2233
- // to the migration and its error-enqueue would land after the arm already cleared the
2234
- // queue — deleting the only proof the user answered.
2235
2212
  needInputWritesInFlight = new Map();
2236
2213
  needInputWriteSeq = 0;
2237
- // Serializes the probe→bump→queue-clear section of concurrent arms for one
2238
- // (agentId, taskId): without it a stale replay probed before a successor's bump can
2239
- // itself bump afterwards, ghost-fencing the installed successor. All locked steps are
2240
- // bounded (memory probe + one store update); subscribe stays outside the lock.
2241
2214
  needInputArmLocks = new Map();
2242
2215
  needInputRetryTimer = null;
2243
2216
  needInputRetryKey(agentId, taskId, epoch) {
@@ -2252,12 +2225,6 @@ export class AgentManager {
2252
2225
  return null;
2253
2226
  return { agentId, taskId, epoch };
2254
2227
  }
2255
- // Standard commit entry: one agentStore.update, NEVER called from inside another
2256
- // updater (the store's per-id chain is non-reentrant).
2257
- // The ledger entry lives until the result is fully handed over — an error is IN the
2258
- // retry queue before deregistration, in the same synchronous continuation. Splitting
2259
- // those two across promise jobs would open a window where neither the ledger nor the
2260
- // queue holds the intent and a concurrent restore snapshot loses the answer.
2261
2228
  async commitNeedInputWatermark(intent) {
2262
2229
  const writeId = ++this.needInputWriteSeq;
2263
2230
  this.needInputWritesInFlight.set(writeId, intent);
@@ -2325,8 +2292,6 @@ export class AgentManager {
2325
2292
  });
2326
2293
  this.ensureNeedInputRetryTimer();
2327
2294
  }
2328
- // ok deletes only when the queued item is still covered by what was written
2329
- // (a concurrent edge may have raised it mid-flight); fenced is terminal either way.
2330
2295
  settleNeedInputRetry(key, wroteAskSeq, wroteAnsweredSeq, result) {
2331
2296
  const cur = this.needInputRetry.get(key);
2332
2297
  if (!cur)
@@ -2356,7 +2321,7 @@ export class AgentManager {
2356
2321
  if (this.needInputRetryTimer)
2357
2322
  return;
2358
2323
  this.needInputRetryTimer = setInterval(() => {
2359
- void this.needInputRetryPass();
2324
+ void this.outsideTaskMutationScope(() => this.needInputRetryPass());
2360
2325
  }, this.needInputRetryIntervalMs);
2361
2326
  this.needInputRetryTimer.unref?.();
2362
2327
  }
@@ -2411,9 +2376,6 @@ export class AgentManager {
2411
2376
  }
2412
2377
  return owed;
2413
2378
  }
2414
- // Only the generation this arm directly succeeds (bumps are +1 and serialized per key).
2415
- // Anything older was already superseded — a fresh arm in between stripped its question
2416
- // on purpose, and transplanting it would relight a prompt nobody asked.
2417
2379
  collectPredecessorNeedInputDelta(agentId, taskId, epoch) {
2418
2380
  let delta = null;
2419
2381
  const merge = (wm) => {
@@ -2449,14 +2411,7 @@ export class AgentManager {
2449
2411
  });
2450
2412
  return run;
2451
2413
  }
2452
- // 'restore' must also merge queued retry seqs of the CURRENT persisted epoch: after
2453
- // session-gone the queue can hold the only proof an answer happened (the entry is
2454
- // gone, so needInputInherit has nothing to merge), and the arm clears the queue.
2455
2414
  async bumpNeedInputEpochForArm(agentId, taskId, mode) {
2456
- // Contributions are collected INSIDE the updater: it runs when the store chain
2457
- // reaches this bump, so an answer whose commit started while the bump was queued
2458
- // (registered in the ledger, or already error-queued) is still visible. A snapshot
2459
- // taken at call time would miss exactly that window.
2460
2415
  const collectContributions = () => {
2461
2416
  const queued = new Map();
2462
2417
  if (mode !== 'restore')
@@ -2507,16 +2462,10 @@ export class AgentManager {
2507
2462
  }
2508
2463
  catch (err) {
2509
2464
  console.warn(`[AgentManager] needInput epoch bump failed (agent=${agentId} task=${taskId}):`, err);
2510
- // Fall back to the persisted watermark for a restore: without it the entry would
2511
- // start at 0/0 and swallow the answer to a question that is still open in the
2512
- // store, leaving the badge lit forever. No bump means no generation advance —
2513
- // the caller must not treat this as a fresh generation.
2514
2465
  if (mode === 'restore') {
2515
2466
  try {
2516
2467
  const cur = await this.agentStore.get(agentId);
2517
2468
  if (cur?.taskId === taskId && cur.needInput) {
2518
- // Same-generation intents still owed to the store hold questions the
2519
- // persisted watermark has not caught up with; merge them in.
2520
2469
  const owed = this.collectSameEpochNeedInput(agentId, taskId, cur.needInput.epoch);
2521
2470
  return {
2522
2471
  wm: {
@@ -2536,16 +2485,12 @@ export class AgentManager {
2536
2485
  }
2537
2486
  return { wm, bumped: wm !== null };
2538
2487
  }
2539
- // Single entry for arming phase-signal watches — every path must flow through the
2540
- // epoch bump, or stale-generation commits could survive the re-arm.
2541
2488
  async startPhaseSignalWatch(args) {
2542
2489
  if (!this.phaseSignalWatcher)
2543
2490
  return true;
2544
2491
  const { needInputMode, ...rest } = args;
2545
2492
  const mode = needInputMode ?? 'restore';
2546
2493
  const watcher = this.phaseSignalWatcher;
2547
- // Claim ownership before bumping: a rejected arm must not bump (it would fence the
2548
- // owner), and the claim keeps this arm visible to later probes while it subscribes.
2549
2494
  const gateResult = await this.withNeedInputArmLock(args.agentId, args.taskId, async () => {
2550
2495
  const claim = watcher.claimArm?.({
2551
2496
  taskId: args.taskId,
@@ -2558,8 +2503,6 @@ export class AgentManager {
2558
2503
  if (claim === null)
2559
2504
  return 'rejected';
2560
2505
  const bump = await this.bumpNeedInputEpochForArm(args.agentId, args.taskId, mode);
2561
- // Only a real generation advance may drop the queue: same-generation intents
2562
- // (bump failed, watermark read back) are still owed to the store.
2563
2506
  if (bump.bumped)
2564
2507
  this.clearNeedInputRetryFor(args.agentId, args.taskId);
2565
2508
  return { ...bump, claim };
@@ -2567,28 +2510,22 @@ export class AgentManager {
2567
2510
  if (gateResult === 'rejected')
2568
2511
  return false;
2569
2512
  const { wm, bumped, claim } = gateResult;
2570
- // Same synchronous section as the bump result: old-generation answers must not slip
2571
- // between the bump and the handoff (start() migrates the predecessor synchronously).
2572
2513
  if (wm !== null && bumped && mode === 'restore') {
2573
2514
  const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
2574
2515
  if (delta) {
2575
- void this.commitNeedInputWatermark({
2516
+ void this.outsideTaskMutationScope(() => this.commitNeedInputWatermark({
2576
2517
  agentId: args.agentId,
2577
2518
  taskId: args.taskId,
2578
2519
  epoch: wm.epoch,
2579
2520
  askSeq: Math.max(delta.askSeq, wm.askSeq),
2580
2521
  answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
2581
- });
2522
+ }));
2582
2523
  }
2583
2524
  }
2584
- // wm === null (no binding / store error): arm anyway with badge commits disabled —
2585
- // the badge is display-only, the signal watch drives task progress.
2586
2525
  try {
2587
2526
  return await watcher.start({
2588
2527
  ...rest,
2589
2528
  ...(claim != null ? { armClaimId: claim } : {}),
2590
- // Inherit is about restore semantics, not about having a generation: a degraded
2591
- // restore still has to keep the predecessor's pending question in memory.
2592
2529
  needInputInherit: mode === 'restore',
2593
2530
  ...(wm !== null ? { needInput: wm } : {}),
2594
2531
  });
@@ -2597,10 +2534,6 @@ export class AgentManager {
2597
2534
  watcher.releaseArm?.(claim);
2598
2535
  }
2599
2536
  }
2600
- // Web-fallback confirm: settles the newest known question (store ∪ retry queue) as
2601
- // answered in ONE store update — usable when no watch entry exists (pre-recovery,
2602
- // arm failure, post-stop). Queue contributions are snapshotted synchronously; the
2603
- // updater derives everything else from `existing`, so no nested store calls.
2604
2537
  async confirmNeedInputAnswered(agentId) {
2605
2538
  const queueAsk = new Map();
2606
2539
  for (const [key, wm] of this.needInputRetry) {
@@ -2654,15 +2587,7 @@ export class AgentManager {
2654
2587
  this.settleNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq, 'ok');
2655
2588
  }
2656
2589
  }
2657
- // The user typed into the agent's terminal — its open question counts as answered.
2658
- // The store fallback runs only when no watch entry exists (pre-recovery, arm failure,
2659
- // post-stop): with a live entry it would race the entry's in-flight writes and could
2660
- // confirm an ask that arrived after this input.
2661
2590
  async notifyHumanTerminalInput(agentId) {
2662
- // Rearm first: it settles entry state synchronously, so a question printed during
2663
- // this call cannot be confirmed by mistake. The binding read only decides whether a
2664
- // watcher of the CURRENT task took the input — a leftover entry of a finished task
2665
- // must not suppress the store fallback.
2666
2591
  const handled = await this.phaseSignalWatcher?.rearmNeedInput(agentId);
2667
2592
  let taskId;
2668
2593
  try {
@@ -2813,11 +2738,8 @@ export class AgentManager {
2813
2738
  }
2814
2739
  if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
2815
2740
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
2816
- // verdict 门禁状态没有在途派发:清掉 hold 后由 spec verdict 重新走完整转码,Resume 放行
2817
2741
  && boundTask.status !== 'spec-ready' && boundTask.status !== 'max_rounds') {
2818
2742
  if (state.taskId && boundTask.qaAgentId === agentId) {
2819
- // 停驻的 review pass 已死(任务离开 review):重派会伪造新 pass,只清 hold 并释放绑定;
2820
- // 快照 hold/task 世代供释放路径 CAS,且不得借用 cancel-cleanup 的绕行凭据
2821
2743
  if (boundTask.status !== 'review') {
2822
2744
  return {
2823
2745
  resumed: true,
@@ -2992,8 +2914,6 @@ export class AgentManager {
2992
2914
  await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
2993
2915
  return { resumed: false, releasedBinding: false, reason };
2994
2916
  }
2995
- // 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
2996
- // 会让下一次 recover 把已送达的 code prompt 再判成未送达
2997
2917
  const lockToken = (await this.agentStore.get(agentId))?.lockToken;
2998
2918
  await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
2999
2919
  }
@@ -3055,7 +2975,6 @@ export class AgentManager {
3055
2975
  console.error(`[AgentManager] resumeAgent review redispatch failed for ${agentId}:`, err);
3056
2976
  const reason = `QA review redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
3057
2977
  const reviewTaskId = result.redispatchReviewTaskId;
3058
- // 锁代未变(未被 successor re-acquire)才允许补挂;markAwaitingHuman 内的 CAS 原子复核同一条件
3059
2978
  const restoreHoldIfStillOurs = async (phase, holdReason) => {
3060
2979
  const after = await this.agentStore.get(agentId);
3061
2980
  if (after?.taskId === reviewTaskId
@@ -3067,7 +2986,6 @@ export class AgentManager {
3067
2986
  });
3068
2987
  }
3069
2988
  };
3070
- // handled 只代表 startSession 已尝试落 hold;落库可能已失败,必须核实并按需补挂
3071
2989
  if (err instanceof EnsureSessionError && err.partial.handled) {
3072
2990
  try {
3073
2991
  await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
@@ -3083,7 +3001,6 @@ export class AgentManager {
3083
3001
  };
3084
3002
  }
3085
3003
  }
3086
- // ack_unknown:prompt 可能已在跑,必须恢复 ack hold 的可见性,而非按普通失败补挂 checkout hold
3087
3004
  if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
3088
3005
  try {
3089
3006
  await restoreHoldIfStillOurs('dispatch-failed:ack_unknown', `${err.message}. Prompt may still be running in the pane; verify before resuming.`);
@@ -3099,7 +3016,6 @@ export class AgentManager {
3099
3016
  };
3100
3017
  }
3101
3018
  }
3102
- // pass 被接管(token 轮换或任务离开 review)说明该 pass 已死,补挂会把新 pass 的 QA 打成 hold
3103
3019
  let passSuperseded = false;
3104
3020
  let recoveryReadFailed = false;
3105
3021
  try {
@@ -3114,7 +3030,6 @@ export class AgentManager {
3114
3030
  return { resumed: false, releasedBinding: false, reason };
3115
3031
  try {
3116
3032
  if (recoveryReadFailed) {
3117
- // 读失败时无法证明接管,优先恢复可见性:锁代/绑定 CAS 全部由 markAwaitingHuman 原子判定
3118
3033
  await this.markAwaitingHuman(agentId, result.previousPhase ?? 'checkout-preparation-failed', reason, {
3119
3034
  expectedTaskId: reviewTaskId,
3120
3035
  ...(recoveryLockToken !== undefined ? { expectedLockToken: recoveryLockToken } : {}),
@@ -3391,7 +3306,6 @@ export class AgentManager {
3391
3306
  const out = [];
3392
3307
  for (const t of tasks) {
3393
3308
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
3394
- // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
3395
3309
  if (t.status === 'merge-ready')
3396
3310
  continue;
3397
3311
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -3456,7 +3370,6 @@ export class AgentManager {
3456
3370
  return last;
3457
3371
  }
3458
3372
  async restartReplOnly(agentId, opts = {}) {
3459
- // Re-verify the entry generation so a request that slept across a DELETE→recreate can't restart the NEW incarnation's REPL (claim check alone can't tell).
3460
3373
  const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
3461
3374
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
3462
3375
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
@@ -3491,7 +3404,6 @@ export class AgentManager {
3491
3404
  const project = this.getProjectConfig(cfg.projectId);
3492
3405
  if (!project)
3493
3406
  throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
3494
- // Re-verify before Workdir/skill/session-option side-effects: pane writes fail-closed on a stale PaneRef, but ensureWorkdir/skills would write old-cfg files under a recreated id.
3495
3407
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
3496
3408
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3497
3409
  }
@@ -3520,7 +3432,6 @@ export class AgentManager {
3520
3432
  await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
3521
3433
  };
3522
3434
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
3523
- // Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
3524
3435
  await this.agentStore.update(agentId, (state) => {
3525
3436
  if (!state || !this.deletionGateOpen(agentId, genAtEntry))
3526
3437
  return AGENT_STORE_NOOP;
@@ -3531,7 +3442,6 @@ export class AgentManager {
3531
3442
  };
3532
3443
  });
3533
3444
  }
3534
- // Holds only the pass captured in the task tuple: a successor pass (rotated token/phase/status) is never overwritten.
3535
3445
  async holdReplayFailureIfCurrent(agentId, taskAtEntry, holdPhase, reason, entryHold, extraCurrent) {
3536
3446
  return this.withTaskLock(async () => {
3537
3447
  const fresh = await this.taskStore.get(taskAtEntry.id);
@@ -3547,7 +3457,6 @@ export class AgentManager {
3547
3457
  expectedHold: entryHold,
3548
3458
  }))
3549
3459
  return true;
3550
- // Entry hold gone (Resume raced): write over the empty generation, never over a foreign hold.
3551
3460
  if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
3552
3461
  expectedTaskId: taskAtEntry.id,
3553
3462
  expectedHold: { phase: undefined, since: undefined, nonce: undefined },
@@ -3678,14 +3587,11 @@ export class AgentManager {
3678
3587
  retireOldOnce();
3679
3588
  }
3680
3589
  }
3681
- async redispatchTaskPromptAfterReplRestart(agentId, taskId, expected) {
3590
+ async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
3682
3591
  const agent = this.getAgentConfig(agentId);
3683
3592
  const loadedTask = await this.taskStore.get(taskId);
3684
3593
  if (!agent || !loadedTask || !TASK_OWNER_ROLES.has(agent.role))
3685
3594
  return false;
3686
- if (expected && !taskMatchesGeneration(loadedTask, expected))
3687
- return false;
3688
- const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
3689
3595
  const task = loadedTask;
3690
3596
  if (task.agentId !== agentId)
3691
3597
  return false;
@@ -3702,8 +3608,7 @@ export class AgentManager {
3702
3608
  return fresh?.agentId === agentId
3703
3609
  && fresh.status === task.status
3704
3610
  && fresh.phase === task.phase
3705
- && fresh.signalToken === signalToken
3706
- && rootGenerationMatches(fresh, signalToken);
3611
+ && fresh.signalToken === signalToken;
3707
3612
  };
3708
3613
  const clearEntryHold = async () => this.withTaskLock(async () => {
3709
3614
  if (!(await taskStillCurrent()))
@@ -3714,10 +3619,10 @@ export class AgentManager {
3714
3619
  }
3715
3620
  return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
3716
3621
  });
3717
- const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold, async (fresh) => rootGenerationMatches(fresh, task.signalToken));
3622
+ const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
3718
3623
  try {
3719
3624
  if (task.status === 'approved' && agent.role === 'dev') {
3720
- return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
3625
+ return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
3721
3626
  }
3722
3627
  const mapped = this.mapTaskStateToExpectedWatcher(task);
3723
3628
  if (!mapped || mapped.agentId !== agentId)
@@ -3736,8 +3641,7 @@ export class AgentManager {
3736
3641
  if (fresh?.agentId !== agentId
3737
3642
  || fresh.status !== task.status
3738
3643
  || fresh.phase !== task.phase
3739
- || fresh.signalToken !== signalToken
3740
- || !rootGenerationMatches(fresh, signalToken))
3644
+ || fresh.signalToken !== signalToken)
3741
3645
  return false;
3742
3646
  const rotatePendingPr = mapped.expectedKinds.includes('pr-created')
3743
3647
  && fresh.pendingPrSignalToken === signalToken;
@@ -3750,7 +3654,7 @@ export class AgentManager {
3750
3654
  return true;
3751
3655
  },
3752
3656
  stillCurrent: taskStillCurrent,
3753
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
3657
+ holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
3754
3658
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
3755
3659
  ...opts,
3756
3660
  signalToken: newToken,
@@ -3773,7 +3677,6 @@ export class AgentManager {
3773
3677
  });
3774
3678
  };
3775
3679
  if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
3776
- // An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
3777
3680
  const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
3778
3681
  return await replayInitialBootstrap('develop', {
3779
3682
  ...(delivered ? { allowDirtyWorkdir: true } : {}),
@@ -3796,7 +3699,6 @@ export class AgentManager {
3796
3699
  ...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
3797
3700
  });
3798
3701
  }
3799
- // A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
3800
3702
  if (task.status === 'fixing'
3801
3703
  && (task.phase === 'code' || task.phase === undefined)
3802
3704
  && agent.role === 'dev') {
@@ -3809,7 +3711,6 @@ export class AgentManager {
3809
3711
  return false;
3810
3712
  }
3811
3713
  catch (err) {
3812
- // A post-entry throw may land after clearEntryHold; only a tuple-checked hold on the live generation is safe.
3813
3714
  const message = err instanceof Error ? err.message : String(err);
3814
3715
  if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
3815
3716
  return true;
@@ -3817,49 +3718,9 @@ export class AgentManager {
3817
3718
  throw err;
3818
3719
  }
3819
3720
  }
3820
- async redispatchCurrentTaskPhase(taskId, expected) {
3821
- const current = await this.taskStore.get(taskId);
3822
- if (!current || !taskMatchesGeneration(current, expected))
3823
- return 'stale';
3824
- if (current.status === 'review') {
3825
- let changedByRedispatch = false;
3826
- try {
3827
- await this.dispatchReviewToQa(taskId, {
3828
- fromStatus: ['review'],
3829
- bumpRound: false,
3830
- expectSignalToken: current.signalToken,
3831
- expectPhase: current.phase,
3832
- expectedTask: expected,
3833
- onSideEffect: () => { changedByRedispatch = true; },
3834
- });
3835
- return 'dispatched';
3836
- }
3837
- catch (err) {
3838
- if (!changedByRedispatch && isBenignDispatchConflict(err))
3839
- return 'stale';
3840
- if (!changedByRedispatch
3841
- && err instanceof ApiError
3842
- && err.code === 'dispatch-unsupported') {
3843
- return 'unsupported';
3844
- }
3845
- if (changedByRedispatch)
3846
- throw err;
3847
- const fresh = await this.taskStore.get(taskId);
3848
- if (!fresh || !taskMatchesGeneration(fresh, expected))
3849
- return 'stale';
3850
- throw err;
3851
- }
3852
- }
3853
- const redispatched = await this.redispatchTaskPromptAfterReplRestart(current.agentId, taskId, expected);
3854
- if (redispatched)
3855
- return 'dispatched';
3856
- const fresh = await this.taskStore.get(taskId);
3857
- return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
3858
- }
3859
- // approved hosts the post-approve holder pass.
3860
3721
  async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
3861
3722
  const taskId = task.id;
3862
- const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
3723
+ const { clearEntryHold, holdReplayFailure } = holdGen;
3863
3724
  if (task.postApproveRevoked) {
3864
3725
  return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
3865
3726
  'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
@@ -3871,7 +3732,6 @@ export class AgentManager {
3871
3732
  return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but the stored completion cannot be tied to a persisted approved head; re-request the review verdict or cancel the task.');
3872
3733
  }
3873
3734
  if (stored.approvedHeadSha !== task.postApproveHeadSha) {
3874
- // Provably a dead episode's residue: retire it by token, then rebuild for the current head.
3875
3735
  await this.clearPostApproveCompletionIfMatches(taskId, { generation: stored.generation, token: stored.token, headSha: stored.approvedHeadSha });
3876
3736
  completion = await this.holdIncompleteGitPostApproveEpisode(holdReplayFailure);
3877
3737
  }
@@ -3891,7 +3751,6 @@ export class AgentManager {
3891
3751
  && fresh.status === task.status
3892
3752
  && fresh.phase === task.phase
3893
3753
  && fresh.signalToken === task.signalToken
3894
- && rootGenerationMatches(fresh, task.signalToken)
3895
3754
  && fresh.postApproveRevoked === undefined
3896
3755
  && fresh.postApproveHeadSha === task.postApproveHeadSha
3897
3756
  && fresh.postApproveGeneration === task.postApproveGeneration
@@ -3952,8 +3811,8 @@ export class AgentManager {
3952
3811
  const project = this.getProjectConfig(cfg.projectId);
3953
3812
  if (!project)
3954
3813
  throw new Error(`Unknown project: ${cfg.projectId}`);
3955
- const group = this.findAgentGroup(agentId);
3956
- return { targets: group?.map(agent => agent.id) ?? [agentId] };
3814
+ const team = this.findAgentTeam(agentId);
3815
+ return { targets: team?.map(agent => agent.id) ?? [agentId] };
3957
3816
  }
3958
3817
  async cleanupRemovedAgentRuntime(targets) {
3959
3818
  const failures = [];
@@ -3963,7 +3822,6 @@ export class AgentManager {
3963
3822
  continue;
3964
3823
  const runner = this.createRunnerFor(cfg);
3965
3824
  const tmux = new TmuxManager(runner);
3966
- // No releaseAgentForTask: the binding dies with the state file in phase 3, and deletability was already enforced by the phase-1 API gates — so cleanup only stops watchers and kills the session.
3967
3825
  this.stopRuntimeMenuWatch(id);
3968
3826
  if (this.paneStreamerManager) {
3969
3827
  try {
@@ -3990,7 +3848,6 @@ export class AgentManager {
3990
3848
  return;
3991
3849
  }
3992
3850
  if (snapshot.claim === null) {
3993
- // A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
3994
3851
  let nonceOwned;
3995
3852
  try {
3996
3853
  nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
@@ -4054,13 +3911,13 @@ export class AgentManager {
4054
3911
  const now = new Date().toISOString();
4055
3912
  if (cfg.role !== 'dev')
4056
3913
  throw new Error(`Agent ${cfg.id} is not a dev agent`);
4057
- const group = this.findAgentGroup(cfg.id);
3914
+ const team = this.findAgentTeam(cfg.id);
4058
3915
  const devAgentId = cfg.id;
4059
3916
  const qaAgentId = input.preferredAgentId === ''
4060
3917
  ? '_unassigned_preview_qa'
4061
- : group?.find(agent => agent.role === 'qa')?.id;
3918
+ : team?.find(agent => agent.role === 'qa')?.id;
4062
3919
  if (!qaAgentId)
4063
- throw new Error(`Agent ${cfg.id} has no qa agent in its group`);
3920
+ throw new Error(`Agent ${cfg.id} has no qa agent in its Agent Team`);
4064
3921
  const fakeTask = {
4065
3922
  id: 'task-9999999999',
4066
3923
  projectId,
@@ -4102,11 +3959,11 @@ export class AgentManager {
4102
3959
  return undefined;
4103
3960
  return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
4104
3961
  }
4105
- findAgentGroup(anchorAgentId) {
3962
+ findAgentTeam(anchorAgentId) {
4106
3963
  for (const project of this.config.project) {
4107
- for (const group of project.agent) {
4108
- if (group.some(agent => agent.id === anchorAgentId))
4109
- return group;
3964
+ for (const team of project.agent) {
3965
+ if (team.some(agent => agent.id === anchorAgentId))
3966
+ return team;
4110
3967
  }
4111
3968
  }
4112
3969
  return undefined;
@@ -4117,8 +3974,8 @@ export class AgentManager {
4117
3974
  return undefined;
4118
3975
  let longest;
4119
3976
  let longestBytes = -1;
4120
- for (const pair of project.agent) {
4121
- for (const a of pair) {
3977
+ for (const team of project.agent) {
3978
+ for (const a of team) {
4122
3979
  if (a.role !== 'dev' || !a.workdir)
4123
3980
  continue;
4124
3981
  const bytes = Buffer.byteLength(a.workdir, 'utf8');
@@ -4133,6 +3990,99 @@ export class AgentManager {
4133
3990
  async getTask(taskId) {
4134
3991
  return this.taskStore.get(taskId);
4135
3992
  }
3993
+ async recordTaskAttention(event) {
3994
+ if (!event.taskId)
3995
+ return;
3996
+ await this.withTaskLock(async () => {
3997
+ const task = await this.taskStore.get(event.taskId);
3998
+ if (!task)
3999
+ return;
4000
+ const reasonValue = typeof event.data.phase === 'string'
4001
+ ? event.data.phase
4002
+ : typeof event.data.reason === 'string'
4003
+ ? event.data.reason
4004
+ : 'human-intervention';
4005
+ const reason = reasonValue.trim() || 'human-intervention';
4006
+ const previousPhase = typeof event.data.previousPhase === 'string'
4007
+ ? event.data.previousPhase.trim()
4008
+ : '';
4009
+ const clearedReason = reason === 'mr-reopened'
4010
+ ? 'mr-closed-unmerged'
4011
+ : reason === 'resumed'
4012
+ || reason === 'git-review-dispatch-hold-cleared'
4013
+ || reason === 'agent_runtime_menu_resolved'
4014
+ ? previousPhase
4015
+ : '';
4016
+ if (clearedReason) {
4017
+ if (task.attention?.reason !== clearedReason
4018
+ || Date.parse(event.timestamp) < Date.parse(task.attention.occurredAt))
4019
+ return;
4020
+ const next = { ...task, updatedAt: new Date().toISOString() };
4021
+ delete next.attention;
4022
+ await this.taskStore.set(next);
4023
+ return;
4024
+ }
4025
+ if (reason === 'resumed'
4026
+ || reason === 'git-review-dispatch-hold-cleared'
4027
+ || reason === 'agent_runtime_menu_resolved')
4028
+ return;
4029
+ const runbookValue = [
4030
+ event.data.note,
4031
+ event.data.recommendation,
4032
+ event.data.message,
4033
+ event.data.error,
4034
+ ...(typeof event.data.phase === 'string' ? [event.data.reason] : []),
4035
+ ]
4036
+ .find(value => typeof value === 'string' && value.trim() !== '');
4037
+ const runbook = typeof runbookValue === 'string'
4038
+ ? runbookValue.trim()
4039
+ : `Task ${task.id} requires human intervention for ${reason}. Inspect the task and its linked agent before choosing an action.`;
4040
+ const occurredAt = Number.isFinite(Date.parse(event.timestamp))
4041
+ ? event.timestamp
4042
+ : new Date().toISOString();
4043
+ const generation = taskAttentionGeneration(task);
4044
+ let recommendedActions;
4045
+ if (task.status === 'max_rounds') {
4046
+ recommendedActions = ['verdict', 'cancel'];
4047
+ }
4048
+ else if (TERMINAL_STATUSES.includes(task.status)) {
4049
+ recommendedActions = task.remoteCleanup !== undefined
4050
+ && task.remoteCleanup.stage !== 'manual'
4051
+ ? ['cancel']
4052
+ : ['retry'];
4053
+ }
4054
+ else if (task.status === 'review') {
4055
+ recommendedActions = isSpecStagePhase(task.phase)
4056
+ ? ['advance', 'cancel']
4057
+ : ['advance', 'verdict', 'cancel'];
4058
+ }
4059
+ else if (task.status === 'spec-ready' || task.status === 'merge-ready') {
4060
+ recommendedActions = ['verdict', 'cancel'];
4061
+ }
4062
+ else {
4063
+ recommendedActions = ['advance', 'cancel'];
4064
+ }
4065
+ if (task.attention
4066
+ && Date.parse(task.attention.occurredAt) > Date.parse(occurredAt))
4067
+ return;
4068
+ if (task.attention
4069
+ && task.attention.reason === reason
4070
+ && task.attention.runbook === runbook
4071
+ && Date.parse(task.attention.occurredAt) >= Date.parse(occurredAt))
4072
+ return;
4073
+ await this.taskStore.set({
4074
+ ...task,
4075
+ attention: {
4076
+ reason,
4077
+ runbook,
4078
+ occurredAt,
4079
+ recommendedActions,
4080
+ generation,
4081
+ },
4082
+ updatedAt: new Date().toISOString(),
4083
+ });
4084
+ });
4085
+ }
4136
4086
  async getAgentState(agentId) {
4137
4087
  return this.agentStore.get(agentId);
4138
4088
  }
@@ -4143,8 +4093,6 @@ export class AgentManager {
4143
4093
  const task = await this.taskStore.get(taskId);
4144
4094
  return task ? this.completionOf(task) : null;
4145
4095
  }
4146
- // completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与 token CAS
4147
- // 必须同一次持久化(spec §6 双写协议)。
4148
4096
  completionOf(task) {
4149
4097
  if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined
4150
4098
  || task.postApproveGeneration === undefined || task.postApprovePhase === undefined)
@@ -4221,15 +4169,30 @@ export class AgentManager {
4221
4169
  delete next.reviewDispatch;
4222
4170
  delete next.reviewRoundPending;
4223
4171
  }
4224
- if (nextStatus !== task.status && next.outbox) {
4225
- const outbox = next.outbox.filter(entry => entry.type !== 'git.spec-verdict');
4226
- if (outbox.length > 0)
4227
- next.outbox = outbox;
4228
- else
4229
- delete next.outbox;
4230
- }
4172
+ if (nextStatus !== task.status)
4173
+ this.stripGitVerdictOutbox(next);
4231
4174
  return next;
4232
4175
  }
4176
+ stripGitVerdictOutbox(task) {
4177
+ if (!task.outbox)
4178
+ return;
4179
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.spec-verdict' && entry.type !== 'git.code-verdict');
4180
+ if (outbox.length > 0)
4181
+ task.outbox = outbox;
4182
+ else
4183
+ delete task.outbox;
4184
+ }
4185
+ stripCodeVerdictOutboxForAnchor(task, anchorSha) {
4186
+ if (!task.outbox)
4187
+ return;
4188
+ const normalizedAnchor = anchorSha.toLowerCase();
4189
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.code-verdict'
4190
+ || entry.data.anchorSha.toLowerCase() === normalizedAnchor);
4191
+ if (outbox.length > 0)
4192
+ task.outbox = outbox;
4193
+ else
4194
+ delete task.outbox;
4195
+ }
4233
4196
  async confirmPostApprovePromptDelivered(taskId, expected) {
4234
4197
  await this.withTaskLock(async () => {
4235
4198
  const task = await this.taskStore.get(taskId);
@@ -4268,7 +4231,6 @@ export class AgentManager {
4268
4231
  this.phaseSignalWatcher?.stop(taskId);
4269
4232
  });
4270
4233
  }
4271
- // CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
4272
4234
  async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
4273
4235
  return this.withTaskLock(async () => {
4274
4236
  const task = await this.taskStore.get(taskId);
@@ -4289,13 +4251,11 @@ export class AgentManager {
4289
4251
  return true;
4290
4252
  });
4291
4253
  }
4292
- // Deliberate clear: the persisted marker keeps a restart replay from rebuilding the completion and bypassing the block.
4293
4254
  async revokePostApproveCompletion(taskId, reason, opts = {}) {
4294
4255
  return this.withTaskLock(async () => {
4295
4256
  const task = await this.taskStore.get(taskId);
4296
4257
  if (!task || task.status !== 'approved')
4297
4258
  return false;
4298
- // Head binds the revoke to one approved episode; an unpersisted head cannot disprove it, so blocking stays fail closed.
4299
4259
  if (opts.expectedHeadSha !== undefined
4300
4260
  && task.postApproveHeadSha !== undefined
4301
4261
  && task.postApproveHeadSha !== opts.expectedHeadSha)
@@ -4333,7 +4293,6 @@ export class AgentManager {
4333
4293
  return true;
4334
4294
  });
4335
4295
  }
4336
- // Marker/token/head/pending are re-checked inside the committing critical section, so a concurrent revoke never loses.
4337
4296
  async completeApprovedPassToMergeReady(taskId, expected) {
4338
4297
  return this.withTaskLock(async () => {
4339
4298
  const task = await this.taskStore.get(taskId);
@@ -4427,6 +4386,9 @@ export class AgentManager {
4427
4386
  specVerdictOutbox(task) {
4428
4387
  return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
4429
4388
  }
4389
+ codeVerdictOutbox(task) {
4390
+ return task.outbox?.find((entry) => entry.type === 'git.code-verdict');
4391
+ }
4430
4392
  async mutateTaskOutboxEntry(taskId, key, mutate) {
4431
4393
  return this.withTaskLock(async () => {
4432
4394
  const task = await this.taskStore.get(taskId);
@@ -4456,6 +4418,10 @@ export class AgentManager {
4456
4418
  await this.deliverSpecVerdictOutbox(taskId, entry.key);
4457
4419
  continue;
4458
4420
  }
4421
+ if (entry.type === 'git.code-verdict') {
4422
+ await this.deliverCodeVerdictOutbox(taskId, entry.key);
4423
+ continue;
4424
+ }
4459
4425
  await this.eventBus.emit({
4460
4426
  id: `outbox:${entry.key}`,
4461
4427
  type: 'human.intervention',
@@ -4498,8 +4464,6 @@ export class AgentManager {
4498
4464
  headSha: completion.approvedHeadSha,
4499
4465
  });
4500
4466
  }
4501
- // 启动期按「未引用」跳过 skill 池的插件,被热配置首次引用时同步重扫;仍不可物化即拒绝提交——
4502
- // 可解析但物化为空的 tool 会让派发要求加载一个不存在的 skill。
4503
4467
  async ensurePluginSkillPools(next) {
4504
4468
  if (!this.pluginRegistry)
4505
4469
  return;
@@ -4508,7 +4472,6 @@ export class AgentManager {
4508
4472
  if (tool === undefined)
4509
4473
  continue;
4510
4474
  const plugin = this.pluginRegistry.resolveTool(tool);
4511
- // 静默放行会存下一个「无 driver、无平台 skill、重启即 fatal」的配置(启动期同判据)
4512
4475
  if (plugin === undefined) {
4513
4476
  throw new Error(`project '${project.id}': no git-driver plugin provides tool '${tool}' — install it under ~/.baxian/plugins/ first`);
4514
4477
  }
@@ -4550,7 +4513,6 @@ export class AgentManager {
4550
4513
  && task.failToken === lease.failToken
4551
4514
  && lease.effectiveRound === effectiveTaskReviewRound(task);
4552
4515
  }
4553
- // startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
4554
4516
  gitReviewQaConfigUsable(task) {
4555
4517
  const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
4556
4518
  const config = this.getAgentConfig(qaAgentId);
@@ -4581,6 +4543,10 @@ export class AgentManager {
4581
4543
  return null;
4582
4544
  if (Object.hasOwn(opts, 'expectPhase') && current.phase !== opts.expectPhase)
4583
4545
  return null;
4546
+ if (current.status === 'review'
4547
+ && current.reviewHeadAnchorSha?.toLowerCase() === opts.headSha.toLowerCase()
4548
+ && this.codeVerdictOutbox(current) !== undefined)
4549
+ return null;
4584
4550
  if (opts.patch?.branch && opts.patch.branch !== current.branch) {
4585
4551
  const existing = await this.findTaskByBranch(opts.patch.branch, current.projectId);
4586
4552
  if (existing && existing.id !== taskId)
@@ -4601,6 +4567,7 @@ export class AgentManager {
4601
4567
  ? 'review'
4602
4568
  : 'recheck');
4603
4569
  const next = this.stripPostApproveEpisodeFields(current);
4570
+ this.stripGitVerdictOutbox(next);
4604
4571
  delete next.passProvenance;
4605
4572
  Object.assign(next, opts.patch ?? {}, {
4606
4573
  status: 'review',
@@ -4670,6 +4637,7 @@ export class AgentManager {
4670
4637
  return null;
4671
4638
  const now = new Date().toISOString();
4672
4639
  const next = this.stripPostApproveEpisodeFields(current);
4640
+ this.stripGitVerdictOutbox(next);
4673
4641
  delete next.reviewDispatch;
4674
4642
  delete next.reviewRoundPending;
4675
4643
  const postApproveGeneration = createSignalToken();
@@ -4769,6 +4737,7 @@ export class AgentManager {
4769
4737
  },
4770
4738
  updatedAt: now,
4771
4739
  };
4740
+ this.stripCodeVerdictOutboxForAnchor(next, verifiedHeadSha);
4772
4741
  await this.taskStore.set(next);
4773
4742
  if (qaHold !== undefined) {
4774
4743
  const cleared = await this.clearAwaitingHuman(qaId, {
@@ -4814,6 +4783,9 @@ export class AgentManager {
4814
4783
  }
4815
4784
  next.reviewDispatchedAt = now;
4816
4785
  delete next.reviewDispatch;
4786
+ if (next.attention?.reason === 'git-review-dispatch-recovery-uncertain') {
4787
+ delete next.attention;
4788
+ }
4817
4789
  await this.taskStore.set(next);
4818
4790
  return true;
4819
4791
  });
@@ -4935,7 +4907,6 @@ export class AgentManager {
4935
4907
  continue;
4936
4908
  const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
4937
4909
  if (!this.gitReviewQaConfigUsable(task)) {
4938
- // 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
4939
4910
  const latch = `qa-config:${task.id}:${lease.generation}`;
4940
4911
  if (!this.gitReviewRecoveryAlerted.has(latch)) {
4941
4912
  try {
@@ -5109,7 +5080,6 @@ export class AgentManager {
5109
5080
  const tasks = await this.taskStore.list({ status: 'approved' });
5110
5081
  for (const task of tasks) {
5111
5082
  if (task.postApproveRevoked) {
5112
- // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
5113
5083
  try {
5114
5084
  if (task.postApproveToken !== undefined) {
5115
5085
  await this.taskStore.set({
@@ -5230,7 +5200,6 @@ export class AgentManager {
5230
5200
  }
5231
5201
  }
5232
5202
  }
5233
- // 创建期快照(spec §4):git 任务在建任务那一刻定身份。
5234
5203
  platformBindingFields(projectId) {
5235
5204
  return { platformBinding: this.platformBindingFor(projectId) };
5236
5205
  }
@@ -5247,7 +5216,6 @@ export class AgentManager {
5247
5216
  tool,
5248
5217
  };
5249
5218
  }
5250
- // agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
5251
5219
  agentGitPreflightContext(projectId) {
5252
5220
  const project = this.getProjectConfig(projectId);
5253
5221
  if (!project || !this.pluginRegistry)
@@ -5268,8 +5236,6 @@ export class AgentManager {
5268
5236
  driverFor: (exec) => new GitDriver(plugin, renderCtx, exec),
5269
5237
  };
5270
5238
  }
5271
- // cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
5272
- // 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
5273
5239
  platformCliContextOf(task) {
5274
5240
  this.assertPlatformBinding(task);
5275
5241
  const project = this.getProjectConfig(task.projectId);
@@ -5287,13 +5253,11 @@ export class AgentManager {
5287
5253
  ...(project.gitCli?.notes !== undefined ? { notes: project.gitCli.notes } : {}),
5288
5254
  };
5289
5255
  }
5290
- // base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
5291
5256
  async ensureGitBaseSnapshot(task, phase) {
5292
5257
  if (phase !== 'develop' && phase !== 'code')
5293
5258
  return task;
5294
5259
  if (task.baseBranch !== undefined)
5295
5260
  return task;
5296
- // 读默认分支与落快照都以 binding 为前提:失配任务先写入新仓库的 base 会永久污染不可变快照
5297
5261
  this.assertPlatformBinding(task);
5298
5262
  const base = this.platformDefaultBranchOf?.(task.projectId);
5299
5263
  if (base === undefined)
@@ -5310,20 +5274,28 @@ export class AgentManager {
5310
5274
  return next;
5311
5275
  });
5312
5276
  }
5313
- async noteReviewConversationRevision(taskId) {
5277
+ async noteReviewConversationRevision(taskId, conversation) {
5314
5278
  await this.withTaskLock(async () => {
5315
5279
  const task = await this.taskStore.get(taskId);
5316
5280
  if (!task || TERMINAL_STATUSES.includes(task.status))
5317
5281
  return;
5318
5282
  const now = new Date().toISOString();
5319
- await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
5283
+ const next = { ...task, reviewConversationUpdatedAt: now, updatedAt: now };
5284
+ const repoKey = next.platformBinding?.repoKey;
5285
+ if (conversation !== undefined
5286
+ && this.prConversationCache !== undefined
5287
+ && repoKey !== undefined
5288
+ && next.prNumber === conversation.prNumber) {
5289
+ this.prConversationCache.put(next.id, prReviewCacheRevision(next, repoKey), conversation.payload, next.reviewConversationUpdatedAt ?? '');
5290
+ }
5291
+ await this.taskStore.set(next);
5320
5292
  });
5321
5293
  }
5322
5294
  assertPlatformBinding(task) {
5323
5295
  const mismatch = platformBindingMismatch(this.config, task);
5324
5296
  if (mismatch === undefined)
5325
5297
  return;
5326
- void this.emitPlatformBindingIntervention(task, mismatch);
5298
+ void this.outsideTaskMutationScope(() => this.emitPlatformBindingIntervention(task, mismatch));
5327
5299
  if (mismatch.reason === 'missing-binding-snapshot') {
5328
5300
  throw new Error(`platform binding missing for task ${task.id}; refusing platform operations`);
5329
5301
  }
@@ -5339,7 +5311,6 @@ export class AgentManager {
5339
5311
  throw new Error(`no git driver resolvable for project ${task.projectId}`);
5340
5312
  return { task, driver };
5341
5313
  }
5342
- // 配置锁也覆盖终态任务尚未完成的远端清理;否则 cancel 落 intent 后即可改写/删除其 binding。
5343
5314
  async listActiveGitTasks(projectId) {
5344
5315
  const tasks = await this.taskStore.listStrict(projectId !== undefined ? { projectId } : undefined);
5345
5316
  return tasks.filter(t => (t.remoteCleanup !== undefined && t.remoteCleanup.stage !== 'manual')
@@ -5352,8 +5323,6 @@ export class AgentManager {
5352
5323
  const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
5353
5324
  return row;
5354
5325
  }
5355
- // git 模式的 head 刷新一律经任务解析出的 driver(spec §4 两执行面):QA checkout 的
5356
- // moved-head fallback 不得旁路到硬编码 gh,否则自定义/非 github driver 会错误调用 gh。
5357
5326
  async platformFetchPrHeadSha(taskId) {
5358
5327
  const headSha = String((await this.platformFetchPrView(taskId)).headSha);
5359
5328
  if (!PLATFORM_HEAD_SHA_RE.test(headSha)) {
@@ -5863,8 +5832,12 @@ export class AgentManager {
5863
5832
  return null;
5864
5833
  if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
5865
5834
  return null;
5835
+ const repairsDeliveryConfirmation = confirmation?.source === 'human'
5836
+ && (task.status === 'review' || task.status === 'fixing')
5837
+ && Object.hasOwn(confirmation, 'expectedSignalToken');
5866
5838
  if (confirmation !== undefined) {
5867
5839
  if (task.status !== 'in_progress'
5840
+ && !repairsDeliveryConfirmation
5868
5841
  && (task.phase !== confirmation.phase
5869
5842
  || task.deliveryConfirmation?.phase !== confirmation.phase))
5870
5843
  return null;
@@ -5891,7 +5864,7 @@ export class AgentManager {
5891
5864
  ...(confirmation !== undefined
5892
5865
  ? {
5893
5866
  phase: confirmation.phase,
5894
- deliveryConfirmation: task.status === 'in_progress'
5867
+ deliveryConfirmation: task.status === 'in_progress' || repairsDeliveryConfirmation
5895
5868
  ? { phase: confirmation.phase, source: confirmation.source, at: now }
5896
5869
  : task.deliveryConfirmation,
5897
5870
  ...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
@@ -5997,8 +5970,6 @@ export class AgentManager {
5997
5970
  return this.repoCache;
5998
5971
  }
5999
5972
  async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
6000
- // Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
6001
- // window must not let the final state write-back resurrect the deleted agent.
6002
5973
  const genAtEntry = this.deletionGenerationOf(agentId);
6003
5974
  const existing = await this.agentStore.get(agentId);
6004
5975
  if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
@@ -6046,8 +6017,6 @@ export class AgentManager {
6046
6017
  else {
6047
6018
  const now = new Date().toISOString();
6048
6019
  await this.agentStore.update(agentId, (latest) => {
6049
- // Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
6050
- // since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
6051
6020
  if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
6052
6021
  return AGENT_STORE_NOOP;
6053
6022
  if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
@@ -6145,17 +6114,15 @@ export class AgentManager {
6145
6114
  };
6146
6115
  return this.persistQueuedTask(unassigned, 'unassigned');
6147
6116
  }
6148
- // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
6149
6117
  const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
6150
6118
  const target = await this.pickAgent(projectId, input.preferredAgentId);
6151
- const group = this.findAgentGroup(input.preferredAgentId);
6152
- const dev = group?.find(agent => agent.role === 'dev');
6119
+ const team = this.findAgentTeam(input.preferredAgentId);
6120
+ const dev = team?.find(agent => agent.role === 'dev');
6153
6121
  if (!dev)
6154
- throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
6155
- const qa = group?.find(agent => agent.role === 'qa');
6122
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its Agent Team`);
6123
+ const qa = team?.find(agent => agent.role === 'qa');
6156
6124
  if (!qa)
6157
- throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
6158
- // Pin EVERY participant's generation: a member (e.g. QA) deleted→reintroduced after the snapshot leaves isDeletionInFlight false and the preferred generation unchanged, yet the task would still carry its stale id.
6125
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its Agent Team`);
6159
6126
  const participantGens = new Map([dev.id, qa.id]
6160
6127
  .map(id => [id, this.deletionGenerationOf(id)]));
6161
6128
  const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
@@ -6168,11 +6135,9 @@ export class AgentManager {
6168
6135
  qaAgentId: qa.id,
6169
6136
  status: 'pending',
6170
6137
  };
6171
- // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
6172
- // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
6173
6138
  const persistQueuedOrReject = (reason) => {
6174
6139
  if (!participantsFresh()) {
6175
- throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
6140
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or an Agent Team member is being deleted or recreated during task creation; please retry`);
6176
6141
  }
6177
6142
  return this.persistQueuedTask(queued, reason, input.preferredAgentId);
6178
6143
  };
@@ -6186,7 +6151,6 @@ export class AgentManager {
6186
6151
  if (!lockToken) {
6187
6152
  return persistQueuedOrReject('agent_locked');
6188
6153
  }
6189
- // Three-step transaction (queued task → binding → in_progress): any failure leaves at worst a queued task and/or a stale binding to it, both reclaimable — never an active task with no binding.
6190
6154
  const releaseLock = async () => {
6191
6155
  try {
6192
6156
  await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
@@ -6219,7 +6183,6 @@ export class AgentManager {
6219
6183
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
6220
6184
  ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
6221
6185
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
6222
- // A new task never inherits the previous one's question: void the watermark.
6223
6186
  needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
6224
6187
  };
6225
6188
  });
@@ -6232,7 +6195,6 @@ export class AgentManager {
6232
6195
  await releaseLock();
6233
6196
  return (await this.taskStore.get(taskId)) ?? queued;
6234
6197
  }
6235
- // Binding-before-active: a DELETE slipping in after the binding commit sees a still-'pending' task and can rotate the lock; re-verify lock ownership + preferred/participant generations before the active write, else roll back.
6236
6198
  if (this.isDeletionInFlight(target.id)
6237
6199
  || !this.deletionGateOpen(target.id, genAtEntry)
6238
6200
  || !participantsFresh()
@@ -6285,6 +6247,9 @@ export class AgentManager {
6285
6247
  }
6286
6248
  async createAndStartTask(projectId, input, opts = {}) {
6287
6249
  const task = await this.createTask(projectId, input);
6250
+ return this.startCreatedTask(task, opts);
6251
+ }
6252
+ async startCreatedTask(task, opts = {}) {
6288
6253
  if (task.status === 'in_progress' && task.agentId) {
6289
6254
  const signalToken = createSignalToken();
6290
6255
  await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
@@ -6378,8 +6343,6 @@ export class AgentManager {
6378
6343
  const cfg = this.getAgentConfig(agentId);
6379
6344
  if (!cfg)
6380
6345
  throw new ApiError(404, `Unknown agent: ${agentId}`);
6381
- // Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
6382
- // DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
6383
6346
  const genAtEntry = this.deletionGenerationOf(agentId);
6384
6347
  const state = await this.agentStore.get(agentId);
6385
6348
  const paneId = state?.paneId;
@@ -6413,7 +6376,6 @@ export class AgentManager {
6413
6376
  await assertUploadStillValid();
6414
6377
  const tmux = new TmuxManager(runner);
6415
6378
  const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
6416
- // resolveClaimedPane resolved by the CURRENT same-name session; re-verify once more before injecting so a DELETE→recreate in that gap can't take the paste.
6417
6379
  await assertUploadStillValid();
6418
6380
  await tmux.injectPrompt(pane, `${path} `, agentId);
6419
6381
  return { path };
@@ -6465,7 +6427,7 @@ export class AgentManager {
6465
6427
  await tmux.sendKeysLiteral(pane, command);
6466
6428
  await tmux.sendEnter(pane);
6467
6429
  guardHandedOff = true;
6468
- void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
6430
+ void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
6469
6431
  .catch(err => {
6470
6432
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
6471
6433
  })
@@ -6486,9 +6448,6 @@ export class AgentManager {
6486
6448
  }
6487
6449
  registerPendingDispatchRetry(taskId, entry, budgetOverride) {
6488
6450
  const prev = this.pendingDispatchRetryByTask.get(taskId);
6489
- // 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
6490
- // budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
6491
- // fence 注入,外部 successor 的登记结构上拿不到
6492
6451
  const sameGeneration = prev?.kind === entry.kind
6493
6452
  && prev.agentId === entry.agentId
6494
6453
  && prev.signalToken === entry.signalToken;
@@ -6511,6 +6470,16 @@ export class AgentManager {
6511
6470
  clearPendingDispatchRetry(taskId) {
6512
6471
  this.pendingDispatchRetryByTask.delete(taskId);
6513
6472
  }
6473
+ resetPendingDispatchRetryBudget(taskId) {
6474
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
6475
+ if (!entry)
6476
+ return;
6477
+ this.pendingDispatchRetryByTask.set(taskId, {
6478
+ ...entry,
6479
+ since: Date.now(),
6480
+ budgetAlerted: undefined,
6481
+ });
6482
+ }
6514
6483
  clearPendingDispatchRetryIfMatches(taskId, expected) {
6515
6484
  const entry = this.pendingDispatchRetryByTask.get(taskId);
6516
6485
  if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
@@ -6528,8 +6497,6 @@ export class AgentManager {
6528
6497
  return null;
6529
6498
  if (phase !== 'review' && phase !== 'recheck')
6530
6499
  return null;
6531
- // 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
6532
- // 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
6533
6500
  const passToken = dispatch.passToken;
6534
6501
  if (!passToken)
6535
6502
  return null;
@@ -6652,7 +6619,6 @@ export class AgentManager {
6652
6619
  error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
6653
6620
  };
6654
6621
  }
6655
- // Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
6656
6622
  const genAtEntry = this.deletionGenerationOf(agentId);
6657
6623
  const cfg = this.getAgentConfig(agentId);
6658
6624
  if (!cfg)
@@ -6683,22 +6649,21 @@ export class AgentManager {
6683
6649
  }
6684
6650
  const now = new Date().toISOString();
6685
6651
  const initiallyUnassigned = fresh.preferredAgentId === '';
6686
- const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
6652
+ const team = initiallyUnassigned ? this.findAgentTeam(agentId) : undefined;
6687
6653
  const devId = initiallyUnassigned
6688
- ? group?.find(agent => agent.role === 'dev')?.id
6654
+ ? team?.find(agent => agent.role === 'dev')?.id
6689
6655
  : fresh.devAgentId;
6690
6656
  if (!devId) {
6691
6657
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6692
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
6658
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its Agent Team` };
6693
6659
  }
6694
6660
  const qaId = initiallyUnassigned
6695
- ? group?.find(agent => agent.role === 'qa')?.id
6661
+ ? team?.find(agent => agent.role === 'qa')?.id
6696
6662
  : fresh.qaAgentId;
6697
6663
  if (!qaId) {
6698
6664
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6699
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its group` };
6665
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its Agent Team` };
6700
6666
  }
6701
- // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
6702
6667
  const participantGens = new Map([devId, qaId]
6703
6668
  .map(pid => [pid, this.deletionGenerationOf(pid)]));
6704
6669
  const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
@@ -6712,8 +6677,6 @@ export class AgentManager {
6712
6677
  status: 'in_progress',
6713
6678
  updatedAt: now,
6714
6679
  };
6715
- // Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
6716
- // step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
6717
6680
  const releaseLock = async () => {
6718
6681
  try {
6719
6682
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
@@ -6739,7 +6702,6 @@ export class AgentManager {
6739
6702
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
6740
6703
  ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
6741
6704
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
6742
- // A new task never inherits the previous one's question: void the watermark.
6743
6705
  needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
6744
6706
  };
6745
6707
  });
@@ -6752,7 +6714,6 @@ export class AgentManager {
6752
6714
  await releaseLock();
6753
6715
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
6754
6716
  }
6755
- // Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
6756
6717
  if (this.isDeletionInFlight(agentId)
6757
6718
  || !this.deletionGateOpen(agentId, genAtEntry)
6758
6719
  || !participantsFresh()
@@ -6883,7 +6844,6 @@ export class AgentManager {
6883
6844
  const agent = this.getAgentConfig(agentId);
6884
6845
  if (!agent)
6885
6846
  throw new Error(`Unknown agent: ${agentId}`);
6886
- // Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
6887
6847
  const genAtEntry = this.deletionGenerationOf(agentId);
6888
6848
  const task = await this.taskStore.get(taskId);
6889
6849
  if (!task)
@@ -6930,8 +6890,6 @@ export class AgentManager {
6930
6890
  return false;
6931
6891
  }
6932
6892
  const assertOwner = async () => {
6933
- // Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
6934
- // reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
6935
6893
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
6936
6894
  throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
6937
6895
  }
@@ -7049,7 +7007,6 @@ export class AgentManager {
7049
7007
  await this.agentStore.update(agentId, (stateNow) => {
7050
7008
  if (!stateNow || stateNow.taskId !== taskId)
7051
7009
  return AGENT_STORE_NOOP;
7052
- // Watermark stays: the arm-time epoch bump owns superseding the old question.
7053
7010
  return {
7054
7011
  ...stateNow,
7055
7012
  paneId,
@@ -7131,7 +7088,6 @@ export class AgentManager {
7131
7088
  cancelHoldWon = true;
7132
7089
  return AGENT_STORE_NOOP;
7133
7090
  }
7134
- // Binding must already exist and still hold our lock; never re-create a deleted agent's state nor write under a bumped deletion generation (ABA).
7135
7091
  if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
7136
7092
  || !this.deletionGateOpen(agentId, genAtEntry)) {
7137
7093
  deletedDuringDispatch = true;
@@ -7162,10 +7118,6 @@ export class AgentManager {
7162
7118
  return false;
7163
7119
  }
7164
7120
  agentMarkedRunning = true;
7165
- // armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
7166
- // push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
7167
- // 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
7168
- // cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
7169
7121
  const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
7170
7122
  const passStillArmed = opts.dispatchPassToken !== undefined
7171
7123
  ? async () => {
@@ -7187,8 +7139,6 @@ export class AgentManager {
7187
7139
  console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
7188
7140
  return false;
7189
7141
  }
7190
- // prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
7191
- // 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
7192
7142
  if (opts.dispatchPassToken !== undefined) {
7193
7143
  this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
7194
7144
  }
@@ -7215,8 +7165,6 @@ export class AgentManager {
7215
7165
  return true;
7216
7166
  }
7217
7167
  catch (err) {
7218
- // 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
7219
- // 在途回合还在使用该工作树,park/release 会拽走它
7220
7168
  const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
7221
7169
  if (injectBusyPend)
7222
7170
  throw injectBusyPend;
@@ -7322,7 +7270,6 @@ export class AgentManager {
7322
7270
  }
7323
7271
  async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
7324
7272
  const paneId = pane.paneId;
7325
- // 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
7326
7273
  const preTitle = await tmux.readPaneTitle(pane);
7327
7274
  const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
7328
7275
  const staticBusy = hasOscTitleWorking(preTitle)
@@ -7333,12 +7280,10 @@ export class AgentManager {
7333
7280
  }
7334
7281
  await revalidate?.();
7335
7282
  if (guardBeforePaste) {
7336
- // A stale replay must not touch the pane at all: the guard runs before the first composer scrub.
7337
7283
  if (!(await guardBeforePaste())) {
7338
7284
  return { acked: false, composerDelivered: false, aborted: true };
7339
7285
  }
7340
7286
  const staged = await tmux.stagePromptBuffer(paneId, prompt, agentId);
7341
- // Fence + scrub + paste share one task-mutation-queue slot, so a rotation lands before or after, never between.
7342
7287
  let pasted = false;
7343
7288
  try {
7344
7289
  pasted = await this.withTaskLock(async () => {
@@ -7350,7 +7295,6 @@ export class AgentManager {
7350
7295
  });
7351
7296
  }
7352
7297
  catch (pasteErr) {
7353
- // Buffer still present proves the paste never landed; missing/unprobeable fails closed to a composer scrub.
7354
7298
  let bufferConsumed = true;
7355
7299
  try {
7356
7300
  await tmux.dropStagedBuffer(staged.buf);
@@ -7390,7 +7334,6 @@ export class AgentManager {
7390
7334
  baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
7391
7335
  baselineTitle = await tmux.readPaneTitle(pane);
7392
7336
  if (guardBeforePaste) {
7393
- // The Enter is the actual submission; it takes the same fenced queue slot.
7394
7337
  const submitted = await this.withTaskLock(async () => {
7395
7338
  if (!(await guardBeforePaste()))
7396
7339
  return false;
@@ -7402,7 +7345,6 @@ export class AgentManager {
7402
7345
  await tmux.clearComposerDraft(pane);
7403
7346
  }
7404
7347
  catch (err) {
7405
- // Fail closed: a submit-ready stale prompt could not be confirmed gone.
7406
7348
  const message = err instanceof Error ? err.message : String(err);
7407
7349
  throw new Error(`fence-rejected prompt could not be scrubbed from the composer of pane ${paneId}; verify the pane before reuse: ${message}`);
7408
7350
  }
@@ -7426,7 +7368,6 @@ export class AgentManager {
7426
7368
  baselineTitle,
7427
7369
  resend: guardBeforePaste
7428
7370
  ? async () => {
7429
- // A resend is a fresh submission of whatever sits in the composer; it takes the same fence.
7430
7371
  const resent = await this.withTaskLock(async () => {
7431
7372
  if (!(await guardBeforePaste()))
7432
7373
  return false;
@@ -7588,8 +7529,6 @@ export class AgentManager {
7588
7529
  await branches.assertClean(verifiedWorkdir);
7589
7530
  const actualRef = await branches.currentRef(verifiedWorkdir);
7590
7531
  if (actualRef !== `refs/heads/${task.branch}`) {
7591
- // dev 可能已被释放过(如 spec max_rounds 停驻默认分支);switchToTaskBranch 自带
7592
- // assertClean,脏树时抛 DirtyWorkdirError fail-closed,不会覆盖未提交的改动
7593
7532
  await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
7594
7533
  await branches.switchToTaskBranch(verifiedWorkdir, taskId, task.branch, task.branchCreatedByBaxian === true, {
7595
7534
  requireExistingWork: true,
@@ -7662,7 +7601,6 @@ export class AgentManager {
7662
7601
  return false;
7663
7602
  }
7664
7603
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
7665
- // A revoke marks before clearing and a push transitions before clearing; every half-window must abort the paste.
7666
7604
  const postApprovePassStillLive = async () => {
7667
7605
  const [taskFresh, completionFresh] = await Promise.all([
7668
7606
  this.taskStore.get(taskId),
@@ -7720,9 +7658,6 @@ export class AgentManager {
7720
7658
  || latest.workdir !== verifiedWorkdir) {
7721
7659
  return AGENT_STORE_NOOP;
7722
7660
  }
7723
- // The continuation prompt supersedes the previous question, but the arm-time
7724
- // epoch bump owns that transition — stripping here would race an arm that
7725
- // already re-established the watermark (post-approve arms before injecting).
7726
7661
  return {
7727
7662
  ...latest,
7728
7663
  paneId,
@@ -7835,7 +7770,6 @@ export class AgentManager {
7835
7770
  return { ...rest, updatedAt: new Date().toISOString() };
7836
7771
  });
7837
7772
  }
7838
- // continueSession never runs startSession's post-ack finalization; a surviving marker would make recover() roll the running task back to pending.
7839
7773
  async finalizeReplayedBootstrap(agentId, taskId, phase, expectedLockToken) {
7840
7774
  let cleared = false;
7841
7775
  let deliveredWorkdir;
@@ -8045,10 +7979,10 @@ export class AgentManager {
8045
7979
  if (await this.rollbackUndeliveredBootstrap(state))
8046
7980
  continue;
8047
7981
  await this.markDialogPending(state.id, state.creationToken);
8048
- void this.slowPollDialogPending(state.id, state.creationToken, {
7982
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(state.id, state.creationToken, {
8049
7983
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
8050
7984
  expectedTaskId: state.taskId,
8051
- }).catch((pollErr) => {
7985
+ })).catch((pollErr) => {
8052
7986
  console.warn(`[recover] slowPoll for ${state.id} crashed:`, pollErr);
8053
7987
  });
8054
7988
  continue;
@@ -8190,8 +8124,6 @@ export class AgentManager {
8190
8124
  if (this.markCompleteInFlight.has(taskId)) {
8191
8125
  throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
8192
8126
  }
8193
- // 终态不早退:状态不再改写,但残留的 pane/绑定(上次清理中途失败)仍走同一条强制清理路径。
8194
- // 唯一例外:清理已在进行中的重复取消——重跑只会重复中断同一批 pane,等它收尾即可。
8195
8127
  const alreadyTerminal = TERMINAL_STATUSES.includes(task.status);
8196
8128
  if (alreadyTerminal && this.cancelCleanupInFlight.has(taskId))
8197
8129
  return task;
@@ -8238,10 +8170,8 @@ export class AgentManager {
8238
8170
  cleanupClaimed = true;
8239
8171
  return task;
8240
8172
  });
8241
- // stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
8242
8173
  if (cleanupClaimed)
8243
8174
  this.phaseSignalWatcher?.stop(taskId);
8244
- // 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
8245
8175
  try {
8246
8176
  const stopped = [];
8247
8177
  for (const id of [devToRelease, qaToRelease]) {
@@ -8638,6 +8568,169 @@ export class AgentManager {
8638
8568
  return dispatched;
8639
8569
  }
8640
8570
  }
8571
+ async advanceTask(taskId, opts = {}) {
8572
+ let task = await this.taskStore.get(taskId);
8573
+ if (!task)
8574
+ throw new ApiError(404, `Task ${taskId} not found`);
8575
+ const executor = opts.executor ?? (task.status === 'review' ? 'qa' : 'dev');
8576
+ let updated;
8577
+ if (task.status === 'pending') {
8578
+ if (executor !== 'dev')
8579
+ throw new ApiError(409, 'Pending tasks can only advance to Dev');
8580
+ const result = await this.dispatchPendingTask(taskId, opts.agentId);
8581
+ if (result.errorCode !== undefined)
8582
+ throw new ApiError(result.errorCode, result.error ?? 'Dispatch failed');
8583
+ if (!result.task)
8584
+ throw new ApiError(404, `Task ${taskId} disappeared during dispatch`);
8585
+ updated = result.task;
8586
+ }
8587
+ else if (executor === 'qa') {
8588
+ if (task.status !== 'in_progress' && task.status !== 'fixing' && task.status !== 'review') {
8589
+ throw new ApiError(409, `Task ${taskId} cannot advance to QA from ${task.status}`);
8590
+ }
8591
+ updated = await this.dispatchReviewToQa(taskId, {
8592
+ fromStatus: [task.status],
8593
+ confirmUncertainNotDelivered: true,
8594
+ ...(opts.stage !== undefined ? { stage: opts.stage } : {}),
8595
+ ...(opts.actorId !== undefined ? { actorId: opts.actorId } : {}),
8596
+ ...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
8597
+ });
8598
+ }
8599
+ else if (task.status === 'review') {
8600
+ throw new ApiError(409, 'Review cannot advance to Dev without a request-changes verdict');
8601
+ }
8602
+ else if (task.status === 'in_progress' || task.status === 'fixing') {
8603
+ if (!task.devAgentId)
8604
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8605
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8606
+ if (!replayed)
8607
+ throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
8608
+ updated = (await this.taskStore.get(taskId)) ?? task;
8609
+ }
8610
+ else if (task.status === 'approved') {
8611
+ if (!task.devAgentId)
8612
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8613
+ if (task.postApproveRevoked) {
8614
+ if (opts.confirmRevoked !== true) {
8615
+ throw new ApiError(409, `Task ${taskId} post-approve replay is revoked; confirmRevoked=true is required`);
8616
+ }
8617
+ task = await this.restoreRevokedPostApprove(task);
8618
+ }
8619
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8620
+ if (!replayed)
8621
+ throw new ApiError(409, `Task ${taskId} changed or its post-approve prompt could not be replayed`);
8622
+ updated = (await this.taskStore.get(taskId)) ?? task;
8623
+ }
8624
+ else {
8625
+ throw new ApiError(409, `Task ${taskId} cannot be advanced from ${task.status}`);
8626
+ }
8627
+ await this.auditHumanTaskOperation(updated, 'advance', executor, opts.note);
8628
+ return updated;
8629
+ }
8630
+ async restoreRevokedPostApprove(task) {
8631
+ if (!task.postApproveRevoked || task.prNumber === undefined) {
8632
+ throw new ApiError(409, `Task ${task.id} has no revoked post-approve episode`);
8633
+ }
8634
+ const [binding, accepted] = await Promise.all([
8635
+ this.platformVerifyPrBinding(task.id, task.prNumber),
8636
+ this.platformVerifyAcceptedPass(task.id),
8637
+ ]);
8638
+ if (!binding.ok) {
8639
+ throw new ApiError(409, `Cannot restore task ${task.id}: PR binding ${binding.reason}`);
8640
+ }
8641
+ if (accepted.kind !== 'valid') {
8642
+ throw new ApiError(409, `Cannot restore task ${task.id}: ${accepted.reason}`);
8643
+ }
8644
+ if (accepted.pendingCount > 0) {
8645
+ throw new ApiError(409, `Cannot restore task ${task.id}: the accepted pass has pending feedback`);
8646
+ }
8647
+ const reviewedPass = task.passProvenance;
8648
+ if (!reviewedPass || binding.headSha.toLowerCase() !== reviewedPass.anchorSha.toLowerCase()) {
8649
+ throw new ApiError(409, `Cannot restore task ${task.id}: the current PR head is not the accepted head`);
8650
+ }
8651
+ return this.withTaskLock(async () => {
8652
+ const fresh = await this.taskStore.get(task.id);
8653
+ if (!fresh || fresh.status !== 'approved'
8654
+ || fresh.postApproveRevoked?.generation !== task.postApproveRevoked.generation
8655
+ || fresh.prNumber !== task.prNumber
8656
+ || !samePassProvenance(fresh.passProvenance, reviewedPass)) {
8657
+ throw new ApiError(409, `Task ${task.id} changed while restoring post-approve replay`);
8658
+ }
8659
+ const generation = createSignalToken();
8660
+ const token = createSignalTokenExcluding(generation);
8661
+ const next = {
8662
+ ...this.stripPostApproveEpisodeFields(fresh),
8663
+ postApproveGeneration: generation,
8664
+ postApproveHeadSha: binding.headSha,
8665
+ postApproveToken: token,
8666
+ postApprovePhase: 'installed',
8667
+ pendingRedispatch: true,
8668
+ redispatchCount: 0,
8669
+ updatedAt: new Date().toISOString(),
8670
+ };
8671
+ await this.taskStore.set(next);
8672
+ return next;
8673
+ });
8674
+ }
8675
+ async submitTaskVerdict(taskId, action, comments, note) {
8676
+ const task = await this.taskStore.get(taskId);
8677
+ if (!task)
8678
+ throw new ApiError(404, `Task ${taskId} not found`);
8679
+ let updated;
8680
+ if (task.status === 'spec-ready'
8681
+ || (task.status === 'max_rounds' && isSpecStagePhase(task.phase))) {
8682
+ if (action !== 'approve' && action !== 'request-changes') {
8683
+ throw new ApiError(409, `Task ${taskId} requires a spec approve or request-changes verdict`);
8684
+ }
8685
+ updated = await this.submitSpecVerdict(taskId, action, comments);
8686
+ }
8687
+ else if (task.status === 'review' && !isSpecStagePhase(task.phase)) {
8688
+ if (action !== 'pass' && action !== 'request-changes') {
8689
+ throw new ApiError(409, `Task ${taskId} requires a code pass or request-changes verdict`);
8690
+ }
8691
+ updated = await this.submitCodeVerdict(taskId, action, comments);
8692
+ }
8693
+ else if (task.status === 'max_rounds') {
8694
+ if (action === 'continue')
8695
+ updated = await this.continueDevRound(taskId);
8696
+ else if (action === 'complete')
8697
+ updated = await this.markTaskComplete(taskId);
8698
+ else
8699
+ throw new ApiError(409, `Task ${taskId} requires continue or complete at code max_rounds`);
8700
+ }
8701
+ else if (task.status === 'merge-ready') {
8702
+ if (action !== 'confirm-merge') {
8703
+ throw new ApiError(409, `Task ${taskId} requires confirm-merge`);
8704
+ }
8705
+ updated = await this.markTaskComplete(taskId);
8706
+ }
8707
+ else {
8708
+ throw new ApiError(409, `Task ${taskId} cannot accept a verdict from ${task.status}`);
8709
+ }
8710
+ await this.auditHumanTaskOperation(updated, 'verdict', action, note);
8711
+ return updated;
8712
+ }
8713
+ async auditHumanTaskOperation(task, operation, action, note, details = {}) {
8714
+ const emitted = await this.safeEmit({
8715
+ id: '',
8716
+ type: 'task.updated',
8717
+ timestamp: new Date().toISOString(),
8718
+ projectId: task.projectId,
8719
+ ...(task.agentId ? { agentId: task.agentId } : {}),
8720
+ taskId: task.id,
8721
+ data: {
8722
+ ...details,
8723
+ operation,
8724
+ action,
8725
+ actor: 'human',
8726
+ provenance: 'human',
8727
+ note: note?.trim() || `${operation}:${action}`,
8728
+ },
8729
+ });
8730
+ if (!emitted) {
8731
+ throw new ApiError(500, `Task ${task.id} ${operation} completed, but its audit event could not be persisted; verify the task state before retrying`);
8732
+ }
8733
+ }
8641
8734
  async dispatchGitFixToDev(taskId) {
8642
8735
  const task = await this.taskStore.get(taskId);
8643
8736
  if (!task || task.status !== 'fixing')
@@ -8730,7 +8823,7 @@ export class AgentManager {
8730
8823
  const devState = await this.agentStore.get(devAgentId);
8731
8824
  if (devState?.taskId !== taskId || !devState.workdir) {
8732
8825
  throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
8733
- `use mark-complete to merge the PR as-is, or cancel the task`);
8826
+ `submit a complete verdict to merge the PR as-is, or cancel the task`);
8734
8827
  }
8735
8828
  const prevReviewRound = task.reviewRound;
8736
8829
  const transitioned = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['max_rounds'] }, { reviewRound: prevReviewRound + 1, fixDispatchedAt: new Date().toISOString() });
@@ -8822,26 +8915,53 @@ export class AgentManager {
8822
8915
  const t = await this.taskStore.get(taskId);
8823
8916
  if (!t)
8824
8917
  throw new ApiError(404, 'Task not found');
8825
- const retryable = TERMINAL_STATUSES.includes(t.status)
8826
- || (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
8827
- if (!retryable) {
8918
+ if (!TERMINAL_STATUSES.includes(t.status)) {
8828
8919
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
8829
8920
  }
8921
+ if (t.replacementTaskId !== undefined) {
8922
+ throw new ApiError(409, `Task ${taskId} was already retried as ${t.replacementTaskId}`);
8923
+ }
8924
+ if (this.retryTaskInFlight.has(taskId)) {
8925
+ throw new ApiError(409, `Task ${taskId} retry is already in progress`);
8926
+ }
8927
+ this.retryTaskInFlight.add(taskId);
8830
8928
  return t;
8831
8929
  });
8832
- const input = {
8833
- title: old.title,
8834
- description: old.description,
8835
- preferredAgentId: this.resolveRetryPreferredAgentId(old),
8836
- };
8837
- if (old.images?.length) {
8838
- input.images = await this.readStagedImages(old.id, old.images);
8930
+ try {
8931
+ const input = {
8932
+ title: old.title,
8933
+ description: old.description,
8934
+ preferredAgentId: this.resolveRetryPreferredAgentId(old),
8935
+ };
8936
+ if (old.images?.length) {
8937
+ input.images = await this.readStagedImages(old.id, old.images);
8938
+ }
8939
+ await this.validateTaskDispatch(old.projectId, input);
8940
+ const replacement = await this.createTask(old.projectId, input);
8941
+ await this.withTaskLock(async () => {
8942
+ const source = await this.taskStore.get(taskId);
8943
+ if (!source)
8944
+ throw new ApiError(404, `Task ${taskId} disappeared while recording its retry`);
8945
+ if (source.replacementTaskId !== undefined && source.replacementTaskId !== replacement.id) {
8946
+ throw new ApiError(409, `Task ${taskId} was already retried as ${source.replacementTaskId}`);
8947
+ }
8948
+ const next = {
8949
+ ...source,
8950
+ replacementTaskId: replacement.id,
8951
+ updatedAt: new Date().toISOString(),
8952
+ };
8953
+ if (old.attention !== undefined
8954
+ && source.attention?.reason === old.attention.reason
8955
+ && source.attention.occurredAt === old.attention.occurredAt) {
8956
+ delete next.attention;
8957
+ }
8958
+ await this.taskStore.set(next);
8959
+ });
8960
+ return this.startCreatedTask(replacement);
8839
8961
  }
8840
- await this.validateTaskDispatch(old.projectId, input);
8841
- if (!TERMINAL_STATUSES.includes(old.status)) {
8842
- await this.cancelTask(old.id);
8962
+ finally {
8963
+ this.retryTaskInFlight.delete(taskId);
8843
8964
  }
8844
- return this.createAndStartTask(old.projectId, input);
8845
8965
  }
8846
8966
  resolveRetryPreferredAgentId(task) {
8847
8967
  const configured = this.getAgentConfig(task.preferredAgentId);
@@ -8851,7 +8971,7 @@ export class AgentManager {
8851
8971
  const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
8852
8972
  .filter((agentId) => typeof agentId === 'string' && agentId !== ''));
8853
8973
  for (const anchor of anchors) {
8854
- const dev = this.findAgentGroup(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8974
+ const dev = this.findAgentTeam(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8855
8975
  const current = dev ? this.getAgentConfig(dev.id) : undefined;
8856
8976
  if (current?.projectId === task.projectId)
8857
8977
  return current.id;
@@ -8893,13 +9013,13 @@ export class AgentManager {
8893
9013
  }
8894
9014
  if (!TASK_OWNER_ROLES.has(cfg.role))
8895
9015
  throw new ApiError(400, 'Agent is not a dev agent');
8896
- const group = this.findAgentGroup(cfg.id);
8897
- const dev = group?.find(agent => agent.role === 'dev');
9016
+ const team = this.findAgentTeam(cfg.id);
9017
+ const dev = team?.find(agent => agent.role === 'dev');
8898
9018
  if (!dev)
8899
- throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
8900
- const qa = group?.find(agent => agent.role === 'qa');
9019
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its Agent Team`);
9020
+ const qa = team?.find(agent => agent.role === 'qa');
8901
9021
  if (!qa)
8902
- throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its group`);
9022
+ throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its Agent Team`);
8903
9023
  task.preferredAgentId = patch.preferredAgentId;
8904
9024
  task.devAgentId = dev.id;
8905
9025
  task.qaAgentId = qa.id;
@@ -8937,22 +9057,22 @@ export class AgentManager {
8937
9057
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
8938
9058
  }
8939
9059
  if (isSpecStagePhase(task.phase)) {
8940
- throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
9060
+ throw new ApiError(409, `Complete verdict is only supported for code-phase tasks`);
8941
9061
  }
8942
9062
  if (!task.prNumber || !task.branch) {
8943
- throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
9063
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot complete`);
8944
9064
  }
8945
9065
  for (const agentId of [task.agentId, task.qaAgentId]) {
8946
9066
  if (!agentId)
8947
9067
  continue;
8948
9068
  const state = await this.agentStore.get(agentId);
8949
9069
  if (state?.status === 'awaiting_human' && state.taskId === taskId) {
8950
- throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before marking complete`);
9070
+ throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before completing`);
8951
9071
  }
8952
9072
  }
8953
9073
  const claimed = await this.transitionTaskStatus(taskId, 'merge-ready', { fromStatus: ['max_rounds'] });
8954
9074
  if (!claimed) {
8955
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9075
+ throw new ApiError(409, `Task ${taskId} changed status during complete verdict; aborted`);
8956
9076
  }
8957
9077
  try {
8958
9078
  await this.mergePr(taskId, { humanOverride: true });
@@ -9005,8 +9125,6 @@ export class AgentManager {
9005
9125
  const agent = this.getAgentConfig(agentId);
9006
9126
  if (!agent)
9007
9127
  return;
9008
- // Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
9009
- // agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
9010
9128
  const genAtEntry = this.deletionGenerationOf(agentId);
9011
9129
  const state = await this.agentStore.get(agentId);
9012
9130
  if (!state)
@@ -9062,7 +9180,6 @@ export class AgentManager {
9062
9180
  return;
9063
9181
  }
9064
9182
  }
9065
- // A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
9066
9183
  if (!this.deletionGateOpen(agentId, genAtEntry))
9067
9184
  return;
9068
9185
  const runner = this.createRunnerFor(agent);
@@ -9284,15 +9401,11 @@ export class AgentManager {
9284
9401
  return;
9285
9402
  }
9286
9403
  if (Date.now() >= deadline) {
9287
- // 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
9288
- // 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
9289
9404
  throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
9290
9405
  }
9291
9406
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
9292
9407
  }
9293
9408
  }
9294
- // 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
9295
- // 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
9296
9409
  async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
9297
9410
  const paneId = pane.paneId;
9298
9411
  const samples = Math.max(1, this.readyStableSamples);
@@ -9370,8 +9483,6 @@ export class AgentManager {
9370
9483
  async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
9371
9484
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
9372
9485
  skipSnapshot,
9373
- // Every post-dispatch arm follows a freshly injected prompt: the previous
9374
- // question is superseded, its watermark must not carry over.
9375
9486
  needInputMode: 'fresh',
9376
9487
  });
9377
9488
  if (!armed)
@@ -9471,6 +9582,247 @@ export class AgentManager {
9471
9582
  }
9472
9583
  return parked;
9473
9584
  }
9585
+ async submitCodeVerdict(taskId, verdict, comments) {
9586
+ const trimmed = comments?.trim() ?? '';
9587
+ if (verdict === 'request-changes' && !trimmed) {
9588
+ throw new ApiError(400, 'comments is required for request-changes');
9589
+ }
9590
+ if (extractReviewTokens(trimmed).length > 0) {
9591
+ throw new ApiError(400, 'comments must not contain baxian review markers');
9592
+ }
9593
+ const task = await this.withTaskLock(async () => {
9594
+ const fresh = await this.taskStore.get(taskId);
9595
+ if (!fresh)
9596
+ throw new ApiError(404, `Task ${taskId} not found`);
9597
+ const pending = this.codeVerdictOutbox(fresh);
9598
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9599
+ if (pending !== undefined
9600
+ && (pending.data.kind !== kind || pending.data.comments !== trimmed)) {
9601
+ throw new ApiError(409, `Task ${taskId} has a different code verdict already in progress`);
9602
+ }
9603
+ if (pending === undefined && (fresh.status !== 'review' || isSpecStagePhase(fresh.phase))) {
9604
+ throw new ApiError(409, `Task ${taskId} is not awaiting a code review verdict`);
9605
+ }
9606
+ if (this.codeVerdictInFlight.has(taskId)) {
9607
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
9608
+ }
9609
+ this.codeVerdictInFlight.add(taskId);
9610
+ return fresh;
9611
+ });
9612
+ try {
9613
+ return await this.executeCodeVerdict(task, verdict, trimmed);
9614
+ }
9615
+ finally {
9616
+ this.codeVerdictInFlight.delete(taskId);
9617
+ }
9618
+ }
9619
+ codeVerdictCommentBody(entry) {
9620
+ const lines = [
9621
+ entry.data.kind === 'pass'
9622
+ ? 'Human code verdict: pass'
9623
+ : 'Human code verdict: request changes',
9624
+ ];
9625
+ if (entry.data.comments)
9626
+ lines.push('', entry.data.comments);
9627
+ lines.push('', buildReviewTokenLine({
9628
+ kind: entry.data.kind,
9629
+ anchorSha: entry.data.anchorSha,
9630
+ token: entry.data.token,
9631
+ }));
9632
+ const body = lines.join('\n');
9633
+ try {
9634
+ validateCommentBody(body);
9635
+ }
9636
+ catch (err) {
9637
+ throw new ApiError(400, `Invalid code verdict comment: ${err instanceof Error ? err.message : String(err)}`);
9638
+ }
9639
+ return body;
9640
+ }
9641
+ async scanCodeVerdictMarker(driver, entry, oppositeToken) {
9642
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9643
+ if (scans.some(scan => !scan.ok))
9644
+ return 'unknown';
9645
+ const dead = deadTokens(scans);
9646
+ if (dead.has(entry.data.token))
9647
+ return 'dead';
9648
+ let present = false;
9649
+ let opposite = false;
9650
+ for (const scan of scans) {
9651
+ for (const row of scan.rows) {
9652
+ if (row.system === true || versionTimeOf(row) === undefined)
9653
+ continue;
9654
+ for (const marker of rowTokens(row)) {
9655
+ if (dead.has(marker.token))
9656
+ continue;
9657
+ if (marker.anchorSha.toLowerCase() !== entry.data.anchorSha.toLowerCase())
9658
+ continue;
9659
+ if (marker.kind === entry.data.kind && marker.token === entry.data.token)
9660
+ present = true;
9661
+ if (marker.kind !== entry.data.kind && marker.token === oppositeToken)
9662
+ opposite = true;
9663
+ }
9664
+ }
9665
+ }
9666
+ if (opposite)
9667
+ return 'opposite';
9668
+ return present ? 'present' : 'absent';
9669
+ }
9670
+ async reconcileCodeVerdictComment(driver, entry) {
9671
+ const expectedDigest = bodyDigest(this.codeVerdictCommentBody(entry));
9672
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9673
+ if (scans.some(scan => !scan.ok))
9674
+ return 'unknown';
9675
+ if (deadTokens(scans).has(entry.data.token))
9676
+ return 'dead';
9677
+ if (scans.some(scan => scan.rows.some(row => row.system !== true
9678
+ && versionTimeOf(row) !== undefined
9679
+ && row.bodyDigest === expectedDigest)))
9680
+ return 'present';
9681
+ return 'absent';
9682
+ }
9683
+ async verifyCodeVerdictHead(taskId, prNumber, anchorSha) {
9684
+ try {
9685
+ const binding = await this.platformVerifyPrBinding(taskId, prNumber);
9686
+ if (!binding.ok)
9687
+ return { ok: false, reason: `the PR binding is ${binding.reason}` };
9688
+ if (binding.headSha.toLowerCase() !== anchorSha.toLowerCase()) {
9689
+ return { ok: false, reason: 'the PR head changed' };
9690
+ }
9691
+ return { ok: true };
9692
+ }
9693
+ catch (err) {
9694
+ throw new ApiError(503, `Cannot verify code verdict head for task ${taskId}: ${safeDriverErrorText(err)}`);
9695
+ }
9696
+ }
9697
+ async deliverCodeVerdictOutbox(taskId, key) {
9698
+ let task = await this.taskStore.get(taskId);
9699
+ let entry = task ? this.codeVerdictOutbox(task) : undefined;
9700
+ if (!task || !entry || entry.key !== key)
9701
+ return;
9702
+ const driver = this.platformDriverFor(task.projectId);
9703
+ if (!driver)
9704
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9705
+ const binding = await this.verifyCodeVerdictHead(task.id, entry.data.prNumber, entry.data.anchorSha);
9706
+ if (!binding.ok) {
9707
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9708
+ throw new ApiError(409, `Cannot write code verdict for task ${task.id}: ${binding.reason}`);
9709
+ }
9710
+ if (entry.data.writeAttemptedAt !== undefined) {
9711
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9712
+ if (reconciled === 'dead') {
9713
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9714
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9715
+ }
9716
+ if (reconciled === 'present') {
9717
+ return;
9718
+ }
9719
+ if (reconciled === 'unknown'
9720
+ || Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
9721
+ throw new ApiError(503, `Code verdict comment outcome is still being reconciled for task ${task.id}`);
9722
+ }
9723
+ }
9724
+ const expectedAttempt = entry.data.writeAttemptedAt;
9725
+ const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.code-verdict' && current.data.writeAttemptedAt === expectedAttempt
9726
+ ? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
9727
+ : null);
9728
+ const claimedEntry = claimed ? this.codeVerdictOutbox(claimed) : undefined;
9729
+ if (!claimed || claimedEntry?.key !== entry.key) {
9730
+ throw new ApiError(503, `Code verdict comment write is already claimed for task ${task.id}`);
9731
+ }
9732
+ task = claimed;
9733
+ entry = claimedEntry;
9734
+ let writeFailure;
9735
+ try {
9736
+ await driver.runOp('comment', {
9737
+ prNumber: entry.data.prNumber,
9738
+ body: this.codeVerdictCommentBody(entry),
9739
+ });
9740
+ }
9741
+ catch (error) {
9742
+ writeFailure = { error };
9743
+ }
9744
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9745
+ if (reconciled === 'dead') {
9746
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9747
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9748
+ }
9749
+ if (writeFailure !== undefined && reconciled !== 'present') {
9750
+ const { error } = writeFailure;
9751
+ const uncertain = !(error instanceof DriverOpError)
9752
+ || error.info.exitCode === 255
9753
+ || isTransientNetworkFailure(error.info.stderrTail ?? '')
9754
+ || isTransientNetworkFailure(error.message);
9755
+ if (reconciled === 'unknown' || uncertain) {
9756
+ throw new ApiError(503, `Code verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
9757
+ }
9758
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9759
+ throw new ApiError(502, `Code verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
9760
+ }
9761
+ if (reconciled !== 'present') {
9762
+ throw new ApiError(503, `Code verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
9763
+ }
9764
+ }
9765
+ async executeCodeVerdict(task, verdict, comments) {
9766
+ if (task.status !== 'review' || isSpecStagePhase(task.phase)
9767
+ || task.prNumber === undefined || task.reviewHeadAnchorSha === undefined
9768
+ || task.passToken === undefined || task.failToken === undefined) {
9769
+ throw new ApiError(409, `Task ${task.id} has no complete code review generation`);
9770
+ }
9771
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9772
+ const token = kind === 'pass' ? task.passToken : task.failToken;
9773
+ const existingEntry = this.codeVerdictOutbox(task);
9774
+ const entry = existingEntry ?? {
9775
+ key: token,
9776
+ type: 'git.code-verdict',
9777
+ data: {
9778
+ prNumber: task.prNumber,
9779
+ kind,
9780
+ anchorSha: task.reviewHeadAnchorSha,
9781
+ token,
9782
+ comments,
9783
+ },
9784
+ };
9785
+ this.codeVerdictCommentBody(entry);
9786
+ const driver = this.platformDriverFor(task.projectId);
9787
+ if (!driver)
9788
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9789
+ const binding = await this.verifyCodeVerdictHead(task.id, task.prNumber, task.reviewHeadAnchorSha);
9790
+ if (!binding.ok) {
9791
+ throw new ApiError(409, `Cannot submit code verdict for task ${task.id}: ${binding.reason}`);
9792
+ }
9793
+ if (existingEntry === undefined) {
9794
+ const marker = await this.scanCodeVerdictMarker(driver, entry, kind === 'pass' ? task.failToken : task.passToken);
9795
+ if (marker === 'present')
9796
+ return task;
9797
+ if (marker === 'opposite') {
9798
+ throw new ApiError(409, `Task ${task.id} already has the opposite verdict on the current review head`);
9799
+ }
9800
+ if (marker === 'dead') {
9801
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9802
+ }
9803
+ if (marker === 'unknown') {
9804
+ throw new ApiError(503, `Cannot safely check existing code verdict comments for task ${task.id}`);
9805
+ }
9806
+ const created = await this.withTaskLock(async () => {
9807
+ const fresh = await this.taskStore.get(task.id);
9808
+ if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
9809
+ || this.codeVerdictOutbox(fresh) !== undefined)
9810
+ return null;
9811
+ const next = {
9812
+ ...fresh,
9813
+ outbox: [...(fresh.outbox ?? []), entry],
9814
+ updatedAt: new Date().toISOString(),
9815
+ };
9816
+ await this.taskStore.set(next);
9817
+ return next;
9818
+ });
9819
+ if (!created)
9820
+ throw new ApiError(409, `Task ${task.id} changed while recording the code verdict`);
9821
+ task = created;
9822
+ }
9823
+ await this.deliverCodeVerdictOutbox(task.id, entry.key);
9824
+ return (await this.taskStore.get(task.id)) ?? task;
9825
+ }
9474
9826
  async submitSpecVerdict(taskId, verdict, comments) {
9475
9827
  const trimmed = comments?.trim();
9476
9828
  if (verdict === 'request-changes' && !trimmed) {
@@ -9812,7 +10164,6 @@ export class AgentManager {
9812
10164
  return null;
9813
10165
  }
9814
10166
  const newToken = createSignalToken();
9815
- // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
9816
10167
  const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
9817
10168
  fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
9818
10169
  ...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
@@ -9868,8 +10219,6 @@ export class AgentManager {
9868
10219
  }
9869
10220
  else if (err instanceof EnsureSessionError && err.partial.handled
9870
10221
  && (task.status === 'spec-ready' || task.status === 'max_rounds')) {
9871
- // dev 已被 checkout/dialog hold 且 prompt 未送达;resumeAgent 不重派这些 hold,
9872
- // 任务停在 in_progress 会同时封死 Resume 与 verdict——退回 verdict 门禁状态
9873
10222
  const rolledBack = await this.transitionTaskStatus(taskId, task.status, { fromStatus: ['in_progress'] }, {
9874
10223
  ...(task.phase !== undefined ? { phase: task.phase } : {}),
9875
10224
  agentId: task.agentId,
@@ -9881,8 +10230,6 @@ export class AgentManager {
9881
10230
  + 'moved on; leaving its status as-is');
9882
10231
  }
9883
10232
  else if (!devBoundBefore) {
9884
- // 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
9885
- // verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
9886
10233
  const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
9887
10234
  .catch(() => false);
9888
10235
  if (!released) {
@@ -9907,8 +10254,6 @@ export class AgentManager {
9907
10254
  });
9908
10255
  return null;
9909
10256
  }
9910
- // 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
9911
- // startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
9912
10257
  await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
9913
10258
  return await this.taskStore.get(taskId);
9914
10259
  }
@@ -9916,7 +10261,6 @@ export class AgentManager {
9916
10261
  const cfg = this.getAgentConfig(agentId);
9917
10262
  if (!cfg)
9918
10263
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
9919
- // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
9920
10264
  const genAtEntry = this.deletionGenerationOf(agentId);
9921
10265
  await this.acquireCompactGuard(agentId);
9922
10266
  try {
@@ -9953,7 +10297,6 @@ export class AgentManager {
9953
10297
  }
9954
10298
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
9955
10299
  const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
9956
- // resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
9957
10300
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
9958
10301
  throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
9959
10302
  }
@@ -10119,7 +10462,6 @@ export class AgentManager {
10119
10462
  }
10120
10463
  }
10121
10464
  startCompactionThenRelease(agentId, state, taskId) {
10122
- // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
10123
10465
  const expectedPaneId = state.paneId;
10124
10466
  if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
10125
10467
  return false;
@@ -10137,8 +10479,8 @@ export class AgentManager {
10137
10479
  function buildAgentIndex(config) {
10138
10480
  const index = new Map();
10139
10481
  for (const project of config.project) {
10140
- for (const pair of project.agent) {
10141
- for (const agent of pair) {
10482
+ for (const team of project.agent) {
10483
+ for (const agent of team) {
10142
10484
  index.set(agent.id, { ...agent, projectId: project.id });
10143
10485
  }
10144
10486
  }