baxian 2.0.0 → 2.0.2

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 (198) hide show
  1. package/README.md +5 -7
  2. package/README.zh-CN.md +5 -7
  3. package/dist/agent/bootstrap.d.ts.map +1 -1
  4. package/dist/agent/bootstrap.js +2 -4
  5. package/dist/agent/bootstrap.js.map +1 -1
  6. package/dist/agent/branch.d.ts +0 -13
  7. package/dist/agent/branch.d.ts.map +1 -1
  8. package/dist/agent/branch.js +1 -71
  9. package/dist/agent/branch.js.map +1 -1
  10. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  11. package/dist/agent/dispatch-reconciler.js +19 -14
  12. package/dist/agent/dispatch-reconciler.js.map +1 -1
  13. package/dist/agent/manager.d.ts +90 -134
  14. package/dist/agent/manager.d.ts.map +1 -1
  15. package/dist/agent/manager.js +1154 -2265
  16. package/dist/agent/manager.js.map +1 -1
  17. package/dist/agent/net-exec.d.ts +3 -0
  18. package/dist/agent/net-exec.d.ts.map +1 -1
  19. package/dist/agent/net-exec.js +6 -0
  20. package/dist/agent/net-exec.js.map +1 -1
  21. package/dist/agent/pane-streamer-manager.d.ts +6 -6
  22. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  23. package/dist/agent/pane-streamer-manager.js.map +1 -1
  24. package/dist/agent/pane-streamer.d.ts +6 -3
  25. package/dist/agent/pane-streamer.d.ts.map +1 -1
  26. package/dist/agent/pane-streamer.js +70 -40
  27. package/dist/agent/pane-streamer.js.map +1 -1
  28. package/dist/agent/phase-signal-watcher.d.ts +7 -2
  29. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  30. package/dist/agent/phase-signal-watcher.js +90 -32
  31. package/dist/agent/phase-signal-watcher.js.map +1 -1
  32. package/dist/agent/phase-signal.d.ts +19 -38
  33. package/dist/agent/phase-signal.d.ts.map +1 -1
  34. package/dist/agent/phase-signal.js +54 -88
  35. package/dist/agent/phase-signal.js.map +1 -1
  36. package/dist/agent/preflight.d.ts +0 -1
  37. package/dist/agent/preflight.d.ts.map +1 -1
  38. package/dist/agent/preflight.js +8 -15
  39. package/dist/agent/preflight.js.map +1 -1
  40. package/dist/agent/prompt.d.ts +1 -35
  41. package/dist/agent/prompt.d.ts.map +1 -1
  42. package/dist/agent/prompt.js +22 -162
  43. package/dist/agent/prompt.js.map +1 -1
  44. package/dist/agent/repo-store.d.ts +4 -1
  45. package/dist/agent/repo-store.d.ts.map +1 -1
  46. package/dist/agent/repo-store.js +33 -11
  47. package/dist/agent/repo-store.js.map +1 -1
  48. package/dist/agent/root-agent-runtime.d.ts +80 -0
  49. package/dist/agent/root-agent-runtime.d.ts.map +1 -0
  50. package/dist/agent/root-agent-runtime.js +637 -0
  51. package/dist/agent/root-agent-runtime.js.map +1 -0
  52. package/dist/agent/root-recovery-coordinator.d.ts +124 -0
  53. package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
  54. package/dist/agent/root-recovery-coordinator.js +1557 -0
  55. package/dist/agent/root-recovery-coordinator.js.map +1 -0
  56. package/dist/agent/runner.d.ts +1 -0
  57. package/dist/agent/runner.d.ts.map +1 -1
  58. package/dist/agent/runner.js +103 -7
  59. package/dist/agent/runner.js.map +1 -1
  60. package/dist/agent/tmux-probe-poller.d.ts +1 -0
  61. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  62. package/dist/agent/tmux-probe-poller.js +16 -10
  63. package/dist/agent/tmux-probe-poller.js.map +1 -1
  64. package/dist/agent/tmux.d.ts +3 -0
  65. package/dist/agent/tmux.d.ts.map +1 -1
  66. package/dist/agent/tmux.js +11 -5
  67. package/dist/agent/tmux.js.map +1 -1
  68. package/dist/agent/vt-visible-text.d.ts +42 -0
  69. package/dist/agent/vt-visible-text.d.ts.map +1 -0
  70. package/dist/agent/vt-visible-text.js +482 -0
  71. package/dist/agent/vt-visible-text.js.map +1 -0
  72. package/dist/api/agents.d.ts.map +1 -1
  73. package/dist/api/agents.js +42 -1
  74. package/dist/api/agents.js.map +1 -1
  75. package/dist/api/config.d.ts.map +1 -1
  76. package/dist/api/config.js +83 -9
  77. package/dist/api/config.js.map +1 -1
  78. package/dist/api/hosts.d.ts.map +1 -1
  79. package/dist/api/hosts.js +68 -13
  80. package/dist/api/hosts.js.map +1 -1
  81. package/dist/api/platform-guard.js +2 -2
  82. package/dist/api/platform-guard.js.map +1 -1
  83. package/dist/api/projects.d.ts.map +1 -1
  84. package/dist/api/projects.js +621 -325
  85. package/dist/api/projects.js.map +1 -1
  86. package/dist/api/tasks.d.ts.map +1 -1
  87. package/dist/api/tasks.js +15 -39
  88. package/dist/api/tasks.js.map +1 -1
  89. package/dist/app.d.ts +2 -0
  90. package/dist/app.d.ts.map +1 -1
  91. package/dist/app.js +6 -0
  92. package/dist/app.js.map +1 -1
  93. package/dist/cli.d.ts +1 -1
  94. package/dist/cli.d.ts.map +1 -1
  95. package/dist/cli.js +49 -7
  96. package/dist/cli.js.map +1 -1
  97. package/dist/config/loader.d.ts +5 -1
  98. package/dist/config/loader.d.ts.map +1 -1
  99. package/dist/config/loader.js +57 -24
  100. package/dist/config/loader.js.map +1 -1
  101. package/dist/config/normalizer.d.ts.map +1 -1
  102. package/dist/config/normalizer.js +18 -0
  103. package/dist/config/normalizer.js.map +1 -1
  104. package/dist/config/validator.d.ts +7 -4
  105. package/dist/config/validator.d.ts.map +1 -1
  106. package/dist/config/validator.js +251 -96
  107. package/dist/config/validator.js.map +1 -1
  108. package/dist/event/handlers.d.ts.map +1 -1
  109. package/dist/event/handlers.js +359 -229
  110. package/dist/event/handlers.js.map +1 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +45 -5
  113. package/dist/index.js.map +1 -1
  114. package/dist/platform/command-renderer.d.ts +6 -0
  115. package/dist/platform/command-renderer.d.ts.map +1 -1
  116. package/dist/platform/command-renderer.js +20 -0
  117. package/dist/platform/command-renderer.js.map +1 -1
  118. package/dist/platform/driver-host.d.ts.map +1 -1
  119. package/dist/platform/driver-host.js +3 -1
  120. package/dist/platform/driver-host.js.map +1 -1
  121. package/dist/platform/driver-spec.d.ts.map +1 -1
  122. package/dist/platform/driver-spec.js +32 -4
  123. package/dist/platform/driver-spec.js.map +1 -1
  124. package/dist/platform/git-driver.d.ts +2 -0
  125. package/dist/platform/git-driver.d.ts.map +1 -1
  126. package/dist/platform/git-driver.js +9 -3
  127. package/dist/platform/git-driver.js.map +1 -1
  128. package/dist/platform/platform-poller.d.ts +0 -1
  129. package/dist/platform/platform-poller.d.ts.map +1 -1
  130. package/dist/platform/platform-poller.js +1 -11
  131. package/dist/platform/platform-poller.js.map +1 -1
  132. package/dist/platform/plugins/github/driver.json +5 -0
  133. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +20 -14
  134. package/dist/platform/startup.d.ts.map +1 -1
  135. package/dist/platform/startup.js +5 -5
  136. package/dist/platform/startup.js.map +1 -1
  137. package/dist/platform/types.d.ts +1 -0
  138. package/dist/platform/types.d.ts.map +1 -1
  139. package/dist/platform/types.js +3 -3
  140. package/dist/platform/types.js.map +1 -1
  141. package/dist/shared/constants.d.ts +8 -7
  142. package/dist/shared/constants.d.ts.map +1 -1
  143. package/dist/shared/constants.js +6 -28
  144. package/dist/shared/constants.js.map +1 -1
  145. package/dist/shared/types.d.ts +52 -125
  146. package/dist/shared/types.d.ts.map +1 -1
  147. package/dist/shared/types.js +24 -5
  148. package/dist/shared/types.js.map +1 -1
  149. package/dist/skills/baxian-pr-feedback/SKILL.md +6 -6
  150. package/dist/skills/baxian-pr-recheck/SKILL.md +6 -0
  151. package/dist/skills/baxian-pr-review/SKILL.md +6 -0
  152. package/dist/skills/baxian-signals/SKILL.md +17 -28
  153. package/dist/skills/baxian-task-check/SKILL.md +13 -14
  154. package/dist/state/init.d.ts.map +1 -1
  155. package/dist/state/init.js +1 -0
  156. package/dist/state/init.js.map +1 -1
  157. package/dist/state/lock.d.ts +6 -0
  158. package/dist/state/lock.d.ts.map +1 -1
  159. package/dist/state/lock.js +8 -0
  160. package/dist/state/lock.js.map +1 -1
  161. package/dist/state/root-recovery-store.d.ts +107 -0
  162. package/dist/state/root-recovery-store.d.ts.map +1 -0
  163. package/dist/state/root-recovery-store.js +400 -0
  164. package/dist/state/root-recovery-store.js.map +1 -0
  165. package/dist/state/snapshot.d.ts.map +1 -1
  166. package/dist/state/snapshot.js +5 -2
  167. package/dist/state/snapshot.js.map +1 -1
  168. package/dist/state/task-store.d.ts.map +1 -1
  169. package/dist/state/task-store.js +57 -137
  170. package/dist/state/task-store.js.map +1 -1
  171. package/dist/terminal/attach.d.ts +3 -3
  172. package/dist/terminal/attach.d.ts.map +1 -1
  173. package/dist/terminal/attach.js.map +1 -1
  174. package/dist/terminal/stream-ws.d.ts.map +1 -1
  175. package/dist/terminal/stream-ws.js.map +1 -1
  176. package/dist/web/assets/index-BKiM0MSM.js +15 -0
  177. package/dist/web/assets/index-BS2adAfI.css +1 -0
  178. package/dist/web/index.html +2 -2
  179. package/package.json +1 -1
  180. package/dist/agent/review-transport.d.ts +0 -92
  181. package/dist/agent/review-transport.d.ts.map +0 -1
  182. package/dist/agent/review-transport.js +0 -512
  183. package/dist/agent/review-transport.js.map +0 -1
  184. package/dist/event/server-handlers.d.ts +0 -4
  185. package/dist/event/server-handlers.d.ts.map +0 -1
  186. package/dist/event/server-handlers.js +0 -1221
  187. package/dist/event/server-handlers.js.map +0 -1
  188. package/dist/skills/baxian-research/SKILL.md +0 -28
  189. package/dist/skills/baxian-server-feedback/SKILL.md +0 -40
  190. package/dist/skills/baxian-server-feedback/agents/openai.yaml +0 -2
  191. package/dist/skills/baxian-server-review/SKILL.md +0 -58
  192. package/dist/skills/baxian-server-review/agents/openai.yaml +0 -2
  193. package/dist/state/review-store.d.ts +0 -33
  194. package/dist/state/review-store.d.ts.map +0 -1
  195. package/dist/state/review-store.js +0 -272
  196. package/dist/state/review-store.js.map +0 -1
  197. package/dist/web/assets/index-BPv6vv3U.css +0 -1
  198. package/dist/web/assets/index-BXfgaFNm.js +0 -18
@@ -4,7 +4,7 @@ import { join, normalize } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
6
6
  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, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.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
8
  import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
9
  import { DriverOpError, buildDriverRunContext, GitDriver, safeDriverErrorText } from '../platform/git-driver.js';
10
10
  import { checkOpenPrBinding, checkPrBinding } from '../platform/pr-binding.js';
@@ -14,20 +14,32 @@ import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../plat
14
14
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
15
15
  import { SkillRegistry } from '../skill/registry.js';
16
16
  import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
17
- import { GIT_NET_ENV, execNetwork, isTransientNetworkFailure } from './net-exec.js';
18
- import { findForeignTaskTip } from './lineage.js';
17
+ import { isTransientNetworkFailure } from './net-exec.js';
19
18
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
20
19
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
21
20
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
22
21
  import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
23
22
  import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
24
- import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
25
- import { reviewFindingsDigest } from '../state/review-store.js';
26
23
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
27
24
  import { ApiError } from '../errors.js';
28
25
  import { prepareConfig } from '../config/loader.js';
29
- import { projectAfterDone, projectReviewMode, resolveProjectTool } from '../config/validator.js';
26
+ import { resolveProjectTool } from '../config/validator.js';
30
27
  import { SHA_HEX_SOURCE } from '../platform/types.js';
28
+ import { bodyDigest } from '../platform/body-digest.js';
29
+ import { validateCommentBody } from '../platform/command-renderer.js';
30
+ // 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
31
+ const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
32
+ export function isBenignDispatchConflict(err) {
33
+ return err instanceof ApiError
34
+ && err.status === 409
35
+ && err.code !== undefined
36
+ && BENIGN_DISPATCH_CONFLICT_CODES.has(err.code);
37
+ }
38
+ function requireTaskQaAgentId(task, operation) {
39
+ if (!task.qaAgentId)
40
+ throw new Error(`${operation}: task ${task.id} has no QA participant`);
41
+ return task.qaAgentId;
42
+ }
31
43
  export class EnsureSessionError extends Error {
32
44
  partial;
33
45
  constructor(partial, message) {
@@ -169,8 +181,7 @@ export class PlatformMergeRecheckError extends Error {
169
181
  this.name = 'PlatformMergeRecheckError';
170
182
  }
171
183
  }
172
- const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
173
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
184
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix']);
174
185
  const RUNTIME_LIVENESS_SAMPLES = 3;
175
186
  export function canDispatchWithBinding(binding) {
176
187
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
@@ -185,7 +196,6 @@ export function isRecoverableQaDispatchHold(binding) {
185
196
  && binding.awaitingPhase != null
186
197
  && RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
187
198
  }
188
- const TURN_COMPLETED_AWAITING_PHASES = new Set();
189
199
  const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
190
200
  function isCancelCleanupHold(binding) {
191
201
  return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
@@ -210,7 +220,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
210
220
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
211
221
  return false;
212
222
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
213
- const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
214
223
  // 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
215
224
  // (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
216
225
  const staleCodeHandoff = state.awaitingPhase != null
@@ -218,7 +227,10 @@ function shouldReleaseHeldBinding(state, boundTask) {
218
227
  && !!boundTask
219
228
  && (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
220
229
  && boundTask.agentId !== state.id;
221
- return !boundTask || taskIsTerminal || turnCompleted || staleCodeHandoff;
230
+ const qaAtVerdictGate = !!boundTask
231
+ && (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
232
+ && boundTask.qaAgentId === state.id;
233
+ return !boundTask || taskIsTerminal || staleCodeHandoff || qaAtVerdictGate;
222
234
  }
223
235
  export class AgentManager {
224
236
  config;
@@ -236,9 +248,6 @@ export class AgentManager {
236
248
  platformDriverExec;
237
249
  phaseSignalWatcher;
238
250
  errorRecordStore;
239
- reviewStore;
240
- reviewTransportInstance;
241
- serverReviewDriver;
242
251
  dispatchAckTimeoutMs;
243
252
  needInputRetryIntervalMs;
244
253
  dispatchSettleTimeoutMs;
@@ -262,10 +271,8 @@ export class AgentManager {
262
271
  readyStableSpacingMs = 1_000;
263
272
  pendingDispatchRetryByTask = new Map();
264
273
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
265
- manualReviewInFlight = new Set();
266
274
  markCompleteInFlight = new Set();
267
275
  specVerdictInFlight = new Set();
268
- codeVerdictInFlight = new Set();
269
276
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
270
277
  cancelCleanupInFlight = new Map();
271
278
  deletionInFlight = new Set();
@@ -298,7 +305,6 @@ export class AgentManager {
298
305
  })
299
306
  : undefined);
300
307
  this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
301
- this.reviewStore = deps.reviewStore;
302
308
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
303
309
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
304
310
  this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
@@ -363,67 +369,6 @@ export class AgentManager {
363
369
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
364
370
  return state;
365
371
  }
366
- getReviewStore() {
367
- return this.reviewStore;
368
- }
369
- setServerReviewDriver(driver) {
370
- this.serverReviewDriver = driver;
371
- }
372
- effectiveReviewMode(projectId) {
373
- const project = this.getProjectConfig(projectId);
374
- return project?.review?.mode ?? this.config.review.mode ?? 'git';
375
- }
376
- resolveAfterDone(task) {
377
- if (task.afterDone !== undefined)
378
- return task.afterDone;
379
- return this.coerceAfterDone(task.projectId);
380
- }
381
- coerceAfterDone(projectId) {
382
- const project = this.getProjectConfig(projectId);
383
- return project ? projectAfterDone(this.config, project) : (this.config.review.afterDone ?? null);
384
- }
385
- async commitServerAfterDone(taskId) {
386
- return this.withTaskLock(async () => {
387
- const task = await this.taskStore.get(taskId);
388
- if (!task)
389
- throw new ApiError(404, `Task ${taskId} not found`);
390
- if (task.afterDone === 'pr' && task.platformBinding === undefined) {
391
- throw new Error(`platform binding missing for task ${task.id}; refusing to reconstruct it from live config`);
392
- }
393
- if (task.afterDone === 'pr')
394
- this.assertPlatformBinding(task);
395
- const afterDone = this.resolveAfterDone(task);
396
- const shouldSnapshotBinding = task.afterDone === undefined && afterDone === 'pr';
397
- const defaultBaseBranch = afterDone === 'pr' && task.baseBranch === undefined
398
- ? this.platformDefaultBranchOf?.(task.projectId)
399
- : undefined;
400
- if (task.afterDone === afterDone && !shouldSnapshotBinding && defaultBaseBranch === undefined)
401
- return afterDone;
402
- await this.taskStore.set({
403
- ...task,
404
- afterDone,
405
- ...(shouldSnapshotBinding ? { platformBinding: this.platformBindingFor(task.projectId) } : {}),
406
- ...(defaultBaseBranch !== undefined ? { baseBranch: defaultBaseBranch } : {}),
407
- updatedAt: new Date().toISOString(),
408
- });
409
- return afterDone;
410
- });
411
- }
412
- getReviewTransport() {
413
- this.reviewTransportInstance ??= new ReviewTransport({
414
- createRunnerFor: (agent) => this.createRunnerFor(agent),
415
- resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
416
- });
417
- return this.reviewTransportInstance;
418
- }
419
- async prepareDispatchArtifacts(agent, workdir, phase, opts) {
420
- const transport = this.getReviewTransport();
421
- if (!opts.preserveOutputs)
422
- await transport.clearDispatchOutputs(agent, workdir, phase);
423
- if (opts.specDocuments) {
424
- await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
425
- }
426
- }
427
372
  bindingWorkdirCache = new Map();
428
373
  async refreshWorkdirCacheFor(agentId) {
429
374
  const state = await this.agentStore.get(agentId);
@@ -542,13 +487,13 @@ export class AgentManager {
542
487
  this.deletionInFlight.add(id);
543
488
  return null;
544
489
  }
545
- // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
546
- async scanActiveThenClaimDeletion(targets) {
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
+ async scanOpenThenClaimDeletion(targets) {
547
492
  return this.withTaskLock(async () => {
548
493
  for (const id of targets) {
549
494
  const state = await this.agentStore.get(id);
550
495
  const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
551
- if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
496
+ if (task && isTaskOpen(task.status)) {
552
497
  return { ok: false, code: 'active', agentId: id, taskId: task.id };
553
498
  }
554
499
  if (state?.status === 'awaiting_human')
@@ -557,12 +502,11 @@ export class AgentManager {
557
502
  return { ok: false, code: 'bootstrapping', agentId: id };
558
503
  }
559
504
  const targetSet = new Set(targets);
560
- const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
505
+ const referencing = (await this.taskStore.list({})).find(t => isTaskOpen(t.status)
561
506
  && ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
562
507
  || (!!t.agentId && targetSet.has(t.agentId))
563
508
  || (!!t.devAgentId && targetSet.has(t.devAgentId))
564
- || (!!t.qaAgentId && targetSet.has(t.qaAgentId))
565
- || (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
509
+ || (!!t.qaAgentId && targetSet.has(t.qaAgentId))));
566
510
  if (referencing)
567
511
  return { ok: false, code: 'referencing', taskId: referencing.id };
568
512
  const conflict = this.tryClaimDeletion(targets);
@@ -594,7 +538,6 @@ export class AgentManager {
594
538
  add(task.agentId);
595
539
  add(task.devAgentId, 'dev');
596
540
  add(task.qaAgentId, 'qa');
597
- add(task.researchAgentId, 'research');
598
541
  return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
599
542
  });
600
543
  }
@@ -695,9 +638,8 @@ export class AgentManager {
695
638
  }
696
639
  agentSkillScope(agentId) {
697
640
  const projectId = this.getAgentConfig(agentId)?.projectId;
698
- if (projectId === undefined || this.effectiveReviewMode(projectId) !== 'git') {
641
+ if (projectId === undefined)
699
642
  return { pluginTools: [] };
700
- }
701
643
  const project = this.getProjectConfig(projectId);
702
644
  const tool = project === undefined ? undefined : resolveProjectTool(project);
703
645
  return { pluginTools: tool === undefined ? [] : [tool] };
@@ -1719,10 +1661,7 @@ export class AgentManager {
1719
1661
  ? await this.resolveTaskLockToken(state, taskId)
1720
1662
  : null;
1721
1663
  const sameTaskLocked = existingToken !== null;
1722
- const reentryPhases = new Set([
1723
- 'fix', 'post-approve', 'code',
1724
- 'server-feedback', 'server-after-done',
1725
- ]);
1664
+ const reentryPhases = new Set(['fix', 'post-approve', 'code']);
1726
1665
  const sameTaskReentry = state?.taskId === taskId &&
1727
1666
  !state.creationToken &&
1728
1667
  state.status !== 'awaiting_human' &&
@@ -1814,9 +1753,13 @@ export class AgentManager {
1814
1753
  }
1815
1754
  if (opts.expectedTask
1816
1755
  && boundTask
1817
- && (boundTask.status !== opts.expectedTask.status
1818
- || boundTask.phase !== opts.expectedTask.phase
1819
- || boundTask.signalToken !== opts.expectedTask.signalToken)) {
1756
+ && (('status' in opts.expectedTask && boundTask.status !== opts.expectedTask.status)
1757
+ || ('phase' in opts.expectedTask && boundTask.phase !== opts.expectedTask.phase)
1758
+ || ('signalToken' in opts.expectedTask && boundTask.signalToken !== opts.expectedTask.signalToken)
1759
+ || ('agentId' in opts.expectedTask && boundTask.agentId !== opts.expectedTask.agentId)
1760
+ || ('reviewRound' in opts.expectedTask && boundTask.reviewRound !== opts.expectedTask.reviewRound)
1761
+ || ('specReviewRound' in opts.expectedTask
1762
+ && boundTask.specReviewRound !== opts.expectedTask.specReviewRound))) {
1820
1763
  console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
1821
1764
  return false;
1822
1765
  }
@@ -2284,7 +2227,7 @@ export class AgentManager {
2284
2227
  // ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
2285
2228
  needInputRetry = new Map();
2286
2229
  needInputRetryInFlight = new Set();
2287
- gitReviewQaConfigAlerted = new Set();
2230
+ gitReviewRecoveryAlerted = new Set();
2288
2231
  // Watermark writes currently in the store chain. A restore bump snapshots these along
2289
2232
  // with the queue: an answer whose write has not settled yet would otherwise be invisible
2290
2233
  // to the migration and its error-enqueue would land after the arm already cleared the
@@ -2930,6 +2873,9 @@ export class AgentManager {
2930
2873
  status: boundTask.status,
2931
2874
  phase: boundTask.phase,
2932
2875
  signalToken: boundTask.signalToken,
2876
+ agentId: boundTask.agentId,
2877
+ reviewRound: boundTask.reviewRound,
2878
+ specReviewRound: boundTask.specReviewRound,
2933
2879
  },
2934
2880
  heldLockToken: state.lockToken,
2935
2881
  projectId: state.projectId,
@@ -3036,15 +2982,11 @@ export class AgentManager {
3036
2982
  if (result.redispatchCodeTaskId) {
3037
2983
  try {
3038
2984
  const task = await this.taskStore.get(result.redispatchCodeTaskId);
3039
- const round = task ? (task.specReviewRound ?? 1) : undefined;
3040
- const stored = round === undefined
3041
- ? null
3042
- : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
3043
- if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
3044
- await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
3045
- return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
3046
- }
3047
- const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
2985
+ if (!task || task.phase !== 'code' || !task.signalToken) {
2986
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted task handoff is missing or invalid. Restore the task state or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
2987
+ return { resumed: false, releasedBinding: false, reason: 'Persisted task handoff is missing or invalid.' };
2988
+ }
2989
+ const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken);
3048
2990
  if (!resumed) {
3049
2991
  const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
3050
2992
  await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
@@ -3052,10 +2994,8 @@ export class AgentManager {
3052
2994
  }
3053
2995
  // 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
3054
2996
  // 会让下一次 recover 把已送达的 code prompt 再判成未送达
3055
- if (!this.isResearchHandoff(task, agentId)) {
3056
- const lockToken = (await this.agentStore.get(agentId))?.lockToken;
3057
- await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
3058
- }
2997
+ const lockToken = (await this.agentStore.get(agentId))?.lockToken;
2998
+ await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
3059
2999
  }
3060
3000
  catch (err) {
3061
3001
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
@@ -3100,12 +3040,13 @@ export class AgentManager {
3100
3040
  && resumeEntry.signalToken === reviewTask?.signalToken
3101
3041
  && resumeEntry.qaPhase !== undefined
3102
3042
  ? resumeEntry.qaPhase
3103
- : (reviewTask?.reviewRound === 0 ? 'review' : undefined);
3043
+ : (reviewTask && taskReviewRound(reviewTask) === 0 ? 'review' : undefined);
3104
3044
  await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
3105
3045
  bumpRound: reviewTask?.reviewRoundPending === true,
3106
3046
  fromStatus: ['review'],
3107
3047
  expectPhase: reviewPass.phase,
3108
3048
  expectSignalToken: reviewPass.signalToken,
3049
+ expectedTask: reviewPass,
3109
3050
  ...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
3110
3051
  onQaAcquired: (lockToken) => { recoveryLockToken = lockToken; },
3111
3052
  });
@@ -3163,9 +3104,7 @@ export class AgentManager {
3163
3104
  let recoveryReadFailed = false;
3164
3105
  try {
3165
3106
  const taskNow = await this.taskStore.get(reviewTaskId);
3166
- passSuperseded = taskNow?.status !== reviewPass.status
3167
- || taskNow.phase !== reviewPass.phase
3168
- || taskNow.signalToken !== reviewPass.signalToken;
3107
+ passSuperseded = !taskNow || !taskMatchesGeneration(taskNow, reviewPass);
3169
3108
  }
3170
3109
  catch (readErr) {
3171
3110
  recoveryReadFailed = true;
@@ -3386,9 +3325,16 @@ export class AgentManager {
3386
3325
  ...(opts.expectedReviewDispatch !== undefined
3387
3326
  ? { expectReviewDispatch: opts.expectedReviewDispatch }
3388
3327
  : {}),
3328
+ ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
3329
+ ...(opts.expectedLockToken !== undefined
3330
+ ? { expectAgentLock: { agentId, token: opts.expectedLockToken } }
3331
+ : {}),
3389
3332
  });
3390
3333
  if (!transitioned
3391
- && (opts.expectedPostApproveEpisode !== undefined || opts.expectedReviewDispatch !== undefined)) {
3334
+ && (opts.expectedPostApproveEpisode !== undefined
3335
+ || opts.expectedReviewDispatch !== undefined
3336
+ || opts.expectedTask !== undefined
3337
+ || opts.expectedLockToken !== undefined)) {
3392
3338
  console.warn(`[AgentManager] failTaskForDispatchError: dispatch generation for ${taskId} was superseded; skipping stale failure`);
3393
3339
  return;
3394
3340
  }
@@ -3446,7 +3392,7 @@ export class AgentManager {
3446
3392
  for (const t of tasks) {
3447
3393
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
3448
3394
  // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
3449
- if (t.status === 'ready' || t.status === 'merge-ready')
3395
+ if (t.status === 'merge-ready')
3450
3396
  continue;
3451
3397
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
3452
3398
  const failedTask = this.stripGitStatusScopedState(t, 'failed');
@@ -3732,16 +3678,18 @@ export class AgentManager {
3732
3678
  retireOldOnce();
3733
3679
  }
3734
3680
  }
3735
- async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
3681
+ async redispatchTaskPromptAfterReplRestart(agentId, taskId, expected) {
3736
3682
  const agent = this.getAgentConfig(agentId);
3737
3683
  const loadedTask = await this.taskStore.get(taskId);
3738
3684
  if (!agent || !loadedTask || !TASK_OWNER_ROLES.has(agent.role))
3739
3685
  return false;
3740
- let task = loadedTask;
3686
+ if (expected && !taskMatchesGeneration(loadedTask, expected))
3687
+ return false;
3688
+ const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
3689
+ const task = loadedTask;
3741
3690
  if (task.agentId !== agentId)
3742
3691
  return false;
3743
- const participantId = agent.role === 'research' ? task.researchAgentId : task.devAgentId;
3744
- if (participantId !== agentId)
3692
+ if (task.devAgentId !== agentId)
3745
3693
  return false;
3746
3694
  const bindingAtEntry = await this.agentStore.get(agentId);
3747
3695
  const entryHold = {
@@ -3754,7 +3702,8 @@ export class AgentManager {
3754
3702
  return fresh?.agentId === agentId
3755
3703
  && fresh.status === task.status
3756
3704
  && fresh.phase === task.phase
3757
- && fresh.signalToken === signalToken;
3705
+ && fresh.signalToken === signalToken
3706
+ && rootGenerationMatches(fresh, signalToken);
3758
3707
  };
3759
3708
  const clearEntryHold = async () => this.withTaskLock(async () => {
3760
3709
  if (!(await taskStillCurrent()))
@@ -3765,32 +3714,10 @@ export class AgentManager {
3765
3714
  }
3766
3715
  return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
3767
3716
  });
3768
- const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
3717
+ const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold, async (fresh) => rootGenerationMatches(fresh, task.signalToken));
3769
3718
  try {
3770
3719
  if (task.status === 'approved' && agent.role === 'dev') {
3771
- return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
3772
- }
3773
- if (task.serverSignalRecovery) {
3774
- const recovery = task.serverSignalRecovery;
3775
- if (recovery.mode !== 'hold') {
3776
- if (!(await clearEntryHold()))
3777
- return false;
3778
- await this.recoverServerSignalRecovery(task);
3779
- return true;
3780
- }
3781
- const cleared = await this.updateTaskIfStatus(task.id, task.status, { serverSignalRecovery: undefined }, {
3782
- signalToken: task.signalToken,
3783
- phase: task.phase,
3784
- ...(recovery.phase === 'spec'
3785
- ? { specReviewRound: task.specReviewRound }
3786
- : { reviewRound: task.reviewRound }),
3787
- });
3788
- if (!cleared)
3789
- return false;
3790
- const fresh = await this.taskStore.get(task.id);
3791
- if (!fresh)
3792
- return false;
3793
- task = fresh;
3720
+ return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
3794
3721
  }
3795
3722
  const mapped = this.mapTaskStateToExpectedWatcher(task);
3796
3723
  if (!mapped || mapped.agentId !== agentId)
@@ -3809,7 +3736,8 @@ export class AgentManager {
3809
3736
  if (fresh?.agentId !== agentId
3810
3737
  || fresh.status !== task.status
3811
3738
  || fresh.phase !== task.phase
3812
- || fresh.signalToken !== signalToken)
3739
+ || fresh.signalToken !== signalToken
3740
+ || !rootGenerationMatches(fresh, signalToken))
3813
3741
  return false;
3814
3742
  const rotatePendingPr = mapped.expectedKinds.includes('pr-created')
3815
3743
  && fresh.pendingPrSignalToken === signalToken;
@@ -3822,11 +3750,10 @@ export class AgentManager {
3822
3750
  return true;
3823
3751
  },
3824
3752
  stillCurrent: taskStillCurrent,
3825
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
3753
+ holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
3826
3754
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
3827
3755
  ...opts,
3828
3756
  signalToken: newToken,
3829
- preserveDispatchOutputs: true,
3830
3757
  expectedHold: { phase: undefined, since: undefined, nonce: undefined },
3831
3758
  ...fence,
3832
3759
  }),
@@ -3845,9 +3772,6 @@ export class AgentManager {
3845
3772
  }
3846
3773
  });
3847
3774
  };
3848
- if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
3849
- return await replayInitialBootstrap('research', {});
3850
- }
3851
3775
  if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
3852
3776
  // An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
3853
3777
  const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
@@ -3857,53 +3781,25 @@ export class AgentManager {
3857
3781
  }
3858
3782
  if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
3859
3783
  const state = await this.agentStore.get(agentId);
3860
- // A no-QA auto-approved spec never writes specReviewRound back; the code dispatch defaults to round 1.
3861
- const round = task.specReviewRound ?? 1;
3862
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
3863
- if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
3864
- return await holdReplayFailure('code-dispatch-failed', 'REPL restarted but the code-phase prompt cannot be resumed in place. Resume to replay the persisted Spec documents, or cancel the task.');
3784
+ const opts = { allowDirtyWorkdir: true };
3785
+ if (state?.bootstrappingTaskId === taskId) {
3786
+ return await replayInitialBootstrap('code', opts);
3865
3787
  }
3866
3788
  if (!(await clearEntryHold()))
3867
3789
  return false;
3868
- return replayTask('code', {
3869
- allowDirtyWorkdir: true,
3870
- specDocuments: stored.documents,
3871
- ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
3872
- });
3790
+ return replayTask('code', opts);
3873
3791
  }
3874
3792
  if (task.status === 'fixing' && task.phase === 'spec') {
3875
- const round = task.specReviewRound ?? 1;
3876
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
3877
- if (!stored?.findings) {
3878
- return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the spec fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the spec review.`);
3879
- }
3880
3793
  if (!(await clearEntryHold()))
3881
3794
  return false;
3882
- return replayTask('server-feedback', {
3795
+ return replayTask('fix', {
3883
3796
  ...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
3884
- serverPriorFindings: JSON.stringify(stored.findings),
3885
- serverFindingsDigest: reviewFindingsDigest(stored.findings),
3886
- ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
3887
3797
  });
3888
3798
  }
3889
3799
  // A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
3890
3800
  if (task.status === 'fixing'
3891
3801
  && (task.phase === 'code' || task.phase === undefined)
3892
3802
  && agent.role === 'dev') {
3893
- if (task.reviewMode === 'server') {
3894
- const round = Math.max(task.reviewRound, 1);
3895
- const stored = await this.reviewStore?.getRound(taskId, 'code', round);
3896
- if (!stored?.findings) {
3897
- return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the code fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the code review.`);
3898
- }
3899
- if (!(await clearEntryHold()))
3900
- return false;
3901
- return replayTask('server-feedback', {
3902
- allowDirtyWorkdir: true,
3903
- serverPriorFindings: JSON.stringify(stored.findings),
3904
- serverFindingsDigest: reviewFindingsDigest(stored.findings),
3905
- });
3906
- }
3907
3803
  if (!(await clearEntryHold()))
3908
3804
  return false;
3909
3805
  return replayTask('fix', {
@@ -3921,68 +3817,49 @@ export class AgentManager {
3921
3817
  throw err;
3922
3818
  }
3923
3819
  }
3924
- // approved hosts two live holder passes — post-approve and server after-done; a parked pre-publish holder stays on the waiting path.
3925
- async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
3926
- const taskId = task.id;
3927
- const { clearEntryHold, holdReplayFailure } = holdGen;
3928
- if (task.reviewMode === 'server') {
3929
- if (!task.publishDispatchedAt)
3930
- return false;
3931
- if (!task.signalToken) {
3932
- return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the rotated publish token is missing; the publish prompt cannot be replayed. Cancel the task or mark-complete to re-dispatch the publish.');
3933
- }
3934
- const token = task.signalToken;
3935
- const afterDone = this.resolveAfterDone(task);
3936
- if (!afterDone) {
3937
- return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the after-done target cannot be resolved anymore. Resume then mark-complete to retry the publish, or cancel the task.');
3938
- }
3939
- let violation;
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;
3940
3826
  try {
3941
- violation = await this.findLineageViolation(taskId);
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';
3942
3836
  }
3943
3837
  catch (err) {
3944
- const message = err instanceof Error ? err.message : String(err);
3945
- return holdReplayFailure('restart-redispatch-failed', `REPL restarted mid-publish but the branch lineage re-check failed: ${message}. Resume then Restart REPL to retry, or cancel the task.`);
3946
- }
3947
- if (violation) {
3948
- return holdReplayFailure('restart-redispatch-failed', `The task branch embeds another active task's commits (${violation.branch}); rebase onto origin/HEAD, then mark-complete to retry the publish.`);
3949
- }
3950
- if (!(await clearEntryHold()))
3951
- return false;
3952
- const stillCurrent = async (signalToken) => {
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;
3953
3847
  const fresh = await this.taskStore.get(taskId);
3954
- return fresh?.agentId === agentId
3955
- && fresh.status === task.status
3956
- && fresh.phase === task.phase
3957
- && fresh.signalToken === signalToken;
3958
- };
3959
- return this.runRotatedReplay({
3960
- agentId,
3961
- taskAtEntry: task,
3962
- oldToken: token,
3963
- expectedKinds: ['code-ready'],
3964
- rotateUnderTaskLock: async (newToken) => {
3965
- const fresh = await this.taskStore.get(taskId);
3966
- if (fresh?.agentId !== agentId
3967
- || fresh.status !== task.status
3968
- || fresh.phase !== task.phase
3969
- || fresh.signalToken !== token)
3970
- return false;
3971
- await this.taskStore.set({ ...fresh, signalToken: newToken, updatedAt: new Date().toISOString() });
3972
- return true;
3973
- },
3974
- stillCurrent,
3975
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
3976
- continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'server-after-done', {
3977
- signalToken: newToken,
3978
- preserveDispatchOutputs: true,
3979
- expectedHold: { phase: undefined, since: undefined, nonce: undefined },
3980
- allowDirtyWorkdir: true,
3981
- serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
3982
- ...fence,
3983
- }),
3984
- });
3848
+ if (!fresh || !taskMatchesGeneration(fresh, expected))
3849
+ return 'stale';
3850
+ throw err;
3851
+ }
3985
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
+ async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
3861
+ const taskId = task.id;
3862
+ const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
3986
3863
  if (task.postApproveRevoked) {
3987
3864
  return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
3988
3865
  'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
@@ -4014,7 +3891,7 @@ export class AgentManager {
4014
3891
  && fresh.status === task.status
4015
3892
  && fresh.phase === task.phase
4016
3893
  && fresh.signalToken === task.signalToken
4017
- && fresh.reviewMode === task.reviewMode
3894
+ && rootGenerationMatches(fresh, task.signalToken)
4018
3895
  && fresh.postApproveRevoked === undefined
4019
3896
  && fresh.postApproveHeadSha === task.postApproveHeadSha
4020
3897
  && fresh.postApproveGeneration === task.postApproveGeneration
@@ -4053,7 +3930,6 @@ export class AgentManager {
4053
3930
  token: newToken,
4054
3931
  headSha: task.postApproveHeadSha,
4055
3932
  },
4056
- preserveDispatchOutputs: true,
4057
3933
  expectedHold: { phase: undefined, since: undefined, nonce: undefined },
4058
3934
  allowDirtyWorkdir: true,
4059
3935
  ...fence,
@@ -4076,8 +3952,6 @@ export class AgentManager {
4076
3952
  const project = this.getProjectConfig(cfg.projectId);
4077
3953
  if (!project)
4078
3954
  throw new Error(`Unknown project: ${cfg.projectId}`);
4079
- if (cfg.role !== 'dev')
4080
- return { targets: [agentId] };
4081
3955
  const group = this.findAgentGroup(agentId);
4082
3956
  return { targets: group?.map(agent => agent.id) ?? [agentId] };
4083
3957
  }
@@ -4178,12 +4052,15 @@ export class AgentManager {
4178
4052
  }
4179
4053
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
4180
4054
  const now = new Date().toISOString();
4181
- const isResearch = cfg.role === 'research';
4055
+ if (cfg.role !== 'dev')
4056
+ throw new Error(`Agent ${cfg.id} is not a dev agent`);
4182
4057
  const group = this.findAgentGroup(cfg.id);
4183
- const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
4184
- if (!devAgentId)
4185
- throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
4186
- const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
4058
+ const devAgentId = cfg.id;
4059
+ const qaAgentId = input.preferredAgentId === ''
4060
+ ? '_unassigned_preview_qa'
4061
+ : group?.find(agent => agent.role === 'qa')?.id;
4062
+ if (!qaAgentId)
4063
+ throw new Error(`Agent ${cfg.id} has no qa agent in its group`);
4187
4064
  const fakeTask = {
4188
4065
  id: 'task-9999999999',
4189
4066
  projectId,
@@ -4192,11 +4069,9 @@ export class AgentManager {
4192
4069
  preferredAgentId: input.preferredAgentId,
4193
4070
  agentId: cfg.id,
4194
4071
  devAgentId,
4195
- ...(qaAgentId ? { qaAgentId } : {}),
4196
- ...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
4072
+ qaAgentId,
4197
4073
  branch: `${BRANCH_PREFIX}task-9999999999`,
4198
4074
  reviewRound: 0,
4199
- reviewMode: this.effectiveReviewMode(projectId),
4200
4075
  ...this.platformBindingFields(projectId),
4201
4076
  status: 'in_progress',
4202
4077
  createdAt: now,
@@ -4204,15 +4079,12 @@ export class AgentManager {
4204
4079
  };
4205
4080
  const fullPrompt = buildPromptInline({
4206
4081
  task: fakeTask,
4207
- phase: isResearch ? 'research' : 'develop',
4082
+ phase: 'develop',
4208
4083
  agent: cfg,
4209
4084
  workdir: workdirGuess,
4210
4085
  skillRegistry: this.skillRegistry,
4211
4086
  signalToken: 'preview-signal-token',
4212
- hasQaPartner: qaAgentId !== undefined,
4213
- ...(!isResearch && fakeTask.reviewMode === 'git'
4214
- ? { platformCli: this.platformCliContextOf(fakeTask) }
4215
- : {}),
4087
+ platformCli: this.platformCliContextOf(fakeTask),
4216
4088
  });
4217
4089
  return Buffer.byteLength(fullPrompt, 'utf8');
4218
4090
  }
@@ -4264,6 +4136,9 @@ export class AgentManager {
4264
4136
  async getAgentState(agentId) {
4265
4137
  return this.agentStore.get(agentId);
4266
4138
  }
4139
+ async getAgentLockOwner(agentId) {
4140
+ return this.lockManager.ownerOf(agentId);
4141
+ }
4267
4142
  async getPostApproveCompletion(taskId) {
4268
4143
  const task = await this.taskStore.get(taskId);
4269
4144
  return task ? this.completionOf(task) : null;
@@ -4296,7 +4171,7 @@ export class AgentManager {
4296
4171
  async armGitPostApproveEpisode(taskId, expected, opts = {}) {
4297
4172
  return this.withTaskLock(async () => {
4298
4173
  const task = await this.taskStore.get(taskId);
4299
- if (!task || task.reviewMode !== 'git' || !this.postApproveEpisodeMatches(task, expected))
4174
+ if (!task || !this.postApproveEpisodeMatches(task, expected))
4300
4175
  return false;
4301
4176
  if (!this.phaseSignalWatcher)
4302
4177
  return true;
@@ -4314,7 +4189,7 @@ export class AgentManager {
4314
4189
  async rotateGitPostApproveEpisode(taskId, expected, redispatchCount) {
4315
4190
  return this.withTaskLock(async () => {
4316
4191
  const task = await this.taskStore.get(taskId);
4317
- if (!task || task.reviewMode !== 'git' || task.pendingRedispatch !== true
4192
+ if (!task || task.pendingRedispatch !== true
4318
4193
  || !this.postApproveEpisodeMatches(task, expected))
4319
4194
  return null;
4320
4195
  const token = createSignalTokenExcluding(expected.generation, expected.token);
@@ -4342,10 +4217,17 @@ export class AgentManager {
4342
4217
  const next = nextStatus === 'approved'
4343
4218
  ? { ...task }
4344
4219
  : this.stripPostApproveEpisodeFields(task);
4345
- if (task.reviewMode === 'git' && nextStatus !== 'review') {
4220
+ if (nextStatus !== 'review') {
4346
4221
  delete next.reviewDispatch;
4347
4222
  delete next.reviewRoundPending;
4348
4223
  }
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
+ }
4349
4231
  return next;
4350
4232
  }
4351
4233
  async confirmPostApprovePromptDelivered(taskId, expected) {
@@ -4501,9 +4383,7 @@ export class AgentManager {
4501
4383
  async recordClosedUnmergedAnchor(taskId, prNumber) {
4502
4384
  return this.withTaskLock(async () => {
4503
4385
  const task = await this.taskStore.get(taskId);
4504
- const tracksPlatformLifecycle = task?.reviewMode === 'git'
4505
- || (task?.reviewMode === 'server' && task.afterDone === 'pr' && task.platformBinding !== undefined);
4506
- if (!task || !tracksPlatformLifecycle || TERMINAL_STATUSES.includes(task.status))
4386
+ if (!task || TERMINAL_STATUSES.includes(task.status))
4507
4387
  return false;
4508
4388
  const anchor = task.closedUnmergedAnchor;
4509
4389
  if (anchor?.prNumber === prNumber && anchor.cleared !== true)
@@ -4544,43 +4424,58 @@ export class AgentManager {
4544
4424
  return true;
4545
4425
  });
4546
4426
  }
4427
+ specVerdictOutbox(task) {
4428
+ return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
4429
+ }
4430
+ async mutateTaskOutboxEntry(taskId, key, mutate) {
4431
+ return this.withTaskLock(async () => {
4432
+ const task = await this.taskStore.get(taskId);
4433
+ const entry = task?.outbox?.find(current => current.key === key);
4434
+ if (!task || !entry)
4435
+ return null;
4436
+ const changed = mutate(entry);
4437
+ if (changed === null)
4438
+ return null;
4439
+ const outbox = changed === undefined
4440
+ ? task.outbox.filter(current => current.key !== key)
4441
+ : task.outbox.map(current => current.key === key ? changed : current);
4442
+ const next = { ...task, outbox, updatedAt: new Date().toISOString() };
4443
+ if (outbox.length === 0)
4444
+ delete next.outbox;
4445
+ await this.taskStore.set(next);
4446
+ return next;
4447
+ });
4448
+ }
4547
4449
  async deliverTaskOutbox(taskId) {
4548
4450
  const task = await this.taskStore.get(taskId);
4549
4451
  if (!task?.outbox?.length)
4550
4452
  return;
4551
4453
  for (const entry of task.outbox) {
4552
4454
  try {
4455
+ if (entry.type === 'git.spec-verdict') {
4456
+ await this.deliverSpecVerdictOutbox(taskId, entry.key);
4457
+ continue;
4458
+ }
4553
4459
  await this.eventBus.emit({
4554
4460
  id: `outbox:${entry.key}`,
4555
- type: entry.type,
4461
+ type: 'human.intervention',
4556
4462
  timestamp: new Date().toISOString(),
4557
4463
  projectId: task.projectId,
4558
4464
  agentId: task.agentId,
4559
4465
  taskId: task.id,
4560
4466
  data: entry.data,
4561
4467
  });
4468
+ await this.mutateTaskOutboxEntry(taskId, entry.key, current => current.type === entry.type ? undefined : null);
4562
4469
  }
4563
4470
  catch (err) {
4564
4471
  console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
4565
4472
  continue;
4566
4473
  }
4567
- await this.withTaskLock(async () => {
4568
- const fresh = await this.taskStore.get(taskId);
4569
- if (!fresh?.outbox?.some(e => e.key === entry.key))
4570
- return;
4571
- const remaining = fresh.outbox.filter(e => e.key !== entry.key);
4572
- const { outbox: _outbox, ...rest } = fresh;
4573
- await this.taskStore.set({
4574
- ...rest,
4575
- ...(remaining.length > 0 ? { outbox: remaining } : {}),
4576
- updatedAt: new Date().toISOString(),
4577
- });
4578
- });
4579
4474
  }
4580
4475
  }
4581
4476
  async rearmPostApproveSignal(taskId, opts = {}) {
4582
4477
  const task = await this.taskStore.get(taskId);
4583
- if (!task || task.reviewMode !== 'git' || task.status !== 'approved')
4478
+ if (!task || task.status !== 'approved')
4584
4479
  return false;
4585
4480
  const completion = this.completionOf(task);
4586
4481
  if (!completion)
@@ -4609,8 +4504,6 @@ export class AgentManager {
4609
4504
  if (!this.pluginRegistry)
4610
4505
  return;
4611
4506
  for (const project of next.project) {
4612
- if (projectReviewMode(next, project) !== 'git')
4613
- continue;
4614
4507
  const tool = resolveProjectTool(project);
4615
4508
  if (tool === undefined)
4616
4509
  continue;
@@ -4650,19 +4543,17 @@ export class AgentManager {
4650
4543
  }
4651
4544
  }
4652
4545
  gitReviewLeaseMatches(task, lease) {
4653
- return task.reviewMode === 'git'
4654
- && task.status === 'review'
4546
+ return task.status === 'review'
4655
4547
  && task.signalToken === lease.signalToken
4656
4548
  && task.reviewHeadAnchorSha === lease.headSha
4657
4549
  && task.passToken === lease.passToken
4658
4550
  && task.failToken === lease.failToken
4659
- && lease.effectiveRound === Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
4551
+ && lease.effectiveRound === effectiveTaskReviewRound(task);
4660
4552
  }
4661
4553
  // startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
4662
4554
  gitReviewQaConfigUsable(task) {
4663
- if (!task.qaAgentId)
4664
- return false;
4665
- const config = this.getAgentConfig(task.qaAgentId);
4555
+ const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
4556
+ const config = this.getAgentConfig(qaAgentId);
4666
4557
  return config?.role === 'qa' && config.projectId === task.projectId;
4667
4558
  }
4668
4559
  async beginGitReviewPass(taskId, opts) {
@@ -4673,7 +4564,14 @@ export class AgentManager {
4673
4564
  if (this.markCompleteInFlight.has(taskId) && opts.allowDuringComplete !== true)
4674
4565
  return null;
4675
4566
  const current = await this.taskStore.get(taskId);
4676
- if (!current || current.reviewMode !== 'git' || TERMINAL_STATUSES.includes(current.status))
4567
+ if (!current || TERMINAL_STATUSES.includes(current.status))
4568
+ return null;
4569
+ if (current.phase === undefined)
4570
+ return null;
4571
+ if (current.status === 'in_progress'
4572
+ && current.deliveryConfirmation?.phase !== current.phase)
4573
+ return null;
4574
+ if (opts.expectedTask && !taskMatchesGeneration(current, opts.expectedTask))
4677
4575
  return null;
4678
4576
  if (!opts.fromStatus.includes(current.status))
4679
4577
  return null;
@@ -4694,14 +4592,16 @@ export class AgentManager {
4694
4592
  const signalToken = createSignalTokenExcluding(pair.passToken, pair.failToken);
4695
4593
  const generation = createSignalTokenExcluding(signalToken, pair.passToken, pair.failToken);
4696
4594
  const reviewRoundPending = opts.bumpRound ? true : undefined;
4697
- const effectiveRound = Math.max(1, current.reviewRound + (reviewRoundPending === true ? 1 : 0));
4595
+ const effectiveRound = Math.max(1, (current.phase === 'spec' ? (current.specReviewRound ?? 0) : current.reviewRound)
4596
+ + (reviewRoundPending === true ? 1 : 0));
4698
4597
  const qaPhase = opts.qaPhase ?? (current.status === 'in_progress'
4699
- || (current.status === 'review' && current.reviewRound === 0 && opts.bumpRound)
4598
+ || (current.status === 'review'
4599
+ && (current.phase === 'spec' ? (current.specReviewRound ?? 0) : current.reviewRound) === 0
4600
+ && opts.bumpRound)
4700
4601
  ? 'review'
4701
4602
  : 'recheck');
4702
4603
  const next = this.stripPostApproveEpisodeFields(current);
4703
4604
  delete next.passProvenance;
4704
- delete next.serverSignalRecovery;
4705
4605
  Object.assign(next, opts.patch ?? {}, {
4706
4606
  status: 'review',
4707
4607
  signalToken,
@@ -4726,7 +4626,7 @@ export class AgentManager {
4726
4626
  return { task: next, previousStatus };
4727
4627
  });
4728
4628
  }
4729
- async claimGitReviewDispatch(taskId, expectedGeneration) {
4629
+ async claimGitReviewDispatch(taskId, expectedGeneration, expectedTask) {
4730
4630
  return this.withTaskLock(async () => {
4731
4631
  if (this.markCompleteInFlight.has(taskId))
4732
4632
  return null;
@@ -4734,6 +4634,8 @@ export class AgentManager {
4734
4634
  const lease = task?.reviewDispatch;
4735
4635
  if (!task || !lease || lease.phase !== 'pending')
4736
4636
  return null;
4637
+ if (expectedTask && !taskMatchesGeneration(task, expectedTask))
4638
+ return null;
4737
4639
  if (expectedGeneration !== undefined && lease.generation !== expectedGeneration)
4738
4640
  return null;
4739
4641
  if (!this.gitReviewLeaseMatches(task, lease))
@@ -4757,7 +4659,7 @@ export class AgentManager {
4757
4659
  }
4758
4660
  return this.withTaskLock(async () => {
4759
4661
  const current = await this.taskStore.get(taskId);
4760
- if (!current || current.reviewMode !== 'git' || current.status !== 'review')
4662
+ if (!current || current.status !== 'review')
4761
4663
  return null;
4762
4664
  if (current.signalToken !== opts.expectedSignalToken
4763
4665
  || current.passToken !== opts.provenance.token
@@ -4838,22 +4740,20 @@ export class AgentManager {
4838
4740
  || current.generation !== expectedGeneration
4839
4741
  || !this.gitReviewLeaseMatches(task, current))
4840
4742
  return null;
4841
- const qaId = task.qaAgentId;
4743
+ const qaId = requireTaskQaAgentId(task, 'confirmUncertainGitReviewDispatch');
4842
4744
  let qaHold;
4843
- if (qaId !== undefined) {
4844
- const qa = await this.agentStore.get(qaId);
4845
- if (qa?.taskId === task.id && qa.status === 'awaiting_human') {
4846
- if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
4847
- return null;
4848
- qaHold = {
4849
- phase: qa.awaitingPhase,
4850
- since: qa.awaitingSince,
4851
- nonce: qa.awaitingNonce,
4852
- };
4853
- }
4854
- else if (qa?.taskId !== task.id && !canDispatchWithBinding(qa)) {
4745
+ const qa = await this.agentStore.get(qaId);
4746
+ if (qa?.taskId === task.id && qa.status === 'awaiting_human') {
4747
+ if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
4855
4748
  return null;
4856
- }
4749
+ qaHold = {
4750
+ phase: qa.awaitingPhase,
4751
+ since: qa.awaitingSince,
4752
+ nonce: qa.awaitingNonce,
4753
+ };
4754
+ }
4755
+ else if (qa?.taskId !== task.id && !canDispatchWithBinding(qa)) {
4756
+ return null;
4857
4757
  }
4858
4758
  const now = new Date().toISOString();
4859
4759
  const { claimId: _claimId, claimedAt: _claimedAt, ...rest } = current;
@@ -4870,7 +4770,7 @@ export class AgentManager {
4870
4770
  updatedAt: now,
4871
4771
  };
4872
4772
  await this.taskStore.set(next);
4873
- if (qaHold !== undefined && qaId !== undefined) {
4773
+ if (qaHold !== undefined) {
4874
4774
  const cleared = await this.clearAwaitingHuman(qaId, {
4875
4775
  expectedTaskId: task.id,
4876
4776
  expectedHold: qaHold,
@@ -4906,7 +4806,10 @@ export class AgentManager {
4906
4806
  const now = new Date().toISOString();
4907
4807
  const next = { ...task, updatedAt: now };
4908
4808
  if (next.reviewRoundPending === true) {
4909
- next.reviewRound += 1;
4809
+ if (next.phase === 'spec')
4810
+ next.specReviewRound = (next.specReviewRound ?? 0) + 1;
4811
+ else
4812
+ next.reviewRound += 1;
4910
4813
  delete next.reviewRoundPending;
4911
4814
  }
4912
4815
  next.reviewDispatchedAt = now;
@@ -4921,10 +4824,10 @@ export class AgentManager {
4921
4824
  const current = task?.reviewDispatch;
4922
4825
  if (!task || !current || current.phase !== 'pending'
4923
4826
  || current.generation !== expectedGeneration
4924
- || !this.gitReviewLeaseMatches(task, current)
4925
- || task.qaAgentId === undefined)
4827
+ || !this.gitReviewLeaseMatches(task, current))
4926
4828
  return null;
4927
- const qa = await this.agentStore.get(task.qaAgentId);
4829
+ const qaAgentId = requireTaskQaAgentId(task, 'recoverPendingGitReviewDispatchHold');
4830
+ const qa = await this.agentStore.get(qaAgentId);
4928
4831
  if (qa?.taskId !== task.id
4929
4832
  || qa.status !== 'awaiting_human'
4930
4833
  || qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
@@ -4949,16 +4852,17 @@ export class AgentManager {
4949
4852
  const tasks = await this.taskStore.list();
4950
4853
  for (const snapshot of tasks) {
4951
4854
  const lease = snapshot.reviewDispatch;
4952
- if (snapshot.reviewMode !== 'git' || !lease || lease.phase !== 'claimed')
4855
+ if (!lease || lease.phase !== 'claimed')
4953
4856
  continue;
4954
- const qa = snapshot.qaAgentId ? await this.agentStore.get(snapshot.qaAgentId) : null;
4857
+ const qaAgentId = requireTaskQaAgentId(snapshot, 'recoverClaimedGitReviewDispatches');
4858
+ const qa = await this.agentStore.get(qaAgentId);
4955
4859
  if (!qa || qa.taskId !== snapshot.id) {
4956
4860
  await this.resetGitReviewDispatchClaim(snapshot.id, lease);
4957
4861
  continue;
4958
4862
  }
4959
4863
  const uncertain = await this.markGitReviewDispatchUncertain(snapshot.id, lease);
4960
4864
  if (uncertain) {
4961
- await this.emitIntervention(snapshot.projectId, snapshot.qaAgentId, snapshot.id, {
4865
+ await this.emitIntervention(snapshot.projectId, qaAgentId, snapshot.id, {
4962
4866
  phase: 'git-review-dispatch-recovery-uncertain',
4963
4867
  generation: lease.generation,
4964
4868
  claimId: lease.claimId,
@@ -4970,41 +4874,97 @@ export class AgentManager {
4970
4874
  async retryPendingGitReviewDispatches() {
4971
4875
  const tasks = await this.taskStore.list();
4972
4876
  for (const snapshot of tasks) {
4973
- const lease = snapshot.reviewDispatch;
4974
- if (snapshot.reviewMode !== 'git' || lease?.phase !== 'pending')
4975
- continue;
4976
- if (!snapshot.qaAgentId)
4877
+ let task = snapshot;
4878
+ if (task.status === 'in_progress'
4879
+ && task.phase !== undefined
4880
+ && task.deliveryConfirmation?.phase === task.phase
4881
+ && task.reviewDispatch === undefined
4882
+ && task.prNumber !== undefined) {
4883
+ try {
4884
+ const verified = await this.platformVerifyPrBinding(task.id, task.prNumber);
4885
+ if (!verified.ok) {
4886
+ const latch = [
4887
+ 'delivery-binding',
4888
+ task.id,
4889
+ task.phase,
4890
+ task.signalToken ?? '',
4891
+ task.prNumber,
4892
+ verified.reason,
4893
+ ].join(':');
4894
+ if (!this.gitReviewRecoveryAlerted.has(latch)) {
4895
+ const live = await this.taskStore.get(task.id);
4896
+ if (live?.status === 'in_progress'
4897
+ && live.phase === task.phase
4898
+ && live.signalToken === task.signalToken
4899
+ && live.deliveryConfirmation?.phase === task.phase
4900
+ && live.reviewDispatch === undefined
4901
+ && live.prNumber === task.prNumber) {
4902
+ const alerted = await this.emitIntervention(live.projectId, live.agentId || undefined, live.id, {
4903
+ phase: 'git-review-delivery-binding-invalid',
4904
+ reason: verified.reason,
4905
+ prNumber: task.prNumber,
4906
+ deliveryPhase: task.phase,
4907
+ });
4908
+ if (alerted)
4909
+ this.gitReviewRecoveryAlerted.add(latch);
4910
+ }
4911
+ }
4912
+ continue;
4913
+ }
4914
+ if (PLATFORM_HEAD_SHA_RE.test(verified.headSha)) {
4915
+ const begun = await this.beginGitReviewPass(task.id, {
4916
+ fromStatus: ['in_progress'],
4917
+ headSha: verified.headSha,
4918
+ bumpRound: true,
4919
+ qaPhase: 'review',
4920
+ expectSignalToken: task.signalToken,
4921
+ expectPhase: task.phase,
4922
+ expectedTask: taskGenerationGuard(task),
4923
+ patch: { latestHeadSha: verified.headSha },
4924
+ });
4925
+ task = begun?.task ?? (await this.taskStore.get(task.id)) ?? task;
4926
+ }
4927
+ }
4928
+ catch (err) {
4929
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} confirmed-delivery recovery failed, continuing:`, err);
4930
+ continue;
4931
+ }
4932
+ }
4933
+ const lease = task.reviewDispatch;
4934
+ if (lease?.phase !== 'pending')
4977
4935
  continue;
4978
- if (!this.gitReviewQaConfigUsable(snapshot)) {
4936
+ const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
4937
+ if (!this.gitReviewQaConfigUsable(task)) {
4979
4938
  // 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
4980
- const latch = `${snapshot.id}:${lease.generation}`;
4981
- if (!this.gitReviewQaConfigAlerted.has(latch)) {
4939
+ const latch = `qa-config:${task.id}:${lease.generation}`;
4940
+ if (!this.gitReviewRecoveryAlerted.has(latch)) {
4982
4941
  try {
4983
- const live = await this.taskStore.get(snapshot.id);
4942
+ const live = await this.taskStore.get(task.id);
4984
4943
  const liveLease = live?.reviewDispatch;
4985
4944
  if (live && liveLease?.phase === 'pending'
4986
4945
  && liveLease.generation === lease.generation
4987
- && live.qaAgentId === snapshot.qaAgentId
4946
+ && live.qaAgentId === qaAgentId
4988
4947
  && !this.gitReviewQaConfigUsable(live)) {
4989
- const alerted = await this.emitIntervention(live.projectId, live.qaAgentId, live.id, {
4948
+ const liveQaAgentId = requireTaskQaAgentId(live, 'retryPendingGitReviewDispatches');
4949
+ const alerted = await this.emitIntervention(live.projectId, liveQaAgentId, live.id, {
4990
4950
  phase: 'git-review-qa-config-missing',
4991
- qaAgentId: live.qaAgentId,
4951
+ qaAgentId: liveQaAgentId,
4992
4952
  generation: lease.generation,
4993
4953
  });
4994
4954
  if (alerted)
4995
- this.gitReviewQaConfigAlerted.add(latch);
4955
+ this.gitReviewRecoveryAlerted.add(latch);
4996
4956
  }
4997
4957
  }
4998
4958
  catch (err) {
4999
- console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${snapshot.id} qa-config alert check failed, continuing:`, err);
4959
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} qa-config alert check failed, continuing:`, err);
5000
4960
  }
5001
4961
  }
5002
4962
  continue;
5003
4963
  }
5004
4964
  try {
5005
- const recovered = await this.recoverPendingGitReviewDispatchHold(snapshot.id, lease.generation);
4965
+ const recovered = await this.recoverPendingGitReviewDispatchHold(task.id, lease.generation);
5006
4966
  if (recovered) {
5007
- await this.emitIntervention(snapshot.projectId, snapshot.qaAgentId, snapshot.id, {
4967
+ await this.emitIntervention(task.projectId, qaAgentId, task.id, {
5008
4968
  phase: 'git-review-dispatch-recovery-uncertain',
5009
4969
  generation: lease.generation,
5010
4970
  claimId: recovered.claimId,
@@ -5012,28 +4972,52 @@ export class AgentManager {
5012
4972
  });
5013
4973
  continue;
5014
4974
  }
5015
- const entry = this.getPendingDispatchRetry(snapshot.id);
4975
+ const entry = this.getPendingDispatchRetry(task.id);
5016
4976
  if (entry?.kind === 'qa-recheck' && entry.signalToken === lease.signalToken)
5017
4977
  continue;
5018
- if (snapshot.prNumber === undefined) {
5019
- throw new Error(`task ${snapshot.id} has no PR bound to its pending review lease`);
4978
+ if (task.prNumber === undefined) {
4979
+ throw new Error(`task ${task.id} has no PR bound to its pending review lease`);
4980
+ }
4981
+ const verified = await this.platformVerifyPrBinding(task.id, task.prNumber);
4982
+ if (!verified.ok) {
4983
+ const latch = `lease-binding:${task.id}:${lease.generation}`;
4984
+ if (!this.gitReviewRecoveryAlerted.has(latch)) {
4985
+ const live = await this.taskStore.get(task.id);
4986
+ const liveLease = live?.reviewDispatch;
4987
+ if (live && liveLease?.phase === 'pending'
4988
+ && liveLease.generation === lease.generation
4989
+ && liveLease.signalToken === lease.signalToken
4990
+ && live.prNumber === task.prNumber
4991
+ && live.qaAgentId === qaAgentId) {
4992
+ const alerted = await this.emitIntervention(live.projectId, qaAgentId, live.id, {
4993
+ phase: 'git-review-lease-binding-invalid',
4994
+ reason: verified.reason,
4995
+ prNumber: task.prNumber,
4996
+ generation: lease.generation,
4997
+ deliveryPhase: live.phase,
4998
+ qaPhase: lease.qaPhase,
4999
+ });
5000
+ if (alerted)
5001
+ this.gitReviewRecoveryAlerted.add(latch);
5002
+ }
5003
+ }
5004
+ continue;
5020
5005
  }
5021
- const verified = await this.platformVerifyPrBinding(snapshot.id, snapshot.prNumber);
5022
- if (!verified.ok || verified.headSha.toLowerCase() !== lease.headSha.toLowerCase()) {
5023
- console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${snapshot.id} binding/head changed; ` +
5006
+ if (verified.headSha.toLowerCase() !== lease.headSha.toLowerCase()) {
5007
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} binding/head changed; ` +
5024
5008
  `preserving generation=${lease.generation} without dispatch`);
5025
5009
  continue;
5026
5010
  }
5027
- await this.dispatchGitReviewLease(snapshot.id, {
5011
+ await this.dispatchGitReviewLease(task.id, {
5028
5012
  expectedGeneration: lease.generation,
5029
5013
  });
5030
5014
  }
5031
5015
  catch (err) {
5032
- if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown' && snapshot.qaAgentId) {
5033
- await this.failTaskForDispatchError(snapshot.id, lease.qaPhase, snapshot.qaAgentId, err, { expectedReviewDispatch: lease });
5016
+ if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown') {
5017
+ await this.failTaskForDispatchError(task.id, lease.qaPhase, qaAgentId, err, { expectedReviewDispatch: lease });
5034
5018
  continue;
5035
5019
  }
5036
- console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${snapshot.id} still failing:`, err);
5020
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
5037
5021
  }
5038
5022
  }
5039
5023
  }
@@ -5041,7 +5025,7 @@ export class AgentManager {
5041
5025
  const tasks = await this.taskStore.list();
5042
5026
  for (const task of tasks) {
5043
5027
  const intent = task.remoteCleanup;
5044
- if (task.reviewMode !== 'git' || !intent || intent.stage === 'manual')
5028
+ if (!intent || intent.stage === 'manual')
5045
5029
  continue;
5046
5030
  try {
5047
5031
  await this.processGitRemoteCleanup(task.id, intent.generation);
@@ -5064,7 +5048,7 @@ export class AgentManager {
5064
5048
  async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
5065
5049
  return this.withTaskLock(async () => {
5066
5050
  const task = await this.taskStore.get(taskId);
5067
- if (!task || task.reviewMode !== 'git')
5051
+ if (!task)
5068
5052
  return { kind: 'gone' };
5069
5053
  if (task.consumedFeedback?.[consume.key] !== undefined)
5070
5054
  return { kind: 'duplicate' };
@@ -5124,8 +5108,6 @@ export class AgentManager {
5124
5108
  async setupRecoveredPostApproveSignals() {
5125
5109
  const tasks = await this.taskStore.list({ status: 'approved' });
5126
5110
  for (const task of tasks) {
5127
- if (task.reviewMode !== 'git')
5128
- continue;
5129
5111
  if (task.postApproveRevoked) {
5130
5112
  // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
5131
5113
  try {
@@ -5165,15 +5147,6 @@ export class AgentManager {
5165
5147
  async setupRecoveredSpecSignals() {
5166
5148
  const tasks = await this.taskStore.list();
5167
5149
  for (const task of tasks) {
5168
- if (task.serverSignalRecovery) {
5169
- try {
5170
- await this.recoverServerSignalRecovery(task);
5171
- }
5172
- catch (err) {
5173
- console.warn(`[AgentManager] setupRecoveredSpecSignals: failed to recover server signal for task=${task.id}:`, err);
5174
- }
5175
- continue;
5176
- }
5177
5150
  if (!this.phaseSignalWatcher)
5178
5151
  continue;
5179
5152
  if (!task.signalToken)
@@ -5186,187 +5159,43 @@ export class AgentManager {
5186
5159
  }
5187
5160
  }
5188
5161
  }
5189
- async recoverServerSignalRecovery(task) {
5190
- const recovery = task.serverSignalRecovery;
5191
- const successorToken = task.signalToken;
5192
- if (!recovery || !successorToken)
5193
- return;
5194
- const agentId = recovery.signalKind.endsWith('-reviewed') ? (task.qaAgentId ?? '') : task.agentId;
5195
- const hold = async (phase, reason) => {
5196
- const held = await this.holdServerSignalRecovery(task.id, agentId, successorToken, phase, reason);
5197
- if (!held)
5198
- return;
5199
- await this.emitIntervention(task.projectId, agentId, task.id, {
5200
- phase,
5201
- recoveryReason: recovery.reason,
5202
- successorToken,
5203
- });
5204
- };
5205
- if (recovery.mode === 'hold') {
5206
- if (!agentId)
5207
- return;
5208
- const state = await this.agentStore.get(agentId);
5209
- if (state?.status === 'awaiting_human')
5210
- return;
5211
- await hold(recovery.failurePhase ?? `server-signal-${recovery.reason}`, `A consumed ${recovery.signalKind} signal is held for ${recovery.reason}. `
5212
- + 'Resolve the cause, then Restart or Cancel the task.');
5213
- return;
5214
- }
5215
- const stored = await this.reviewStore?.getRound(task.id, recovery.phase, recovery.round);
5216
- if (!stored?.findings || !recovery.findingsDigest
5217
- || reviewFindingsDigest(stored.findings) !== recovery.findingsDigest) {
5218
- await hold(`server-${recovery.phase}-feedback-recovery-findings-missing`, `Cannot recover the rejected response because round ${recovery.round} findings are missing or changed.`);
5219
- return;
5220
- }
5221
- const responseReasons = new Set([
5222
- 'response-missing', 'response-invalid', 'round-mismatch', 'token-mismatch',
5223
- 'findings-digest-mismatch', 'coverage-gap',
5224
- ]);
5225
- const dev = agentId ? this.getAgentConfig(agentId) : undefined;
5226
- if (!dev || !recovery.failureSignature
5227
- || (recovery.signalKind !== 'code-fixed' && recovery.signalKind !== 'spec-fixed')
5228
- || !responseReasons.has(recovery.reason)) {
5229
- await hold(`server-${recovery.phase}-feedback-recovery-invalid`, 'The persisted response recovery intent is incomplete and cannot be resumed safely.');
5230
- return;
5231
- }
5232
- const readResponse = async () => {
5233
- try {
5234
- const result = await this.getReviewTransport().readResponseWithRaw(task, dev);
5235
- if (result.kind === 'unknown') {
5236
- await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, 'The rejected response could not be read while finishing crash recovery; the file was preserved.');
5237
- return null;
5238
- }
5239
- return result;
5240
- }
5241
- catch (err) {
5242
- await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, `The rejected response could not be read while finishing crash recovery: `
5243
- + `${err instanceof Error ? err.message : String(err)}`);
5244
- return null;
5245
- }
5246
- };
5247
- const holdChangedResponse = async () => hold(`server-${recovery.phase}-feedback-recovery-response-changed`, 'The response changed after its token was retired, so recovery preserved the file for manual inspection.');
5248
- if (recovery.mode === 'classify-response') {
5249
- const result = await readResponse();
5250
- if (!result)
5251
- return;
5252
- let rawResponse;
5253
- let responseDigest;
5254
- if (recovery.reason === 'response-missing') {
5255
- if (result.kind !== 'absent') {
5256
- await holdChangedResponse();
5257
- return;
5258
- }
5259
- }
5260
- else if (result.kind === 'absent' || result.responseDigest !== recovery.responseDigest) {
5261
- await holdChangedResponse();
5262
- return;
5263
- }
5264
- else {
5265
- rawResponse = result.raw;
5266
- responseDigest = result.responseDigest;
5267
- }
5268
- let recorded;
5269
- try {
5270
- recorded = await this.reviewStore.recordServerResponseFailure(task.id, recovery.phase, recovery.round, {
5271
- signalKind: recovery.signalKind,
5272
- sourceToken: recovery.sourceToken,
5273
- successorToken,
5274
- failureSignature: recovery.failureSignature,
5275
- reason: recovery.reason,
5276
- missingFindingIds: [...(recovery.missingFindingIds ?? [])],
5277
- unknownFindingIds: [...(recovery.unknownFindingIds ?? [])],
5278
- schemaViolationCodes: [...(recovery.schemaViolationCodes ?? [])],
5279
- createdAt: recovery.createdAt,
5280
- ...(responseDigest ? { responseDigest } : {}),
5281
- ...(rawResponse !== undefined ? { rawResponse } : {}),
5282
- });
5283
- }
5284
- catch (err) {
5285
- await hold(`server-${recovery.phase}-feedback-recovery-audit-failed`, `Could not archive the rejected response during recovery: `
5286
- + `${err instanceof Error ? err.message : String(err)}`);
5287
- return;
5288
- }
5289
- const mode = recorded.disposition === 'auto-correct' ? 'correct-response' : 'hold';
5290
- const finalized = await this.setServerSignalRecoveryMode(task.id, successorToken, mode);
5291
- if (!finalized)
5292
- return;
5293
- if (mode === 'hold') {
5294
- await hold(recorded.disposition === 'hold-repeated-signature'
5295
- ? 'server-feedback-response-repeated'
5296
- : 'server-feedback-correction-limit', `Automatic correction stopped during recovery (${recorded.disposition}).`);
5297
- return;
5298
- }
5299
- }
5300
- if (agentId && (await this.agentStore.get(agentId))?.status === 'awaiting_human')
5301
- return;
5302
- const liveResponse = await readResponse();
5303
- if (!liveResponse)
5304
- return;
5305
- if (liveResponse.kind !== 'absent') {
5306
- if (!recovery.responseDigest || liveResponse.responseDigest !== recovery.responseDigest) {
5307
- await holdChangedResponse();
5308
- return;
5309
- }
5310
- const fresh = await this.taskStore.get(task.id);
5311
- const current = fresh?.serverSignalRecovery;
5312
- const currentRound = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
5313
- if (fresh?.status !== task.status
5314
- || fresh.phase !== task.phase
5315
- || fresh.signalToken !== successorToken
5316
- || currentRound !== recovery.round
5317
- || current?.mode !== 'correct-response'
5318
- || current.sourceToken !== recovery.sourceToken
5319
- || current.failureSignature !== recovery.failureSignature) {
5320
- return;
5321
- }
5322
- try {
5323
- await this.getReviewTransport().deleteResponse(dev);
5324
- }
5325
- catch (err) {
5326
- await hold(`server-${recovery.phase}-feedback-recovery-cleanup-failed`, `The rejected response was archived but could not be removed during recovery: `
5327
- + `${err instanceof Error ? err.message : String(err)}`);
5328
- return;
5329
- }
5330
- }
5331
- await this.dispatchServerFeedbackCorrection(task.id, JSON.stringify(stored.findings));
5332
- }
5333
5162
  async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
5334
5163
  {
5335
5164
  const { expectedKinds, agentId } = mapped;
5336
- const specStage = isSpecStagePhase(task.phase);
5337
5165
  const interventionKindLabel = expectedKinds.length === 0 ? undefined
5338
- : specStage && task.status === 'review' ? 'spec-reviewed'
5339
- : specStage && task.status === 'fixing' ? 'spec-fixed'
5340
- : !specStage && task.status === 'review' ? 'pr-verdict-comment'
5341
- : !specStage && task.status === 'fixing' ? 'pr-fixed'
5342
- : undefined;
5343
- const isServerProtocol = task.reviewMode === 'server' || specStage;
5344
- const scanSnapshotOnRecover = isServerProtocol
5345
- || (task.phase === undefined && task.status === 'in_progress')
5346
- || (!specStage && (task.status === 'review' || task.status === 'fixing'));
5347
- const allowRecoveredReadFile = task.status === 'review'
5348
- && (specStage
5349
- || (task.reviewMode === 'server' && task.batchTotal !== undefined)
5350
- || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
5166
+ : task.status === 'review' ? 'pr-verdict-comment'
5167
+ : task.status === 'fixing' ? 'pr-fixed'
5168
+ : undefined;
5351
5169
  try {
5352
- await this.startPhaseSignalWatch({
5170
+ const armed = await this.startPhaseSignalWatch({
5353
5171
  taskId: task.id,
5354
5172
  projectId: task.projectId,
5355
5173
  agentId,
5356
5174
  expectedKinds,
5357
5175
  token: signalToken,
5358
- skipSnapshot: !scanSnapshotOnRecover,
5176
+ skipSnapshot: false,
5359
5177
  recovered: true,
5360
5178
  ...(hasSiblings ? { replaceScope: 'agent' } : {}),
5361
- ...(allowRecoveredReadFile
5362
- ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
5363
- : {}),
5364
5179
  });
5365
- if (interventionKindLabel) {
5180
+ if (interventionKindLabel && armed && this.phaseSignalWatcher?.isSettling(task.id)) {
5181
+ await this.phaseSignalWatcher.awaitSettled(task.id);
5182
+ }
5183
+ const current = interventionKindLabel && armed
5184
+ ? await this.taskStore.get(task.id)
5185
+ : undefined;
5186
+ const generationStillWaiting = current != null
5187
+ && current.status === task.status
5188
+ && current.phase === task.phase
5189
+ && current.signalToken === signalToken
5190
+ && current.agentId === task.agentId
5191
+ && current.reviewRound === task.reviewRound
5192
+ && current.specReviewRound === task.specReviewRound
5193
+ && this.phaseSignalWatcher?.has(task.id, agentId) === true;
5194
+ if (interventionKindLabel && generationStillWaiting) {
5366
5195
  await this.emitIntervention(task.projectId, agentId, task.id, {
5367
- phase: 'spec-signal-setup-during-recovery',
5196
+ phase: 'phase-signal-setup-during-recovery',
5368
5197
  kind: interventionKindLabel,
5369
- note: 'Task is waiting for a spec signal after server recovery; if no signal arrives, the prompt may not have been fully delivered before the previous crash. Inspect the agent pane and consider manual retry or transition.',
5198
+ note: 'Task is waiting for a recovered phase signal. Inspect the agent pane and retry the phase manually if the original signal is no longer available.',
5370
5199
  });
5371
5200
  }
5372
5201
  }
@@ -5403,9 +5232,7 @@ export class AgentManager {
5403
5232
  }
5404
5233
  // 创建期快照(spec §4):git 任务在建任务那一刻定身份。
5405
5234
  platformBindingFields(projectId) {
5406
- return this.effectiveReviewMode(projectId) === 'git'
5407
- ? { platformBinding: this.platformBindingFor(projectId) }
5408
- : {};
5235
+ return { platformBinding: this.platformBindingFor(projectId) };
5409
5236
  }
5410
5237
  platformBindingFor(projectId) {
5411
5238
  const project = this.getProjectConfig(projectId);
@@ -5415,15 +5242,13 @@ export class AgentManager {
5415
5242
  if (tool === undefined)
5416
5243
  throw new Error(`project ${projectId} has no resolved platform tool`);
5417
5244
  return {
5418
- mode: this.effectiveReviewMode(projectId),
5245
+ mode: 'git',
5419
5246
  repoKey: repoIdentityKey(project.repo),
5420
5247
  tool,
5421
5248
  };
5422
5249
  }
5423
5250
  // agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
5424
5251
  agentGitPreflightContext(projectId) {
5425
- if (this.effectiveReviewMode(projectId) !== 'git')
5426
- return undefined;
5427
5252
  const project = this.getProjectConfig(projectId);
5428
5253
  if (!project || !this.pluginRegistry)
5429
5254
  return undefined;
@@ -5446,8 +5271,6 @@ export class AgentManager {
5446
5271
  // cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
5447
5272
  // 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
5448
5273
  platformCliContextOf(task) {
5449
- if (task.reviewMode !== 'git')
5450
- return undefined;
5451
5274
  this.assertPlatformBinding(task);
5452
5275
  const project = this.getProjectConfig(task.projectId);
5453
5276
  if (!project)
@@ -5466,7 +5289,7 @@ export class AgentManager {
5466
5289
  }
5467
5290
  // base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
5468
5291
  async ensureGitBaseSnapshot(task, phase) {
5469
- if (task.reviewMode !== 'git' || (phase !== 'develop' && phase !== 'code'))
5292
+ if (phase !== 'develop' && phase !== 'code')
5470
5293
  return task;
5471
5294
  if (task.baseBranch !== undefined)
5472
5295
  return task;
@@ -5556,6 +5379,7 @@ export class AgentManager {
5556
5379
  }
5557
5380
  return {
5558
5381
  ok: true,
5382
+ prUrl: String(row.prUrl),
5559
5383
  headSha: String(row.headSha),
5560
5384
  branch: String(row.branch),
5561
5385
  targetBranch: String(row.targetBranch),
@@ -5629,7 +5453,7 @@ export class AgentManager {
5629
5453
  async processGitRemoteCleanup(taskId, expectedGeneration) {
5630
5454
  const snapshot = await this.taskStore.get(taskId);
5631
5455
  const snapshotIntent = snapshot?.remoteCleanup;
5632
- if (!snapshot || snapshot.reviewMode !== 'git' || !snapshotIntent || snapshotIntent.stage === 'manual')
5456
+ if (!snapshot || !snapshotIntent || snapshotIntent.stage === 'manual')
5633
5457
  return;
5634
5458
  if (expectedGeneration !== undefined && snapshotIntent.generation !== expectedGeneration)
5635
5459
  return;
@@ -5796,6 +5620,29 @@ export class AgentManager {
5796
5620
  return;
5797
5621
  }
5798
5622
  if (branch.headSha === undefined) {
5623
+ try {
5624
+ await this.eventBus.emit({
5625
+ id: `outbox:${task.id}:${intent.prNumber}:remote-branch-deleted:${generation}`,
5626
+ type: 'task.updated',
5627
+ timestamp: new Date().toISOString(),
5628
+ projectId: task.projectId,
5629
+ taskId: task.id,
5630
+ data: {
5631
+ status: task.status,
5632
+ operation: 'git-branch-delete',
5633
+ outcome: 'succeeded',
5634
+ generation,
5635
+ prNumber: intent.prNumber,
5636
+ branch: intent.branch,
5637
+ expectedHeadSha: intent.expectedHeadSha,
5638
+ remoteProjectId: intent.remoteProjectId,
5639
+ },
5640
+ });
5641
+ }
5642
+ catch (err) {
5643
+ await this.recordRemoteCleanupFailure(task, generation, 'persist', safeDriverErrorText(err));
5644
+ throw err;
5645
+ }
5799
5646
  await this.mutateRemoteCleanup(taskId, generation, () => undefined);
5800
5647
  return;
5801
5648
  }
@@ -5871,7 +5718,7 @@ export class AgentManager {
5871
5718
  if (String(row.headSha) !== opts.expectedHeadSha) {
5872
5719
  throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
5873
5720
  }
5874
- if (task.reviewMode === 'git' && opts.humanOverride !== true) {
5721
+ if (opts.humanOverride !== true) {
5875
5722
  const provenance = task.passProvenance;
5876
5723
  if (!provenance) {
5877
5724
  throw new PlatformMergeRecheckError('provenance-invalid', 'no accepted pass provenance on task');
@@ -5904,9 +5751,7 @@ export class AgentManager {
5904
5751
  }
5905
5752
  const fresh = await this.taskStore.get(taskId);
5906
5753
  if (!fresh
5907
- || (opts.humanOverride === true
5908
- ? fresh.status !== 'merge-ready'
5909
- : (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
5754
+ || fresh.status !== 'merge-ready'
5910
5755
  || fresh.latestHeadSha !== opts.expectedHeadSha
5911
5756
  || fresh.signalToken !== task.signalToken
5912
5757
  || fresh.pendingRedispatch === true) {
@@ -5950,7 +5795,7 @@ export class AgentManager {
5950
5795
  return [];
5951
5796
  const key = repoIdentityKey(project.repo);
5952
5797
  const entryBinding = {
5953
- mode: this.effectiveReviewMode(projectId),
5798
+ mode: 'git',
5954
5799
  repoKey: key,
5955
5800
  tool: resolveProjectTool(project) ?? '',
5956
5801
  };
@@ -5960,31 +5805,16 @@ export class AgentManager {
5960
5805
  && this.taskBelongsToPlatformEntry(task, entryBinding));
5961
5806
  }
5962
5807
  async findTaskByBranch(branch, projectId) {
5963
- const repoKey = this.projectRepoKey(projectId);
5964
- if (!repoKey)
5808
+ const repo = this.getProjectConfig(projectId)?.repo;
5809
+ if (!repo)
5965
5810
  return undefined;
5811
+ const repoKey = repoIdentityKey(repo);
5966
5812
  const all = await this.taskStore.list();
5967
- return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
5968
- }
5969
- async adoptServerPr(taskId, pr) {
5970
- return this.withTaskLock(async () => {
5971
- const task = await this.taskStore.get(taskId);
5972
- if (!task || task.reviewMode !== 'server' || task.afterDone !== 'pr'
5973
- || task.platformBinding === undefined || TERMINAL_STATUSES.includes(task.status))
5974
- return false;
5975
- if (task.prNumber !== undefined && task.prNumber !== pr.prNumber)
5976
- return false;
5977
- await this.taskStore.set({
5978
- ...task,
5979
- prNumber: pr.prNumber,
5980
- ...(pr.prUrl !== undefined ? { prUrl: pr.prUrl } : {}),
5981
- ...(pr.headSha !== undefined ? { latestHeadSha: pr.headSha } : {}),
5982
- ...(task.baseBranch === undefined && pr.targetBranch !== undefined
5983
- ? { baseBranch: pr.targetBranch }
5984
- : {}),
5985
- updatedAt: new Date().toISOString(),
5986
- });
5987
- return true;
5813
+ return all.find((task) => {
5814
+ const candidateRepo = this.getProjectConfig(task.projectId)?.repo;
5815
+ return task.branch === branch
5816
+ && candidateRepo !== undefined
5817
+ && repoIdentityKey(candidateRepo) === repoKey;
5988
5818
  });
5989
5819
  }
5990
5820
  async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
@@ -5996,6 +5826,8 @@ export class AgentManager {
5996
5826
  return false;
5997
5827
  if ('phase' in alsoExpect && task.phase !== alsoExpect.phase)
5998
5828
  return false;
5829
+ if ('agentId' in alsoExpect && task.agentId !== alsoExpect.agentId)
5830
+ return false;
5999
5831
  if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
6000
5832
  return false;
6001
5833
  if ('specReviewRound' in alsoExpect && task.specReviewRound !== alsoExpect.specReviewRound)
@@ -6005,184 +5837,6 @@ export class AgentManager {
6005
5837
  return true;
6006
5838
  });
6007
5839
  }
6008
- async claimServerSignalRecovery(taskAtEntry, agentId, signalKind, input) {
6009
- const sourceToken = taskAtEntry.signalToken;
6010
- if (!sourceToken)
6011
- return null;
6012
- const currentRound = input.phase === 'spec'
6013
- ? (taskAtEntry.specReviewRound ?? 0)
6014
- : taskAtEntry.reviewRound;
6015
- const phaseMatches = input.phase === 'spec'
6016
- ? taskAtEntry.phase !== 'code'
6017
- : taskAtEntry.phase === 'code' || taskAtEntry.phase === undefined;
6018
- if (!phaseMatches || input.round !== currentRound)
6019
- return null;
6020
- let successorToken = createSignalToken();
6021
- while (successorToken === sourceToken)
6022
- successorToken = createSignalToken();
6023
- const recovery = {
6024
- ...input,
6025
- signalKind,
6026
- sourceToken,
6027
- missingFindingIds: [...(input.missingFindingIds ?? [])].sort(),
6028
- unknownFindingIds: [...(input.unknownFindingIds ?? [])].sort(),
6029
- schemaViolationCodes: [...(input.schemaViolationCodes ?? [])].sort(),
6030
- createdAt: new Date().toISOString(),
6031
- };
6032
- const expectedRound = input.phase === 'spec'
6033
- ? { specReviewRound: taskAtEntry.specReviewRound }
6034
- : { reviewRound: taskAtEntry.reviewRound };
6035
- const claimed = await this.updateTaskIfStatus(taskAtEntry.id, taskAtEntry.status, {
6036
- signalToken: successorToken,
6037
- serverSignalRecovery: recovery,
6038
- ...(taskAtEntry.status === 'fixing' ? { fixDispatchedAt: new Date().toISOString() } : {}),
6039
- }, {
6040
- signalToken: sourceToken,
6041
- phase: taskAtEntry.phase,
6042
- ...expectedRound,
6043
- });
6044
- if (!claimed)
6045
- return null;
6046
- if (agentId)
6047
- this.phaseSignalWatcher?.stopAgentIfToken(taskAtEntry.id, agentId, sourceToken);
6048
- else
6049
- this.phaseSignalWatcher?.stopIfToken(taskAtEntry.id, sourceToken);
6050
- const fresh = await this.taskStore.get(taskAtEntry.id);
6051
- if (fresh?.signalToken !== successorToken
6052
- || fresh.serverSignalRecovery?.sourceToken !== sourceToken
6053
- || fresh.serverSignalRecovery.signalKind !== signalKind) {
6054
- return null;
6055
- }
6056
- if (input.mode === 'hold' && agentId) {
6057
- const holdPhase = input.failurePhase ?? `server-signal-${input.reason}`;
6058
- const held = await this.markAwaitingHuman(agentId, holdPhase, `The ${signalKind} marker was consumed but could not be completed (${input.reason}). `
6059
- + 'The old token is retired. Restart or cancel the task after resolving the reported cause.', { expectedTaskId: taskAtEntry.id });
6060
- if (!held) {
6061
- console.error(`[AgentManager] failed to hold ${agentId} for consumed ${signalKind} on ${taskAtEntry.id}; `
6062
- + `recovery=${successorToken}`);
6063
- }
6064
- }
6065
- return fresh;
6066
- }
6067
- async holdConsumedServerSignal(taskAtEntry, agentId, signalKind, input) {
6068
- return this.claimServerSignalRecovery(taskAtEntry, agentId, signalKind, {
6069
- mode: 'hold',
6070
- ...input,
6071
- });
6072
- }
6073
- async setServerSignalRecoveryMode(taskId, successorToken, mode) {
6074
- const task = await this.taskStore.get(taskId);
6075
- const recovery = task?.serverSignalRecovery;
6076
- if (!task || !recovery || task.signalToken !== successorToken)
6077
- return null;
6078
- const currentRound = recovery.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
6079
- const phaseMatches = recovery.phase === 'spec'
6080
- ? task.phase !== 'code'
6081
- : task.phase === 'code' || task.phase === undefined;
6082
- if (!phaseMatches || currentRound !== recovery.round)
6083
- return null;
6084
- const expectedRound = recovery.phase === 'spec'
6085
- ? { specReviewRound: task.specReviewRound }
6086
- : { reviewRound: task.reviewRound };
6087
- const updated = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: { ...recovery, mode } }, {
6088
- signalToken: successorToken,
6089
- phase: task.phase,
6090
- ...expectedRound,
6091
- });
6092
- if (!updated)
6093
- return null;
6094
- const fresh = await this.taskStore.get(taskId);
6095
- if (fresh?.signalToken !== successorToken
6096
- || fresh.serverSignalRecovery?.mode !== mode
6097
- || fresh.serverSignalRecovery.sourceToken !== recovery.sourceToken) {
6098
- return null;
6099
- }
6100
- return fresh;
6101
- }
6102
- async holdServerSignalRecovery(taskId, agentId, successorToken, holdPhase, reason) {
6103
- const task = await this.setServerSignalRecoveryMode(taskId, successorToken, 'hold');
6104
- if (!task)
6105
- return null;
6106
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, agentId, successorToken);
6107
- if (agentId) {
6108
- const held = await this.markAwaitingHuman(agentId, holdPhase, reason, { expectedTaskId: taskId });
6109
- if (!held) {
6110
- console.error(`[AgentManager] failed to hold ${agentId} for recovery ${taskId}/${successorToken}`);
6111
- }
6112
- }
6113
- return task;
6114
- }
6115
- async dispatchServerFeedbackCorrection(taskId, findingsJson) {
6116
- const task = await this.taskStore.get(taskId);
6117
- const recovery = task?.serverSignalRecovery;
6118
- if (!task || !recovery || recovery.mode !== 'correct-response' || !task.signalToken)
6119
- return false;
6120
- const agentId = task.agentId;
6121
- const successorToken = task.signalToken;
6122
- if (!agentId || !this.getAgentConfig(agentId)) {
6123
- await this.holdServerSignalRecovery(taskId, agentId, successorToken, `server-${recovery.phase}-feedback-correction-no-dev`, 'The response was rejected, but the correction prompt cannot be delivered because the dev agent is unavailable.');
6124
- return false;
6125
- }
6126
- const stillCurrent = async () => {
6127
- const fresh = await this.taskStore.get(taskId);
6128
- const current = fresh?.serverSignalRecovery;
6129
- const round = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
6130
- return fresh?.status === task.status
6131
- && fresh.phase === task.phase
6132
- && fresh.signalToken === successorToken
6133
- && round === recovery.round
6134
- && current?.mode === 'correct-response'
6135
- && current.sourceToken === recovery.sourceToken
6136
- && current.failureSignature === recovery.failureSignature;
6137
- };
6138
- try {
6139
- const delivered = await this.continueSession(taskId, agentId, 'server-feedback', {
6140
- allowDirtyWorkdir: true,
6141
- signalToken: successorToken,
6142
- serverPriorFindings: findingsJson,
6143
- serverFindingsDigest: recovery.findingsDigest,
6144
- serverFeedbackCorrection: {
6145
- reason: recovery.reason,
6146
- missingFindingIds: recovery.missingFindingIds,
6147
- unknownFindingIds: recovery.unknownFindingIds,
6148
- schemaViolationCodes: recovery.schemaViolationCodes,
6149
- },
6150
- ...(recovery.phase === 'spec' ? { currentSpecRound: recovery.round } : {}),
6151
- armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, recovery.signalKind, successorToken, {
6152
- skipSnapshot: true,
6153
- onlyReplaceOwnToken: true,
6154
- replaceFromToken: recovery.sourceToken,
6155
- needInputMode: 'fresh',
6156
- }),
6157
- guardBeforeInject: stillCurrent,
6158
- });
6159
- if (!delivered)
6160
- throw new Error('correction prompt was not delivered');
6161
- const cleared = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: undefined }, {
6162
- signalToken: successorToken,
6163
- phase: task.phase,
6164
- ...(recovery.phase === 'spec'
6165
- ? { specReviewRound: task.specReviewRound }
6166
- : { reviewRound: task.reviewRound }),
6167
- });
6168
- if (!cleared && await stillCurrent()) {
6169
- throw new Error('correction prompt delivered but recovery intent could not be cleared');
6170
- }
6171
- return true;
6172
- }
6173
- catch (err) {
6174
- const message = err instanceof Error ? err.message : String(err);
6175
- const held = await this.holdServerSignalRecovery(taskId, agentId, successorToken, `server-${recovery.phase}-feedback-correction-failed`, `${message}. The old token remains retired; Restart or Cancel the task.`);
6176
- if (!held)
6177
- return false;
6178
- await this.emitIntervention(task.projectId, agentId, taskId, {
6179
- phase: `server-${recovery.phase}-feedback-correction-failed`,
6180
- error: message,
6181
- successorToken,
6182
- });
6183
- return false;
6184
- }
6185
- }
6186
5840
  async updateTask(taskId, updates) {
6187
5841
  await this.withTaskLock(async () => {
6188
5842
  const task = await this.taskStore.get(taskId);
@@ -6192,40 +5846,127 @@ export class AgentManager {
6192
5846
  await this.taskStore.set(task);
6193
5847
  });
6194
5848
  }
6195
- async transitionTaskStatus(taskId, toStatus, guard, patch) {
5849
+ async recordGitPrObservation(taskId, observation, expected) {
5850
+ return this.persistGitPrObservation(taskId, observation, undefined, expected);
5851
+ }
5852
+ async confirmGitDelivery(taskId, confirmation) {
5853
+ return this.persistGitPrObservation(taskId, confirmation, confirmation);
5854
+ }
5855
+ async persistGitPrObservation(taskId, observation, confirmation, expected) {
6196
5856
  return this.withTaskLock(async () => {
6197
5857
  const task = await this.taskStore.get(taskId);
6198
- if (!task)
6199
- return null;
6200
- const previousStatus = task.status;
6201
- if (TERMINAL_STATUSES.includes(previousStatus))
6202
- return null;
6203
- if (!guard.fromStatus.includes(previousStatus))
6204
- return null;
6205
- if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
6206
- return null;
6207
- if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
5858
+ if (!task || TERMINAL_STATUSES.includes(task.status))
6208
5859
  return null;
6209
- if (guard.expectPostApproveEpisode !== undefined
6210
- && !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
5860
+ if (expected
5861
+ && (!taskMatchesGeneration(task, expected.task)
5862
+ || task.latestHeadSha !== expected.latestHeadSha))
6211
5863
  return null;
6212
- if (guard.expectReviewDispatch !== undefined
6213
- && !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
5864
+ if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
6214
5865
  return null;
6215
- if (patch?.branch && patch.branch !== task.branch) {
6216
- const existing = await this.findTaskByBranch(patch.branch, task.projectId);
5866
+ if (confirmation !== undefined) {
5867
+ if (task.status !== 'in_progress'
5868
+ && (task.phase !== confirmation.phase
5869
+ || task.deliveryConfirmation?.phase !== confirmation.phase))
5870
+ return null;
5871
+ if (Object.hasOwn(confirmation, 'expectedSignalToken')
5872
+ && task.signalToken !== confirmation.expectedSignalToken)
5873
+ return null;
5874
+ if (task.phase !== undefined && task.phase !== confirmation.phase)
5875
+ return null;
5876
+ }
5877
+ if (confirmation?.branch !== undefined && confirmation.branch !== task.branch) {
5878
+ const existing = await this.findTaskByBranch(confirmation.branch, task.projectId);
6217
5879
  if (existing && existing.id !== taskId)
6218
5880
  return null;
6219
5881
  }
6220
- const next = this.stripGitStatusScopedState(task, toStatus);
6221
- if (previousStatus !== toStatus)
6222
- delete next.serverSignalRecovery;
6223
- Object.assign(next, patch ?? {}, {
6224
- status: toStatus,
6225
- updatedAt: new Date().toISOString(),
6226
- });
5882
+ const now = new Date().toISOString();
5883
+ const next = {
5884
+ ...task,
5885
+ prNumber: observation.prNumber,
5886
+ ...(observation.prUrl !== undefined ? { prUrl: observation.prUrl } : {}),
5887
+ latestHeadSha: observation.headSha,
5888
+ ...(observation.targetBranch !== undefined && task.baseBranch === undefined
5889
+ ? { baseBranch: observation.targetBranch }
5890
+ : {}),
5891
+ ...(confirmation !== undefined
5892
+ ? {
5893
+ phase: confirmation.phase,
5894
+ deliveryConfirmation: task.status === 'in_progress'
5895
+ ? { phase: confirmation.phase, source: confirmation.source, at: now }
5896
+ : task.deliveryConfirmation,
5897
+ ...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
5898
+ replyActorId: confirmation.actorId,
5899
+ replyActorStatus: 'verified',
5900
+ pendingPrSignalToken: undefined,
5901
+ }
5902
+ : task.replyActorStatus !== 'verified' && observation.prAuthorId !== undefined
5903
+ ? { replyActorId: observation.prAuthorId, replyActorStatus: 'provisional' }
5904
+ : {}),
5905
+ updatedAt: now,
5906
+ };
6227
5907
  await this.taskStore.set(next);
6228
- return { task: next, previousStatus };
5908
+ return next;
5909
+ });
5910
+ }
5911
+ async transitionTaskStatus(taskId, toStatus, guard, patch) {
5912
+ return this.withTaskLock(async () => {
5913
+ const transition = async () => {
5914
+ if (guard.expectAgentLock) {
5915
+ const binding = await this.agentStore.get(guard.expectAgentLock.agentId);
5916
+ if (binding?.taskId !== taskId
5917
+ || binding.lockToken !== guard.expectAgentLock.token)
5918
+ return null;
5919
+ }
5920
+ const task = await this.taskStore.get(taskId);
5921
+ if (!task)
5922
+ return null;
5923
+ const previousStatus = task.status;
5924
+ if (TERMINAL_STATUSES.includes(previousStatus))
5925
+ return null;
5926
+ if (!guard.fromStatus.includes(previousStatus))
5927
+ return null;
5928
+ if (guard.expectTask && !taskMatchesGeneration(task, guard.expectTask))
5929
+ return null;
5930
+ if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
5931
+ return null;
5932
+ if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
5933
+ return null;
5934
+ const expectedOutbox = guard.expectOutbox;
5935
+ if (expectedOutbox !== undefined
5936
+ && !task.outbox?.some(entry => entry.key === expectedOutbox.key && entry.type === expectedOutbox.type))
5937
+ return null;
5938
+ if (guard.expectPostApproveEpisode !== undefined
5939
+ && !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
5940
+ return null;
5941
+ if (guard.expectReviewDispatch !== undefined
5942
+ && !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
5943
+ return null;
5944
+ if (Object.hasOwn(guard, 'expectLatestHeadSha')
5945
+ && task.latestHeadSha !== guard.expectLatestHeadSha)
5946
+ return null;
5947
+ if (Object.hasOwn(guard, 'expectReviewHeadAnchorSha')
5948
+ && task.reviewHeadAnchorSha !== guard.expectReviewHeadAnchorSha)
5949
+ return null;
5950
+ if (Object.hasOwn(guard, 'expectPrNumber')
5951
+ && task.prNumber !== guard.expectPrNumber)
5952
+ return null;
5953
+ if (patch?.branch && patch.branch !== task.branch) {
5954
+ const existing = await this.findTaskByBranch(patch.branch, task.projectId);
5955
+ if (existing && existing.id !== taskId)
5956
+ return null;
5957
+ }
5958
+ const next = this.stripGitStatusScopedState(task, toStatus);
5959
+ Object.assign(next, patch ?? {}, {
5960
+ status: toStatus,
5961
+ updatedAt: new Date().toISOString(),
5962
+ });
5963
+ await this.taskStore.set(next);
5964
+ return { task: next, previousStatus };
5965
+ };
5966
+ if (!guard.expectAgentLock)
5967
+ return transition();
5968
+ const guarded = await this.lockManager.runIfOwner(guard.expectAgentLock.agentId, taskId, guard.expectAgentLock.token, transition);
5969
+ return guarded.ran ? guarded.value : null;
6229
5970
  });
6230
5971
  }
6231
5972
  createRunnerFor(agent) {
@@ -6239,9 +5980,7 @@ export class AgentManager {
6239
5980
  if (this.repoStoreFactory) {
6240
5981
  return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
6241
5982
  }
6242
- const cloneViaGh = this.effectiveReviewMode(project.id) === 'git'
6243
- ? resolveProjectTool(project) === 'gh'
6244
- : undefined;
5983
+ const cloneViaGh = resolveProjectTool(project) === 'gh';
6245
5984
  return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
6246
5985
  }
6247
5986
  async ensureWorkdir(agent, project, runner) {
@@ -6336,7 +6075,7 @@ export class AgentManager {
6336
6075
  throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
6337
6076
  }
6338
6077
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
6339
- throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
6078
+ throw new ApiError(400, `Agent ${preferredAgentId} is not a dev agent`);
6340
6079
  }
6341
6080
  const state = await this.agentStore.get(preferredAgentId);
6342
6081
  if (!canDispatchWithBinding(state))
@@ -6392,7 +6131,6 @@ export class AgentManager {
6392
6131
  reviewRound: 0,
6393
6132
  branch: taskBranch,
6394
6133
  branchCreatedByBaxian,
6395
- reviewMode: this.effectiveReviewMode(projectId),
6396
6134
  ...this.platformBindingFields(projectId),
6397
6135
  createdAt: now,
6398
6136
  updatedAt: now,
@@ -6410,18 +6148,15 @@ export class AgentManager {
6410
6148
  // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
6411
6149
  const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
6412
6150
  const target = await this.pickAgent(projectId, input.preferredAgentId);
6413
- const targetConfig = this.getAgentConfig(input.preferredAgentId);
6414
6151
  const group = this.findAgentGroup(input.preferredAgentId);
6415
6152
  const dev = group?.find(agent => agent.role === 'dev');
6416
6153
  if (!dev)
6417
6154
  throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
6418
6155
  const qa = group?.find(agent => agent.role === 'qa');
6419
- const research = targetConfig.role === 'research' ? targetConfig : undefined;
6420
- const researchFields = research
6421
- ? { researchAgentId: research.id, phase: 'research' }
6422
- : {};
6156
+ if (!qa)
6157
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
6423
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.
6424
- const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
6159
+ const participantGens = new Map([dev.id, qa.id]
6425
6160
  .map(id => [id, this.deletionGenerationOf(id)]));
6426
6161
  const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
6427
6162
  && !this.isDeletionInFlight(input.preferredAgentId)
@@ -6430,9 +6165,8 @@ export class AgentManager {
6430
6165
  ...taskBase,
6431
6166
  agentId: '',
6432
6167
  devAgentId: dev.id,
6433
- ...researchFields,
6168
+ qaAgentId: qa.id,
6434
6169
  status: 'pending',
6435
- ...(qa ? { qaAgentId: qa.id } : {}),
6436
6170
  };
6437
6171
  // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
6438
6172
  // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
@@ -6516,8 +6250,7 @@ export class AgentManager {
6516
6250
  ...taskBase,
6517
6251
  agentId: target.id,
6518
6252
  devAgentId: dev.id,
6519
- ...(qa ? { qaAgentId: qa.id } : {}),
6520
- ...researchFields,
6253
+ qaAgentId: qa.id,
6521
6254
  status: 'in_progress',
6522
6255
  };
6523
6256
  try {
@@ -6554,7 +6287,7 @@ export class AgentManager {
6554
6287
  const task = await this.createTask(projectId, input);
6555
6288
  if (task.status === 'in_progress' && task.agentId) {
6556
6289
  const signalToken = createSignalToken();
6557
- await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
6290
+ await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
6558
6291
  const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
6559
6292
  const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
6560
6293
  if (opts.background) {
@@ -6574,7 +6307,7 @@ export class AgentManager {
6574
6307
  const initialTask = await this.taskStore.get(taskId);
6575
6308
  if (!initialTask)
6576
6309
  return null;
6577
- const initialDispatch = this.resolveInitialDispatch(initialTask);
6310
+ const initialDispatch = this.resolveInitialDispatch();
6578
6311
  const dispatchPhase = initialDispatch.phase;
6579
6312
  let started = false;
6580
6313
  let dispatchErr = null;
@@ -6609,7 +6342,7 @@ export class AgentManager {
6609
6342
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
6610
6343
  return null;
6611
6344
  }
6612
- const initialKinds = this.resolveInitialDispatch(fresh).kinds;
6345
+ const initialKinds = this.resolveInitialDispatch().kinds;
6613
6346
  try {
6614
6347
  await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
6615
6348
  }
@@ -6824,7 +6557,7 @@ export class AgentManager {
6824
6557
  `${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
6825
6558
  return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
6826
6559
  }
6827
- async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
6560
+ async clearRuntimeForDispatchBoundary(tmux, pane, agentId, runtime, revalidate) {
6828
6561
  await this.acquireCompactGuard(agentId);
6829
6562
  try {
6830
6563
  await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
@@ -6843,7 +6576,7 @@ export class AgentManager {
6843
6576
  });
6844
6577
  await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
6845
6578
  if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
6846
- throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
6579
+ throw new Error('Runtime rejected /clear at the dispatch boundary; refusing to reuse prior context');
6847
6580
  }
6848
6581
  await revalidate();
6849
6582
  }
@@ -6928,7 +6661,7 @@ export class AgentManager {
6928
6661
  return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
6929
6662
  }
6930
6663
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
6931
- return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
6664
+ return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not a dev agent` };
6932
6665
  }
6933
6666
  if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
6934
6667
  return {
@@ -6961,9 +6694,12 @@ export class AgentManager {
6961
6694
  const qaId = initiallyUnassigned
6962
6695
  ? group?.find(agent => agent.role === 'qa')?.id
6963
6696
  : fresh.qaAgentId;
6964
- const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
6697
+ if (!qaId) {
6698
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6699
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its group` };
6700
+ }
6965
6701
  // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
6966
- const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
6702
+ const participantGens = new Map([devId, qaId]
6967
6703
  .map(pid => [pid, this.deletionGenerationOf(pid)]));
6968
6704
  const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
6969
6705
  const claimedTask = {
@@ -6971,9 +6707,8 @@ export class AgentManager {
6971
6707
  preferredAgentId: agentId,
6972
6708
  agentId,
6973
6709
  devAgentId: devId,
6974
- researchAgentId: researchId,
6975
6710
  qaAgentId: qaId,
6976
- phase: researchId ? 'research' : undefined,
6711
+ phase: undefined,
6977
6712
  status: 'in_progress',
6978
6713
  updatedAt: now,
6979
6714
  };
@@ -7066,10 +6801,10 @@ export class AgentManager {
7066
6801
  return claim;
7067
6802
  const claimed = claim.task;
7068
6803
  const signalToken = createSignalToken();
7069
- await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
6804
+ await this.updateTask(claimed.id, this.dispatchTokenFields(signalToken));
7070
6805
  let started = false;
7071
6806
  let dispatchErr = null;
7072
- const initialDispatch = this.resolveInitialDispatch(claimed);
6807
+ const initialDispatch = this.resolveInitialDispatch();
7073
6808
  try {
7074
6809
  started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
7075
6810
  }
@@ -7248,30 +6983,13 @@ export class AgentManager {
7248
6983
  throw busyPend;
7249
6984
  throw err;
7250
6985
  }
7251
- const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
7252
- const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
7253
- let reviewCheckout;
7254
6986
  const dispatchWorkdir = workdir;
7255
6987
  try {
7256
- if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
7257
- reviewCheckout = await branchManager.materializeReviewHead(workdir, {
7258
- patch: opts.serverContent,
7259
- baseSha: opts.serverBaseSha,
7260
- headSha: opts.serverHeadSha,
7261
- headTree: opts.serverHeadTree,
7262
- });
7263
- }
7264
- else if (isServerQaPhase) {
7265
- await branchManager.switchToDefaultDetached(workdir);
7266
- }
7267
- else if (phase === 'review' || phase === 'recheck') {
6988
+ if (phase === 'review' || phase === 'recheck') {
7268
6989
  if (!task.branch)
7269
6990
  throw new Error(`Task ${taskId} has no review branch`);
7270
6991
  await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
7271
6992
  }
7272
- else if (phase === 'research') {
7273
- await branchManager.switchToDefaultDetached(workdir);
7274
- }
7275
6993
  else {
7276
6994
  if (!task.branch)
7277
6995
  throw new Error(`Task ${taskId} has no task branch`);
@@ -7283,7 +7001,7 @@ export class AgentManager {
7283
7001
  }
7284
7002
  await assertOwner();
7285
7003
  if (!ensure.freshRuntime) {
7286
- await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
7004
+ await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
7287
7005
  if (ensure.skillsStale)
7288
7006
  await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
7289
7007
  }
@@ -7340,32 +7058,9 @@ export class AgentManager {
7340
7058
  };
7341
7059
  });
7342
7060
  const promptSignalToken = opts.signalToken ?? task.signalToken;
7343
- const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
7344
- const hasQaPartner = task.qaAgentId !== undefined;
7345
7061
  let prompt;
7346
7062
  try {
7347
- await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
7348
- specDocuments: opts.specDocuments,
7349
- preserveOutputs: false,
7350
- assertOwner,
7351
- });
7352
7063
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
7353
- let payloadOpts = {};
7354
- if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
7355
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
7356
- phase,
7357
- taskPhase: task.phase,
7358
- ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
7359
- reviewRound: task.reviewRound,
7360
- ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
7361
- ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
7362
- ...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
7363
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
7364
- ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
7365
- ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
7366
- ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
7367
- });
7368
- }
7369
7064
  const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
7370
7065
  const platformCli = this.platformCliContextOf(taskForPrompt);
7371
7066
  prompt = buildPromptInline({
@@ -7374,20 +7069,9 @@ export class AgentManager {
7374
7069
  agent,
7375
7070
  workdir: dispatchWorkdir,
7376
7071
  skillRegistry: this.skillRegistry,
7377
- hasQaPartner,
7378
7072
  ...(platformCli ? { platformCli } : {}),
7379
7073
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
7380
- ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
7381
7074
  ...(imagePaths.length ? { imagePaths } : {}),
7382
- ...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
7383
- ...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
7384
- ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
7385
- ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
7386
- ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
7387
- ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
7388
- ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
7389
- ...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
7390
- ...payloadOpts,
7391
7075
  });
7392
7076
  }
7393
7077
  catch (err) {
@@ -7433,7 +7117,7 @@ export class AgentManager {
7433
7117
  await cleanupCheckoutOrHold();
7434
7118
  return false;
7435
7119
  }
7436
- if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
7120
+ if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
7437
7121
  await cleanupCheckoutOrHold();
7438
7122
  return false;
7439
7123
  }
@@ -7918,40 +7602,9 @@ export class AgentManager {
7918
7602
  await this.clearBranchLocalCleaned(taskId);
7919
7603
  }
7920
7604
  }
7921
- else if (agent.role === 'research') {
7922
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
7923
- await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
7924
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
7925
- }
7926
- const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
7927
7605
  let prompt;
7928
7606
  try {
7929
- await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
7930
- specDocuments: opts.specDocuments,
7931
- preserveOutputs: opts.preserveDispatchOutputs ?? false,
7932
- assertOwner: async () => {
7933
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
7934
- },
7935
- });
7936
- const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
7937
- && opts.postApproveRedispatchCount > 0
7938
- && !ensure.freshRuntime;
7939
7607
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
7940
- let payloadOpts = {};
7941
- if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
7942
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
7943
- phase,
7944
- taskPhase: task.phase,
7945
- ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
7946
- reviewRound: task.reviewRound,
7947
- ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
7948
- ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
7949
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
7950
- ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
7951
- ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
7952
- ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
7953
- });
7954
- }
7955
7608
  const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
7956
7609
  const platformCli = this.platformCliContextOf(taskForPrompt);
7957
7610
  prompt = buildPromptInline({
@@ -7960,19 +7613,9 @@ export class AgentManager {
7960
7613
  agent,
7961
7614
  workdir: verifiedWorkdir,
7962
7615
  skillRegistry: this.skillRegistry,
7963
- hasQaPartner: task.qaAgentId !== undefined,
7964
7616
  ...(platformCli ? { platformCli } : {}),
7965
7617
  ...(signalToken ? { signalToken } : {}),
7966
- ...(useIncrementalNudge
7967
- ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
7968
- : {}),
7969
- ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
7970
7618
  ...(imagePaths.length ? { imagePaths } : {}),
7971
- ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
7972
- ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
7973
- ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
7974
- ...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
7975
- ...payloadOpts,
7976
7619
  });
7977
7620
  }
7978
7621
  catch (err) {
@@ -8040,7 +7683,33 @@ export class AgentManager {
8040
7683
  return false;
8041
7684
  }
8042
7685
  const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
8043
- if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
7686
+ if (!ensure.freshRuntime && ensure.skillsStale) {
7687
+ const revalidateSkillRefresh = async () => {
7688
+ const [currentTask, currentAgent] = await Promise.all([
7689
+ this.taskStore.get(taskId),
7690
+ this.agentStore.get(agentId),
7691
+ ]);
7692
+ const taskStillDispatchable = currentTask !== null
7693
+ && !TERMINAL_STATUSES.includes(currentTask.status)
7694
+ && (opts.bypassTaskStatusGate || expectedStatuses.includes(currentTask.status));
7695
+ const bindingStillDispatchable = PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]
7696
+ ? currentAgent?.taskId === taskId
7697
+ : currentAgent?.taskId === undefined || currentAgent.taskId === taskId;
7698
+ if (!taskStillDispatchable
7699
+ || !bindingStillDispatchable
7700
+ || currentAgent?.lockToken !== lockToken
7701
+ || currentAgent.workdir !== verifiedWorkdir) {
7702
+ throw new Error(`Task or agent ownership changed while refreshing skills for ${phase} dispatch`);
7703
+ }
7704
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
7705
+ if (guardBeforeInject && !(await guardBeforeInject())) {
7706
+ throw new Error(`Task generation changed while refreshing skills for ${phase} dispatch`);
7707
+ }
7708
+ };
7709
+ await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, revalidateSkillRefresh);
7710
+ await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
7711
+ }
7712
+ if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
8044
7713
  return false;
8045
7714
  }
8046
7715
  const now = new Date().toISOString();
@@ -8123,15 +7792,13 @@ export class AgentManager {
8123
7792
  }
8124
7793
  if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
8125
7794
  return false;
8126
- const dispatchPhase = boundTask.phase === 'research'
8127
- ? 'research'
8128
- : boundTask.specReviewRound !== undefined ? 'code' : 'develop';
7795
+ const dispatchPhase = boundTask.specReviewRound !== undefined ? 'code' : 'develop';
8129
7796
  if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
8130
7797
  await this.clearBootstrapMarker(state.id, state.taskId);
8131
7798
  return false;
8132
7799
  }
8133
7800
  if (dispatchPhase === 'code') {
8134
- await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the persisted Spec documents.', { expectedTaskId: state.taskId });
7801
+ await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the code-phase prompt.', { expectedTaskId: state.taskId });
8135
7802
  return true;
8136
7803
  }
8137
7804
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
@@ -8514,7 +8181,6 @@ export class AgentManager {
8514
8181
  let devToRelease;
8515
8182
  let qaToRelease;
8516
8183
  let remoteCleanupGeneration;
8517
- let publishedCleanup;
8518
8184
  this.phaseSignalWatcher?.stop(taskId);
8519
8185
  let cleanupClaimed = false;
8520
8186
  const result = await this.withTaskLock(async () => {
@@ -8535,27 +8201,13 @@ export class AgentManager {
8535
8201
  devToRelease = task.agentId;
8536
8202
  if (task.qaAgentId)
8537
8203
  qaToRelease = task.qaAgentId;
8538
- const publishedAtGate = task.status === 'ready'
8539
- || (task.status === 'approved' && !!task.publishDispatchedAt);
8540
- if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
8541
- const afterDone = this.resolveAfterDone(task);
8542
- if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
8543
- publishedCleanup = {
8544
- afterDone,
8545
- branch: task.branch,
8546
- prNumber: task.prNumber,
8547
- devAgentId: task.agentId,
8548
- mayBeInFlight: task.status === 'approved',
8549
- };
8550
- }
8551
- }
8552
8204
  for (const id of [devToRelease, qaToRelease]) {
8553
8205
  if (id)
8554
8206
  await this.markPaneCancelClearing(id, taskId);
8555
8207
  }
8556
8208
  if (!alreadyTerminal) {
8557
8209
  const now = new Date().toISOString();
8558
- if (task.reviewMode === 'git' && task.prNumber !== undefined && task.branch !== undefined) {
8210
+ if (task.prNumber !== undefined && task.branch !== undefined) {
8559
8211
  const expectedHeadSha = typeof task.latestHeadSha === 'string'
8560
8212
  && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha) ? task.latestHeadSha : undefined;
8561
8213
  const intent = task.remoteCleanup ?? {
@@ -8589,7 +8241,6 @@ export class AgentManager {
8589
8241
  // stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
8590
8242
  if (cleanupClaimed)
8591
8243
  this.phaseSignalWatcher?.stop(taskId);
8592
- let devStopConfirmed = false;
8593
8244
  // 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
8594
8245
  try {
8595
8246
  const stopped = [];
@@ -8603,11 +8254,10 @@ export class AgentManager {
8603
8254
  continue;
8604
8255
  }
8605
8256
  if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
8606
- await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but ESC / REPL ready check failed; agent may still be running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
8257
+ await this.markAwaitingHuman(id, 'cancel-interrupt-failed', `Task is ${result.status}, but ESC / REPL ready check failed; agent may still be running its prompt. ` +
8258
+ 'Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
8607
8259
  continue;
8608
8260
  }
8609
- if (id === publishedCleanup?.devAgentId)
8610
- devStopConfirmed = true;
8611
8261
  stopped.push(id);
8612
8262
  }
8613
8263
  for (const id of stopped) {
@@ -8636,46 +8286,6 @@ export class AgentManager {
8636
8286
  });
8637
8287
  }
8638
8288
  }
8639
- if (publishedCleanup) {
8640
- if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
8641
- await this.emitIntervention(result.projectId, undefined, taskId, {
8642
- phase: 'cancel-published-artifact-cleanup-skipped',
8643
- afterDone: publishedCleanup.afterDone,
8644
- branch: publishedCleanup.branch,
8645
- prNumber: publishedCleanup.prNumber,
8646
- reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
8647
- });
8648
- return result;
8649
- }
8650
- try {
8651
- const { driver } = await this.platformTaskAndDriver(taskId);
8652
- await driver.runOp('close', { prNumber: publishedCleanup.prNumber });
8653
- await this.safeEmit({
8654
- id: '',
8655
- type: 'task.updated',
8656
- timestamp: new Date().toISOString(),
8657
- projectId: result.projectId,
8658
- taskId,
8659
- data: {
8660
- status: 'cancelled',
8661
- operation: 'server-pr-close',
8662
- outcome: 'succeeded',
8663
- branch: publishedCleanup.branch,
8664
- prNumber: publishedCleanup.prNumber,
8665
- },
8666
- });
8667
- }
8668
- catch (err) {
8669
- console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
8670
- await this.emitIntervention(result.projectId, undefined, taskId, {
8671
- phase: 'cancel-published-artifact-cleanup-failed',
8672
- afterDone: publishedCleanup.afterDone,
8673
- branch: publishedCleanup.branch,
8674
- prNumber: publishedCleanup.prNumber,
8675
- error: err instanceof Error ? err.message : String(err),
8676
- });
8677
- }
8678
- }
8679
8289
  return result;
8680
8290
  }
8681
8291
  async validateTaskDispatch(projectId, input) {
@@ -8688,7 +8298,7 @@ export class AgentManager {
8688
8298
  throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
8689
8299
  }
8690
8300
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
8691
- throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
8301
+ throw new ApiError(400, `Agent ${input.preferredAgentId} is not a dev agent`);
8692
8302
  }
8693
8303
  }
8694
8304
  let previewBytes;
@@ -8732,22 +8342,27 @@ export class AgentManager {
8732
8342
  && this.gitReviewLeaseMatches(task, lease);
8733
8343
  }
8734
8344
  async dispatchGitReviewLease(taskId, opts = {}) {
8735
- const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration);
8345
+ const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration, opts.expectedTask);
8736
8346
  if (!claimed) {
8737
8347
  throw new ApiError(409, `Git review dispatch for ${taskId} was already claimed or superseded`, 'dispatch-superseded');
8738
8348
  }
8349
+ opts.onSideEffect?.();
8739
8350
  const { task, lease } = claimed;
8740
8351
  const qaId = task.qaAgentId;
8741
- if (!qaId || !this.gitReviewQaConfigUsable(task)) {
8352
+ if (!qaId) {
8353
+ await this.resetGitReviewDispatchClaim(taskId, lease);
8354
+ throw new Error(`dispatchGitReviewLease: task ${taskId} has no QA participant`);
8355
+ }
8356
+ if (!this.gitReviewQaConfigUsable(task)) {
8742
8357
  await this.resetGitReviewDispatchClaim(taskId, lease);
8743
- throw new ApiError(409, `Task ${taskId} has no available QA participant`);
8358
+ throw new ApiError(409, `Task ${taskId} QA participant is unavailable`);
8744
8359
  }
8745
8360
  const qaPhase = lease.qaPhase;
8746
8361
  const previousQa = await this.agentStore.get(qaId);
8747
8362
  if (previousQa?.taskId === taskId) {
8748
8363
  if (previousQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(previousQa)) {
8749
8364
  await this.resetGitReviewDispatchClaim(taskId, lease);
8750
- throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})`);
8365
+ throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
8751
8366
  }
8752
8367
  const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
8753
8368
  expectedTask: { status: 'review', phase: task.phase, signalToken: lease.signalToken },
@@ -8768,11 +8383,13 @@ export class AgentManager {
8768
8383
  await this.resetGitReviewDispatchClaim(taskId, lease);
8769
8384
  throw new ApiError(409, `QA agent ${qaId} could not be released for git review dispatch`);
8770
8385
  }
8386
+ opts.onSideEffect?.();
8771
8387
  }
8772
8388
  let acquiredLockToken;
8773
8389
  const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
8774
8390
  onAcquired: token => {
8775
8391
  acquiredLockToken = token;
8392
+ opts.onSideEffect?.();
8776
8393
  opts.onQaAcquired?.(token);
8777
8394
  },
8778
8395
  });
@@ -8866,7 +8483,67 @@ export class AgentManager {
8866
8483
  }
8867
8484
  async dispatchReviewToQa(taskId, opts = {}) {
8868
8485
  let routeTask = await this.taskStore.get(taskId);
8869
- if (routeTask?.reviewMode === 'git' && !isSpecStagePhase(routeTask.phase)) {
8486
+ if (!routeTask)
8487
+ throw new ApiError(404, `Task ${taskId} not found`);
8488
+ {
8489
+ if (opts.prNumber !== undefined
8490
+ && (!Number.isSafeInteger(opts.prNumber) || opts.prNumber < 1)) {
8491
+ throw new ApiError(400, 'prNumber must be a positive safe integer', 'dispatch-unsupported');
8492
+ }
8493
+ if (routeTask.prNumber !== undefined
8494
+ && opts.prNumber !== undefined
8495
+ && routeTask.prNumber !== opts.prNumber) {
8496
+ throw new ApiError(409, `Task ${taskId} is already bound to PR #${routeTask.prNumber}, not #${opts.prNumber}`, 'dispatch-unsupported');
8497
+ }
8498
+ const deliveryUnconfirmed = routeTask.phase === undefined
8499
+ || routeTask.deliveryConfirmation?.phase !== routeTask.phase;
8500
+ if (deliveryUnconfirmed && opts.stage === undefined) {
8501
+ throw new ApiError(400, `Task ${taskId} has no verified delivery phase; stage is required for manual recovery`, 'dispatch-unsupported');
8502
+ }
8503
+ if (opts.stage !== undefined && routeTask.phase !== undefined && routeTask.phase !== opts.stage) {
8504
+ throw new ApiError(409, `Task ${taskId} is in ${routeTask.phase}, not ${opts.stage}`);
8505
+ }
8506
+ if (deliveryUnconfirmed
8507
+ || routeTask.replyActorStatus !== 'verified'
8508
+ || routeTask.prNumber === undefined) {
8509
+ const actorId = routeTask.replyActorStatus === 'verified'
8510
+ ? routeTask.replyActorId
8511
+ : opts.actorId?.trim();
8512
+ if (!actorId) {
8513
+ throw new ApiError(400, `Task ${taskId} has no verified PR actor; actorId is required for manual recovery`, 'dispatch-unsupported');
8514
+ }
8515
+ const prNumber = routeTask.prNumber ?? opts.prNumber;
8516
+ if (prNumber === undefined) {
8517
+ throw new ApiError(400, `Task ${taskId} has no tracked PR; prNumber is required for manual recovery`, 'dispatch-unsupported');
8518
+ }
8519
+ const verified = await this.platformVerifyPrBinding(taskId, prNumber);
8520
+ if (!verified.ok) {
8521
+ throw new ApiError(409, `Cannot recover git review for ${taskId}: binding ${verified.reason}`);
8522
+ }
8523
+ const phase = opts.stage ?? routeTask.phase;
8524
+ if (phase === undefined) {
8525
+ throw new ApiError(409, `Task ${taskId} changed before its delivery phase could be recovered`);
8526
+ }
8527
+ const confirmed = await this.confirmGitDelivery(taskId, {
8528
+ phase,
8529
+ source: 'human',
8530
+ prNumber,
8531
+ prUrl: verified.prUrl,
8532
+ branch: verified.branch,
8533
+ headSha: verified.headSha,
8534
+ targetBranch: verified.targetBranch,
8535
+ actorId,
8536
+ expectedSignalToken: routeTask.signalToken,
8537
+ });
8538
+ if (!confirmed) {
8539
+ throw new ApiError(409, `Task ${taskId} changed during manual delivery recovery`, 'dispatch-superseded');
8540
+ }
8541
+ routeTask = confirmed;
8542
+ opts.onSideEffect?.();
8543
+ }
8544
+ if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
8545
+ throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
8546
+ }
8870
8547
  if (opts.fromStatus && !opts.fromStatus.includes(routeTask.status)) {
8871
8548
  throw new ApiError(409, `Task ${taskId} status is ${routeTask.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
8872
8549
  }
@@ -8876,6 +8553,10 @@ export class AgentManager {
8876
8553
  if (Object.hasOwn(opts, 'expectPhase') && routeTask.phase !== opts.expectPhase) {
8877
8554
  throw new ApiError(409, `Task ${taskId} review phase changed during redispatch`, 'dispatch-superseded');
8878
8555
  }
8556
+ const routeQaAgentId = requireTaskQaAgentId(routeTask, 'dispatchReviewToQa');
8557
+ if (this.getAgentConfig(routeQaAgentId)?.role !== 'qa') {
8558
+ throw new ApiError(409, `Task ${taskId} QA participant is unavailable`, 'dispatch-unsupported');
8559
+ }
8879
8560
  let lease = routeTask.reviewDispatch;
8880
8561
  if (lease?.phase === 'uncertain') {
8881
8562
  if (opts.confirmUncertainNotDelivered !== true) {
@@ -8897,17 +8578,32 @@ export class AgentManager {
8897
8578
  }
8898
8579
  routeTask = confirmed;
8899
8580
  lease = confirmed.reviewDispatch;
8581
+ opts.onSideEffect?.();
8582
+ if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
8583
+ throw new ApiError(409, `Task ${taskId} review generation changed during uncertain-delivery confirmation`, 'dispatch-superseded');
8584
+ }
8585
+ }
8586
+ const routeQaId = requireTaskQaAgentId(routeTask, 'dispatchReviewToQa');
8587
+ if (this.getAgentConfig(routeQaId)?.role !== 'qa') {
8588
+ throw new ApiError(409, `Task ${taskId} QA participant is unavailable`, 'dispatch-unsupported');
8589
+ }
8590
+ const qaBinding = await this.agentStore.get(routeQaId);
8591
+ if (qaBinding?.taskId === taskId
8592
+ && qaBinding.status === 'awaiting_human'
8593
+ && !isRecoverableQaDispatchHold(qaBinding)) {
8594
+ throw new ApiError(409, `QA agent ${routeQaId} is awaiting human (${qaBinding.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
8900
8595
  }
8596
+ let dispatchExpectedTask = opts.expectedTask;
8901
8597
  if (lease === undefined) {
8902
8598
  if (routeTask.prNumber === undefined) {
8903
- throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`);
8599
+ throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`, 'dispatch-unsupported');
8904
8600
  }
8905
8601
  const verified = await this.platformVerifyPrBinding(taskId, routeTask.prNumber);
8906
8602
  if (!verified.ok) {
8907
- throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}`);
8603
+ throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}`, 'dispatch-unsupported');
8908
8604
  }
8909
8605
  if (!PLATFORM_HEAD_SHA_RE.test(verified.headSha)) {
8910
- throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head`);
8606
+ throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head`, 'dispatch-unsupported');
8911
8607
  }
8912
8608
  const begun = await this.beginGitReviewPass(taskId, {
8913
8609
  fromStatus: [routeTask.status],
@@ -8916,106 +8612,100 @@ export class AgentManager {
8916
8612
  ...(opts.qaPhase !== undefined ? { qaPhase: opts.qaPhase } : {}),
8917
8613
  expectSignalToken: routeTask.signalToken,
8918
8614
  expectPhase: routeTask.phase,
8615
+ ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
8919
8616
  patch: { latestHeadSha: verified.headSha },
8920
8617
  });
8921
8618
  if (!begun?.task.reviewDispatch) {
8922
8619
  throw new ApiError(409, `Task ${taskId} changed before git review pass creation`, 'dispatch-superseded');
8923
8620
  }
8621
+ opts.onSideEffect?.();
8924
8622
  lease = begun.task.reviewDispatch;
8623
+ if (opts.expectedTask) {
8624
+ dispatchExpectedTask = {
8625
+ ...opts.expectedTask,
8626
+ signalToken: begun.task.signalToken,
8627
+ };
8628
+ }
8925
8629
  }
8926
8630
  const dispatched = await this.dispatchGitReviewLease(taskId, {
8927
8631
  expectedGeneration: lease.generation,
8632
+ ...(dispatchExpectedTask !== undefined ? { expectedTask: dispatchExpectedTask } : {}),
8928
8633
  ...(opts.pendingBudget !== undefined ? { pendingBudget: opts.pendingBudget } : {}),
8929
8634
  ...(opts.onQaAcquired !== undefined ? { onQaAcquired: opts.onQaAcquired } : {}),
8635
+ ...(opts.onSideEffect !== undefined ? { onSideEffect: opts.onSideEffect } : {}),
8930
8636
  });
8931
8637
  opts.onPassArmed?.(lease.signalToken);
8932
8638
  return dispatched;
8933
8639
  }
8934
- const claim = await this.withTaskLock(async () => {
8935
- if (this.manualReviewInFlight.has(taskId)) {
8936
- throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
8937
- }
8938
- if (this.markCompleteInFlight.has(taskId)) {
8939
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
8940
- }
8941
- const task = await this.taskStore.get(taskId);
8942
- if (!task)
8943
- throw new ApiError(404, `Task ${taskId} not found`);
8944
- if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
8945
- throw new ApiError(409, `Task ${taskId} review mode changed during dispatch; retry`, 'dispatch-superseded');
8946
- }
8947
- if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
8948
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
8949
- }
8950
- if (Object.hasOwn(opts, 'expectSignalToken') && task.signalToken !== opts.expectSignalToken) {
8951
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
8952
- }
8953
- if (Object.hasOwn(opts, 'expectPhase') && task.phase !== opts.expectPhase) {
8954
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (phase changed); aborting`, 'dispatch-superseded');
8955
- }
8956
- if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
8957
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
8958
- }
8959
- if (!this.serverReviewDriver) {
8960
- throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
8961
- }
8962
- if (task.status === 'in_progress' && task.phase === undefined) {
8963
- throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
8964
- }
8965
- const qaAvailable = task.qaAgentId !== undefined && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
8966
- if (!qaAvailable) {
8967
- throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
8968
- }
8969
- const isSpec = isSpecStagePhase(task.phase);
8970
- const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
8971
- const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
8972
- const nextRound = opts.bumpRound === false ? round : round + 1;
8973
- if (nextRound < 1) {
8974
- throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`);
8975
- }
8976
- if (nextRound > cap) {
8977
- throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`);
8978
- }
8979
- this.manualReviewInFlight.add(taskId);
8980
- return {
8981
- isSpec,
8982
- reviewPass: {
8983
- status: task.status,
8984
- phase: task.phase,
8985
- signalToken: task.signalToken,
8986
- },
8987
- bumpRound: opts.bumpRound,
8988
- };
8989
- });
8990
- return this.runManualServerReview(taskId, claim);
8991
8640
  }
8992
- async runManualServerReview(taskId, claim) {
8641
+ async dispatchGitFixToDev(taskId) {
8642
+ const task = await this.taskStore.get(taskId);
8643
+ if (!task || task.status !== 'fixing')
8644
+ return false;
8645
+ const qaAgentId = requireTaskQaAgentId(task, 'dispatchGitFixToDev');
8646
+ const qaState = await this.agentStore.get(qaAgentId);
8647
+ const qaReleased = qaState?.taskId !== task.id
8648
+ ? true
8649
+ : await this.releaseAgentForTask(qaAgentId, task.id, 'idle', { allowAwaitingHuman: true }).catch(err => {
8650
+ console.error(`[AgentManager] git fix QA release(${qaAgentId}) failed:`, err);
8651
+ return false;
8652
+ });
8653
+ if (!qaReleased) {
8654
+ await this.emitIntervention(task.projectId, task.agentId, task.id, {
8655
+ phase: 'qa-release-failed-but-dev-dispatched',
8656
+ qaAgentId,
8657
+ });
8658
+ }
8659
+ const acquired = await this.acquireAgentForTask(task.agentId, task.id, 'fix');
8660
+ if (!acquired) {
8661
+ await this.emitIntervention(task.projectId, task.agentId, task.id, {
8662
+ phase: 'dev-acquire-failed-fix',
8663
+ devAgentId: task.agentId,
8664
+ });
8665
+ return false;
8666
+ }
8667
+ const { token, armed } = await this.rotateAndSetupPhaseSignal(task.id, task.agentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: task.signalToken });
8668
+ if (!armed) {
8669
+ await this.markAwaitingHuman(task.agentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: task.id });
8670
+ return false;
8671
+ }
8672
+ let dispatchError;
8993
8673
  try {
8994
- // 不在此处预释放旧 QA:可失败的准备工作(读 diff/spec、存轮次)失败时旧 pass 必须原样保留;
8995
- // 同任务的 QA 重新绑定由 dispatchServerReviewToQa 在派发前完成
8996
- const fresh = await this.taskStore.get(taskId);
8997
- if (!fresh
8998
- || fresh.status !== claim.reviewPass.status
8999
- || fresh.phase !== claim.reviewPass.phase
9000
- || fresh.signalToken !== claim.reviewPass.signalToken
9001
- || !MANUAL_SERVER_REVIEW_STATUSES.includes(fresh.status)) {
9002
- throw new ApiError(409, `Task ${taskId} changed during manual review dispatch; aborting`);
9003
- }
9004
- const dispatched = claim.isSpec
9005
- ? claim.bumpRound === false
9006
- ? await this.serverReviewDriver.dispatchSpecReview(fresh, { bumpRound: false })
9007
- : await this.serverReviewDriver.dispatchSpecReview(fresh)
9008
- : claim.bumpRound === false
9009
- ? await this.serverReviewDriver.dispatchCodeReview(fresh, { bumpRound: false })
9010
- : await this.serverReviewDriver.dispatchCodeReview(fresh);
9011
- if (!dispatched) {
9012
- throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
9013
- }
9014
- return (await this.taskStore.get(taskId));
8674
+ const resumed = await this.continueSession(task.id, task.agentId, 'fix', {
8675
+ signalToken: token,
8676
+ guardBeforeInject: async () => {
8677
+ const fresh = await this.taskStore.get(task.id);
8678
+ return fresh?.status === 'fixing' && fresh.signalToken === token;
8679
+ },
8680
+ });
8681
+ if (resumed)
8682
+ return true;
9015
8683
  }
9016
- finally {
9017
- this.manualReviewInFlight.delete(taskId);
8684
+ catch (err) {
8685
+ dispatchError = err;
8686
+ console.error(`[AgentManager] git fix dispatch(${task.id}) failed:`, err);
8687
+ }
8688
+ if (dispatchError instanceof DispatchTerminalError) {
8689
+ await this.failTaskForDispatchError(task.id, 'fix', task.agentId, dispatchError);
8690
+ return false;
8691
+ }
8692
+ const parked = await this.markAgentWaiting(task.agentId, task.id).catch(err => {
8693
+ console.error(`[AgentManager] git fix park(${task.agentId}) failed:`, err);
8694
+ return false;
8695
+ });
8696
+ if (parked && (dispatchError instanceof ReplNotReadyError || dispatchError instanceof DirtyWorkdirError)) {
8697
+ this.registerPendingDispatchRetry(task.id, {
8698
+ kind: 'dev-fix',
8699
+ agentId: task.agentId,
8700
+ signalToken: token,
8701
+ });
8702
+ return false;
9018
8703
  }
8704
+ await this.emitIntervention(task.projectId, task.agentId, task.id, {
8705
+ phase: 'fix-resume-failed',
8706
+ reviewRound: taskReviewRound(task),
8707
+ });
8708
+ return false;
9019
8709
  }
9020
8710
  async continueDevRound(taskId) {
9021
8711
  if (this.markCompleteInFlight.has(taskId)) {
@@ -9030,47 +8720,6 @@ export class AgentManager {
9030
8720
  if (isSpecStagePhase(task.phase)) {
9031
8721
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
9032
8722
  }
9033
- if (task.reviewMode === 'server') {
9034
- if (!task.agentId) {
9035
- throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
9036
- }
9037
- const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
9038
- if (!stored?.findings) {
9039
- throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
9040
- }
9041
- await this.withTaskLock(async () => {
9042
- if (this.markCompleteInFlight.has(taskId)) {
9043
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
9044
- }
9045
- const fresh = await this.taskStore.get(taskId);
9046
- if (!fresh || fresh.status !== 'max_rounds') {
9047
- throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
9048
- }
9049
- fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
9050
- fresh.updatedAt = new Date().toISOString();
9051
- await this.taskStore.set(fresh);
9052
- });
9053
- let dispatched = null;
9054
- try {
9055
- dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
9056
- }
9057
- finally {
9058
- if (!dispatched) {
9059
- await this.withTaskLock(async () => {
9060
- const fresh = await this.taskStore.get(taskId);
9061
- if (!fresh)
9062
- return;
9063
- fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
9064
- fresh.updatedAt = new Date().toISOString();
9065
- await this.taskStore.set(fresh);
9066
- }).catch(() => undefined);
9067
- }
9068
- }
9069
- if (!dispatched) {
9070
- throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
9071
- }
9072
- return dispatched;
9073
- }
9074
8723
  if (!task.prNumber || !task.branch) {
9075
8724
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
9076
8725
  }
@@ -9089,56 +8738,44 @@ export class AgentManager {
9089
8738
  throw new ApiError(409, `Task ${taskId} changed status during continue; aborted`);
9090
8739
  }
9091
8740
  const rollback = async () => {
9092
- await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['fixing'] }, { reviewRound: prevReviewRound }).catch(() => undefined);
9093
- };
9094
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'fix');
9095
- if (!acquired) {
9096
- await rollback();
9097
- throw new ApiError(409, `Dev ${devAgentId} is no longer available for task ${taskId}`);
9098
- }
9099
- const { armed } = await this.rotateAndSetupPhaseSignal(taskId, devAgentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: transitioned.task.signalToken });
9100
- if (!armed) {
9101
- await this.markAwaitingHuman(devAgentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
9102
- await rollback();
9103
- throw new ApiError(500, `Failed to arm pr-fixed watcher for task ${taskId}`);
9104
- }
9105
- const rollbackAndRepark = async () => {
9106
- await rollback();
9107
- await this.markAgentWaiting(devAgentId, taskId).catch(() => undefined);
8741
+ const current = await this.taskStore.get(taskId);
8742
+ if (current?.signalToken) {
8743
+ this.clearPendingDispatchRetryIfMatches(taskId, {
8744
+ agentId: devAgentId,
8745
+ signalToken: current.signalToken,
8746
+ });
8747
+ }
8748
+ return (await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['fixing'] }, { reviewRound: prevReviewRound })) !== null;
9108
8749
  };
9109
- let resumed = false;
9110
8750
  try {
9111
- resumed = await this.continueSession(taskId, devAgentId, 'fix');
8751
+ if (await this.dispatchGitFixToDev(taskId))
8752
+ return (await this.taskStore.get(taskId));
9112
8753
  }
9113
8754
  catch (err) {
9114
- if (err instanceof DispatchTerminalError) {
9115
- await this.failTaskForDispatchError(taskId, 'fix', devAgentId, err);
9116
- throw new ApiError(500, `Continue dispatch failed: ${err.message}`);
8755
+ if ((await this.taskStore.get(taskId))?.status !== 'failed' && !await rollback()) {
8756
+ throw new ApiError(409, `Task ${taskId} changed while its failed fix dispatch was rolling back`);
9117
8757
  }
9118
- await rollbackAndRepark();
9119
8758
  throw err;
9120
8759
  }
9121
- if (!resumed) {
9122
- await rollbackAndRepark();
9123
- throw new ApiError(500, `Failed to dispatch fix to dev ${devAgentId} for task ${taskId}`);
8760
+ const after = await this.taskStore.get(taskId);
8761
+ if (after?.status === 'failed') {
8762
+ throw new ApiError(500, `Fix dispatch failed terminally for task ${taskId}; use Retry`);
9124
8763
  }
9125
- const fresh = await this.taskStore.get(taskId);
9126
- return fresh;
8764
+ if (!await rollback()) {
8765
+ throw new ApiError(409, `Task ${taskId} changed while its failed fix dispatch was rolling back`);
8766
+ }
8767
+ throw new ApiError(500, `Failed to dispatch fix to dev ${devAgentId} for task ${taskId}`);
9127
8768
  }
9128
- devInitialSignalKinds(reviewMode) {
9129
- return reviewMode === 'server'
9130
- ? ['spec-done', 'code-done']
9131
- : ['spec-done', 'pr-created'];
8769
+ devInitialSignalKinds() {
8770
+ return ['spec-done', 'pr-created'];
9132
8771
  }
9133
- resolveInitialDispatch(task) {
9134
- if (task.phase === 'research')
9135
- return { phase: 'research', kinds: ['spec-done'] };
9136
- return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
8772
+ resolveInitialDispatch() {
8773
+ return { phase: 'develop', kinds: this.devInitialSignalKinds() };
9137
8774
  }
9138
8775
  mapTaskStateToExpectedWatchers(task) {
9139
8776
  const single = this.mapTaskStateToExpectedWatcher(task);
9140
8777
  const mappings = single ? [single] : [];
9141
- if (task.reviewMode === 'git' && task.status === 'review'
8778
+ if (task.status === 'review'
9142
8779
  && task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
9143
8780
  && task.devAgentId && task.prNumber !== undefined) {
9144
8781
  mappings.push({
@@ -9150,24 +8787,19 @@ export class AgentManager {
9150
8787
  return mappings;
9151
8788
  }
9152
8789
  mapTaskStateToExpectedWatcher(task) {
9153
- if (task.reviewMode === 'server')
9154
- return this.mapServerTaskToExpectedWatcher(task);
9155
- const specStage = isSpecStagePhase(task.phase);
9156
- if (task.status === 'review' && task.qaAgentId) {
8790
+ if (task.status === 'review') {
9157
8791
  return {
9158
- expectedKinds: specStage ? ['spec-reviewed'] : PASSIVE_VERDICT_WATCH,
9159
- agentId: task.qaAgentId,
8792
+ expectedKinds: PASSIVE_VERDICT_WATCH,
8793
+ agentId: requireTaskQaAgentId(task, 'mapTaskStateToExpectedWatcher'),
9160
8794
  };
9161
8795
  }
9162
8796
  if (task.status === 'fixing' && task.agentId) {
9163
8797
  return {
9164
- expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
8798
+ expectedKinds: ['pr-fixed'],
9165
8799
  agentId: task.agentId,
9166
8800
  };
9167
8801
  }
9168
8802
  if (task.status === 'in_progress' && task.agentId) {
9169
- if (task.phase === 'research')
9170
- return { expectedKinds: ['spec-done'], agentId: task.agentId };
9171
8803
  if (task.phase === 'code')
9172
8804
  return { expectedKinds: ['pr-created'], agentId: task.agentId };
9173
8805
  if (task.phase === undefined) {
@@ -9176,27 +8808,6 @@ export class AgentManager {
9176
8808
  }
9177
8809
  return undefined;
9178
8810
  }
9179
- mapServerTaskToExpectedWatcher(task) {
9180
- const isSpec = isSpecStagePhase(task.phase);
9181
- if (task.status === 'review' && task.qaAgentId) {
9182
- return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
9183
- }
9184
- if (task.status === 'fixing' && task.agentId) {
9185
- return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
9186
- }
9187
- if (task.status === 'in_progress' && task.agentId) {
9188
- if (task.phase === 'research')
9189
- return { expectedKinds: ['spec-done'], agentId: task.agentId };
9190
- if (task.phase === 'code') {
9191
- return { expectedKinds: ['code-done'], agentId: task.agentId };
9192
- }
9193
- return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
9194
- }
9195
- if (task.status === 'approved' && task.agentId) {
9196
- return { expectedKinds: ['code-ready'], agentId: task.agentId };
9197
- }
9198
- return undefined;
9199
- }
9200
8811
  async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
9201
8812
  const task = await this.taskStore.get(taskId);
9202
8813
  if (!task?.signalToken)
@@ -9221,7 +8832,7 @@ export class AgentManager {
9221
8832
  const input = {
9222
8833
  title: old.title,
9223
8834
  description: old.description,
9224
- preferredAgentId: old.preferredAgentId,
8835
+ preferredAgentId: this.resolveRetryPreferredAgentId(old),
9225
8836
  };
9226
8837
  if (old.images?.length) {
9227
8838
  input.images = await this.readStagedImages(old.id, old.images);
@@ -9232,6 +8843,27 @@ export class AgentManager {
9232
8843
  }
9233
8844
  return this.createAndStartTask(old.projectId, input);
9234
8845
  }
8846
+ resolveRetryPreferredAgentId(task) {
8847
+ const configured = this.getAgentConfig(task.preferredAgentId);
8848
+ if (configured?.projectId === task.projectId && TASK_OWNER_ROLES.has(configured.role)) {
8849
+ return task.preferredAgentId;
8850
+ }
8851
+ const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
8852
+ .filter((agentId) => typeof agentId === 'string' && agentId !== ''));
8853
+ for (const anchor of anchors) {
8854
+ const dev = this.findAgentGroup(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8855
+ const current = dev ? this.getAgentConfig(dev.id) : undefined;
8856
+ if (current?.projectId === task.projectId)
8857
+ return current.id;
8858
+ }
8859
+ const project = this.getProjectConfig(task.projectId);
8860
+ if (project?.agent.length === 1) {
8861
+ const dev = project.agent[0].find(agent => TASK_OWNER_ROLES.has(agent.role));
8862
+ if (dev)
8863
+ return dev.id;
8864
+ }
8865
+ return task.preferredAgentId;
8866
+ }
9235
8867
  async editTask(taskId, patch) {
9236
8868
  return this.withTaskLock(async () => {
9237
8869
  const task = await this.taskStore.get(taskId);
@@ -9251,7 +8883,6 @@ export class AgentManager {
9251
8883
  task.devAgentId = '';
9252
8884
  delete task.phase;
9253
8885
  delete task.qaAgentId;
9254
- delete task.researchAgentId;
9255
8886
  }
9256
8887
  else {
9257
8888
  const cfg = this.getAgentConfig(patch.preferredAgentId);
@@ -9261,28 +8892,18 @@ export class AgentManager {
9261
8892
  throw new ApiError(400, `Agent not in project ${task.projectId}`);
9262
8893
  }
9263
8894
  if (!TASK_OWNER_ROLES.has(cfg.role))
9264
- throw new ApiError(400, `Agent is not dev or research role`);
8895
+ throw new ApiError(400, 'Agent is not a dev agent');
9265
8896
  const group = this.findAgentGroup(cfg.id);
9266
8897
  const dev = group?.find(agent => agent.role === 'dev');
9267
8898
  if (!dev)
9268
8899
  throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
8900
+ const qa = group?.find(agent => agent.role === 'qa');
8901
+ if (!qa)
8902
+ throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its group`);
9269
8903
  task.preferredAgentId = patch.preferredAgentId;
9270
8904
  task.devAgentId = dev.id;
9271
- if (cfg.role === 'research') {
9272
- task.phase = 'research';
9273
- task.researchAgentId = cfg.id;
9274
- }
9275
- else {
9276
- delete task.phase;
9277
- delete task.researchAgentId;
9278
- }
9279
- const qaId = group?.find(agent => agent.role === 'qa')?.id;
9280
- if (qaId) {
9281
- task.qaAgentId = qaId;
9282
- }
9283
- else {
9284
- delete task.qaAgentId;
9285
- }
8905
+ task.qaAgentId = qa.id;
8906
+ delete task.phase;
9286
8907
  }
9287
8908
  }
9288
8909
  task.updatedAt = new Date().toISOString();
@@ -9295,72 +8916,32 @@ export class AgentManager {
9295
8916
  if (!task || !task.prNumber) {
9296
8917
  throw new Error(`mergePr: no PR number for task ${taskId}`);
9297
8918
  }
9298
- if (task.reviewMode === 'git') {
9299
- const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
9300
- if (!expectedHeadSha)
9301
- throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
9302
- await this.platformConfirmMerge(taskId, {
9303
- expectedHeadSha,
9304
- ...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
9305
- });
9306
- return;
9307
- }
9308
8919
  const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
9309
- if (!expectedHeadSha || !PLATFORM_HEAD_SHA_RE.test(expectedHeadSha)) {
9310
- throw new Error(`mergePr: no valid expected head sha for server task ${taskId}`);
9311
- }
9312
- await this.platformConfirmMerge(taskId, { expectedHeadSha });
8920
+ if (!expectedHeadSha)
8921
+ throw new Error(`mergePr: no expected head sha for task ${taskId}`);
8922
+ await this.platformConfirmMerge(taskId, {
8923
+ expectedHeadSha,
8924
+ ...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
8925
+ });
9313
8926
  }
9314
8927
  async markTaskComplete(taskId) {
9315
8928
  const peek = await this.taskStore.get(taskId);
9316
8929
  if (!peek)
9317
8930
  throw new ApiError(404, `Task ${taskId} not found`);
9318
- if (peek.status === 'ready' || peek.status === 'merge-ready') {
8931
+ if (peek.status === 'merge-ready') {
9319
8932
  return this.confirmHumanGate(taskId);
9320
8933
  }
9321
- const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
8934
+ const task = await this.claimCompleteGate(taskId, ['max_rounds']);
9322
8935
  try {
9323
- const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
9324
- if (!serverApprovedRetry && task.status !== 'max_rounds') {
8936
+ if (task.status !== 'max_rounds') {
9325
8937
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
9326
8938
  }
9327
8939
  if (isSpecStagePhase(task.phase)) {
9328
8940
  throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
9329
8941
  }
9330
- if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
8942
+ if (!task.prNumber || !task.branch) {
9331
8943
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
9332
8944
  }
9333
- if (serverApprovedRetry) {
9334
- const publishState = this.checkPublishInFlight(taskId, task.publishDispatchedAt);
9335
- if (publishState === 'live') {
9336
- throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
9337
- }
9338
- if (publishState === 'delivered') {
9339
- throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
9340
- `retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
9341
- }
9342
- const afterDone = this.resolveAfterDone(task);
9343
- if (afterDone === null) {
9344
- throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
9345
- }
9346
- await this.dispatchServerAfterDone(taskId, afterDone);
9347
- return (await this.taskStore.get(taskId));
9348
- }
9349
- if (task.reviewMode === 'server') {
9350
- const afterDone = await this.commitServerAfterDone(taskId);
9351
- if (afterDone === null) {
9352
- const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
9353
- if (!done)
9354
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9355
- await this.releaseTaskAgents(taskId);
9356
- return (await this.taskStore.get(taskId));
9357
- }
9358
- const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
9359
- if (!approved)
9360
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9361
- await this.dispatchServerAfterDone(taskId, afterDone);
9362
- return (await this.taskStore.get(taskId));
9363
- }
9364
8945
  for (const agentId of [task.agentId, task.qaAgentId]) {
9365
8946
  if (!agentId)
9366
8947
  continue;
@@ -9741,15 +9322,6 @@ export class AgentManager {
9741
9322
  stopPhaseSignalWatcher(taskId) {
9742
9323
  this.phaseSignalWatcher?.stop(taskId);
9743
9324
  }
9744
- checkPublishInFlight(taskId, publishDispatchedAt) {
9745
- if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
9746
- if (!this.phaseSignalWatcher.isRecovered(taskId) || publishDispatchedAt)
9747
- return 'live';
9748
- this.phaseSignalWatcher.stop(taskId);
9749
- return false;
9750
- }
9751
- return publishDispatchedAt ? 'delivered' : false;
9752
- }
9753
9325
  async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
9754
9326
  if (!this.phaseSignalWatcher)
9755
9327
  return true;
@@ -9787,7 +9359,6 @@ export class AgentManager {
9787
9359
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
9788
9360
  ...(opts.replaceFromToken ? { replaceFromToken: opts.replaceFromToken } : {}),
9789
9361
  ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
9790
- ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
9791
9362
  ...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
9792
9363
  });
9793
9364
  }
@@ -9796,14 +9367,12 @@ export class AgentManager {
9796
9367
  return false;
9797
9368
  }
9798
9369
  }
9799
- async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
9370
+ async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
9800
9371
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
9801
9372
  skipSnapshot,
9802
9373
  // Every post-dispatch arm follows a freshly injected prompt: the previous
9803
- // question is superseded, its watermark must not carry over (fix/publish
9804
- // continuations included).
9374
+ // question is superseded, its watermark must not carry over.
9805
9375
  needInputMode: 'fresh',
9806
- ...(onReadFile ? { onReadFile } : {}),
9807
9376
  });
9808
9377
  if (!armed)
9809
9378
  await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
@@ -9812,14 +9381,12 @@ export class AgentManager {
9812
9381
  const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
9813
9382
  await this.markAwaitingHuman(agentId, `signal-arm-failed:${label}`, 'Pane-signal watcher failed to arm after dispatch; the prompt expects a signal with no consumer. Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
9814
9383
  }
9815
- dispatchTokenFields(task, signalToken) {
9816
- const wantsPrCreated = task.reviewMode === 'git'
9817
- && this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
9818
- return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
9384
+ dispatchTokenFields(signalToken) {
9385
+ return { signalToken, pendingPrSignalToken: signalToken };
9819
9386
  }
9820
9387
  async rearmGitReconciliationWatcher(taskId, opts = {}) {
9821
9388
  const task = await this.taskStore.get(taskId);
9822
- if (!task || task.reviewMode !== 'git' || task.status !== 'review')
9389
+ if (!task || task.status !== 'review')
9823
9390
  return;
9824
9391
  if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
9825
9392
  return;
@@ -9835,21 +9402,20 @@ export class AgentManager {
9835
9402
  const result = await this.withTaskLock(async () => {
9836
9403
  const task = await this.taskStore.get(taskId);
9837
9404
  if (!task)
9838
- return { token: newToken, armed: false, isGit: false };
9839
- const isGit = task.reviewMode === 'git';
9840
- if (isGit && guard === undefined) {
9841
- throw new Error('git phase-signal rotation requires a status/token guard');
9405
+ return { token: newToken, armed: false };
9406
+ if (guard === undefined) {
9407
+ throw new Error('phase-signal rotation requires a status/token guard');
9842
9408
  }
9843
- if (guard && (task.status !== guard.expectedStatus
9844
- || (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken))) {
9845
- return { token: newToken, armed: false, isGit };
9409
+ if (task.status !== guard.expectedStatus
9410
+ || (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken)) {
9411
+ return { token: newToken, armed: false };
9846
9412
  }
9847
9413
  await this.taskStore.set({ ...task, signalToken: newToken, updatedAt: new Date().toISOString() });
9848
9414
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
9849
9415
  needInputMode: 'fresh',
9850
- ...(isGit ? { replaceScope: 'agent' } : {}),
9416
+ replaceScope: 'agent',
9851
9417
  });
9852
- return { token: newToken, armed, isGit };
9418
+ return { token: newToken, armed };
9853
9419
  });
9854
9420
  return { token: result.token, armed: result.armed };
9855
9421
  }
@@ -9857,27 +9423,53 @@ export class AgentManager {
9857
9423
  const task = await this.taskStore.get(taskId);
9858
9424
  if (!task)
9859
9425
  return null;
9860
- const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
9426
+ const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
9427
+ fromStatus: ['review', 'in_progress', 'fixing'],
9428
+ ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
9429
+ ...(opts.expectedSignalToken !== undefined ? { expectSignalToken: opts.expectedSignalToken } : {}),
9430
+ }, {
9861
9431
  phase: 'spec',
9862
9432
  ...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
9433
+ ...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
9434
+ ...(opts.prUrl !== undefined ? { prUrl: opts.prUrl } : {}),
9435
+ ...(opts.headSha !== undefined ? { latestHeadSha: opts.headSha } : {}),
9436
+ ...(opts.passProvenance !== undefined ? { passProvenance: opts.passProvenance } : {}),
9863
9437
  });
9864
9438
  if (!transition)
9865
9439
  return null;
9440
+ const parked = transition.task;
9441
+ const qaAgentId = requireTaskQaAgentId(parked, 'parkTaskAtSpecReady');
9442
+ const parkedGeneration = {
9443
+ status: parked.status,
9444
+ phase: parked.phase,
9445
+ signalToken: parked.signalToken,
9446
+ agentId: parked.agentId,
9447
+ reviewRound: parked.reviewRound,
9448
+ specReviewRound: parked.specReviewRound,
9449
+ };
9866
9450
  this.stopPhaseSignalWatcher(taskId);
9867
- if (task.agentId) {
9868
- const devOk = await this.markAgentWaiting(task.agentId, taskId).catch(() => false);
9451
+ if (parked.agentId) {
9452
+ const devOk = await this.markAgentWaiting(parked.agentId, taskId, {
9453
+ expectedTask: parkedGeneration,
9454
+ }).catch(() => false);
9869
9455
  if (!devOk) {
9870
- await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'spec-ready-dev-park-failed', devAgentId: task.agentId });
9456
+ await this.emitIntervention(parked.projectId, parked.agentId, taskId, {
9457
+ phase: 'spec-ready-dev-park-failed',
9458
+ devAgentId: parked.agentId,
9459
+ });
9871
9460
  }
9872
9461
  }
9873
- if (task.qaAgentId) {
9874
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
9875
- .catch(() => false);
9876
- if (!released) {
9877
- await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'spec-ready-qa-release-failed', qaAgentId: task.qaAgentId });
9878
- }
9462
+ const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle', {
9463
+ expectedTask: parkedGeneration,
9464
+ })
9465
+ .catch(() => false);
9466
+ if (!released) {
9467
+ await this.emitIntervention(parked.projectId, qaAgentId, taskId, {
9468
+ phase: 'spec-ready-qa-release-failed',
9469
+ qaAgentId,
9470
+ });
9879
9471
  }
9880
- return transition.task;
9472
+ return parked;
9881
9473
  }
9882
9474
  async submitSpecVerdict(taskId, verdict, comments) {
9883
9475
  const trimmed = comments?.trim();
@@ -9888,12 +9480,17 @@ export class AgentManager {
9888
9480
  const fresh = await this.taskStore.get(taskId);
9889
9481
  if (!fresh)
9890
9482
  throw new ApiError(404, `Task ${taskId} not found`);
9891
- const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
9892
- if (fresh.status !== 'spec-ready' && !specMaxRounds) {
9893
- throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
9483
+ const pending = this.specVerdictOutbox(fresh);
9484
+ if (pending !== undefined) {
9485
+ if (verdict !== 'request-changes' || pending.data.comments !== trimmed) {
9486
+ throw new ApiError(409, `Task ${taskId} has a different spec verdict already in progress`);
9487
+ }
9894
9488
  }
9895
- if (verdict === 'archive' && !fresh.researchAgentId) {
9896
- throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
9489
+ else {
9490
+ const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
9491
+ if (fresh.status !== 'spec-ready' && !specMaxRounds) {
9492
+ throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
9493
+ }
9897
9494
  }
9898
9495
  if (this.specVerdictInFlight.has(taskId)) {
9899
9496
  throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
@@ -9902,326 +9499,284 @@ export class AgentManager {
9902
9499
  return fresh;
9903
9500
  });
9904
9501
  try {
9905
- return await this.executeSpecVerdict(task, verdict, trimmed);
9502
+ return await this.executeGitSpecVerdict(task, verdict, trimmed);
9906
9503
  }
9907
9504
  finally {
9908
9505
  this.specVerdictInFlight.delete(taskId);
9909
9506
  }
9910
9507
  }
9911
- async executeSpecVerdict(task, verdict, trimmed) {
9912
- const taskId = task.id;
9913
- const store = this.getReviewStore();
9914
- if (!store)
9915
- throw new ApiError(500, 'Review store unavailable');
9916
- const round = task.specReviewRound ?? 1;
9917
- const at = new Date().toISOString();
9918
- const roundData = await store.getRound(taskId, 'spec', round);
9919
- if (!roundData || roundData.phase !== 'spec') {
9920
- throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
9921
- }
9922
- const base = roundData;
9923
- if (verdict === 'archive') {
9924
- await store.putRound(taskId, 'spec', {
9925
- ...base,
9926
- userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
9927
- });
9928
- const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
9929
- if (!transition)
9930
- throw new ApiError(409, `Task ${taskId} changed while archiving`);
9931
- this.stopPhaseSignalWatcher(taskId);
9932
- const participants = new Set([
9933
- task.agentId,
9934
- task.devAgentId,
9935
- task.qaAgentId,
9936
- task.researchAgentId,
9937
- ].filter((id) => typeof id === 'string' && id !== ''));
9938
- for (const agentId of participants) {
9939
- const state = await this.agentStore.get(agentId);
9940
- if (state?.taskId !== taskId)
9941
- continue;
9942
- const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
9943
- if (!released) {
9944
- await this.emitIntervention(task.projectId, agentId, taskId, {
9945
- phase: 'spec-archive-agent-release-failed',
9946
- agentId,
9947
- });
9948
- }
9949
- }
9950
- return (await this.taskStore.get(taskId));
9951
- }
9952
- if (verdict === 'approve') {
9953
- await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
9954
- let result;
9955
- try {
9956
- result = await this.transitionToCodePhase(taskId);
9957
- }
9958
- catch (err) {
9959
- if (err instanceof ApiError)
9960
- throw err;
9961
- const message = err instanceof Error ? err.message : String(err);
9962
- const after = await this.taskStore.get(taskId);
9963
- if (after?.status === 'failed') {
9964
- const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
9965
- throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
9966
- + 'The task has been marked failed; use Retry to run it as a fresh task.');
9967
- }
9968
- if (after?.phase === 'code' && after.status === 'in_progress') {
9969
- throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
9970
- + 'The task already moved to the code phase and the dev is held; '
9971
- + 'Resume the dev agent to redeliver the prompt.');
9972
- }
9973
- throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
9974
- + 'The task was rolled back to await the verdict; fix the dev Workdir '
9975
- + '(Resume the dev first if it is still held), then retry the verdict.');
9976
- }
9977
- if (!result) {
9978
- const after = await this.taskStore.get(taskId);
9979
- if (after?.phase === 'code' && after.status === 'in_progress') {
9980
- throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
9981
- + 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
9982
- + '(retrying the verdict will be rejected).');
9983
- }
9984
- throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
9985
- }
9986
- return result;
9987
- }
9988
- if (!trimmed)
9989
- throw new ApiError(400, 'comments is required for request-changes');
9990
- const priorFindings = base.findings?.findings ?? [];
9991
- const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
9992
- const mergedFindings = {
9993
- round,
9994
- verdict: 'request-changes',
9995
- findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
9996
- };
9997
- const rejectedRound = {
9998
- ...base,
9999
- findings: mergedFindings,
10000
- userDecision: { verdict, comments: trimmed, at },
10001
- };
10002
- // 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
10003
- // holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
10004
- // 因配置热更新出现余量,打回前也必须先恢复 holder
10005
- const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
10006
- const claim = task.status === 'max_rounds' || round >= cap
10007
- ? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
10008
- : null;
10009
- let dispatched = null;
9508
+ specVerdictCommentBody(entry) {
9509
+ const body = [
9510
+ 'Human spec verdict: request changes',
9511
+ '',
9512
+ entry.data.comments,
9513
+ '',
9514
+ `<!-- baxian:spec-verdict:${entry.key} -->`,
9515
+ ].join('\n');
10010
9516
  try {
10011
- await store.putRound(taskId, 'spec', rejectedRound);
10012
- dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
9517
+ validateCommentBody(body);
10013
9518
  }
10014
9519
  catch (err) {
10015
- if (err instanceof ApiError)
10016
- throw err;
10017
- const message = err instanceof Error ? err.message : String(err);
10018
- const after = await this.taskStore.get(taskId);
10019
- if (after?.status === 'failed') {
10020
- const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
10021
- throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
10022
- + 'The task has been marked failed with the rejection kept on record; '
10023
- + 'use Retry to run it as a fresh task.');
10024
- }
10025
- if (after?.status === 'fixing') {
10026
- throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
10027
- + 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
10028
- }
10029
- throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
10030
- + 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
10031
- }
10032
- finally {
10033
- if (!dispatched)
10034
- await this.recoverSpecRejectionState(taskId, claim, base);
9520
+ throw new ApiError(400, `Invalid spec verdict comment: ${err instanceof Error ? err.message : String(err)}`);
10035
9521
  }
10036
- if (!dispatched)
10037
- throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
10038
- return dispatched;
9522
+ return body;
10039
9523
  }
10040
- async claimSpecFixDispatch(taskId, opts) {
10041
- return await this.withTaskLock(async () => {
10042
- const fresh = await this.taskStore.get(taskId);
10043
- if (!fresh)
10044
- throw new ApiError(404, `Task ${taskId} not found`);
10045
- if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
10046
- throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
10047
- }
10048
- const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
10049
- if (!holder || !this.getAgentConfig(holder)) {
10050
- throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
10051
- + 'restore the agent config or cancel the task');
10052
- }
10053
- const originalAgentId = fresh.agentId;
10054
- const originalCounter = fresh.maxRoundsContinues ?? 0;
10055
- let counterAfter = originalCounter;
10056
- if (opts.extendCapToFit !== null) {
10057
- // cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
10058
- counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
10059
- fresh.maxRoundsContinues = counterAfter;
10060
- }
10061
- fresh.agentId = holder;
10062
- fresh.updatedAt = new Date().toISOString();
10063
- await this.taskStore.set(fresh);
10064
- return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
10065
- });
9524
+ async reconcileSpecVerdictComment(driver, entry) {
9525
+ const expectedDigest = bodyDigest(this.specVerdictCommentBody(entry));
9526
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9527
+ if (scans.some(scan => scan.rows.some(row => row.bodyDigest === expectedDigest)))
9528
+ return 'present';
9529
+ return scans.every(scan => scan.ok) ? 'absent' : 'unknown';
10066
9530
  }
10067
- // 补偿以 claim 时的 status+counter+holder 为代际证明:任务已被推进(fixing/cancelled/新扩轮)
10068
- // 就放弃恢复——尤其派发把任务带进 held fixing 时,打回 round 必须保留给后续 Resume 重派
10069
- async recoverSpecRejectionState(taskId, claim, baseRound) {
10070
- const store = this.getReviewStore();
10071
- await this.withTaskLock(async () => {
10072
- const fresh = await this.taskStore.get(taskId);
10073
- if (!fresh)
10074
- return;
10075
- if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
10076
- console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: task moved to ${fresh.status}`);
10077
- return;
10078
- }
10079
- if (claim) {
10080
- if ((fresh.maxRoundsContinues ?? 0) !== claim.counterAfter
10081
- || fresh.agentId !== claim.holderWritten) {
10082
- console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: `
10083
- + `continues=${fresh.maxRoundsContinues ?? 0} agentId=${fresh.agentId} `
10084
- + `(expected ${claim.counterAfter}/${claim.holderWritten})`);
9531
+ async settleSpecVerdictOutbox(taskId, key) {
9532
+ const task = await this.taskStore.get(taskId);
9533
+ const entry = task ? this.specVerdictOutbox(task) : undefined;
9534
+ const round = task?.specReviewRound;
9535
+ if (!task || entry?.key !== key || round === undefined || round < 1)
9536
+ return null;
9537
+ const holder = task.agentId || task.devAgentId;
9538
+ if (!holder || this.getAgentConfig(holder)?.role !== 'dev') {
9539
+ throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; restore the agent config or cancel the task`);
9540
+ }
9541
+ const maxRoundsContinues = Math.max(task.maxRoundsContinues ?? 0, round + 1 - this.getConfig().review.rounds);
9542
+ return (await this.transitionTaskStatus(taskId, 'fixing', {
9543
+ fromStatus: ['spec-ready', 'max_rounds'],
9544
+ expectTask: taskGenerationGuard(task),
9545
+ expectOutbox: { key, type: 'git.spec-verdict' },
9546
+ }, {
9547
+ agentId: holder,
9548
+ specReviewRound: round + 1,
9549
+ maxRoundsContinues,
9550
+ fixDispatchedAt: new Date().toISOString(),
9551
+ }))?.task ?? null;
9552
+ }
9553
+ async deliverSpecVerdictOutbox(taskId, key) {
9554
+ let task = await this.taskStore.get(taskId);
9555
+ let entry = task ? this.specVerdictOutbox(task) : undefined;
9556
+ if (!task || !entry || entry.key !== key)
9557
+ return;
9558
+ const driver = this.platformDriverFor(task.projectId);
9559
+ if (!driver)
9560
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9561
+ if (entry.data.writeAttemptedAt !== undefined) {
9562
+ const reconciled = await this.reconcileSpecVerdictComment(driver, entry);
9563
+ if (reconciled === 'present') {
9564
+ const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
9565
+ if (!settled)
10085
9566
  return;
10086
- }
10087
- fresh.maxRoundsContinues = claim.originalCounter;
10088
- fresh.agentId = claim.originalAgentId;
10089
- fresh.updatedAt = new Date().toISOString();
10090
- await this.taskStore.set(fresh);
10091
- }
10092
- await store?.putRound(taskId, 'spec', baseRound);
10093
- }).catch(err => {
10094
- console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
10095
- });
10096
- }
10097
- async submitCodeVerdict(taskId, comments) {
10098
- const trimmed = comments?.trim();
10099
- if (!trimmed) {
10100
- throw new ApiError(400, 'comments is required for request-changes');
10101
- }
10102
- const task = await this.withTaskLock(async () => {
10103
- const fresh = await this.taskStore.get(taskId);
10104
- if (!fresh)
10105
- throw new ApiError(404, `Task ${taskId} not found`);
10106
- if (fresh.status !== 'ready') {
10107
- throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
9567
+ task = settled;
10108
9568
  }
10109
- if (isSpecStagePhase(fresh.phase)) {
10110
- throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
9569
+ else if (reconciled === 'unknown'
9570
+ || Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
9571
+ throw new ApiError(503, `Spec verdict comment outcome is still being reconciled for task ${task.id}`);
10111
9572
  }
10112
- if (fresh.reviewMode !== 'server') {
10113
- throw new ApiError(409, `Task ${taskId} is not in server review mode; request changes on the PR instead`);
9573
+ }
9574
+ if (task.status !== 'fixing') {
9575
+ const expectedAttempt = entry.data.writeAttemptedAt;
9576
+ const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.spec-verdict' && current.data.writeAttemptedAt === expectedAttempt
9577
+ ? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
9578
+ : null);
9579
+ const claimedEntry = claimed ? this.specVerdictOutbox(claimed) : undefined;
9580
+ if (!claimed || claimedEntry?.key !== entry.key) {
9581
+ throw new ApiError(503, `Spec verdict comment write is already claimed for task ${task.id}`);
10114
9582
  }
10115
- if (!fresh.agentId) {
10116
- throw new ApiError(400, `Task ${taskId} has no dev agent; cannot request changes`);
9583
+ task = claimed;
9584
+ entry = claimedEntry;
9585
+ let writeFailure;
9586
+ try {
9587
+ await driver.runOp('comment', {
9588
+ prNumber: entry.data.prNumber,
9589
+ body: this.specVerdictCommentBody(entry),
9590
+ });
10117
9591
  }
10118
- if (this.markCompleteInFlight.has(taskId)) {
10119
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
9592
+ catch (error) {
9593
+ writeFailure = { error };
9594
+ }
9595
+ const reconciled = await this.reconcileSpecVerdictComment(driver, entry);
9596
+ if (writeFailure !== undefined) {
9597
+ const { error } = writeFailure;
9598
+ if (reconciled !== 'present') {
9599
+ const uncertain = !(error instanceof DriverOpError)
9600
+ || error.info.exitCode === 255
9601
+ || isTransientNetworkFailure(error.info.stderrTail ?? '')
9602
+ || isTransientNetworkFailure(error.message);
9603
+ if (reconciled === 'unknown' || uncertain) {
9604
+ throw new ApiError(503, `Spec verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
9605
+ }
9606
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.spec-verdict' ? undefined : null);
9607
+ throw new ApiError(502, `Spec verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
9608
+ }
10120
9609
  }
10121
- if (this.codeVerdictInFlight.has(taskId)) {
10122
- throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
9610
+ if (reconciled !== 'present') {
9611
+ throw new ApiError(503, `Spec verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
10123
9612
  }
10124
- this.codeVerdictInFlight.add(taskId);
10125
- return fresh;
10126
- });
10127
- try {
10128
- return await this.executeCodeVerdict(task, trimmed);
9613
+ const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
9614
+ if (!settled)
9615
+ return;
9616
+ task = settled;
10129
9617
  }
10130
- finally {
10131
- this.codeVerdictInFlight.delete(taskId);
9618
+ const dispatched = await this.dispatchGitFixToDev(task.id);
9619
+ const fresh = (await this.taskStore.get(task.id)) ?? task;
9620
+ if (!dispatched) {
9621
+ if (fresh.status === 'failed') {
9622
+ throw new ApiError(500, `Spec fix dispatch failed terminally for task ${task.id}; use Retry`);
9623
+ }
9624
+ throw new ApiError(500, `Spec fix dispatch is pending for task ${task.id}; the fixing-state reconciler will retry it`);
10132
9625
  }
10133
9626
  }
10134
- async executeCodeVerdict(task, trimmed) {
10135
- const taskId = task.id;
10136
- const store = this.getReviewStore();
10137
- if (!store)
10138
- throw new ApiError(500, 'Review store unavailable');
10139
- const round = Math.max(task.reviewRound, 1);
10140
- const at = new Date().toISOString();
10141
- // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
10142
- const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
10143
- if (round >= cap) {
10144
- throw new ApiError(409, `Task ${taskId} has reached the code review round cap (${cap}); `
10145
- + 'confirm completion, or cancel the task');
10146
- }
10147
- const roundData = await store.getRound(taskId, 'code', round);
10148
- // QA 项目经 autoApproveCode 到达 ready 时从未存轮;补底轮次保证 userDecision 留痕与 fix 闭环
10149
- const base = roundData ?? await this.synthesizeEmptyCodeRound(task, round, at);
10150
- // runCodeFixSubmission 按 Math.max(reviewRound,1) 读轮、recheck 按 reviewRound+1 存新轮;
10151
- // reviewRound 停在 0 会让 recheck 覆写本轮,故落库为 1 对齐两侧
10152
- if (task.reviewRound === 0) {
10153
- await this.withTaskLock(async () => {
10154
- const fresh = await this.taskStore.get(taskId);
10155
- if (!fresh)
10156
- return;
10157
- fresh.reviewRound = 1;
10158
- fresh.updatedAt = new Date().toISOString();
10159
- await this.taskStore.set(fresh);
9627
+ async executeGitSpecVerdict(task, verdict, trimmed) {
9628
+ if (verdict === 'approve') {
9629
+ const humanOverride = task.status === 'max_rounds';
9630
+ if (task.prNumber === undefined || task.phase !== 'spec'
9631
+ || task.specReviewRound === undefined || task.specReviewRound < 1
9632
+ || task.signalToken === undefined
9633
+ || (!humanOverride && task.passProvenance === undefined)) {
9634
+ throw new ApiError(409, `Task ${task.id} has no complete reviewed spec PR generation`);
9635
+ }
9636
+ const prNumber = task.prNumber;
9637
+ const specReviewRound = task.specReviewRound;
9638
+ const signalToken = task.signalToken;
9639
+ const passProvenance = task.passProvenance;
9640
+ const reviewedHead = passProvenance?.anchorSha;
9641
+ if (!humanOverride && (reviewedHead === undefined || !PLATFORM_HEAD_SHA_RE.test(reviewedHead))) {
9642
+ throw new ApiError(409, `Task ${task.id} has no valid reviewed spec head`);
9643
+ }
9644
+ let verified;
9645
+ try {
9646
+ verified = await this.platformVerifyPrBinding(task.id, prNumber);
9647
+ }
9648
+ catch (err) {
9649
+ throw new ApiError(503, `Cannot verify the current PR head for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
9650
+ }
9651
+ if (!verified.ok) {
9652
+ throw new ApiError(409, `Cannot approve task ${task.id}: PR binding ${verified.reason}`);
9653
+ }
9654
+ const observed = await this.recordGitPrObservation(task.id, {
9655
+ prNumber,
9656
+ ...(task.prUrl !== undefined ? { prUrl: task.prUrl } : {}),
9657
+ headSha: verified.headSha,
9658
+ targetBranch: verified.targetBranch,
9659
+ }, {
9660
+ task: taskGenerationGuard(task),
9661
+ latestHeadSha: task.latestHeadSha,
10160
9662
  });
9663
+ if (!observed || !taskMatchesGeneration(observed, taskGenerationGuard(task))) {
9664
+ throw new ApiError(409, `Task ${task.id} changed while validating the current PR head`);
9665
+ }
9666
+ task = observed;
9667
+ if (!humanOverride && verified.headSha.toLowerCase() !== reviewedHead.toLowerCase()) {
9668
+ if (task.status === 'spec-ready') {
9669
+ try {
9670
+ await this.dispatchReviewToQa(task.id, {
9671
+ fromStatus: ['spec-ready'],
9672
+ bumpRound: true,
9673
+ qaPhase: 'recheck',
9674
+ expectSignalToken: signalToken,
9675
+ expectPhase: 'spec',
9676
+ expectedTask: taskGenerationGuard(task),
9677
+ });
9678
+ }
9679
+ catch (err) {
9680
+ throw new ApiError(409, `Task ${task.id} PR head changed after spec review; re-review could not start: ${err instanceof Error ? err.message : String(err)}`);
9681
+ }
9682
+ }
9683
+ throw new ApiError(409, `Task ${task.id} PR head changed after spec review; approve only after the current head is reviewed`);
9684
+ }
9685
+ try {
9686
+ const transitioned = await this.transitionToCodePhase(task.id, taskGenerationGuard(task), {
9687
+ specReviewRound,
9688
+ expectedSignalToken: signalToken,
9689
+ prNumber,
9690
+ ...(task.prUrl !== undefined ? { prUrl: task.prUrl } : {}),
9691
+ headSha: verified.headSha,
9692
+ ...(humanOverride
9693
+ ? { humanOverride: true }
9694
+ : { passProvenance: passProvenance }),
9695
+ });
9696
+ if (transitioned) {
9697
+ if (humanOverride) {
9698
+ await this.safeEmit({
9699
+ id: '',
9700
+ type: 'task.updated',
9701
+ timestamp: new Date().toISOString(),
9702
+ projectId: transitioned.projectId,
9703
+ taskId: transitioned.id,
9704
+ data: {
9705
+ status: transitioned.status,
9706
+ operation: 'git-spec-approve',
9707
+ source: 'human-override',
9708
+ headSha: verified.headSha,
9709
+ },
9710
+ });
9711
+ }
9712
+ return transitioned;
9713
+ }
9714
+ }
9715
+ catch (err) {
9716
+ const after = await this.taskStore.get(task.id);
9717
+ const message = err instanceof Error ? err.message : String(err);
9718
+ if (after?.status === 'failed') {
9719
+ throw new ApiError(500, `Code-phase dispatch failed terminally for task ${task.id}: ${message}. The task has been marked failed; use Retry.`);
9720
+ }
9721
+ throw new ApiError(500, `Code-phase dispatch failed for task ${task.id}: ${message}. Resume the dev agent.`);
9722
+ }
9723
+ throw new ApiError(409, `Task ${task.id} changed before spec approval could advance it`);
10161
9724
  }
10162
- const priorFindings = base.findings?.findings ?? [];
10163
- const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
10164
- const mergedFindings = {
10165
- round,
10166
- verdict: 'request-changes',
10167
- findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
10168
- };
10169
- await store.putRound(taskId, 'code', {
10170
- ...base,
10171
- findings: mergedFindings,
10172
- userDecision: { verdict: 'request-changes', comments: trimmed, at },
10173
- });
10174
- const dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
10175
- if (!dispatched)
10176
- throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
10177
- return dispatched;
10178
- }
10179
- async synthesizeEmptyCodeRound(task, round, at) {
10180
- const empty = { round, phase: 'code', content: '', startedAt: at };
10181
- const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
10182
- if (!dev || !task.agentId)
10183
- return empty;
10184
- // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
10185
- try {
10186
- await this.refreshWorkdirCacheFor(task.agentId);
10187
- const content = await this.getReviewTransport().readContent(task, dev, 'code');
10188
- return {
10189
- round,
10190
- phase: 'code',
10191
- content: content.content,
10192
- ...(content.diffstat ? { diffstat: content.diffstat } : {}),
10193
- ...(content.baseSha ? { baseSha: content.baseSha } : {}),
10194
- startedAt: at,
9725
+ if (!trimmed)
9726
+ throw new ApiError(400, 'comments is required for request-changes');
9727
+ if (task.prNumber === undefined || task.phase !== 'spec') {
9728
+ throw new ApiError(409, `Task ${task.id} has no bound spec PR`);
9729
+ }
9730
+ if (task.specReviewRound === undefined || task.specReviewRound < 1) {
9731
+ throw new ApiError(409, `Task ${task.id} has no completed spec review round`);
9732
+ }
9733
+ let entry = this.specVerdictOutbox(task);
9734
+ if (!entry) {
9735
+ entry = {
9736
+ key: randomBytes(6).toString('hex'),
9737
+ type: 'git.spec-verdict',
9738
+ data: { prNumber: task.prNumber, comments: trimmed },
10195
9739
  };
9740
+ this.specVerdictCommentBody(entry);
9741
+ const created = await this.withTaskLock(async () => {
9742
+ const fresh = await this.taskStore.get(task.id);
9743
+ if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
9744
+ || this.specVerdictOutbox(fresh) !== undefined) {
9745
+ return null;
9746
+ }
9747
+ const next = {
9748
+ ...fresh,
9749
+ outbox: [...(fresh.outbox ?? []), entry],
9750
+ updatedAt: new Date().toISOString(),
9751
+ };
9752
+ await this.taskStore.set(next);
9753
+ return next;
9754
+ });
9755
+ if (!created)
9756
+ throw new ApiError(409, `Task ${task.id} changed while recording the spec verdict`);
9757
+ task = created;
10196
9758
  }
10197
- catch (err) {
10198
- console.warn(`[AgentManager] submitCodeVerdict: code content read failed for ${task.id}; using empty round:`, err);
10199
- return empty;
10200
- }
9759
+ await this.deliverSpecVerdictOutbox(task.id, entry.key);
9760
+ return (await this.taskStore.get(task.id)) ?? task;
10201
9761
  }
10202
- isResearchHandoff(task, devAgentId) {
10203
- return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
10204
- }
10205
- dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
10206
- const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
9762
+ dispatchCodePhasePrompt(task, devAgentId, signalToken) {
10207
9763
  const dispatchOpts = {
10208
9764
  signalToken,
10209
- specDocuments,
10210
- ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
10211
- armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
9765
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, 'pr-created', signalToken, { needInputMode: 'fresh' }),
10212
9766
  };
10213
- return this.isResearchHandoff(task, devAgentId)
10214
- ? this.startSession(task.id, devAgentId, 'code', {
10215
- ...dispatchOpts,
10216
- preserveBindingOnFailure: true,
10217
- })
10218
- : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
9767
+ return this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
10219
9768
  }
10220
- async transitionToCodePhase(taskId) {
10221
- const task = await this.taskStore.get(taskId);
9769
+ async transitionToCodePhase(taskId, expectedTask, approval) {
9770
+ const task = await this.withTaskLock(async () => {
9771
+ const current = await this.taskStore.get(taskId);
9772
+ if (expectedTask && current && !taskMatchesGeneration(current, expectedTask))
9773
+ return null;
9774
+ return current;
9775
+ });
10222
9776
  if (!task)
10223
9777
  return null;
10224
9778
  const devAgentId = task.devAgentId;
9779
+ const qaAgentId = requireTaskQaAgentId(task, 'transitionToCodePhase');
10225
9780
  const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
10226
9781
  if (!devAgentId || dev?.role !== 'dev') {
10227
9782
  await this.emitIntervention(task.projectId, task.agentId, taskId, {
@@ -10230,34 +9785,25 @@ export class AgentManager {
10230
9785
  });
10231
9786
  return null;
10232
9787
  }
10233
- const round = task.specReviewRound ?? 1;
10234
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
10235
- if (!stored || stored.phase !== 'spec') {
10236
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10237
- phase: 'code-spec-round-missing',
10238
- round,
10239
- });
10240
- return null;
10241
- }
10242
- const researchAgentId = task.researchAgentId;
10243
- const researchHandoff = this.isResearchHandoff(task, devAgentId);
10244
9788
  const releaseParticipant = async (agentId, failurePhase) => {
10245
- const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
9789
+ const released = await this.releaseAgentIfBound(agentId, taskId, {
9790
+ allowAwaitingHuman: true,
9791
+ ...(expectedTask !== undefined ? { expectedTask } : {}),
9792
+ })
10246
9793
  .catch(() => false);
10247
9794
  if (!released) {
10248
9795
  await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
10249
9796
  }
10250
9797
  return released;
10251
9798
  };
10252
- if (researchHandoff && researchAgentId) {
10253
- if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
10254
- return null;
10255
- }
10256
- if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
9799
+ if (!await releaseParticipant(qaAgentId, 'code-phase-qa-release-failed')) {
10257
9800
  return null;
10258
9801
  }
10259
9802
  const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
10260
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
9803
+ let acquiredLockToken;
9804
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code', {
9805
+ onAcquired: token => { acquiredLockToken = token; },
9806
+ });
10261
9807
  if (!acquired) {
10262
9808
  await this.emitIntervention(task.projectId, devAgentId, taskId, {
10263
9809
  phase: 'code-dev-acquire-failed',
@@ -10265,19 +9811,56 @@ export class AgentManager {
10265
9811
  });
10266
9812
  return null;
10267
9813
  }
10268
- const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
10269
9814
  const newToken = createSignalToken();
10270
9815
  // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
10271
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
9816
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
9817
+ fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
9818
+ ...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
9819
+ ...(approval !== undefined ? { expectSignalToken: approval.expectedSignalToken } : {}),
9820
+ ...(approval !== undefined ? {
9821
+ expectLatestHeadSha: approval.headSha,
9822
+ ...(approval.humanOverride === true
9823
+ ? {}
9824
+ : { expectReviewHeadAnchorSha: approval.headSha }),
9825
+ } : {}),
9826
+ }, {
9827
+ agentId: devAgentId,
9828
+ phase: 'code',
9829
+ deliveryConfirmation: undefined,
9830
+ maxRoundsContinues: 0,
9831
+ ...(approval !== undefined ? {
9832
+ specReviewRound: approval.specReviewRound,
9833
+ latestHeadSha: approval.headSha,
9834
+ ...(approval.humanOverride === true
9835
+ ? {
9836
+ reviewHeadAnchorSha: approval.headSha,
9837
+ passProvenance: undefined,
9838
+ }
9839
+ : { passProvenance: approval.passProvenance }),
9840
+ ...(approval.prNumber !== undefined ? { prNumber: approval.prNumber } : {}),
9841
+ ...(approval.prUrl !== undefined ? { prUrl: approval.prUrl } : {}),
9842
+ } : {}),
9843
+ ...this.dispatchTokenFields(newToken),
9844
+ });
10272
9845
  if (!transition) {
10273
- await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
10274
- .catch(() => undefined);
9846
+ if (!devBoundBefore) {
9847
+ try {
9848
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
9849
+ allowAwaitingHuman: true,
9850
+ ...(expectedTask !== undefined ? { expectedTask } : {}),
9851
+ ...(acquiredLockToken !== undefined ? { expectedLockToken: acquiredLockToken } : {}),
9852
+ });
9853
+ }
9854
+ catch (err) {
9855
+ console.warn(`[AgentManager] transitionToCodePhase(${taskId}): stale-generation acquire rollback failed:`, err);
9856
+ }
9857
+ }
10275
9858
  return null;
10276
9859
  }
10277
9860
  this.stopPhaseSignalWatcher(taskId);
10278
9861
  let started = false;
10279
9862
  try {
10280
- started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
9863
+ started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken);
10281
9864
  }
10282
9865
  catch (err) {
10283
9866
  if (err instanceof DispatchTerminalError) {
@@ -10298,7 +9881,7 @@ export class AgentManager {
10298
9881
  + 'moved on; leaving its status as-is');
10299
9882
  }
10300
9883
  else if (!devBoundBefore) {
10301
- // 转码前 dev 未持有本任务(如 research handoff / max_rounds 重占用):任务已退回
9884
+ // 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
10302
9885
  // verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
10303
9886
  const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
10304
9887
  .catch(() => false);
@@ -10319,635 +9902,16 @@ export class AgentManager {
10319
9902
  if (!started) {
10320
9903
  await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
10321
9904
  await this.emitIntervention(task.projectId, devAgentId, taskId, {
10322
- phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
9905
+ phase: 'code-resume-failed',
10323
9906
  devAgentId,
10324
9907
  });
10325
9908
  return null;
10326
9909
  }
10327
9910
  // 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
10328
9911
  // startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
10329
- if (!researchHandoff) {
10330
- await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
10331
- }
10332
- return await this.taskStore.get(taskId);
10333
- }
10334
- async dispatchServerReviewToQa(taskId, opts) {
10335
- const dispatchPhase = opts.phase === 'spec'
10336
- ? 'server-spec-review'
10337
- : (opts.recheck ? 'server-recheck' : 'server-review');
10338
- const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10339
- const claim = await this.withTaskLock(async () => {
10340
- const task = await this.taskStore.get(taskId);
10341
- if (!task)
10342
- throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
10343
- if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
10344
- throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
10345
- }
10346
- const qaId = task.qaAgentId;
10347
- if (!qaId) {
10348
- if (!opts.callerOwnsConsumedSignalFailure) {
10349
- const entryKind = task.status === 'fixing'
10350
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10351
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10352
- await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
10353
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10354
- phase: 'server-review-no-qa-partner',
10355
- devAgentId: task.agentId,
10356
- });
10357
- }
10358
- return null;
10359
- }
10360
- if (this.getAgentConfig(qaId)?.role !== 'qa') {
10361
- if (!opts.callerOwnsConsumedSignalFailure) {
10362
- const entryKind = task.status === 'fixing'
10363
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10364
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10365
- await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
10366
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10367
- phase: 'server-review-qa-unavailable',
10368
- qaAgentId: qaId,
10369
- });
10370
- }
10371
- return null;
10372
- }
10373
- const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
10374
- const newRound = opts.continuation
10375
- ? Math.max(roundField, 1)
10376
- : opts.bumpRound === false
10377
- ? roundField
10378
- : roundField + 1;
10379
- if (newRound < 1) {
10380
- throw new Error(`dispatchServerReviewToQa: task ${taskId} has no current ${opts.phase} review round to redispatch`);
10381
- }
10382
- return {
10383
- qaId,
10384
- devAgentId: task.agentId,
10385
- projectId: task.projectId,
10386
- newToken: createSignalToken(),
10387
- newRound,
10388
- originalStatus: task.status,
10389
- originalToken: task.signalToken,
10390
- originalRound: roundField,
10391
- originalBatchIndex: task.batchIndex,
10392
- originalBatchTotal: task.batchTotal,
10393
- originalPhase: task.phase,
10394
- };
10395
- });
10396
- if (!claim)
10397
- return null;
10398
- const { qaId, devAgentId, projectId, newToken, newRound } = claim;
10399
- const rollback = async (installedPass) => {
10400
- try {
10401
- return (await this.transitionTaskStatus(taskId, claim.originalStatus, {
10402
- fromStatus: [installedPass.status],
10403
- expectPhase: installedPass.phase,
10404
- expectSignalToken: installedPass.signalToken,
10405
- }, {
10406
- signalToken: claim.originalToken,
10407
- batchIndex: claim.originalBatchIndex,
10408
- batchTotal: claim.originalBatchTotal,
10409
- phase: claim.originalPhase,
10410
- ...(opts.phase === 'spec'
10411
- ? { specReviewRound: claim.originalRound }
10412
- : { reviewRound: claim.originalRound }),
10413
- })) !== null;
10414
- }
10415
- catch (err) {
10416
- console.error(`[AgentManager] dispatchServerReviewToQa rollback failed for ${taskId}:`, err);
10417
- return false;
10418
- }
10419
- };
10420
- const rearmEntrySignal = async () => {
10421
- if (opts.callerOwnsConsumedSignalFailure)
10422
- return;
10423
- // 从 review 重派(手工发起):入口信号早已被消费,dev 停驻等待中,无可重挂
10424
- if (claim.originalStatus === 'review')
10425
- return;
10426
- const entryKind = claim.originalStatus === 'fixing'
10427
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10428
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10429
- await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
10430
- };
10431
- let acquiredLockToken;
10432
- const ownAcquire = () => acquiredLockToken === undefined
10433
- ? {}
10434
- : { expectedLockToken: acquiredLockToken };
10435
- const releaseOwnAcquire = async (context) => {
10436
- try {
10437
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle', ownAcquire());
10438
- if (!released) {
10439
- console.warn(`[AgentManager] dispatchServerReviewToQa: QA release refused for ${qaId}/${taskId} (${context})`);
10440
- }
10441
- return released;
10442
- }
10443
- catch (err) {
10444
- console.error(`[AgentManager] dispatchServerReviewToQa: QA release failed for ${qaId}/${taskId} (${context}):`, err);
10445
- return false;
10446
- }
10447
- };
10448
- if (!opts.continuation) {
10449
- const prevQa = await this.agentStore.get(qaId);
10450
- if (prevQa?.taskId === taskId) {
10451
- // 手工重派:上一 pass 的 QA 仍绑定本任务,先释放再重新 acquire(与 github 手工路径一致)
10452
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
10453
- ...(prevQa.lockToken !== undefined ? { expectedLockToken: prevQa.lockToken } : {}),
10454
- });
10455
- if (!released) {
10456
- await rearmEntrySignal();
10457
- return null;
10458
- }
10459
- }
10460
- const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase, {
10461
- onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
10462
- });
10463
- if (!acquired) {
10464
- await rearmEntrySignal();
10465
- if (!opts.callerOwnsConsumedSignalFailure) {
10466
- await this.emitIntervention(projectId, qaId, taskId, {
10467
- phase: 'server-review-qa-acquire-failed',
10468
- qaAgentId: qaId,
10469
- });
10470
- }
10471
- return null;
10472
- }
10473
- if (devAgentId) {
10474
- const devOk = await this.markAgentWaiting(devAgentId, taskId);
10475
- if (!devOk) {
10476
- await releaseOwnAcquire('dev park failed');
10477
- await rearmEntrySignal();
10478
- if (!opts.callerOwnsConsumedSignalFailure) {
10479
- await this.emitIntervention(projectId, devAgentId, taskId, {
10480
- phase: 'server-review-dev-park-failed',
10481
- devAgentId,
10482
- });
10483
- }
10484
- return null;
10485
- }
10486
- }
10487
- }
10488
- else {
10489
- const qaState = await this.agentStore.get(qaId);
10490
- if (qaState?.taskId !== taskId) {
10491
- if (!opts.callerOwnsConsumedSignalFailure) {
10492
- await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
10493
- await this.emitIntervention(projectId, qaId, taskId, {
10494
- phase: 'server-review-continuation-qa-not-bound',
10495
- qaAgentId: qaId,
10496
- });
10497
- }
10498
- return null;
10499
- }
10500
- }
10501
- const roundPatch = opts.phase === 'spec'
10502
- ? { specReviewRound: newRound, phase: 'spec' }
10503
- : { reviewRound: newRound };
10504
- const transition = await this.transitionTaskStatus(taskId, 'review', {
10505
- fromStatus: [claim.originalStatus],
10506
- expectPhase: claim.originalPhase,
10507
- expectSignalToken: claim.originalToken,
10508
- }, {
10509
- signalToken: newToken,
10510
- reviewDispatchedAt: new Date().toISOString(),
10511
- ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
10512
- ...(opts.batch
10513
- ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
10514
- : { batchIndex: undefined, batchTotal: undefined }),
10515
- // Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
10516
- // so a crash in this window recovers conservatively (no read-file) rather than
10517
- // re-arming it for what might be a head-mode review.
10518
- ...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
10519
- ...roundPatch,
10520
- });
10521
- if (!transition) {
10522
- if (!opts.continuation) {
10523
- await releaseOwnAcquire('pass transition rejected');
10524
- }
10525
- if (!opts.callerOwnsConsumedSignalFailure) {
10526
- await this.emitIntervention(projectId, qaId, taskId, {
10527
- phase: 'server-review-transition-failed',
10528
- qaAgentId: qaId,
10529
- });
10530
- }
10531
- return null;
10532
- }
10533
- const installedPass = {
10534
- status: transition.task.status,
10535
- phase: transition.task.phase,
10536
- signalToken: transition.task.signalToken,
10537
- };
10538
- let dispatchedCheckoutMode;
10539
- const sessionOpts = {
10540
- bypassTaskStatusGate: true,
10541
- signalToken: newToken,
10542
- serverContent: opts.content,
10543
- ...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
10544
- ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
10545
- ...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
10546
- ...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
10547
- ...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
10548
- ...(opts.batch ? { serverBatch: opts.batch } : {}),
10549
- ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
10550
- ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
10551
- ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
10552
- armBeforeInject: (ctx) => {
10553
- dispatchedCheckoutMode = ctx.serverReviewCheckout;
10554
- const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
10555
- return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
10556
- needInputMode: 'fresh',
10557
- ...(allowReadFile
10558
- ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
10559
- : {}),
10560
- });
10561
- },
10562
- };
10563
- const rearmConsumedSignal = async () => {
10564
- if (opts.callerOwnsConsumedSignalFailure)
10565
- return;
10566
- if (opts.continuation) {
10567
- await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
10568
- }
10569
- else {
10570
- await rearmEntrySignal();
10571
- }
10572
- };
10573
- let started = false;
10574
- try {
10575
- started = opts.continuation
10576
- ? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
10577
- : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
10578
- }
10579
- catch (err) {
10580
- this.stopPhaseSignalWatcher(taskId);
10581
- if (err instanceof DispatchTerminalError) {
10582
- await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, ownAcquire());
10583
- }
10584
- else if (err instanceof EnsureSessionError && err.partial.handled) {
10585
- }
10586
- else {
10587
- const rolledBack = await rollback(installedPass);
10588
- if (!opts.continuation) {
10589
- await releaseOwnAcquire('session dispatch threw');
10590
- }
10591
- if (rolledBack)
10592
- await rearmConsumedSignal();
10593
- }
10594
- throw err;
10595
- }
10596
- if (!started) {
10597
- this.stopPhaseSignalWatcher(taskId);
10598
- const rolledBack = await rollback(installedPass);
10599
- if (!opts.continuation) {
10600
- await releaseOwnAcquire('session did not start');
10601
- }
10602
- if (rolledBack)
10603
- await rearmConsumedSignal();
10604
- if (!opts.callerOwnsConsumedSignalFailure) {
10605
- await this.emitIntervention(projectId, qaId, taskId, {
10606
- phase: 'server-review-start-failed',
10607
- qaAgentId: qaId,
10608
- });
10609
- }
10610
- return null;
10611
- }
10612
- // Persist the checkout mode so base-mode recovery can re-arm read-file.
10613
- if (opts.phase !== 'spec' && !opts.continuation) {
10614
- try {
10615
- await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
10616
- }
10617
- catch (err) {
10618
- const message = err instanceof Error ? err.message : String(err);
10619
- console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
10620
- await this.emitIntervention(projectId, qaId, taskId, {
10621
- phase: 'review-checkout-mode-persist-failed',
10622
- error: message,
10623
- note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
10624
- });
10625
- }
10626
- }
10627
- return await this.taskStore.get(taskId);
10628
- }
10629
- async dispatchServerFixToDev(taskId, _findingsJson, opts = {}) {
10630
- const claim = await this.withTaskLock(async () => {
10631
- const task = await this.taskStore.get(taskId);
10632
- if (!task)
10633
- throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
10634
- if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
10635
- throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
10636
- }
10637
- if (!task.agentId)
10638
- throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
10639
- return {
10640
- devAgentId: task.agentId,
10641
- qaAgentId: task.qaAgentId,
10642
- projectId: task.projectId,
10643
- newToken: createSignalToken(),
10644
- taskPhase: task.phase,
10645
- currentReviewRound: task.reviewRound,
10646
- currentSpecRound: task.specReviewRound,
10647
- originalStatus: task.status,
10648
- originalToken: task.signalToken,
10649
- };
10650
- });
10651
- if (!claim)
10652
- return null;
10653
- const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
10654
- const rollbackToEntry = async () => {
10655
- await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
10656
- };
10657
- const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
10658
- const rearmReviewedSignal = async () => {
10659
- if (opts.callerOwnsConsumedSignalFailure)
10660
- return;
10661
- if (!qaAgentId)
10662
- return;
10663
- const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10664
- await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
10665
- };
10666
- const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
10667
- const releaseFreshAcquire = async () => {
10668
- if (devBoundBefore)
10669
- return;
10670
- const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
10671
- .catch(() => false);
10672
- if (!released) {
10673
- console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
10674
- }
10675
- };
10676
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
10677
- if (!acquired) {
10678
- await rearmReviewedSignal();
10679
- if (!opts.callerOwnsConsumedSignalFailure) {
10680
- await this.emitIntervention(projectId, devAgentId, taskId, {
10681
- phase: 'server-fix-dev-acquire-failed',
10682
- devAgentId,
10683
- });
10684
- }
10685
- return null;
10686
- }
10687
- if (qaAgentId) {
10688
- const released = await this.releaseAgentIfBound(qaAgentId, taskId)
10689
- .catch(() => false);
10690
- if (!released) {
10691
- await releaseFreshAcquire();
10692
- await rearmReviewedSignal();
10693
- if (!opts.callerOwnsConsumedSignalFailure) {
10694
- await this.emitIntervention(projectId, qaAgentId, taskId, {
10695
- phase: 'server-fix-qa-release-failed',
10696
- qaAgentId,
10697
- });
10698
- }
10699
- return null;
10700
- }
10701
- }
10702
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
10703
- if (!transition) {
10704
- await releaseFreshAcquire();
10705
- if (!opts.callerOwnsConsumedSignalFailure) {
10706
- await this.emitIntervention(projectId, devAgentId, taskId, {
10707
- phase: 'server-fix-transition-failed',
10708
- devAgentId,
10709
- });
10710
- }
10711
- return null;
10712
- }
10713
- let resumed = false;
10714
- try {
10715
- const feedbackPhase = taskPhase === 'spec' ? 'spec' : 'code';
10716
- const feedbackRound = feedbackPhase === 'spec'
10717
- ? (currentSpecRound ?? 1)
10718
- : Math.max(claim.currentReviewRound, 1);
10719
- const stored = await this.reviewStore?.getRound(taskId, feedbackPhase, feedbackRound);
10720
- if (!stored?.findings) {
10721
- throw new Error(`dispatchServerFixToDev: persisted aggregate findings missing for ${taskId}/${feedbackPhase}/${feedbackRound}`);
10722
- }
10723
- const findingsJson = JSON.stringify(stored.findings);
10724
- const findingsDigest = reviewFindingsDigest(stored.findings);
10725
- resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
10726
- bypassTaskStatusGate: true,
10727
- signalToken: newToken,
10728
- serverPriorFindings: findingsJson,
10729
- serverFindingsDigest: findingsDigest,
10730
- ...(taskPhase === 'spec' && currentSpecRound !== undefined
10731
- ? { currentSpecRound }
10732
- : {}),
10733
- });
10734
- }
10735
- catch (err) {
10736
- if (err instanceof DispatchTerminalError) {
10737
- await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
10738
- }
10739
- else if (err instanceof EnsureSessionError && err.partial.handled) {
10740
- }
10741
- else {
10742
- await rollbackToEntry();
10743
- await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
10744
- await rearmReviewedSignal();
10745
- }
10746
- throw err;
10747
- }
10748
- if (!resumed) {
10749
- await rollbackToEntry();
10750
- await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
10751
- await rearmReviewedSignal();
10752
- if (!opts.callerOwnsConsumedSignalFailure) {
10753
- await this.emitIntervention(projectId, devAgentId, taskId, {
10754
- phase: 'server-fix-resume-failed',
10755
- devAgentId,
10756
- });
10757
- }
10758
- return null;
10759
- }
10760
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
10761
- return await this.taskStore.get(taskId);
10762
- }
10763
- projectRepoKey(projectId) {
10764
- const repo = this.getProjectConfig(projectId)?.repo;
10765
- if (!repo)
10766
- return null;
10767
- if (isGitHubRepo(repo))
10768
- return `gh:${repoSlug(repo).toLowerCase()}`;
10769
- const parsed = parseGitRemote(repo);
10770
- if (parsed)
10771
- return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
10772
- return `raw:${repo.trim()}`;
10773
- }
10774
- async computeCodeInterdiff(taskId, round) {
10775
- const store = this.getReviewStore();
10776
- if (!store || !Number.isInteger(round) || round < 2)
10777
- return { ok: false, reason: 'no-anchor' };
10778
- const [cur, prev] = await Promise.all([
10779
- store.getRound(taskId, 'code', round),
10780
- store.getRound(taskId, 'code', round - 1),
10781
- ]);
10782
- const curSha = cur?.headSha;
10783
- const prevSha = prev?.headSha;
10784
- if (!curSha || !prevSha)
10785
- return { ok: false, reason: 'no-anchor' };
10786
- const task = await this.taskStore.get(taskId);
10787
- if (!task?.agentId)
10788
- return { ok: false, reason: 'released' };
10789
- const agent = this.getAgentConfig(task.agentId);
10790
- const agentState = await this.agentStore.get(task.agentId);
10791
- // A rebound agent's Workdir checkout belongs to its new task — its git state describes
10792
- // another branch, so the interdiff would be meaningless. Same skip rule as
10793
- // findLineageViolation: the Workdir must still be bound to THIS task.
10794
- if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
10795
- return { ok: false, reason: 'released' };
10796
- }
10797
- await this.refreshWorkdirCacheFor(task.agentId);
10798
- const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
10799
- return { ok: true, diff };
10800
- }
10801
- async findLineageViolation(taskId, baseSha) {
10802
- const task = await this.taskStore.get(taskId);
10803
- if (!task?.agentId)
10804
- return null;
10805
- const agent = this.getAgentConfig(task.agentId);
10806
- const agentState = await this.agentStore.get(task.agentId);
10807
- // A rebound agent's Workdir checkout belongs to its new task — checking it would
10808
- // produce verdicts about the wrong branch. Skip; the dispatch path's own
10809
- // binding guards (acquire) surface the real failure.
10810
- if (agentState?.taskId !== taskId)
10811
- return null;
10812
- const workdir = agentState.workdir;
10813
- if (!agent || !workdir)
10814
- return null;
10815
- const runner = this.createRunnerFor(agent);
10816
- let base = baseSha;
10817
- if (!base) {
10818
- // Callers without a baseSha (publish) run long after the review diff was
10819
- // read; refresh origin/HEAD first or upstream commits merged since then
10820
- // would linger in base..HEAD and flag tasks that leak nothing.
10821
- const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
10822
- if (fetch.exitCode !== 0) {
10823
- throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
10824
- }
10825
- const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
10826
- if (mb.exitCode !== 0) {
10827
- throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
10828
- }
10829
- base = mb.stdout.trim();
10830
- }
10831
- // Projects pointing at the same repo share one store and branch namespace,
10832
- // so candidates are scoped by repo identity, not by projectId.
10833
- const selfRepoKey = this.projectRepoKey(task.projectId);
10834
- if (!selfRepoKey)
10835
- return null;
10836
- const tasks = await this.taskStore.list();
10837
- const candidates = tasks
10838
- .filter(t => t.id !== taskId
10839
- && !TERMINAL_STATUSES.includes(t.status)
10840
- && this.projectRepoKey(t.projectId) === selfRepoKey)
10841
- .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
10842
- if (candidates.length === 0)
10843
- return null;
10844
- return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
10845
- }
10846
- async dispatchServerAfterDone(taskId, kind) {
10847
- const task = await this.taskStore.get(taskId);
10848
- if (!task)
10849
- throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
10850
- const devAgentId = task.agentId;
10851
- if (!devAgentId)
10852
- throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
10853
- // 幂等兜底:主路径已在 approve 前经 commitServerAfterDone 落盘 afterDone+binding;
10854
- // 重启/重试直连本函数时,这里补齐(binding 不可变,已在则短路)。
10855
- await this.commitServerAfterDone(taskId);
10856
- let violation;
10857
- try {
10858
- violation = await this.findLineageViolation(taskId);
10859
- }
10860
- catch (err) {
10861
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
10862
- phase: 'server-after-done-lineage-check-failed',
10863
- error: err instanceof Error ? err.message : String(err),
10864
- note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
10865
- });
10866
- return null;
10867
- }
10868
- if (violation) {
10869
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
10870
- phase: 'server-after-done-lineage-violation',
10871
- offendingTaskId: violation.taskId,
10872
- offendingBranch: violation.branch,
10873
- offendingSha: violation.sha,
10874
- note: 'The task branch embeds another active task\'s commits; publishing would leak them into this PR. Rebase the branch onto origin/HEAD, then mark-complete to retry the publish.',
10875
- });
10876
- return null;
10877
- }
10878
- const branch = task.branch ?? BRANCH_PREFIX + taskId;
10879
- const originalToken = task.signalToken;
10880
- const newToken = createSignalToken();
10881
- await this.updateTask(taskId, { signalToken: newToken });
10882
- const rollbackToken = async () => {
10883
- await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
10884
- .catch(() => undefined);
10885
- };
10886
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
10887
- if (!acquired) {
10888
- await rollbackToken();
10889
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
10890
- return null;
10891
- }
10892
- await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
10893
- let resumed = false;
10894
- try {
10895
- resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
10896
- bypassTaskStatusGate: true,
10897
- signalToken: newToken,
10898
- serverAfterDone: { kind, branch },
10899
- });
10900
- }
10901
- catch (err) {
10902
- if (err instanceof DispatchTerminalError) {
10903
- await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
10904
- }
10905
- else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
10906
- await rollbackToken();
10907
- }
10908
- throw err;
10909
- }
10910
- if (!resumed) {
10911
- await rollbackToken();
10912
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
10913
- phase: 'server-after-done-resume-failed',
10914
- devAgentId,
10915
- note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
10916
- });
10917
- return null;
10918
- }
10919
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
9912
+ await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
10920
9913
  return await this.taskStore.get(taskId);
10921
9914
  }
10922
- async handleReadFileRequest(taskId, qaAgentId, req) {
10923
- const task = await this.taskStore.get(taskId);
10924
- if (!task)
10925
- return;
10926
- const dev = this.getAgentConfig(task.agentId);
10927
- if (!dev)
10928
- return;
10929
- await this.refreshWorkdirCacheFor(task.agentId);
10930
- let body;
10931
- try {
10932
- const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
10933
- body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
10934
- }
10935
- catch (err) {
10936
- const reason = err instanceof Error ? err.message : String(err);
10937
- body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
10938
- }
10939
- const qaState = await this.agentStore.get(qaAgentId);
10940
- if (qaState?.taskId !== taskId) {
10941
- console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
10942
- return;
10943
- }
10944
- try {
10945
- await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
10946
- }
10947
- catch (err) {
10948
- console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
10949
- }
10950
- }
10951
9915
  async injectTextToAgent(agentId, text, opts = {}) {
10952
9916
  const cfg = this.getAgentConfig(agentId);
10953
9917
  if (!cfg)
@@ -11006,35 +9970,13 @@ export class AgentManager {
11006
9970
  }
11007
9971
  }
11008
9972
  async confirmHumanGate(taskId) {
11009
- const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
9973
+ const task = await this.claimCompleteGate(taskId, ['merge-ready']);
11010
9974
  try {
11011
9975
  const project = this.getProjectConfig(task.projectId);
11012
9976
  const mergeAuto = project?.merge === 'auto';
11013
- const afterDone = this.resolveAfterDone(task);
11014
- if (task.status === 'merge-ready') {
11015
- if (mergeAuto && task.prNumber) {
11016
- if (!task.latestHeadSha) {
11017
- throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
11018
- }
11019
- await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
11020
- matchHeadSha: task.latestHeadSha,
11021
- }));
11022
- await this.eventBus.emit({
11023
- id: '',
11024
- type: 'pr.merged',
11025
- timestamp: new Date().toISOString(),
11026
- projectId: task.projectId,
11027
- agentId: task.agentId,
11028
- taskId: task.id,
11029
- data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
11030
- });
11031
- return (await this.taskStore.get(taskId));
11032
- }
11033
- return this.finishTaskAsDone(taskId);
11034
- }
11035
- if (afterDone === 'pr' && mergeAuto && task.prNumber) {
9977
+ if (mergeAuto && task.prNumber) {
11036
9978
  if (!task.latestHeadSha) {
11037
- throw new ApiError(409, `Task ${taskId} has no reviewed head recorded; cannot safely merge`);
9979
+ throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
11038
9980
  }
11039
9981
  await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
11040
9982
  matchHeadSha: task.latestHeadSha,
@@ -11050,13 +9992,6 @@ export class AgentManager {
11050
9992
  });
11051
9993
  return (await this.taskStore.get(taskId));
11052
9994
  }
11053
- if (afterDone === 'branch' && mergeAuto && task.branch) {
11054
- await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
11055
- const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
11056
- if (merged)
11057
- await this.releaseTaskAgents(taskId);
11058
- return (await this.taskStore.get(taskId));
11059
- }
11060
9995
  return this.finishTaskAsDone(taskId);
11061
9996
  }
11062
9997
  finally {
@@ -11074,9 +10009,6 @@ export class AgentManager {
11074
10009
  if (this.markCompleteInFlight.has(taskId)) {
11075
10010
  throw new ApiError(409, `Task ${taskId} is already being completed`);
11076
10011
  }
11077
- if (this.codeVerdictInFlight.has(taskId)) {
11078
- throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
11079
- }
11080
10012
  this.markCompleteInFlight.add(taskId);
11081
10013
  return fresh;
11082
10014
  });
@@ -11087,7 +10019,7 @@ export class AgentManager {
11087
10019
  }
11088
10020
  catch (err) {
11089
10021
  const message = err instanceof Error ? err.message : String(err);
11090
- if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
10022
+ if (err instanceof PlatformMergeRecheckError && task.status === 'merge-ready') {
11091
10023
  let retreated = false;
11092
10024
  if (err.kind === 'pending-feedback') {
11093
10025
  retreated = await this.withTaskLock(async () => {
@@ -11161,7 +10093,7 @@ export class AgentManager {
11161
10093
  }
11162
10094
  }
11163
10095
  async finishTaskAsDone(taskId) {
11164
- const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
10096
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['merge-ready'] });
11165
10097
  if (!done)
11166
10098
  throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
11167
10099
  await this.releaseTaskAgents(taskId);
@@ -11201,49 +10133,6 @@ export class AgentManager {
11201
10133
  })().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
11202
10134
  return true;
11203
10135
  }
11204
- repoMergeQueue = new Map();
11205
- async ffMergeBranch(task) {
11206
- const dev = this.getAgentConfig(task.agentId);
11207
- if (!dev)
11208
- throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
11209
- const state = await this.agentStore.get(task.agentId);
11210
- const workdir = state?.workdir;
11211
- if (!workdir)
11212
- throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
11213
- const branch = task.branch ?? BRANCH_PREFIX + task.id;
11214
- const runner = this.createRunnerFor(dev);
11215
- const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
11216
- const run = prev.then(async () => {
11217
- // fetch/push reach a remote derived from THIS Workdir; a rebound Workdir would redirect the push to a foreign repo, so each mutation carries the canonical self-guard in its own command.
11218
- const guard = `${canonicalSelfGuard(workdir)} && `;
11219
- const cd = `cd ${shellQuote(workdir)} && `;
11220
- const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
11221
- const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
11222
- if (db.exitCode !== 0 || defaultBranch === '') {
11223
- throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
11224
- }
11225
- const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
11226
- if (fetch.exitCode !== 0) {
11227
- throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
11228
- }
11229
- if (task.latestHeadSha) {
11230
- const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
11231
- if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
11232
- throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
11233
- `!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
11234
- }
11235
- }
11236
- else {
11237
- throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
11238
- }
11239
- const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
11240
- if (push.exitCode !== 0) {
11241
- throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
11242
- }
11243
- });
11244
- this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
11245
- await run;
11246
- }
11247
10136
  }
11248
10137
  function buildAgentIndex(config) {
11249
10138
  const index = new Map();