baxian 2.0.1 → 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 (144) 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 -12
  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 +9 -9
  12. package/dist/agent/dispatch-reconciler.js.map +1 -1
  13. package/dist/agent/manager.d.ts +65 -138
  14. package/dist/agent/manager.d.ts.map +1 -1
  15. package/dist/agent/manager.js +928 -2442
  16. package/dist/agent/manager.js.map +1 -1
  17. package/dist/agent/pane-streamer.d.ts +4 -1
  18. package/dist/agent/pane-streamer.d.ts.map +1 -1
  19. package/dist/agent/pane-streamer.js +25 -5
  20. package/dist/agent/pane-streamer.js.map +1 -1
  21. package/dist/agent/phase-signal-watcher.d.ts +2 -11
  22. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  23. package/dist/agent/phase-signal-watcher.js +38 -87
  24. package/dist/agent/phase-signal-watcher.js.map +1 -1
  25. package/dist/agent/phase-signal.d.ts +14 -40
  26. package/dist/agent/phase-signal.d.ts.map +1 -1
  27. package/dist/agent/phase-signal.js +47 -90
  28. package/dist/agent/phase-signal.js.map +1 -1
  29. package/dist/agent/preflight.d.ts +0 -1
  30. package/dist/agent/preflight.d.ts.map +1 -1
  31. package/dist/agent/preflight.js +8 -15
  32. package/dist/agent/preflight.js.map +1 -1
  33. package/dist/agent/prompt.d.ts +1 -30
  34. package/dist/agent/prompt.d.ts.map +1 -1
  35. package/dist/agent/prompt.js +21 -148
  36. package/dist/agent/prompt.js.map +1 -1
  37. package/dist/agent/repo-store.d.ts.map +1 -1
  38. package/dist/agent/repo-store.js +0 -4
  39. package/dist/agent/repo-store.js.map +1 -1
  40. package/dist/agent/root-agent-runtime.d.ts.map +1 -1
  41. package/dist/agent/root-agent-runtime.js +4 -2
  42. package/dist/agent/root-agent-runtime.js.map +1 -1
  43. package/dist/agent/root-recovery-coordinator.d.ts +0 -1
  44. package/dist/agent/root-recovery-coordinator.d.ts.map +1 -1
  45. package/dist/agent/root-recovery-coordinator.js +39 -61
  46. package/dist/agent/root-recovery-coordinator.js.map +1 -1
  47. package/dist/agent/vt-visible-text.d.ts +42 -0
  48. package/dist/agent/vt-visible-text.d.ts.map +1 -0
  49. package/dist/agent/vt-visible-text.js +482 -0
  50. package/dist/agent/vt-visible-text.js.map +1 -0
  51. package/dist/api/config.d.ts.map +1 -1
  52. package/dist/api/config.js +6 -2
  53. package/dist/api/config.js.map +1 -1
  54. package/dist/api/platform-guard.js +2 -2
  55. package/dist/api/platform-guard.js.map +1 -1
  56. package/dist/api/projects.d.ts.map +1 -1
  57. package/dist/api/projects.js +598 -318
  58. package/dist/api/projects.js.map +1 -1
  59. package/dist/api/tasks.d.ts.map +1 -1
  60. package/dist/api/tasks.js +15 -39
  61. package/dist/api/tasks.js.map +1 -1
  62. package/dist/config/loader.d.ts +5 -1
  63. package/dist/config/loader.d.ts.map +1 -1
  64. package/dist/config/loader.js +36 -24
  65. package/dist/config/loader.js.map +1 -1
  66. package/dist/config/validator.d.ts +7 -4
  67. package/dist/config/validator.d.ts.map +1 -1
  68. package/dist/config/validator.js +57 -73
  69. package/dist/config/validator.js.map +1 -1
  70. package/dist/event/handlers.d.ts.map +1 -1
  71. package/dist/event/handlers.js +359 -226
  72. package/dist/event/handlers.js.map +1 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +0 -5
  75. package/dist/index.js.map +1 -1
  76. package/dist/platform/command-renderer.d.ts +6 -0
  77. package/dist/platform/command-renderer.d.ts.map +1 -1
  78. package/dist/platform/command-renderer.js +20 -0
  79. package/dist/platform/command-renderer.js.map +1 -1
  80. package/dist/platform/driver-host.d.ts.map +1 -1
  81. package/dist/platform/driver-host.js +3 -1
  82. package/dist/platform/driver-host.js.map +1 -1
  83. package/dist/platform/driver-spec.d.ts.map +1 -1
  84. package/dist/platform/driver-spec.js +32 -4
  85. package/dist/platform/driver-spec.js.map +1 -1
  86. package/dist/platform/git-driver.d.ts +2 -0
  87. package/dist/platform/git-driver.d.ts.map +1 -1
  88. package/dist/platform/git-driver.js +9 -3
  89. package/dist/platform/git-driver.js.map +1 -1
  90. package/dist/platform/platform-poller.d.ts +0 -1
  91. package/dist/platform/platform-poller.d.ts.map +1 -1
  92. package/dist/platform/platform-poller.js +0 -6
  93. package/dist/platform/platform-poller.js.map +1 -1
  94. package/dist/platform/plugins/github/driver.json +5 -0
  95. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +20 -14
  96. package/dist/platform/startup.d.ts.map +1 -1
  97. package/dist/platform/startup.js +5 -5
  98. package/dist/platform/startup.js.map +1 -1
  99. package/dist/platform/types.d.ts +1 -0
  100. package/dist/platform/types.d.ts.map +1 -1
  101. package/dist/platform/types.js +3 -3
  102. package/dist/platform/types.js.map +1 -1
  103. package/dist/shared/constants.d.ts +1 -6
  104. package/dist/shared/constants.d.ts.map +1 -1
  105. package/dist/shared/constants.js +2 -29
  106. package/dist/shared/constants.js.map +1 -1
  107. package/dist/shared/types.d.ts +28 -124
  108. package/dist/shared/types.d.ts.map +1 -1
  109. package/dist/shared/types.js +7 -6
  110. package/dist/shared/types.js.map +1 -1
  111. package/dist/skills/baxian-pr-feedback/SKILL.md +5 -3
  112. package/dist/skills/baxian-pr-recheck/SKILL.md +4 -0
  113. package/dist/skills/baxian-pr-review/SKILL.md +4 -0
  114. package/dist/skills/baxian-signals/SKILL.md +15 -32
  115. package/dist/skills/baxian-task-check/SKILL.md +13 -14
  116. package/dist/state/snapshot.d.ts.map +1 -1
  117. package/dist/state/snapshot.js +5 -2
  118. package/dist/state/snapshot.js.map +1 -1
  119. package/dist/state/task-store.d.ts.map +1 -1
  120. package/dist/state/task-store.js +56 -140
  121. package/dist/state/task-store.js.map +1 -1
  122. package/dist/web/assets/index-BKiM0MSM.js +15 -0
  123. package/dist/web/assets/index-BS2adAfI.css +1 -0
  124. package/dist/web/index.html +2 -2
  125. package/package.json +1 -1
  126. package/dist/agent/review-transport.d.ts +0 -92
  127. package/dist/agent/review-transport.d.ts.map +0 -1
  128. package/dist/agent/review-transport.js +0 -512
  129. package/dist/agent/review-transport.js.map +0 -1
  130. package/dist/event/server-handlers.d.ts +0 -4
  131. package/dist/event/server-handlers.d.ts.map +0 -1
  132. package/dist/event/server-handlers.js +0 -1294
  133. package/dist/event/server-handlers.js.map +0 -1
  134. package/dist/skills/baxian-research/SKILL.md +0 -28
  135. package/dist/skills/baxian-server-feedback/SKILL.md +0 -40
  136. package/dist/skills/baxian-server-feedback/agents/openai.yaml +0 -2
  137. package/dist/skills/baxian-server-review/SKILL.md +0 -58
  138. package/dist/skills/baxian-server-review/agents/openai.yaml +0 -2
  139. package/dist/state/review-store.d.ts +0 -33
  140. package/dist/state/review-store.d.ts.map +0 -1
  141. package/dist/state/review-store.js +0 -272
  142. package/dist/state/review-store.js.map +0 -1
  143. package/dist/web/assets/index-BPv6vv3U.css +0 -1
  144. 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, taskGenerationGuard, taskMatchesGeneration, } 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,19 @@ 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';
31
30
  // 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
32
31
  const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
33
32
  export function isBenignDispatchConflict(err) {
@@ -36,6 +35,11 @@ export function isBenignDispatchConflict(err) {
36
35
  && err.code !== undefined
37
36
  && BENIGN_DISPATCH_CONFLICT_CODES.has(err.code);
38
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
+ }
39
43
  export class EnsureSessionError extends Error {
40
44
  partial;
41
45
  constructor(partial, message) {
@@ -177,8 +181,7 @@ export class PlatformMergeRecheckError extends Error {
177
181
  this.name = 'PlatformMergeRecheckError';
178
182
  }
179
183
  }
180
- const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
181
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
184
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix']);
182
185
  const RUNTIME_LIVENESS_SAMPLES = 3;
183
186
  export function canDispatchWithBinding(binding) {
184
187
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
@@ -193,14 +196,6 @@ export function isRecoverableQaDispatchHold(binding) {
193
196
  && binding.awaitingPhase != null
194
197
  && RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
195
198
  }
196
- function shouldBumpServerReviewRound(requested, task, qaBinding) {
197
- if (requested !== undefined)
198
- return requested;
199
- return !(task.status === 'review'
200
- && qaBinding?.taskId === task.id
201
- && isRecoverableQaDispatchHold(qaBinding));
202
- }
203
- const TURN_COMPLETED_AWAITING_PHASES = new Set();
204
199
  const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
205
200
  function isCancelCleanupHold(binding) {
206
201
  return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
@@ -225,7 +220,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
225
220
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
226
221
  return false;
227
222
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
228
- const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
229
223
  // 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
230
224
  // (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
231
225
  const staleCodeHandoff = state.awaitingPhase != null
@@ -233,7 +227,10 @@ function shouldReleaseHeldBinding(state, boundTask) {
233
227
  && !!boundTask
234
228
  && (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
235
229
  && boundTask.agentId !== state.id;
236
- 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;
237
234
  }
238
235
  export class AgentManager {
239
236
  config;
@@ -251,9 +248,6 @@ export class AgentManager {
251
248
  platformDriverExec;
252
249
  phaseSignalWatcher;
253
250
  errorRecordStore;
254
- reviewStore;
255
- reviewTransportInstance;
256
- serverReviewDriver;
257
251
  dispatchAckTimeoutMs;
258
252
  needInputRetryIntervalMs;
259
253
  dispatchSettleTimeoutMs;
@@ -277,10 +271,8 @@ export class AgentManager {
277
271
  readyStableSpacingMs = 1_000;
278
272
  pendingDispatchRetryByTask = new Map();
279
273
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
280
- manualReviewInFlight = new Set();
281
274
  markCompleteInFlight = new Set();
282
275
  specVerdictInFlight = new Set();
283
- codeVerdictInFlight = new Set();
284
276
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
285
277
  cancelCleanupInFlight = new Map();
286
278
  deletionInFlight = new Set();
@@ -313,7 +305,6 @@ export class AgentManager {
313
305
  })
314
306
  : undefined);
315
307
  this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
316
- this.reviewStore = deps.reviewStore;
317
308
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
318
309
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
319
310
  this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
@@ -378,106 +369,6 @@ export class AgentManager {
378
369
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
379
370
  return state;
380
371
  }
381
- getReviewStore() {
382
- return this.reviewStore;
383
- }
384
- async putReviewRoundIfCurrent(taskId, data, expectedTask) {
385
- return this.withTaskLock(async () => {
386
- const task = await this.taskStore.get(taskId);
387
- if (!task || !taskMatchesGeneration(task, expectedTask))
388
- return false;
389
- if (!this.reviewStore)
390
- throw new Error('ReviewStore is not configured');
391
- await this.reviewStore.putRound(taskId, data.phase, data);
392
- return true;
393
- });
394
- }
395
- async deleteReviewExchangeIfCurrent(taskId, agent, artifact, expectedTask, expectedRecovery) {
396
- return this.withTaskLock(async () => {
397
- const task = await this.taskStore.get(taskId);
398
- if (!task || !taskMatchesGeneration(task, expectedTask))
399
- return false;
400
- const recovery = task.serverSignalRecovery;
401
- if (expectedRecovery && (!recovery
402
- || recovery.mode !== expectedRecovery.mode
403
- || recovery.signalKind !== expectedRecovery.signalKind
404
- || recovery.phase !== expectedRecovery.phase
405
- || recovery.round !== expectedRecovery.round
406
- || recovery.sourceToken !== expectedRecovery.sourceToken
407
- || recovery.failureSignature !== expectedRecovery.failureSignature
408
- || recovery.createdAt !== expectedRecovery.createdAt))
409
- return false;
410
- const transport = this.getReviewTransport();
411
- if (artifact === 'findings') {
412
- await transport.deleteFindings(agent);
413
- }
414
- else {
415
- await transport.deleteResponse(agent);
416
- }
417
- return true;
418
- });
419
- }
420
- setServerReviewDriver(driver) {
421
- this.serverReviewDriver = driver;
422
- }
423
- effectiveReviewMode(projectId) {
424
- const project = this.getProjectConfig(projectId);
425
- return project?.review?.mode ?? this.config.review.mode ?? 'git';
426
- }
427
- resolveAfterDone(task) {
428
- if (task.afterDone !== undefined)
429
- return task.afterDone;
430
- return this.coerceAfterDone(task.projectId);
431
- }
432
- coerceAfterDone(projectId) {
433
- const project = this.getProjectConfig(projectId);
434
- return project ? projectAfterDone(this.config, project) : (this.config.review.afterDone ?? null);
435
- }
436
- async commitServerAfterDone(taskId, expectedTask) {
437
- return this.withTaskLock(async () => {
438
- const task = await this.taskStore.get(taskId);
439
- if (!task)
440
- throw new ApiError(404, `Task ${taskId} not found`);
441
- if (expectedTask && !taskMatchesGeneration(task, expectedTask)) {
442
- throw new ApiError(409, `Task ${taskId} review generation changed before afterDone was committed`, 'dispatch-superseded');
443
- }
444
- if (task.afterDone === 'pr' && task.platformBinding === undefined) {
445
- throw new Error(`platform binding missing for task ${task.id}; refusing to reconstruct it from live config`);
446
- }
447
- if (task.afterDone === 'pr')
448
- this.assertPlatformBinding(task);
449
- const afterDone = this.resolveAfterDone(task);
450
- const shouldSnapshotBinding = task.afterDone === undefined && afterDone === 'pr';
451
- const defaultBaseBranch = afterDone === 'pr' && task.baseBranch === undefined
452
- ? this.platformDefaultBranchOf?.(task.projectId)
453
- : undefined;
454
- if (task.afterDone === afterDone && !shouldSnapshotBinding && defaultBaseBranch === undefined)
455
- return afterDone;
456
- await this.taskStore.set({
457
- ...task,
458
- afterDone,
459
- ...(shouldSnapshotBinding ? { platformBinding: this.platformBindingFor(task.projectId) } : {}),
460
- ...(defaultBaseBranch !== undefined ? { baseBranch: defaultBaseBranch } : {}),
461
- updatedAt: new Date().toISOString(),
462
- });
463
- return afterDone;
464
- });
465
- }
466
- getReviewTransport() {
467
- this.reviewTransportInstance ??= new ReviewTransport({
468
- createRunnerFor: (agent) => this.createRunnerFor(agent),
469
- resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
470
- });
471
- return this.reviewTransportInstance;
472
- }
473
- async prepareDispatchArtifacts(agent, workdir, phase, opts) {
474
- const transport = this.getReviewTransport();
475
- if (!opts.preserveOutputs)
476
- await transport.clearDispatchOutputs(agent, workdir, phase);
477
- if (opts.specDocuments) {
478
- await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
479
- }
480
- }
481
372
  bindingWorkdirCache = new Map();
482
373
  async refreshWorkdirCacheFor(agentId) {
483
374
  const state = await this.agentStore.get(agentId);
@@ -596,13 +487,13 @@ export class AgentManager {
596
487
  this.deletionInFlight.add(id);
597
488
  return null;
598
489
  }
599
- // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
600
- 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) {
601
492
  return this.withTaskLock(async () => {
602
493
  for (const id of targets) {
603
494
  const state = await this.agentStore.get(id);
604
495
  const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
605
- if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
496
+ if (task && isTaskOpen(task.status)) {
606
497
  return { ok: false, code: 'active', agentId: id, taskId: task.id };
607
498
  }
608
499
  if (state?.status === 'awaiting_human')
@@ -611,12 +502,11 @@ export class AgentManager {
611
502
  return { ok: false, code: 'bootstrapping', agentId: id };
612
503
  }
613
504
  const targetSet = new Set(targets);
614
- 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)
615
506
  && ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
616
507
  || (!!t.agentId && targetSet.has(t.agentId))
617
508
  || (!!t.devAgentId && targetSet.has(t.devAgentId))
618
- || (!!t.qaAgentId && targetSet.has(t.qaAgentId))
619
- || (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
509
+ || (!!t.qaAgentId && targetSet.has(t.qaAgentId))));
620
510
  if (referencing)
621
511
  return { ok: false, code: 'referencing', taskId: referencing.id };
622
512
  const conflict = this.tryClaimDeletion(targets);
@@ -648,7 +538,6 @@ export class AgentManager {
648
538
  add(task.agentId);
649
539
  add(task.devAgentId, 'dev');
650
540
  add(task.qaAgentId, 'qa');
651
- add(task.researchAgentId, 'research');
652
541
  return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
653
542
  });
654
543
  }
@@ -749,9 +638,8 @@ export class AgentManager {
749
638
  }
750
639
  agentSkillScope(agentId) {
751
640
  const projectId = this.getAgentConfig(agentId)?.projectId;
752
- if (projectId === undefined || this.effectiveReviewMode(projectId) !== 'git') {
641
+ if (projectId === undefined)
753
642
  return { pluginTools: [] };
754
- }
755
643
  const project = this.getProjectConfig(projectId);
756
644
  const tool = project === undefined ? undefined : resolveProjectTool(project);
757
645
  return { pluginTools: tool === undefined ? [] : [tool] };
@@ -1773,10 +1661,7 @@ export class AgentManager {
1773
1661
  ? await this.resolveTaskLockToken(state, taskId)
1774
1662
  : null;
1775
1663
  const sameTaskLocked = existingToken !== null;
1776
- const reentryPhases = new Set([
1777
- 'fix', 'post-approve', 'code',
1778
- 'server-feedback', 'server-after-done',
1779
- ]);
1664
+ const reentryPhases = new Set(['fix', 'post-approve', 'code']);
1780
1665
  const sameTaskReentry = state?.taskId === taskId &&
1781
1666
  !state.creationToken &&
1782
1667
  state.status !== 'awaiting_human' &&
@@ -2342,7 +2227,7 @@ export class AgentManager {
2342
2227
  // ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
2343
2228
  needInputRetry = new Map();
2344
2229
  needInputRetryInFlight = new Set();
2345
- gitReviewQaConfigAlerted = new Set();
2230
+ gitReviewRecoveryAlerted = new Set();
2346
2231
  // Watermark writes currently in the store chain. A restore bump snapshots these along
2347
2232
  // with the queue: an answer whose write has not settled yet would otherwise be invisible
2348
2233
  // to the migration and its error-enqueue would land after the arm already cleared the
@@ -3097,15 +2982,11 @@ export class AgentManager {
3097
2982
  if (result.redispatchCodeTaskId) {
3098
2983
  try {
3099
2984
  const task = await this.taskStore.get(result.redispatchCodeTaskId);
3100
- const round = task ? (task.specReviewRound ?? 1) : undefined;
3101
- const stored = round === undefined
3102
- ? null
3103
- : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
3104
- if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
3105
- 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);
3106
- return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
3107
- }
3108
- 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);
3109
2990
  if (!resumed) {
3110
2991
  const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
3111
2992
  await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
@@ -3113,10 +2994,8 @@ export class AgentManager {
3113
2994
  }
3114
2995
  // 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
3115
2996
  // 会让下一次 recover 把已送达的 code prompt 再判成未送达
3116
- if (!this.isResearchHandoff(task, agentId)) {
3117
- const lockToken = (await this.agentStore.get(agentId))?.lockToken;
3118
- await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
3119
- }
2997
+ const lockToken = (await this.agentStore.get(agentId))?.lockToken;
2998
+ await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
3120
2999
  }
3121
3000
  catch (err) {
3122
3001
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
@@ -3161,7 +3040,7 @@ export class AgentManager {
3161
3040
  && resumeEntry.signalToken === reviewTask?.signalToken
3162
3041
  && resumeEntry.qaPhase !== undefined
3163
3042
  ? resumeEntry.qaPhase
3164
- : (reviewTask?.reviewRound === 0 ? 'review' : undefined);
3043
+ : (reviewTask && taskReviewRound(reviewTask) === 0 ? 'review' : undefined);
3165
3044
  await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
3166
3045
  bumpRound: reviewTask?.reviewRoundPending === true,
3167
3046
  fromStatus: ['review'],
@@ -3513,7 +3392,7 @@ export class AgentManager {
3513
3392
  for (const t of tasks) {
3514
3393
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
3515
3394
  // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
3516
- if (t.status === 'ready' || t.status === 'merge-ready')
3395
+ if (t.status === 'merge-ready')
3517
3396
  continue;
3518
3397
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
3519
3398
  const failedTask = this.stripGitStatusScopedState(t, 'failed');
@@ -3807,11 +3686,10 @@ export class AgentManager {
3807
3686
  if (expected && !taskMatchesGeneration(loadedTask, expected))
3808
3687
  return false;
3809
3688
  const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
3810
- let task = loadedTask;
3689
+ const task = loadedTask;
3811
3690
  if (task.agentId !== agentId)
3812
3691
  return false;
3813
- const participantId = agent.role === 'research' ? task.researchAgentId : task.devAgentId;
3814
- if (participantId !== agentId)
3692
+ if (task.devAgentId !== agentId)
3815
3693
  return false;
3816
3694
  const bindingAtEntry = await this.agentStore.get(agentId);
3817
3695
  const entryHold = {
@@ -3841,28 +3719,6 @@ export class AgentManager {
3841
3719
  if (task.status === 'approved' && agent.role === 'dev') {
3842
3720
  return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
3843
3721
  }
3844
- if (task.serverSignalRecovery) {
3845
- const recovery = task.serverSignalRecovery;
3846
- if (recovery.mode !== 'hold') {
3847
- if (!(await clearEntryHold()))
3848
- return false;
3849
- await this.recoverServerSignalRecovery(task);
3850
- return true;
3851
- }
3852
- const cleared = await this.updateTaskIfStatus(task.id, task.status, { serverSignalRecovery: undefined }, {
3853
- signalToken: task.signalToken,
3854
- phase: task.phase,
3855
- ...(recovery.phase === 'spec'
3856
- ? { specReviewRound: task.specReviewRound }
3857
- : { reviewRound: task.reviewRound }),
3858
- });
3859
- if (!cleared)
3860
- return false;
3861
- const fresh = await this.taskStore.get(task.id);
3862
- if (!fresh)
3863
- return false;
3864
- task = fresh;
3865
- }
3866
3722
  const mapped = this.mapTaskStateToExpectedWatcher(task);
3867
3723
  if (!mapped || mapped.agentId !== agentId)
3868
3724
  return false;
@@ -3898,7 +3754,6 @@ export class AgentManager {
3898
3754
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
3899
3755
  ...opts,
3900
3756
  signalToken: newToken,
3901
- preserveDispatchOutputs: true,
3902
3757
  expectedHold: { phase: undefined, since: undefined, nonce: undefined },
3903
3758
  ...fence,
3904
3759
  }),
@@ -3917,9 +3772,6 @@ export class AgentManager {
3917
3772
  }
3918
3773
  });
3919
3774
  };
3920
- if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
3921
- return await replayInitialBootstrap('research', {});
3922
- }
3923
3775
  if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
3924
3776
  // An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
3925
3777
  const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
@@ -3929,53 +3781,25 @@ export class AgentManager {
3929
3781
  }
3930
3782
  if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
3931
3783
  const state = await this.agentStore.get(agentId);
3932
- // A no-QA auto-approved spec never writes specReviewRound back; the code dispatch defaults to round 1.
3933
- const round = task.specReviewRound ?? 1;
3934
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
3935
- if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
3936
- 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);
3937
3787
  }
3938
3788
  if (!(await clearEntryHold()))
3939
3789
  return false;
3940
- return replayTask('code', {
3941
- allowDirtyWorkdir: true,
3942
- specDocuments: stored.documents,
3943
- ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
3944
- });
3790
+ return replayTask('code', opts);
3945
3791
  }
3946
3792
  if (task.status === 'fixing' && task.phase === 'spec') {
3947
- const round = task.specReviewRound ?? 1;
3948
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
3949
- if (!stored?.findings) {
3950
- 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.`);
3951
- }
3952
3793
  if (!(await clearEntryHold()))
3953
3794
  return false;
3954
- return replayTask('server-feedback', {
3795
+ return replayTask('fix', {
3955
3796
  ...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
3956
- serverPriorFindings: JSON.stringify(stored.findings),
3957
- serverFindingsDigest: reviewFindingsDigest(stored.findings),
3958
- ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
3959
3797
  });
3960
3798
  }
3961
3799
  // A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
3962
3800
  if (task.status === 'fixing'
3963
3801
  && (task.phase === 'code' || task.phase === undefined)
3964
3802
  && agent.role === 'dev') {
3965
- if (task.reviewMode === 'server') {
3966
- const round = Math.max(task.reviewRound, 1);
3967
- const stored = await this.reviewStore?.getRound(taskId, 'code', round);
3968
- if (!stored?.findings) {
3969
- 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.`);
3970
- }
3971
- if (!(await clearEntryHold()))
3972
- return false;
3973
- return replayTask('server-feedback', {
3974
- allowDirtyWorkdir: true,
3975
- serverPriorFindings: JSON.stringify(stored.findings),
3976
- serverFindingsDigest: reviewFindingsDigest(stored.findings),
3977
- });
3978
- }
3979
3803
  if (!(await clearEntryHold()))
3980
3804
  return false;
3981
3805
  return replayTask('fix', {
@@ -4032,70 +3856,10 @@ export class AgentManager {
4032
3856
  const fresh = await this.taskStore.get(taskId);
4033
3857
  return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
4034
3858
  }
4035
- // approved hosts two live holder passes — post-approve and server after-done; a parked pre-publish holder stays on the waiting path.
3859
+ // approved hosts the post-approve holder pass.
4036
3860
  async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
4037
3861
  const taskId = task.id;
4038
3862
  const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
4039
- if (task.reviewMode === 'server') {
4040
- if (!task.publishDispatchedAt)
4041
- return false;
4042
- if (!task.signalToken) {
4043
- 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.');
4044
- }
4045
- const token = task.signalToken;
4046
- const afterDone = this.resolveAfterDone(task);
4047
- if (!afterDone) {
4048
- 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.');
4049
- }
4050
- let violation;
4051
- try {
4052
- violation = await this.findLineageViolation(taskId);
4053
- }
4054
- catch (err) {
4055
- const message = err instanceof Error ? err.message : String(err);
4056
- 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.`);
4057
- }
4058
- if (violation) {
4059
- 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.`);
4060
- }
4061
- if (!(await clearEntryHold()))
4062
- return false;
4063
- const stillCurrent = async (signalToken) => {
4064
- const fresh = await this.taskStore.get(taskId);
4065
- return fresh?.agentId === agentId
4066
- && fresh.status === task.status
4067
- && fresh.phase === task.phase
4068
- && fresh.signalToken === signalToken
4069
- && rootGenerationMatches(fresh, signalToken);
4070
- };
4071
- return this.runRotatedReplay({
4072
- agentId,
4073
- taskAtEntry: task,
4074
- oldToken: token,
4075
- expectedKinds: ['code-ready'],
4076
- rotateUnderTaskLock: async (newToken) => {
4077
- const fresh = await this.taskStore.get(taskId);
4078
- if (fresh?.agentId !== agentId
4079
- || fresh.status !== task.status
4080
- || fresh.phase !== task.phase
4081
- || fresh.signalToken !== token
4082
- || !rootGenerationMatches(fresh, token))
4083
- return false;
4084
- await this.taskStore.set({ ...fresh, signalToken: newToken, updatedAt: new Date().toISOString() });
4085
- return true;
4086
- },
4087
- stillCurrent,
4088
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
4089
- continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'server-after-done', {
4090
- signalToken: newToken,
4091
- preserveDispatchOutputs: true,
4092
- expectedHold: { phase: undefined, since: undefined, nonce: undefined },
4093
- allowDirtyWorkdir: true,
4094
- serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
4095
- ...fence,
4096
- }),
4097
- });
4098
- }
4099
3863
  if (task.postApproveRevoked) {
4100
3864
  return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
4101
3865
  'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
@@ -4128,7 +3892,6 @@ export class AgentManager {
4128
3892
  && fresh.phase === task.phase
4129
3893
  && fresh.signalToken === task.signalToken
4130
3894
  && rootGenerationMatches(fresh, task.signalToken)
4131
- && fresh.reviewMode === task.reviewMode
4132
3895
  && fresh.postApproveRevoked === undefined
4133
3896
  && fresh.postApproveHeadSha === task.postApproveHeadSha
4134
3897
  && fresh.postApproveGeneration === task.postApproveGeneration
@@ -4167,7 +3930,6 @@ export class AgentManager {
4167
3930
  token: newToken,
4168
3931
  headSha: task.postApproveHeadSha,
4169
3932
  },
4170
- preserveDispatchOutputs: true,
4171
3933
  expectedHold: { phase: undefined, since: undefined, nonce: undefined },
4172
3934
  allowDirtyWorkdir: true,
4173
3935
  ...fence,
@@ -4190,8 +3952,6 @@ export class AgentManager {
4190
3952
  const project = this.getProjectConfig(cfg.projectId);
4191
3953
  if (!project)
4192
3954
  throw new Error(`Unknown project: ${cfg.projectId}`);
4193
- if (cfg.role !== 'dev')
4194
- return { targets: [agentId] };
4195
3955
  const group = this.findAgentGroup(agentId);
4196
3956
  return { targets: group?.map(agent => agent.id) ?? [agentId] };
4197
3957
  }
@@ -4292,12 +4052,15 @@ export class AgentManager {
4292
4052
  }
4293
4053
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
4294
4054
  const now = new Date().toISOString();
4295
- const isResearch = cfg.role === 'research';
4055
+ if (cfg.role !== 'dev')
4056
+ throw new Error(`Agent ${cfg.id} is not a dev agent`);
4296
4057
  const group = this.findAgentGroup(cfg.id);
4297
- const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
4298
- if (!devAgentId)
4299
- throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
4300
- 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`);
4301
4064
  const fakeTask = {
4302
4065
  id: 'task-9999999999',
4303
4066
  projectId,
@@ -4306,11 +4069,9 @@ export class AgentManager {
4306
4069
  preferredAgentId: input.preferredAgentId,
4307
4070
  agentId: cfg.id,
4308
4071
  devAgentId,
4309
- ...(qaAgentId ? { qaAgentId } : {}),
4310
- ...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
4072
+ qaAgentId,
4311
4073
  branch: `${BRANCH_PREFIX}task-9999999999`,
4312
4074
  reviewRound: 0,
4313
- reviewMode: this.effectiveReviewMode(projectId),
4314
4075
  ...this.platformBindingFields(projectId),
4315
4076
  status: 'in_progress',
4316
4077
  createdAt: now,
@@ -4318,15 +4079,12 @@ export class AgentManager {
4318
4079
  };
4319
4080
  const fullPrompt = buildPromptInline({
4320
4081
  task: fakeTask,
4321
- phase: isResearch ? 'research' : 'develop',
4082
+ phase: 'develop',
4322
4083
  agent: cfg,
4323
4084
  workdir: workdirGuess,
4324
4085
  skillRegistry: this.skillRegistry,
4325
4086
  signalToken: 'preview-signal-token',
4326
- hasQaPartner: qaAgentId !== undefined,
4327
- ...(!isResearch && fakeTask.reviewMode === 'git'
4328
- ? { platformCli: this.platformCliContextOf(fakeTask) }
4329
- : {}),
4087
+ platformCli: this.platformCliContextOf(fakeTask),
4330
4088
  });
4331
4089
  return Buffer.byteLength(fullPrompt, 'utf8');
4332
4090
  }
@@ -4378,6 +4136,9 @@ export class AgentManager {
4378
4136
  async getAgentState(agentId) {
4379
4137
  return this.agentStore.get(agentId);
4380
4138
  }
4139
+ async getAgentLockOwner(agentId) {
4140
+ return this.lockManager.ownerOf(agentId);
4141
+ }
4381
4142
  async getPostApproveCompletion(taskId) {
4382
4143
  const task = await this.taskStore.get(taskId);
4383
4144
  return task ? this.completionOf(task) : null;
@@ -4410,7 +4171,7 @@ export class AgentManager {
4410
4171
  async armGitPostApproveEpisode(taskId, expected, opts = {}) {
4411
4172
  return this.withTaskLock(async () => {
4412
4173
  const task = await this.taskStore.get(taskId);
4413
- if (!task || task.reviewMode !== 'git' || !this.postApproveEpisodeMatches(task, expected))
4174
+ if (!task || !this.postApproveEpisodeMatches(task, expected))
4414
4175
  return false;
4415
4176
  if (!this.phaseSignalWatcher)
4416
4177
  return true;
@@ -4428,7 +4189,7 @@ export class AgentManager {
4428
4189
  async rotateGitPostApproveEpisode(taskId, expected, redispatchCount) {
4429
4190
  return this.withTaskLock(async () => {
4430
4191
  const task = await this.taskStore.get(taskId);
4431
- if (!task || task.reviewMode !== 'git' || task.pendingRedispatch !== true
4192
+ if (!task || task.pendingRedispatch !== true
4432
4193
  || !this.postApproveEpisodeMatches(task, expected))
4433
4194
  return null;
4434
4195
  const token = createSignalTokenExcluding(expected.generation, expected.token);
@@ -4456,10 +4217,17 @@ export class AgentManager {
4456
4217
  const next = nextStatus === 'approved'
4457
4218
  ? { ...task }
4458
4219
  : this.stripPostApproveEpisodeFields(task);
4459
- if (task.reviewMode === 'git' && nextStatus !== 'review') {
4220
+ if (nextStatus !== 'review') {
4460
4221
  delete next.reviewDispatch;
4461
4222
  delete next.reviewRoundPending;
4462
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
+ }
4463
4231
  return next;
4464
4232
  }
4465
4233
  async confirmPostApprovePromptDelivered(taskId, expected) {
@@ -4615,9 +4383,7 @@ export class AgentManager {
4615
4383
  async recordClosedUnmergedAnchor(taskId, prNumber) {
4616
4384
  return this.withTaskLock(async () => {
4617
4385
  const task = await this.taskStore.get(taskId);
4618
- const tracksPlatformLifecycle = task?.reviewMode === 'git'
4619
- || (task?.reviewMode === 'server' && task.afterDone === 'pr' && task.platformBinding !== undefined);
4620
- if (!task || !tracksPlatformLifecycle || TERMINAL_STATUSES.includes(task.status))
4386
+ if (!task || TERMINAL_STATUSES.includes(task.status))
4621
4387
  return false;
4622
4388
  const anchor = task.closedUnmergedAnchor;
4623
4389
  if (anchor?.prNumber === prNumber && anchor.cleared !== true)
@@ -4658,43 +4424,58 @@ export class AgentManager {
4658
4424
  return true;
4659
4425
  });
4660
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
+ }
4661
4449
  async deliverTaskOutbox(taskId) {
4662
4450
  const task = await this.taskStore.get(taskId);
4663
4451
  if (!task?.outbox?.length)
4664
4452
  return;
4665
4453
  for (const entry of task.outbox) {
4666
4454
  try {
4455
+ if (entry.type === 'git.spec-verdict') {
4456
+ await this.deliverSpecVerdictOutbox(taskId, entry.key);
4457
+ continue;
4458
+ }
4667
4459
  await this.eventBus.emit({
4668
4460
  id: `outbox:${entry.key}`,
4669
- type: entry.type,
4461
+ type: 'human.intervention',
4670
4462
  timestamp: new Date().toISOString(),
4671
4463
  projectId: task.projectId,
4672
4464
  agentId: task.agentId,
4673
4465
  taskId: task.id,
4674
4466
  data: entry.data,
4675
4467
  });
4468
+ await this.mutateTaskOutboxEntry(taskId, entry.key, current => current.type === entry.type ? undefined : null);
4676
4469
  }
4677
4470
  catch (err) {
4678
4471
  console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
4679
4472
  continue;
4680
4473
  }
4681
- await this.withTaskLock(async () => {
4682
- const fresh = await this.taskStore.get(taskId);
4683
- if (!fresh?.outbox?.some(e => e.key === entry.key))
4684
- return;
4685
- const remaining = fresh.outbox.filter(e => e.key !== entry.key);
4686
- const { outbox: _outbox, ...rest } = fresh;
4687
- await this.taskStore.set({
4688
- ...rest,
4689
- ...(remaining.length > 0 ? { outbox: remaining } : {}),
4690
- updatedAt: new Date().toISOString(),
4691
- });
4692
- });
4693
4474
  }
4694
4475
  }
4695
4476
  async rearmPostApproveSignal(taskId, opts = {}) {
4696
4477
  const task = await this.taskStore.get(taskId);
4697
- if (!task || task.reviewMode !== 'git' || task.status !== 'approved')
4478
+ if (!task || task.status !== 'approved')
4698
4479
  return false;
4699
4480
  const completion = this.completionOf(task);
4700
4481
  if (!completion)
@@ -4723,8 +4504,6 @@ export class AgentManager {
4723
4504
  if (!this.pluginRegistry)
4724
4505
  return;
4725
4506
  for (const project of next.project) {
4726
- if (projectReviewMode(next, project) !== 'git')
4727
- continue;
4728
4507
  const tool = resolveProjectTool(project);
4729
4508
  if (tool === undefined)
4730
4509
  continue;
@@ -4764,19 +4543,17 @@ export class AgentManager {
4764
4543
  }
4765
4544
  }
4766
4545
  gitReviewLeaseMatches(task, lease) {
4767
- return task.reviewMode === 'git'
4768
- && task.status === 'review'
4546
+ return task.status === 'review'
4769
4547
  && task.signalToken === lease.signalToken
4770
4548
  && task.reviewHeadAnchorSha === lease.headSha
4771
4549
  && task.passToken === lease.passToken
4772
4550
  && task.failToken === lease.failToken
4773
- && lease.effectiveRound === Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
4551
+ && lease.effectiveRound === effectiveTaskReviewRound(task);
4774
4552
  }
4775
4553
  // startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
4776
4554
  gitReviewQaConfigUsable(task) {
4777
- if (!task.qaAgentId)
4778
- return false;
4779
- const config = this.getAgentConfig(task.qaAgentId);
4555
+ const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
4556
+ const config = this.getAgentConfig(qaAgentId);
4780
4557
  return config?.role === 'qa' && config.projectId === task.projectId;
4781
4558
  }
4782
4559
  async beginGitReviewPass(taskId, opts) {
@@ -4787,7 +4564,12 @@ export class AgentManager {
4787
4564
  if (this.markCompleteInFlight.has(taskId) && opts.allowDuringComplete !== true)
4788
4565
  return null;
4789
4566
  const current = await this.taskStore.get(taskId);
4790
- 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)
4791
4573
  return null;
4792
4574
  if (opts.expectedTask && !taskMatchesGeneration(current, opts.expectedTask))
4793
4575
  return null;
@@ -4810,14 +4592,16 @@ export class AgentManager {
4810
4592
  const signalToken = createSignalTokenExcluding(pair.passToken, pair.failToken);
4811
4593
  const generation = createSignalTokenExcluding(signalToken, pair.passToken, pair.failToken);
4812
4594
  const reviewRoundPending = opts.bumpRound ? true : undefined;
4813
- 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));
4814
4597
  const qaPhase = opts.qaPhase ?? (current.status === 'in_progress'
4815
- || (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)
4816
4601
  ? 'review'
4817
4602
  : 'recheck');
4818
4603
  const next = this.stripPostApproveEpisodeFields(current);
4819
4604
  delete next.passProvenance;
4820
- delete next.serverSignalRecovery;
4821
4605
  Object.assign(next, opts.patch ?? {}, {
4822
4606
  status: 'review',
4823
4607
  signalToken,
@@ -4875,7 +4659,7 @@ export class AgentManager {
4875
4659
  }
4876
4660
  return this.withTaskLock(async () => {
4877
4661
  const current = await this.taskStore.get(taskId);
4878
- if (!current || current.reviewMode !== 'git' || current.status !== 'review')
4662
+ if (!current || current.status !== 'review')
4879
4663
  return null;
4880
4664
  if (current.signalToken !== opts.expectedSignalToken
4881
4665
  || current.passToken !== opts.provenance.token
@@ -4956,22 +4740,20 @@ export class AgentManager {
4956
4740
  || current.generation !== expectedGeneration
4957
4741
  || !this.gitReviewLeaseMatches(task, current))
4958
4742
  return null;
4959
- const qaId = task.qaAgentId;
4743
+ const qaId = requireTaskQaAgentId(task, 'confirmUncertainGitReviewDispatch');
4960
4744
  let qaHold;
4961
- if (qaId !== undefined) {
4962
- const qa = await this.agentStore.get(qaId);
4963
- if (qa?.taskId === task.id && qa.status === 'awaiting_human') {
4964
- if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
4965
- return null;
4966
- qaHold = {
4967
- phase: qa.awaitingPhase,
4968
- since: qa.awaitingSince,
4969
- nonce: qa.awaitingNonce,
4970
- };
4971
- }
4972
- 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')
4973
4748
  return null;
4974
- }
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;
4975
4757
  }
4976
4758
  const now = new Date().toISOString();
4977
4759
  const { claimId: _claimId, claimedAt: _claimedAt, ...rest } = current;
@@ -4988,7 +4770,7 @@ export class AgentManager {
4988
4770
  updatedAt: now,
4989
4771
  };
4990
4772
  await this.taskStore.set(next);
4991
- if (qaHold !== undefined && qaId !== undefined) {
4773
+ if (qaHold !== undefined) {
4992
4774
  const cleared = await this.clearAwaitingHuman(qaId, {
4993
4775
  expectedTaskId: task.id,
4994
4776
  expectedHold: qaHold,
@@ -5024,7 +4806,10 @@ export class AgentManager {
5024
4806
  const now = new Date().toISOString();
5025
4807
  const next = { ...task, updatedAt: now };
5026
4808
  if (next.reviewRoundPending === true) {
5027
- next.reviewRound += 1;
4809
+ if (next.phase === 'spec')
4810
+ next.specReviewRound = (next.specReviewRound ?? 0) + 1;
4811
+ else
4812
+ next.reviewRound += 1;
5028
4813
  delete next.reviewRoundPending;
5029
4814
  }
5030
4815
  next.reviewDispatchedAt = now;
@@ -5039,10 +4824,10 @@ export class AgentManager {
5039
4824
  const current = task?.reviewDispatch;
5040
4825
  if (!task || !current || current.phase !== 'pending'
5041
4826
  || current.generation !== expectedGeneration
5042
- || !this.gitReviewLeaseMatches(task, current)
5043
- || task.qaAgentId === undefined)
4827
+ || !this.gitReviewLeaseMatches(task, current))
5044
4828
  return null;
5045
- const qa = await this.agentStore.get(task.qaAgentId);
4829
+ const qaAgentId = requireTaskQaAgentId(task, 'recoverPendingGitReviewDispatchHold');
4830
+ const qa = await this.agentStore.get(qaAgentId);
5046
4831
  if (qa?.taskId !== task.id
5047
4832
  || qa.status !== 'awaiting_human'
5048
4833
  || qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
@@ -5067,16 +4852,17 @@ export class AgentManager {
5067
4852
  const tasks = await this.taskStore.list();
5068
4853
  for (const snapshot of tasks) {
5069
4854
  const lease = snapshot.reviewDispatch;
5070
- if (snapshot.reviewMode !== 'git' || !lease || lease.phase !== 'claimed')
4855
+ if (!lease || lease.phase !== 'claimed')
5071
4856
  continue;
5072
- 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);
5073
4859
  if (!qa || qa.taskId !== snapshot.id) {
5074
4860
  await this.resetGitReviewDispatchClaim(snapshot.id, lease);
5075
4861
  continue;
5076
4862
  }
5077
4863
  const uncertain = await this.markGitReviewDispatchUncertain(snapshot.id, lease);
5078
4864
  if (uncertain) {
5079
- await this.emitIntervention(snapshot.projectId, snapshot.qaAgentId, snapshot.id, {
4865
+ await this.emitIntervention(snapshot.projectId, qaAgentId, snapshot.id, {
5080
4866
  phase: 'git-review-dispatch-recovery-uncertain',
5081
4867
  generation: lease.generation,
5082
4868
  claimId: lease.claimId,
@@ -5088,41 +4874,97 @@ export class AgentManager {
5088
4874
  async retryPendingGitReviewDispatches() {
5089
4875
  const tasks = await this.taskStore.list();
5090
4876
  for (const snapshot of tasks) {
5091
- const lease = snapshot.reviewDispatch;
5092
- if (snapshot.reviewMode !== 'git' || lease?.phase !== 'pending')
5093
- continue;
5094
- 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')
5095
4935
  continue;
5096
- if (!this.gitReviewQaConfigUsable(snapshot)) {
4936
+ const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
4937
+ if (!this.gitReviewQaConfigUsable(task)) {
5097
4938
  // 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
5098
- const latch = `${snapshot.id}:${lease.generation}`;
5099
- if (!this.gitReviewQaConfigAlerted.has(latch)) {
4939
+ const latch = `qa-config:${task.id}:${lease.generation}`;
4940
+ if (!this.gitReviewRecoveryAlerted.has(latch)) {
5100
4941
  try {
5101
- const live = await this.taskStore.get(snapshot.id);
4942
+ const live = await this.taskStore.get(task.id);
5102
4943
  const liveLease = live?.reviewDispatch;
5103
4944
  if (live && liveLease?.phase === 'pending'
5104
4945
  && liveLease.generation === lease.generation
5105
- && live.qaAgentId === snapshot.qaAgentId
4946
+ && live.qaAgentId === qaAgentId
5106
4947
  && !this.gitReviewQaConfigUsable(live)) {
5107
- 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, {
5108
4950
  phase: 'git-review-qa-config-missing',
5109
- qaAgentId: live.qaAgentId,
4951
+ qaAgentId: liveQaAgentId,
5110
4952
  generation: lease.generation,
5111
4953
  });
5112
4954
  if (alerted)
5113
- this.gitReviewQaConfigAlerted.add(latch);
4955
+ this.gitReviewRecoveryAlerted.add(latch);
5114
4956
  }
5115
4957
  }
5116
4958
  catch (err) {
5117
- 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);
5118
4960
  }
5119
4961
  }
5120
4962
  continue;
5121
4963
  }
5122
4964
  try {
5123
- const recovered = await this.recoverPendingGitReviewDispatchHold(snapshot.id, lease.generation);
4965
+ const recovered = await this.recoverPendingGitReviewDispatchHold(task.id, lease.generation);
5124
4966
  if (recovered) {
5125
- await this.emitIntervention(snapshot.projectId, snapshot.qaAgentId, snapshot.id, {
4967
+ await this.emitIntervention(task.projectId, qaAgentId, task.id, {
5126
4968
  phase: 'git-review-dispatch-recovery-uncertain',
5127
4969
  generation: lease.generation,
5128
4970
  claimId: recovered.claimId,
@@ -5130,28 +4972,52 @@ export class AgentManager {
5130
4972
  });
5131
4973
  continue;
5132
4974
  }
5133
- const entry = this.getPendingDispatchRetry(snapshot.id);
4975
+ const entry = this.getPendingDispatchRetry(task.id);
5134
4976
  if (entry?.kind === 'qa-recheck' && entry.signalToken === lease.signalToken)
5135
4977
  continue;
5136
- if (snapshot.prNumber === undefined) {
5137
- 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;
5138
5005
  }
5139
- const verified = await this.platformVerifyPrBinding(snapshot.id, snapshot.prNumber);
5140
- if (!verified.ok || verified.headSha.toLowerCase() !== lease.headSha.toLowerCase()) {
5141
- 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; ` +
5142
5008
  `preserving generation=${lease.generation} without dispatch`);
5143
5009
  continue;
5144
5010
  }
5145
- await this.dispatchGitReviewLease(snapshot.id, {
5011
+ await this.dispatchGitReviewLease(task.id, {
5146
5012
  expectedGeneration: lease.generation,
5147
5013
  });
5148
5014
  }
5149
5015
  catch (err) {
5150
- if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown' && snapshot.qaAgentId) {
5151
- 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 });
5152
5018
  continue;
5153
5019
  }
5154
- console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${snapshot.id} still failing:`, err);
5020
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
5155
5021
  }
5156
5022
  }
5157
5023
  }
@@ -5159,7 +5025,7 @@ export class AgentManager {
5159
5025
  const tasks = await this.taskStore.list();
5160
5026
  for (const task of tasks) {
5161
5027
  const intent = task.remoteCleanup;
5162
- if (task.reviewMode !== 'git' || !intent || intent.stage === 'manual')
5028
+ if (!intent || intent.stage === 'manual')
5163
5029
  continue;
5164
5030
  try {
5165
5031
  await this.processGitRemoteCleanup(task.id, intent.generation);
@@ -5182,7 +5048,7 @@ export class AgentManager {
5182
5048
  async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
5183
5049
  return this.withTaskLock(async () => {
5184
5050
  const task = await this.taskStore.get(taskId);
5185
- if (!task || task.reviewMode !== 'git')
5051
+ if (!task)
5186
5052
  return { kind: 'gone' };
5187
5053
  if (task.consumedFeedback?.[consume.key] !== undefined)
5188
5054
  return { kind: 'duplicate' };
@@ -5242,8 +5108,6 @@ export class AgentManager {
5242
5108
  async setupRecoveredPostApproveSignals() {
5243
5109
  const tasks = await this.taskStore.list({ status: 'approved' });
5244
5110
  for (const task of tasks) {
5245
- if (task.reviewMode !== 'git')
5246
- continue;
5247
5111
  if (task.postApproveRevoked) {
5248
5112
  // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
5249
5113
  try {
@@ -5283,15 +5147,6 @@ export class AgentManager {
5283
5147
  async setupRecoveredSpecSignals() {
5284
5148
  const tasks = await this.taskStore.list();
5285
5149
  for (const task of tasks) {
5286
- if (task.serverSignalRecovery) {
5287
- try {
5288
- await this.recoverServerSignalRecovery(task);
5289
- }
5290
- catch (err) {
5291
- console.warn(`[AgentManager] setupRecoveredSpecSignals: failed to recover server signal for task=${task.id}:`, err);
5292
- }
5293
- continue;
5294
- }
5295
5150
  if (!this.phaseSignalWatcher)
5296
5151
  continue;
5297
5152
  if (!task.signalToken)
@@ -5304,213 +5159,44 @@ export class AgentManager {
5304
5159
  }
5305
5160
  }
5306
5161
  }
5307
- async recoverServerSignalRecovery(task) {
5308
- const recovery = task.serverSignalRecovery;
5309
- const successorToken = task.signalToken;
5310
- if (!recovery || !successorToken)
5311
- return;
5312
- const agentId = recovery.signalKind.endsWith('-reviewed') ? (task.qaAgentId ?? '') : task.agentId;
5313
- const hold = async (phase, reason) => {
5314
- const held = await this.holdServerSignalRecovery(task.id, agentId, successorToken, phase, reason);
5315
- if (!held)
5316
- return;
5317
- await this.emitIntervention(task.projectId, agentId, task.id, {
5318
- phase,
5319
- recoveryReason: recovery.reason,
5320
- successorToken,
5321
- });
5322
- };
5323
- if (recovery.mode === 'hold') {
5324
- if (!agentId)
5325
- return;
5326
- const state = await this.agentStore.get(agentId);
5327
- if (state?.status === 'awaiting_human')
5328
- return;
5329
- await hold(recovery.failurePhase ?? `server-signal-${recovery.reason}`, `A consumed ${recovery.signalKind} signal is held for ${recovery.reason}. `
5330
- + 'Resolve the cause, then Restart or Cancel the task.');
5331
- return;
5332
- }
5333
- const stored = await this.reviewStore?.getRound(task.id, recovery.phase, recovery.round);
5334
- if (!stored?.findings || !recovery.findingsDigest
5335
- || reviewFindingsDigest(stored.findings) !== recovery.findingsDigest) {
5336
- await hold(`server-${recovery.phase}-feedback-recovery-findings-missing`, `Cannot recover the rejected response because round ${recovery.round} findings are missing or changed.`);
5337
- return;
5338
- }
5339
- const responseReasons = new Set([
5340
- 'response-missing', 'response-invalid', 'round-mismatch', 'token-mismatch',
5341
- 'findings-digest-mismatch', 'coverage-gap',
5342
- ]);
5343
- const dev = agentId ? this.getAgentConfig(agentId) : undefined;
5344
- if (!dev || !recovery.failureSignature
5345
- || (recovery.signalKind !== 'code-fixed' && recovery.signalKind !== 'spec-fixed')
5346
- || !responseReasons.has(recovery.reason)) {
5347
- await hold(`server-${recovery.phase}-feedback-recovery-invalid`, 'The persisted response recovery intent is incomplete and cannot be resumed safely.');
5348
- return;
5349
- }
5350
- const readResponse = async () => {
5351
- try {
5352
- const result = await this.getReviewTransport().readResponseWithRaw(task, dev);
5353
- if (result.kind === 'unknown') {
5354
- await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, 'The rejected response could not be read while finishing crash recovery; the file was preserved.');
5355
- return null;
5356
- }
5357
- return result;
5358
- }
5359
- catch (err) {
5360
- await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, `The rejected response could not be read while finishing crash recovery: `
5361
- + `${err instanceof Error ? err.message : String(err)}`);
5362
- return null;
5363
- }
5364
- };
5365
- 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.');
5366
- if (recovery.mode === 'classify-response') {
5367
- const result = await readResponse();
5368
- if (!result)
5369
- return;
5370
- let rawResponse;
5371
- let responseDigest;
5372
- if (recovery.reason === 'response-missing') {
5373
- if (result.kind !== 'absent') {
5374
- await holdChangedResponse();
5375
- return;
5376
- }
5377
- }
5378
- else if (result.kind === 'absent' || result.responseDigest !== recovery.responseDigest) {
5379
- await holdChangedResponse();
5380
- return;
5381
- }
5382
- else {
5383
- rawResponse = result.raw;
5384
- responseDigest = result.responseDigest;
5385
- }
5386
- let recorded;
5387
- try {
5388
- recorded = await this.reviewStore.recordServerResponseFailure(task.id, recovery.phase, recovery.round, {
5389
- signalKind: recovery.signalKind,
5390
- sourceToken: recovery.sourceToken,
5391
- successorToken,
5392
- failureSignature: recovery.failureSignature,
5393
- reason: recovery.reason,
5394
- missingFindingIds: [...(recovery.missingFindingIds ?? [])],
5395
- unknownFindingIds: [...(recovery.unknownFindingIds ?? [])],
5396
- schemaViolationCodes: [...(recovery.schemaViolationCodes ?? [])],
5397
- createdAt: recovery.createdAt,
5398
- ...(responseDigest ? { responseDigest } : {}),
5399
- ...(rawResponse !== undefined ? { rawResponse } : {}),
5400
- });
5401
- }
5402
- catch (err) {
5403
- await hold(`server-${recovery.phase}-feedback-recovery-audit-failed`, `Could not archive the rejected response during recovery: `
5404
- + `${err instanceof Error ? err.message : String(err)}`);
5405
- return;
5406
- }
5407
- const mode = recorded.disposition === 'auto-correct' ? 'correct-response' : 'hold';
5408
- const finalized = await this.setServerSignalRecoveryMode(task.id, successorToken, mode);
5409
- if (!finalized)
5410
- return;
5411
- if (mode === 'hold') {
5412
- await hold(recorded.disposition === 'hold-repeated-signature'
5413
- ? 'server-feedback-response-repeated'
5414
- : 'server-feedback-correction-limit', `Automatic correction stopped during recovery (${recorded.disposition}).`);
5415
- return;
5416
- }
5417
- }
5418
- if (agentId && (await this.agentStore.get(agentId))?.status === 'awaiting_human')
5419
- return;
5420
- const liveResponse = await readResponse();
5421
- if (!liveResponse)
5422
- return;
5423
- if (liveResponse.kind !== 'absent') {
5424
- if (!recovery.responseDigest || liveResponse.responseDigest !== recovery.responseDigest) {
5425
- await holdChangedResponse();
5426
- return;
5427
- }
5428
- const fresh = await this.taskStore.get(task.id);
5429
- const current = fresh?.serverSignalRecovery;
5430
- const currentRound = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
5431
- if (fresh?.status !== task.status
5432
- || fresh.phase !== task.phase
5433
- || fresh.signalToken !== successorToken
5434
- || currentRound !== recovery.round
5435
- || current?.mode !== 'correct-response'
5436
- || current.sourceToken !== recovery.sourceToken
5437
- || current.failureSignature !== recovery.failureSignature) {
5438
- return;
5439
- }
5440
- try {
5441
- const deleted = await this.deleteReviewExchangeIfCurrent(task.id, dev, 'response', taskGenerationGuard(fresh), current);
5442
- if (!deleted)
5443
- return;
5444
- }
5445
- catch (err) {
5446
- await hold(`server-${recovery.phase}-feedback-recovery-cleanup-failed`, `The rejected response was archived but could not be removed during recovery: `
5447
- + `${err instanceof Error ? err.message : String(err)}`);
5448
- return;
5449
- }
5450
- }
5451
- await this.dispatchServerFeedbackCorrection(task.id, JSON.stringify(stored.findings));
5452
- }
5453
5162
  async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
5454
5163
  {
5455
5164
  const { expectedKinds, agentId } = mapped;
5456
- const specStage = isSpecStagePhase(task.phase);
5457
5165
  const interventionKindLabel = expectedKinds.length === 0 ? undefined
5458
- : specStage && task.status === 'review' ? 'spec-reviewed'
5459
- : specStage && task.status === 'fixing' ? 'spec-fixed'
5460
- : !specStage && task.status === 'review' ? 'pr-verdict-comment'
5461
- : !specStage && task.status === 'fixing' ? 'pr-fixed'
5462
- : undefined;
5463
- const isServerProtocol = task.reviewMode === 'server' || specStage;
5464
- const scanSnapshotOnRecover = isServerProtocol
5465
- || (task.phase === undefined && task.status === 'in_progress')
5466
- || (!specStage && (task.status === 'review' || task.status === 'fixing'));
5467
- // 侧道读 dev workdir,对 head 评审会注入未验证内容——mode 不明时关侧道、发干预交人工。
5468
- const allowRecoveredReadFile = task.status === 'review'
5469
- && (specStage
5470
- || (task.reviewMode === 'server' && task.batchTotal !== undefined)
5471
- || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
5472
- const ambiguousCheckout = task.status === 'review'
5473
- && task.reviewMode === 'server'
5474
- && !specStage
5475
- && task.reviewCheckoutMode === undefined
5476
- && task.batchTotal === undefined;
5166
+ : task.status === 'review' ? 'pr-verdict-comment'
5167
+ : task.status === 'fixing' ? 'pr-fixed'
5168
+ : undefined;
5477
5169
  try {
5478
- await this.startPhaseSignalWatch({
5170
+ const armed = await this.startPhaseSignalWatch({
5479
5171
  taskId: task.id,
5480
5172
  projectId: task.projectId,
5481
5173
  agentId,
5482
5174
  expectedKinds,
5483
5175
  token: signalToken,
5484
- skipSnapshot: !scanSnapshotOnRecover,
5176
+ skipSnapshot: false,
5485
5177
  recovered: true,
5486
5178
  ...(hasSiblings ? { replaceScope: 'agent' } : {}),
5487
- ...(allowRecoveredReadFile
5488
- ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
5489
- : {}),
5490
5179
  });
5491
- if (interventionKindLabel || ambiguousCheckout) {
5492
- // snapshot 命中完成信号时不发邀请手工 retry 的干预:结算窗口内的人工重派正是要杜绝的入口;
5493
- // 结算若推进了任务态(token 轮换/状态离开),该干预已无的放矢。
5494
- if (this.phaseSignalWatcher?.isSettling(task.id)) {
5495
- await this.phaseSignalWatcher.awaitSettled(task.id);
5496
- const fresh = await this.taskStore.get(task.id);
5497
- if (!fresh || fresh.status !== task.status || fresh.signalToken !== signalToken) {
5498
- return;
5499
- }
5500
- }
5501
- if (ambiguousCheckout) {
5502
- await this.emitIntervention(task.projectId, agentId, task.id, {
5503
- phase: 'server-review-recovery-ambiguous-checkout',
5504
- note: 'The recovered server code review has no persisted checkout mode (crash before persist, or a pre-rename base task rewritten since). The read-file side-channel stays closed because it reads the dev workdir, not the reviewed head. Inspect the QA pane; redispatch the review to converge on a verified head checkout.',
5505
- });
5506
- }
5507
- if (interventionKindLabel) {
5508
- await this.emitIntervention(task.projectId, agentId, task.id, {
5509
- phase: 'spec-signal-setup-during-recovery',
5510
- kind: interventionKindLabel,
5511
- 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.',
5512
- });
5513
- }
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) {
5195
+ await this.emitIntervention(task.projectId, agentId, task.id, {
5196
+ phase: 'phase-signal-setup-during-recovery',
5197
+ kind: interventionKindLabel,
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.',
5199
+ });
5514
5200
  }
5515
5201
  }
5516
5202
  catch (err) {
@@ -5546,9 +5232,7 @@ export class AgentManager {
5546
5232
  }
5547
5233
  // 创建期快照(spec §4):git 任务在建任务那一刻定身份。
5548
5234
  platformBindingFields(projectId) {
5549
- return this.effectiveReviewMode(projectId) === 'git'
5550
- ? { platformBinding: this.platformBindingFor(projectId) }
5551
- : {};
5235
+ return { platformBinding: this.platformBindingFor(projectId) };
5552
5236
  }
5553
5237
  platformBindingFor(projectId) {
5554
5238
  const project = this.getProjectConfig(projectId);
@@ -5558,15 +5242,13 @@ export class AgentManager {
5558
5242
  if (tool === undefined)
5559
5243
  throw new Error(`project ${projectId} has no resolved platform tool`);
5560
5244
  return {
5561
- mode: this.effectiveReviewMode(projectId),
5245
+ mode: 'git',
5562
5246
  repoKey: repoIdentityKey(project.repo),
5563
5247
  tool,
5564
5248
  };
5565
5249
  }
5566
5250
  // agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
5567
5251
  agentGitPreflightContext(projectId) {
5568
- if (this.effectiveReviewMode(projectId) !== 'git')
5569
- return undefined;
5570
5252
  const project = this.getProjectConfig(projectId);
5571
5253
  if (!project || !this.pluginRegistry)
5572
5254
  return undefined;
@@ -5589,8 +5271,6 @@ export class AgentManager {
5589
5271
  // cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
5590
5272
  // 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
5591
5273
  platformCliContextOf(task) {
5592
- if (task.reviewMode !== 'git')
5593
- return undefined;
5594
5274
  this.assertPlatformBinding(task);
5595
5275
  const project = this.getProjectConfig(task.projectId);
5596
5276
  if (!project)
@@ -5609,7 +5289,7 @@ export class AgentManager {
5609
5289
  }
5610
5290
  // base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
5611
5291
  async ensureGitBaseSnapshot(task, phase) {
5612
- if (task.reviewMode !== 'git' || (phase !== 'develop' && phase !== 'code'))
5292
+ if (phase !== 'develop' && phase !== 'code')
5613
5293
  return task;
5614
5294
  if (task.baseBranch !== undefined)
5615
5295
  return task;
@@ -5699,6 +5379,7 @@ export class AgentManager {
5699
5379
  }
5700
5380
  return {
5701
5381
  ok: true,
5382
+ prUrl: String(row.prUrl),
5702
5383
  headSha: String(row.headSha),
5703
5384
  branch: String(row.branch),
5704
5385
  targetBranch: String(row.targetBranch),
@@ -5772,7 +5453,7 @@ export class AgentManager {
5772
5453
  async processGitRemoteCleanup(taskId, expectedGeneration) {
5773
5454
  const snapshot = await this.taskStore.get(taskId);
5774
5455
  const snapshotIntent = snapshot?.remoteCleanup;
5775
- if (!snapshot || snapshot.reviewMode !== 'git' || !snapshotIntent || snapshotIntent.stage === 'manual')
5456
+ if (!snapshot || !snapshotIntent || snapshotIntent.stage === 'manual')
5776
5457
  return;
5777
5458
  if (expectedGeneration !== undefined && snapshotIntent.generation !== expectedGeneration)
5778
5459
  return;
@@ -6037,7 +5718,7 @@ export class AgentManager {
6037
5718
  if (String(row.headSha) !== opts.expectedHeadSha) {
6038
5719
  throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
6039
5720
  }
6040
- if (task.reviewMode === 'git' && opts.humanOverride !== true) {
5721
+ if (opts.humanOverride !== true) {
6041
5722
  const provenance = task.passProvenance;
6042
5723
  if (!provenance) {
6043
5724
  throw new PlatformMergeRecheckError('provenance-invalid', 'no accepted pass provenance on task');
@@ -6070,9 +5751,7 @@ export class AgentManager {
6070
5751
  }
6071
5752
  const fresh = await this.taskStore.get(taskId);
6072
5753
  if (!fresh
6073
- || (opts.humanOverride === true
6074
- ? fresh.status !== 'merge-ready'
6075
- : (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
5754
+ || fresh.status !== 'merge-ready'
6076
5755
  || fresh.latestHeadSha !== opts.expectedHeadSha
6077
5756
  || fresh.signalToken !== task.signalToken
6078
5757
  || fresh.pendingRedispatch === true) {
@@ -6116,7 +5795,7 @@ export class AgentManager {
6116
5795
  return [];
6117
5796
  const key = repoIdentityKey(project.repo);
6118
5797
  const entryBinding = {
6119
- mode: this.effectiveReviewMode(projectId),
5798
+ mode: 'git',
6120
5799
  repoKey: key,
6121
5800
  tool: resolveProjectTool(project) ?? '',
6122
5801
  };
@@ -6126,31 +5805,16 @@ export class AgentManager {
6126
5805
  && this.taskBelongsToPlatformEntry(task, entryBinding));
6127
5806
  }
6128
5807
  async findTaskByBranch(branch, projectId) {
6129
- const repoKey = this.projectRepoKey(projectId);
6130
- if (!repoKey)
5808
+ const repo = this.getProjectConfig(projectId)?.repo;
5809
+ if (!repo)
6131
5810
  return undefined;
5811
+ const repoKey = repoIdentityKey(repo);
6132
5812
  const all = await this.taskStore.list();
6133
- return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
6134
- }
6135
- async adoptServerPr(taskId, pr) {
6136
- return this.withTaskLock(async () => {
6137
- const task = await this.taskStore.get(taskId);
6138
- if (!task || task.reviewMode !== 'server' || task.afterDone !== 'pr'
6139
- || task.platformBinding === undefined || TERMINAL_STATUSES.includes(task.status))
6140
- return false;
6141
- if (task.prNumber !== undefined && task.prNumber !== pr.prNumber)
6142
- return false;
6143
- await this.taskStore.set({
6144
- ...task,
6145
- prNumber: pr.prNumber,
6146
- ...(pr.prUrl !== undefined ? { prUrl: pr.prUrl } : {}),
6147
- ...(pr.headSha !== undefined ? { latestHeadSha: pr.headSha } : {}),
6148
- ...(task.baseBranch === undefined && pr.targetBranch !== undefined
6149
- ? { baseBranch: pr.targetBranch }
6150
- : {}),
6151
- updatedAt: new Date().toISOString(),
6152
- });
6153
- 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;
6154
5818
  });
6155
5819
  }
6156
5820
  async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
@@ -6173,184 +5837,6 @@ export class AgentManager {
6173
5837
  return true;
6174
5838
  });
6175
5839
  }
6176
- async claimServerSignalRecovery(taskAtEntry, agentId, signalKind, input) {
6177
- const sourceToken = taskAtEntry.signalToken;
6178
- if (!sourceToken)
6179
- return null;
6180
- const currentRound = input.phase === 'spec'
6181
- ? (taskAtEntry.specReviewRound ?? 0)
6182
- : taskAtEntry.reviewRound;
6183
- const phaseMatches = input.phase === 'spec'
6184
- ? taskAtEntry.phase !== 'code'
6185
- : taskAtEntry.phase === 'code' || taskAtEntry.phase === undefined;
6186
- if (!phaseMatches || input.round !== currentRound)
6187
- return null;
6188
- let successorToken = createSignalToken();
6189
- while (successorToken === sourceToken)
6190
- successorToken = createSignalToken();
6191
- const recovery = {
6192
- ...input,
6193
- signalKind,
6194
- sourceToken,
6195
- missingFindingIds: [...(input.missingFindingIds ?? [])].sort(),
6196
- unknownFindingIds: [...(input.unknownFindingIds ?? [])].sort(),
6197
- schemaViolationCodes: [...(input.schemaViolationCodes ?? [])].sort(),
6198
- createdAt: new Date().toISOString(),
6199
- };
6200
- const expectedRound = input.phase === 'spec'
6201
- ? { specReviewRound: taskAtEntry.specReviewRound }
6202
- : { reviewRound: taskAtEntry.reviewRound };
6203
- const claimed = await this.updateTaskIfStatus(taskAtEntry.id, taskAtEntry.status, {
6204
- signalToken: successorToken,
6205
- serverSignalRecovery: recovery,
6206
- ...(taskAtEntry.status === 'fixing' ? { fixDispatchedAt: new Date().toISOString() } : {}),
6207
- }, {
6208
- signalToken: sourceToken,
6209
- phase: taskAtEntry.phase,
6210
- ...expectedRound,
6211
- });
6212
- if (!claimed)
6213
- return null;
6214
- if (agentId)
6215
- this.phaseSignalWatcher?.stopAgentIfToken(taskAtEntry.id, agentId, sourceToken);
6216
- else
6217
- this.phaseSignalWatcher?.stopIfToken(taskAtEntry.id, sourceToken);
6218
- const fresh = await this.taskStore.get(taskAtEntry.id);
6219
- if (fresh?.signalToken !== successorToken
6220
- || fresh.serverSignalRecovery?.sourceToken !== sourceToken
6221
- || fresh.serverSignalRecovery.signalKind !== signalKind) {
6222
- return null;
6223
- }
6224
- if (input.mode === 'hold' && agentId) {
6225
- const holdPhase = input.failurePhase ?? `server-signal-${input.reason}`;
6226
- const held = await this.markAwaitingHuman(agentId, holdPhase, `The ${signalKind} marker was consumed but could not be completed (${input.reason}). `
6227
- + 'The old token is retired. Restart or cancel the task after resolving the reported cause.', { expectedTaskId: taskAtEntry.id });
6228
- if (!held) {
6229
- console.error(`[AgentManager] failed to hold ${agentId} for consumed ${signalKind} on ${taskAtEntry.id}; `
6230
- + `recovery=${successorToken}`);
6231
- }
6232
- }
6233
- return fresh;
6234
- }
6235
- async holdConsumedServerSignal(taskAtEntry, agentId, signalKind, input) {
6236
- return this.claimServerSignalRecovery(taskAtEntry, agentId, signalKind, {
6237
- mode: 'hold',
6238
- ...input,
6239
- });
6240
- }
6241
- async setServerSignalRecoveryMode(taskId, successorToken, mode) {
6242
- const task = await this.taskStore.get(taskId);
6243
- const recovery = task?.serverSignalRecovery;
6244
- if (!task || !recovery || task.signalToken !== successorToken)
6245
- return null;
6246
- const currentRound = recovery.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
6247
- const phaseMatches = recovery.phase === 'spec'
6248
- ? task.phase !== 'code'
6249
- : task.phase === 'code' || task.phase === undefined;
6250
- if (!phaseMatches || currentRound !== recovery.round)
6251
- return null;
6252
- const expectedRound = recovery.phase === 'spec'
6253
- ? { specReviewRound: task.specReviewRound }
6254
- : { reviewRound: task.reviewRound };
6255
- const updated = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: { ...recovery, mode } }, {
6256
- signalToken: successorToken,
6257
- phase: task.phase,
6258
- ...expectedRound,
6259
- });
6260
- if (!updated)
6261
- return null;
6262
- const fresh = await this.taskStore.get(taskId);
6263
- if (fresh?.signalToken !== successorToken
6264
- || fresh.serverSignalRecovery?.mode !== mode
6265
- || fresh.serverSignalRecovery.sourceToken !== recovery.sourceToken) {
6266
- return null;
6267
- }
6268
- return fresh;
6269
- }
6270
- async holdServerSignalRecovery(taskId, agentId, successorToken, holdPhase, reason) {
6271
- const task = await this.setServerSignalRecoveryMode(taskId, successorToken, 'hold');
6272
- if (!task)
6273
- return null;
6274
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, agentId, successorToken);
6275
- if (agentId) {
6276
- const held = await this.markAwaitingHuman(agentId, holdPhase, reason, { expectedTaskId: taskId });
6277
- if (!held) {
6278
- console.error(`[AgentManager] failed to hold ${agentId} for recovery ${taskId}/${successorToken}`);
6279
- }
6280
- }
6281
- return task;
6282
- }
6283
- async dispatchServerFeedbackCorrection(taskId, findingsJson) {
6284
- const task = await this.taskStore.get(taskId);
6285
- const recovery = task?.serverSignalRecovery;
6286
- if (!task || !recovery || recovery.mode !== 'correct-response' || !task.signalToken)
6287
- return false;
6288
- const agentId = task.agentId;
6289
- const successorToken = task.signalToken;
6290
- if (!agentId || !this.getAgentConfig(agentId)) {
6291
- 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.');
6292
- return false;
6293
- }
6294
- const stillCurrent = async () => {
6295
- const fresh = await this.taskStore.get(taskId);
6296
- const current = fresh?.serverSignalRecovery;
6297
- const round = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
6298
- return fresh?.status === task.status
6299
- && fresh.phase === task.phase
6300
- && fresh.signalToken === successorToken
6301
- && round === recovery.round
6302
- && current?.mode === 'correct-response'
6303
- && current.sourceToken === recovery.sourceToken
6304
- && current.failureSignature === recovery.failureSignature;
6305
- };
6306
- try {
6307
- const delivered = await this.continueSession(taskId, agentId, 'server-feedback', {
6308
- allowDirtyWorkdir: true,
6309
- signalToken: successorToken,
6310
- serverPriorFindings: findingsJson,
6311
- serverFindingsDigest: recovery.findingsDigest,
6312
- serverFeedbackCorrection: {
6313
- reason: recovery.reason,
6314
- missingFindingIds: recovery.missingFindingIds,
6315
- unknownFindingIds: recovery.unknownFindingIds,
6316
- schemaViolationCodes: recovery.schemaViolationCodes,
6317
- },
6318
- ...(recovery.phase === 'spec' ? { currentSpecRound: recovery.round } : {}),
6319
- armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, recovery.signalKind, successorToken, {
6320
- skipSnapshot: true,
6321
- onlyReplaceOwnToken: true,
6322
- replaceFromToken: recovery.sourceToken,
6323
- needInputMode: 'fresh',
6324
- }),
6325
- guardBeforeInject: stillCurrent,
6326
- });
6327
- if (!delivered)
6328
- throw new Error('correction prompt was not delivered');
6329
- const cleared = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: undefined }, {
6330
- signalToken: successorToken,
6331
- phase: task.phase,
6332
- ...(recovery.phase === 'spec'
6333
- ? { specReviewRound: task.specReviewRound }
6334
- : { reviewRound: task.reviewRound }),
6335
- });
6336
- if (!cleared && await stillCurrent()) {
6337
- throw new Error('correction prompt delivered but recovery intent could not be cleared');
6338
- }
6339
- return true;
6340
- }
6341
- catch (err) {
6342
- const message = err instanceof Error ? err.message : String(err);
6343
- 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.`);
6344
- if (!held)
6345
- return false;
6346
- await this.emitIntervention(task.projectId, agentId, taskId, {
6347
- phase: `server-${recovery.phase}-feedback-correction-failed`,
6348
- error: message,
6349
- successorToken,
6350
- });
6351
- return false;
6352
- }
6353
- }
6354
5840
  async updateTask(taskId, updates) {
6355
5841
  await this.withTaskLock(async () => {
6356
5842
  const task = await this.taskStore.get(taskId);
@@ -6360,6 +5846,68 @@ export class AgentManager {
6360
5846
  await this.taskStore.set(task);
6361
5847
  });
6362
5848
  }
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) {
5856
+ return this.withTaskLock(async () => {
5857
+ const task = await this.taskStore.get(taskId);
5858
+ if (!task || TERMINAL_STATUSES.includes(task.status))
5859
+ return null;
5860
+ if (expected
5861
+ && (!taskMatchesGeneration(task, expected.task)
5862
+ || task.latestHeadSha !== expected.latestHeadSha))
5863
+ return null;
5864
+ if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
5865
+ return null;
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);
5879
+ if (existing && existing.id !== taskId)
5880
+ return null;
5881
+ }
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
+ };
5907
+ await this.taskStore.set(next);
5908
+ return next;
5909
+ });
5910
+ }
6363
5911
  async transitionTaskStatus(taskId, toStatus, guard, patch) {
6364
5912
  return this.withTaskLock(async () => {
6365
5913
  const transition = async () => {
@@ -6383,20 +5931,31 @@ export class AgentManager {
6383
5931
  return null;
6384
5932
  if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
6385
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;
6386
5938
  if (guard.expectPostApproveEpisode !== undefined
6387
5939
  && !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
6388
5940
  return null;
6389
5941
  if (guard.expectReviewDispatch !== undefined
6390
5942
  && !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
6391
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;
6392
5953
  if (patch?.branch && patch.branch !== task.branch) {
6393
5954
  const existing = await this.findTaskByBranch(patch.branch, task.projectId);
6394
5955
  if (existing && existing.id !== taskId)
6395
5956
  return null;
6396
5957
  }
6397
5958
  const next = this.stripGitStatusScopedState(task, toStatus);
6398
- if (previousStatus !== toStatus)
6399
- delete next.serverSignalRecovery;
6400
5959
  Object.assign(next, patch ?? {}, {
6401
5960
  status: toStatus,
6402
5961
  updatedAt: new Date().toISOString(),
@@ -6421,9 +5980,7 @@ export class AgentManager {
6421
5980
  if (this.repoStoreFactory) {
6422
5981
  return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
6423
5982
  }
6424
- const cloneViaGh = this.effectiveReviewMode(project.id) === 'git'
6425
- ? resolveProjectTool(project) === 'gh'
6426
- : undefined;
5983
+ const cloneViaGh = resolveProjectTool(project) === 'gh';
6427
5984
  return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
6428
5985
  }
6429
5986
  async ensureWorkdir(agent, project, runner) {
@@ -6518,7 +6075,7 @@ export class AgentManager {
6518
6075
  throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
6519
6076
  }
6520
6077
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
6521
- throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
6078
+ throw new ApiError(400, `Agent ${preferredAgentId} is not a dev agent`);
6522
6079
  }
6523
6080
  const state = await this.agentStore.get(preferredAgentId);
6524
6081
  if (!canDispatchWithBinding(state))
@@ -6574,7 +6131,6 @@ export class AgentManager {
6574
6131
  reviewRound: 0,
6575
6132
  branch: taskBranch,
6576
6133
  branchCreatedByBaxian,
6577
- reviewMode: this.effectiveReviewMode(projectId),
6578
6134
  ...this.platformBindingFields(projectId),
6579
6135
  createdAt: now,
6580
6136
  updatedAt: now,
@@ -6592,18 +6148,15 @@ export class AgentManager {
6592
6148
  // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
6593
6149
  const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
6594
6150
  const target = await this.pickAgent(projectId, input.preferredAgentId);
6595
- const targetConfig = this.getAgentConfig(input.preferredAgentId);
6596
6151
  const group = this.findAgentGroup(input.preferredAgentId);
6597
6152
  const dev = group?.find(agent => agent.role === 'dev');
6598
6153
  if (!dev)
6599
6154
  throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
6600
6155
  const qa = group?.find(agent => agent.role === 'qa');
6601
- const research = targetConfig.role === 'research' ? targetConfig : undefined;
6602
- const researchFields = research
6603
- ? { researchAgentId: research.id, phase: 'research' }
6604
- : {};
6156
+ if (!qa)
6157
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
6605
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.
6606
- const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
6159
+ const participantGens = new Map([dev.id, qa.id]
6607
6160
  .map(id => [id, this.deletionGenerationOf(id)]));
6608
6161
  const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
6609
6162
  && !this.isDeletionInFlight(input.preferredAgentId)
@@ -6612,9 +6165,8 @@ export class AgentManager {
6612
6165
  ...taskBase,
6613
6166
  agentId: '',
6614
6167
  devAgentId: dev.id,
6615
- ...researchFields,
6168
+ qaAgentId: qa.id,
6616
6169
  status: 'pending',
6617
- ...(qa ? { qaAgentId: qa.id } : {}),
6618
6170
  };
6619
6171
  // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
6620
6172
  // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
@@ -6698,8 +6250,7 @@ export class AgentManager {
6698
6250
  ...taskBase,
6699
6251
  agentId: target.id,
6700
6252
  devAgentId: dev.id,
6701
- ...(qa ? { qaAgentId: qa.id } : {}),
6702
- ...researchFields,
6253
+ qaAgentId: qa.id,
6703
6254
  status: 'in_progress',
6704
6255
  };
6705
6256
  try {
@@ -6736,7 +6287,7 @@ export class AgentManager {
6736
6287
  const task = await this.createTask(projectId, input);
6737
6288
  if (task.status === 'in_progress' && task.agentId) {
6738
6289
  const signalToken = createSignalToken();
6739
- await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
6290
+ await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
6740
6291
  const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
6741
6292
  const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
6742
6293
  if (opts.background) {
@@ -6756,7 +6307,7 @@ export class AgentManager {
6756
6307
  const initialTask = await this.taskStore.get(taskId);
6757
6308
  if (!initialTask)
6758
6309
  return null;
6759
- const initialDispatch = this.resolveInitialDispatch(initialTask);
6310
+ const initialDispatch = this.resolveInitialDispatch();
6760
6311
  const dispatchPhase = initialDispatch.phase;
6761
6312
  let started = false;
6762
6313
  let dispatchErr = null;
@@ -6791,7 +6342,7 @@ export class AgentManager {
6791
6342
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
6792
6343
  return null;
6793
6344
  }
6794
- const initialKinds = this.resolveInitialDispatch(fresh).kinds;
6345
+ const initialKinds = this.resolveInitialDispatch().kinds;
6795
6346
  try {
6796
6347
  await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
6797
6348
  }
@@ -7006,7 +6557,7 @@ export class AgentManager {
7006
6557
  `${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
7007
6558
  return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
7008
6559
  }
7009
- async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
6560
+ async clearRuntimeForDispatchBoundary(tmux, pane, agentId, runtime, revalidate) {
7010
6561
  await this.acquireCompactGuard(agentId);
7011
6562
  try {
7012
6563
  await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
@@ -7025,7 +6576,7 @@ export class AgentManager {
7025
6576
  });
7026
6577
  await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
7027
6578
  if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
7028
- 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');
7029
6580
  }
7030
6581
  await revalidate();
7031
6582
  }
@@ -7110,7 +6661,7 @@ export class AgentManager {
7110
6661
  return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
7111
6662
  }
7112
6663
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
7113
- 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` };
7114
6665
  }
7115
6666
  if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
7116
6667
  return {
@@ -7143,9 +6694,12 @@ export class AgentManager {
7143
6694
  const qaId = initiallyUnassigned
7144
6695
  ? group?.find(agent => agent.role === 'qa')?.id
7145
6696
  : fresh.qaAgentId;
7146
- 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
+ }
7147
6701
  // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
7148
- const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
6702
+ const participantGens = new Map([devId, qaId]
7149
6703
  .map(pid => [pid, this.deletionGenerationOf(pid)]));
7150
6704
  const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
7151
6705
  const claimedTask = {
@@ -7153,9 +6707,8 @@ export class AgentManager {
7153
6707
  preferredAgentId: agentId,
7154
6708
  agentId,
7155
6709
  devAgentId: devId,
7156
- researchAgentId: researchId,
7157
6710
  qaAgentId: qaId,
7158
- phase: researchId ? 'research' : undefined,
6711
+ phase: undefined,
7159
6712
  status: 'in_progress',
7160
6713
  updatedAt: now,
7161
6714
  };
@@ -7248,10 +6801,10 @@ export class AgentManager {
7248
6801
  return claim;
7249
6802
  const claimed = claim.task;
7250
6803
  const signalToken = createSignalToken();
7251
- await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
6804
+ await this.updateTask(claimed.id, this.dispatchTokenFields(signalToken));
7252
6805
  let started = false;
7253
6806
  let dispatchErr = null;
7254
- const initialDispatch = this.resolveInitialDispatch(claimed);
6807
+ const initialDispatch = this.resolveInitialDispatch();
7255
6808
  try {
7256
6809
  started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
7257
6810
  }
@@ -7430,30 +6983,13 @@ export class AgentManager {
7430
6983
  throw busyPend;
7431
6984
  throw err;
7432
6985
  }
7433
- const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
7434
- const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
7435
- let reviewCheckout;
7436
6986
  const dispatchWorkdir = workdir;
7437
6987
  try {
7438
- if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
7439
- reviewCheckout = await branchManager.materializeReviewHead(workdir, {
7440
- patch: opts.serverContent,
7441
- baseSha: opts.serverBaseSha,
7442
- headSha: opts.serverHeadSha,
7443
- headTree: opts.serverHeadTree,
7444
- });
7445
- }
7446
- else if (isServerQaPhase) {
7447
- await branchManager.switchToDefaultDetached(workdir);
7448
- }
7449
- else if (phase === 'review' || phase === 'recheck') {
6988
+ if (phase === 'review' || phase === 'recheck') {
7450
6989
  if (!task.branch)
7451
6990
  throw new Error(`Task ${taskId} has no review branch`);
7452
6991
  await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
7453
6992
  }
7454
- else if (phase === 'research') {
7455
- await branchManager.switchToDefaultDetached(workdir);
7456
- }
7457
6993
  else {
7458
6994
  if (!task.branch)
7459
6995
  throw new Error(`Task ${taskId} has no task branch`);
@@ -7465,7 +7001,7 @@ export class AgentManager {
7465
7001
  }
7466
7002
  await assertOwner();
7467
7003
  if (!ensure.freshRuntime) {
7468
- await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
7004
+ await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
7469
7005
  if (ensure.skillsStale)
7470
7006
  await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
7471
7007
  }
@@ -7522,32 +7058,9 @@ export class AgentManager {
7522
7058
  };
7523
7059
  });
7524
7060
  const promptSignalToken = opts.signalToken ?? task.signalToken;
7525
- const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
7526
- const hasQaPartner = task.qaAgentId !== undefined;
7527
7061
  let prompt;
7528
7062
  try {
7529
- await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
7530
- specDocuments: opts.specDocuments,
7531
- preserveOutputs: false,
7532
- assertOwner,
7533
- });
7534
7063
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
7535
- let payloadOpts = {};
7536
- if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
7537
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
7538
- phase,
7539
- taskPhase: task.phase,
7540
- ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
7541
- reviewRound: task.reviewRound,
7542
- ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
7543
- ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
7544
- ...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
7545
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
7546
- ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
7547
- ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
7548
- ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
7549
- });
7550
- }
7551
7064
  const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
7552
7065
  const platformCli = this.platformCliContextOf(taskForPrompt);
7553
7066
  prompt = buildPromptInline({
@@ -7556,15 +7069,9 @@ export class AgentManager {
7556
7069
  agent,
7557
7070
  workdir: dispatchWorkdir,
7558
7071
  skillRegistry: this.skillRegistry,
7559
- hasQaPartner,
7560
7072
  ...(platformCli ? { platformCli } : {}),
7561
7073
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
7562
- ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
7563
7074
  ...(imagePaths.length ? { imagePaths } : {}),
7564
- ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
7565
- ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
7566
- ...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
7567
- ...payloadOpts,
7568
7075
  });
7569
7076
  }
7570
7077
  catch (err) {
@@ -7610,7 +7117,7 @@ export class AgentManager {
7610
7117
  await cleanupCheckoutOrHold();
7611
7118
  return false;
7612
7119
  }
7613
- if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
7120
+ if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
7614
7121
  await cleanupCheckoutOrHold();
7615
7122
  return false;
7616
7123
  }
@@ -8095,37 +7602,9 @@ export class AgentManager {
8095
7602
  await this.clearBranchLocalCleaned(taskId);
8096
7603
  }
8097
7604
  }
8098
- else if (agent.role === 'research') {
8099
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
8100
- await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
8101
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
8102
- }
8103
- const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
8104
7605
  let prompt;
8105
7606
  try {
8106
- await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
8107
- specDocuments: opts.specDocuments,
8108
- preserveOutputs: opts.preserveDispatchOutputs ?? false,
8109
- assertOwner: async () => {
8110
- await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
8111
- },
8112
- });
8113
7607
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
8114
- let payloadOpts = {};
8115
- if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
8116
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
8117
- phase,
8118
- taskPhase: task.phase,
8119
- ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
8120
- reviewRound: task.reviewRound,
8121
- ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
8122
- ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
8123
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
8124
- ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
8125
- ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
8126
- ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
8127
- });
8128
- }
8129
7608
  const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
8130
7609
  const platformCli = this.platformCliContextOf(taskForPrompt);
8131
7610
  prompt = buildPromptInline({
@@ -8134,17 +7613,9 @@ export class AgentManager {
8134
7613
  agent,
8135
7614
  workdir: verifiedWorkdir,
8136
7615
  skillRegistry: this.skillRegistry,
8137
- hasQaPartner: task.qaAgentId !== undefined,
8138
7616
  ...(platformCli ? { platformCli } : {}),
8139
7617
  ...(signalToken ? { signalToken } : {}),
8140
- ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
8141
7618
  ...(imagePaths.length ? { imagePaths } : {}),
8142
- ...(opts.serverReviewCheckout ? { serverReviewCheckout: opts.serverReviewCheckout } : {}),
8143
- ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
8144
- ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
8145
- ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
8146
- ...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
8147
- ...payloadOpts,
8148
7619
  });
8149
7620
  }
8150
7621
  catch (err) {
@@ -8212,7 +7683,33 @@ export class AgentManager {
8212
7683
  return false;
8213
7684
  }
8214
7685
  const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
8215
- 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())) {
8216
7713
  return false;
8217
7714
  }
8218
7715
  const now = new Date().toISOString();
@@ -8295,15 +7792,13 @@ export class AgentManager {
8295
7792
  }
8296
7793
  if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
8297
7794
  return false;
8298
- const dispatchPhase = boundTask.phase === 'research'
8299
- ? 'research'
8300
- : boundTask.specReviewRound !== undefined ? 'code' : 'develop';
7795
+ const dispatchPhase = boundTask.specReviewRound !== undefined ? 'code' : 'develop';
8301
7796
  if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
8302
7797
  await this.clearBootstrapMarker(state.id, state.taskId);
8303
7798
  return false;
8304
7799
  }
8305
7800
  if (dispatchPhase === 'code') {
8306
- 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 });
8307
7802
  return true;
8308
7803
  }
8309
7804
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
@@ -8686,7 +8181,6 @@ export class AgentManager {
8686
8181
  let devToRelease;
8687
8182
  let qaToRelease;
8688
8183
  let remoteCleanupGeneration;
8689
- let publishedCleanup;
8690
8184
  this.phaseSignalWatcher?.stop(taskId);
8691
8185
  let cleanupClaimed = false;
8692
8186
  const result = await this.withTaskLock(async () => {
@@ -8707,27 +8201,13 @@ export class AgentManager {
8707
8201
  devToRelease = task.agentId;
8708
8202
  if (task.qaAgentId)
8709
8203
  qaToRelease = task.qaAgentId;
8710
- const publishedAtGate = task.status === 'ready'
8711
- || (task.status === 'approved' && !!task.publishDispatchedAt);
8712
- if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
8713
- const afterDone = this.resolveAfterDone(task);
8714
- if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
8715
- publishedCleanup = {
8716
- afterDone,
8717
- branch: task.branch,
8718
- prNumber: task.prNumber,
8719
- devAgentId: task.agentId,
8720
- mayBeInFlight: task.status === 'approved',
8721
- };
8722
- }
8723
- }
8724
8204
  for (const id of [devToRelease, qaToRelease]) {
8725
8205
  if (id)
8726
8206
  await this.markPaneCancelClearing(id, taskId);
8727
8207
  }
8728
8208
  if (!alreadyTerminal) {
8729
8209
  const now = new Date().toISOString();
8730
- if (task.reviewMode === 'git' && task.prNumber !== undefined && task.branch !== undefined) {
8210
+ if (task.prNumber !== undefined && task.branch !== undefined) {
8731
8211
  const expectedHeadSha = typeof task.latestHeadSha === 'string'
8732
8212
  && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha) ? task.latestHeadSha : undefined;
8733
8213
  const intent = task.remoteCleanup ?? {
@@ -8761,7 +8241,6 @@ export class AgentManager {
8761
8241
  // stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
8762
8242
  if (cleanupClaimed)
8763
8243
  this.phaseSignalWatcher?.stop(taskId);
8764
- let devStopConfirmed = false;
8765
8244
  // 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
8766
8245
  try {
8767
8246
  const stopped = [];
@@ -8775,11 +8254,10 @@ export class AgentManager {
8775
8254
  continue;
8776
8255
  }
8777
8256
  if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
8778
- 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 });
8779
8259
  continue;
8780
8260
  }
8781
- if (id === publishedCleanup?.devAgentId)
8782
- devStopConfirmed = true;
8783
8261
  stopped.push(id);
8784
8262
  }
8785
8263
  for (const id of stopped) {
@@ -8808,46 +8286,6 @@ export class AgentManager {
8808
8286
  });
8809
8287
  }
8810
8288
  }
8811
- if (publishedCleanup) {
8812
- if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
8813
- await this.emitIntervention(result.projectId, undefined, taskId, {
8814
- phase: 'cancel-published-artifact-cleanup-skipped',
8815
- afterDone: publishedCleanup.afterDone,
8816
- branch: publishedCleanup.branch,
8817
- prNumber: publishedCleanup.prNumber,
8818
- reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
8819
- });
8820
- return result;
8821
- }
8822
- try {
8823
- const { driver } = await this.platformTaskAndDriver(taskId);
8824
- await driver.runOp('close', { prNumber: publishedCleanup.prNumber });
8825
- await this.safeEmit({
8826
- id: '',
8827
- type: 'task.updated',
8828
- timestamp: new Date().toISOString(),
8829
- projectId: result.projectId,
8830
- taskId,
8831
- data: {
8832
- status: 'cancelled',
8833
- operation: 'server-pr-close',
8834
- outcome: 'succeeded',
8835
- branch: publishedCleanup.branch,
8836
- prNumber: publishedCleanup.prNumber,
8837
- },
8838
- });
8839
- }
8840
- catch (err) {
8841
- console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
8842
- await this.emitIntervention(result.projectId, undefined, taskId, {
8843
- phase: 'cancel-published-artifact-cleanup-failed',
8844
- afterDone: publishedCleanup.afterDone,
8845
- branch: publishedCleanup.branch,
8846
- prNumber: publishedCleanup.prNumber,
8847
- error: err instanceof Error ? err.message : String(err),
8848
- });
8849
- }
8850
- }
8851
8289
  return result;
8852
8290
  }
8853
8291
  async validateTaskDispatch(projectId, input) {
@@ -8860,7 +8298,7 @@ export class AgentManager {
8860
8298
  throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
8861
8299
  }
8862
8300
  if (!TASK_OWNER_ROLES.has(cfg.role)) {
8863
- 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`);
8864
8302
  }
8865
8303
  }
8866
8304
  let previewBytes;
@@ -8911,9 +8349,13 @@ export class AgentManager {
8911
8349
  opts.onSideEffect?.();
8912
8350
  const { task, lease } = claimed;
8913
8351
  const qaId = task.qaAgentId;
8914
- if (!qaId || !this.gitReviewQaConfigUsable(task)) {
8352
+ if (!qaId) {
8915
8353
  await this.resetGitReviewDispatchClaim(taskId, lease);
8916
- throw new ApiError(409, `Task ${taskId} has no available QA participant`);
8354
+ throw new Error(`dispatchGitReviewLease: task ${taskId} has no QA participant`);
8355
+ }
8356
+ if (!this.gitReviewQaConfigUsable(task)) {
8357
+ await this.resetGitReviewDispatchClaim(taskId, lease);
8358
+ throw new ApiError(409, `Task ${taskId} QA participant is unavailable`);
8917
8359
  }
8918
8360
  const qaPhase = lease.qaPhase;
8919
8361
  const previousQa = await this.agentStore.get(qaId);
@@ -9041,7 +8483,64 @@ export class AgentManager {
9041
8483
  }
9042
8484
  async dispatchReviewToQa(taskId, opts = {}) {
9043
8485
  let routeTask = await this.taskStore.get(taskId);
9044
- 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
+ }
9045
8544
  if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
9046
8545
  throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
9047
8546
  }
@@ -9054,8 +8553,9 @@ export class AgentManager {
9054
8553
  if (Object.hasOwn(opts, 'expectPhase') && routeTask.phase !== opts.expectPhase) {
9055
8554
  throw new ApiError(409, `Task ${taskId} review phase changed during redispatch`, 'dispatch-superseded');
9056
8555
  }
9057
- if (!routeTask.qaAgentId || this.getAgentConfig(routeTask.qaAgentId)?.role !== 'qa') {
9058
- throw new ApiError(409, `Task ${taskId} has no available QA participant`, 'dispatch-unsupported');
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');
9059
8559
  }
9060
8560
  let lease = routeTask.reviewDispatch;
9061
8561
  if (lease?.phase === 'uncertain') {
@@ -9083,9 +8583,9 @@ export class AgentManager {
9083
8583
  throw new ApiError(409, `Task ${taskId} review generation changed during uncertain-delivery confirmation`, 'dispatch-superseded');
9084
8584
  }
9085
8585
  }
9086
- const routeQaId = routeTask.qaAgentId;
9087
- if (!routeQaId || this.getAgentConfig(routeQaId)?.role !== 'qa') {
9088
- throw new ApiError(409, `Task ${taskId} has no available QA participant`, 'dispatch-unsupported');
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');
9089
8589
  }
9090
8590
  const qaBinding = await this.agentStore.get(routeQaId);
9091
8591
  if (qaBinding?.taskId === taskId
@@ -9137,110 +8637,75 @@ export class AgentManager {
9137
8637
  opts.onPassArmed?.(lease.signalToken);
9138
8638
  return dispatched;
9139
8639
  }
9140
- const claim = await this.withTaskLock(async () => {
9141
- if (this.manualReviewInFlight.has(taskId)) {
9142
- throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
9143
- }
9144
- if (this.markCompleteInFlight.has(taskId)) {
9145
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
9146
- }
9147
- // 早闸在 claim 前零副作用拒绝:driver 的 putRound/删 response 副作用都在终闸之前。
9148
- if (this.phaseSignalWatcher?.isSettling(taskId)) {
9149
- throw new ApiError(409, `Task ${taskId} has a review completion settling; retry after it lands`, 'review-settling');
9150
- }
9151
- const task = await this.taskStore.get(taskId);
9152
- if (!task)
9153
- throw new ApiError(404, `Task ${taskId} not found`);
9154
- if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
9155
- throw new ApiError(409, `Task ${taskId} review mode changed during dispatch; retry`, 'dispatch-superseded');
9156
- }
9157
- if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
9158
- throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
9159
- }
9160
- if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
9161
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
9162
- }
9163
- if (Object.hasOwn(opts, 'expectSignalToken') && task.signalToken !== opts.expectSignalToken) {
9164
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
9165
- }
9166
- if (Object.hasOwn(opts, 'expectPhase') && task.phase !== opts.expectPhase) {
9167
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (phase changed); aborting`, 'dispatch-superseded');
9168
- }
9169
- if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
9170
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`, 'dispatch-unsupported');
9171
- }
9172
- if (!this.serverReviewDriver) {
9173
- throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`, 'dispatch-unsupported');
9174
- }
9175
- if (task.status === 'in_progress' && task.phase === undefined) {
9176
- 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`, 'dispatch-unsupported');
9177
- }
9178
- const qaAvailable = task.qaAgentId !== undefined && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
9179
- if (!qaAvailable) {
9180
- throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`, 'dispatch-unsupported');
9181
- }
9182
- const isSpec = isSpecStagePhase(task.phase);
9183
- const qaBinding = await this.agentStore.get(task.qaAgentId);
9184
- const bumpRound = shouldBumpServerReviewRound(opts.bumpRound, task, qaBinding);
9185
- const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
9186
- const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
9187
- const nextRound = bumpRound ? round + 1 : round;
9188
- if (nextRound < 1) {
9189
- throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`, 'dispatch-unsupported');
9190
- }
9191
- if (nextRound > cap) {
9192
- throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`, 'dispatch-unsupported');
9193
- }
9194
- this.manualReviewInFlight.add(taskId);
9195
- return {
9196
- isSpec,
9197
- reviewPass: {
9198
- status: task.status,
9199
- phase: task.phase,
9200
- signalToken: task.signalToken,
9201
- agentId: task.agentId,
9202
- reviewRound: task.reviewRound,
9203
- specReviewRound: task.specReviewRound,
9204
- },
9205
- bumpRound,
9206
- onSideEffect: opts.onSideEffect,
9207
- };
9208
- });
9209
- return this.runManualServerReview(taskId, claim);
9210
8640
  }
9211
- 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;
9212
8673
  try {
9213
- const run = async (ownerPermit) => {
9214
- // 不在此处预释放旧 QA:可失败的准备工作(读 diff/spec、存轮次)失败时旧 pass 必须原样保留;
9215
- // 同任务的 QA 重新绑定由 dispatchServerReviewToQa 在派发前完成。
9216
- // 先入的结算持互斥跑完后,下面的任务代 CAS 拒绝本次手工;反向由 owner permit 过终闸。
9217
- const fresh = await this.taskStore.get(taskId);
9218
- if (!fresh
9219
- || !taskMatchesGeneration(fresh, claim.reviewPass)
9220
- || !MANUAL_SERVER_REVIEW_STATUSES.includes(fresh.status)) {
9221
- throw new ApiError(409, `Task ${taskId} changed during manual review dispatch; aborting`);
9222
- }
9223
- const driverOpts = {
9224
- bumpRound: claim.bumpRound,
9225
- expectedTask: claim.reviewPass,
9226
- ...(claim.onSideEffect !== undefined ? { onSideEffect: claim.onSideEffect } : {}),
9227
- ...(ownerPermit !== undefined ? { settlePermit: ownerPermit } : {}),
9228
- };
9229
- const dispatched = claim.isSpec
9230
- ? await this.serverReviewDriver.dispatchSpecReview(fresh, driverOpts)
9231
- : await this.serverReviewDriver.dispatchCodeReview(fresh, driverOpts);
9232
- if (!dispatched) {
9233
- throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
9234
- }
9235
- return (await this.taskStore.get(taskId));
9236
- };
9237
- return this.phaseSignalWatcher
9238
- ? await this.phaseSignalWatcher.runExclusive(taskId, run)
9239
- : await run();
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;
9240
8683
  }
9241
- finally {
9242
- 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;
9243
8703
  }
8704
+ await this.emitIntervention(task.projectId, task.agentId, task.id, {
8705
+ phase: 'fix-resume-failed',
8706
+ reviewRound: taskReviewRound(task),
8707
+ });
8708
+ return false;
9244
8709
  }
9245
8710
  async continueDevRound(taskId) {
9246
8711
  if (this.markCompleteInFlight.has(taskId)) {
@@ -9255,47 +8720,6 @@ export class AgentManager {
9255
8720
  if (isSpecStagePhase(task.phase)) {
9256
8721
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
9257
8722
  }
9258
- if (task.reviewMode === 'server') {
9259
- if (!task.agentId) {
9260
- throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
9261
- }
9262
- const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
9263
- if (!stored?.findings) {
9264
- throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
9265
- }
9266
- await this.withTaskLock(async () => {
9267
- if (this.markCompleteInFlight.has(taskId)) {
9268
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
9269
- }
9270
- const fresh = await this.taskStore.get(taskId);
9271
- if (!fresh || fresh.status !== 'max_rounds') {
9272
- throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
9273
- }
9274
- fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
9275
- fresh.updatedAt = new Date().toISOString();
9276
- await this.taskStore.set(fresh);
9277
- });
9278
- let dispatched = null;
9279
- try {
9280
- dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
9281
- }
9282
- finally {
9283
- if (!dispatched) {
9284
- await this.withTaskLock(async () => {
9285
- const fresh = await this.taskStore.get(taskId);
9286
- if (!fresh)
9287
- return;
9288
- fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
9289
- fresh.updatedAt = new Date().toISOString();
9290
- await this.taskStore.set(fresh);
9291
- }).catch(() => undefined);
9292
- }
9293
- }
9294
- if (!dispatched) {
9295
- throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
9296
- }
9297
- return dispatched;
9298
- }
9299
8723
  if (!task.prNumber || !task.branch) {
9300
8724
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
9301
8725
  }
@@ -9314,56 +8738,44 @@ export class AgentManager {
9314
8738
  throw new ApiError(409, `Task ${taskId} changed status during continue; aborted`);
9315
8739
  }
9316
8740
  const rollback = async () => {
9317
- await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['fixing'] }, { reviewRound: prevReviewRound }).catch(() => undefined);
9318
- };
9319
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'fix');
9320
- if (!acquired) {
9321
- await rollback();
9322
- throw new ApiError(409, `Dev ${devAgentId} is no longer available for task ${taskId}`);
9323
- }
9324
- const { armed } = await this.rotateAndSetupPhaseSignal(taskId, devAgentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: transitioned.task.signalToken });
9325
- if (!armed) {
9326
- 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 });
9327
- await rollback();
9328
- throw new ApiError(500, `Failed to arm pr-fixed watcher for task ${taskId}`);
9329
- }
9330
- const rollbackAndRepark = async () => {
9331
- await rollback();
9332
- 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;
9333
8749
  };
9334
- let resumed = false;
9335
8750
  try {
9336
- resumed = await this.continueSession(taskId, devAgentId, 'fix');
8751
+ if (await this.dispatchGitFixToDev(taskId))
8752
+ return (await this.taskStore.get(taskId));
9337
8753
  }
9338
8754
  catch (err) {
9339
- if (err instanceof DispatchTerminalError) {
9340
- await this.failTaskForDispatchError(taskId, 'fix', devAgentId, err);
9341
- 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`);
9342
8757
  }
9343
- await rollbackAndRepark();
9344
8758
  throw err;
9345
8759
  }
9346
- if (!resumed) {
9347
- await rollbackAndRepark();
9348
- 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`);
9349
8763
  }
9350
- const fresh = await this.taskStore.get(taskId);
9351
- 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}`);
9352
8768
  }
9353
- devInitialSignalKinds(reviewMode) {
9354
- return reviewMode === 'server'
9355
- ? ['spec-done', 'code-done']
9356
- : ['spec-done', 'pr-created'];
8769
+ devInitialSignalKinds() {
8770
+ return ['spec-done', 'pr-created'];
9357
8771
  }
9358
- resolveInitialDispatch(task) {
9359
- if (task.phase === 'research')
9360
- return { phase: 'research', kinds: ['spec-done'] };
9361
- return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
8772
+ resolveInitialDispatch() {
8773
+ return { phase: 'develop', kinds: this.devInitialSignalKinds() };
9362
8774
  }
9363
8775
  mapTaskStateToExpectedWatchers(task) {
9364
8776
  const single = this.mapTaskStateToExpectedWatcher(task);
9365
8777
  const mappings = single ? [single] : [];
9366
- if (task.reviewMode === 'git' && task.status === 'review'
8778
+ if (task.status === 'review'
9367
8779
  && task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
9368
8780
  && task.devAgentId && task.prNumber !== undefined) {
9369
8781
  mappings.push({
@@ -9375,24 +8787,19 @@ export class AgentManager {
9375
8787
  return mappings;
9376
8788
  }
9377
8789
  mapTaskStateToExpectedWatcher(task) {
9378
- if (task.reviewMode === 'server')
9379
- return this.mapServerTaskToExpectedWatcher(task);
9380
- const specStage = isSpecStagePhase(task.phase);
9381
- if (task.status === 'review' && task.qaAgentId) {
8790
+ if (task.status === 'review') {
9382
8791
  return {
9383
- expectedKinds: specStage ? ['spec-reviewed'] : PASSIVE_VERDICT_WATCH,
9384
- agentId: task.qaAgentId,
8792
+ expectedKinds: PASSIVE_VERDICT_WATCH,
8793
+ agentId: requireTaskQaAgentId(task, 'mapTaskStateToExpectedWatcher'),
9385
8794
  };
9386
8795
  }
9387
8796
  if (task.status === 'fixing' && task.agentId) {
9388
8797
  return {
9389
- expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
8798
+ expectedKinds: ['pr-fixed'],
9390
8799
  agentId: task.agentId,
9391
8800
  };
9392
8801
  }
9393
8802
  if (task.status === 'in_progress' && task.agentId) {
9394
- if (task.phase === 'research')
9395
- return { expectedKinds: ['spec-done'], agentId: task.agentId };
9396
8803
  if (task.phase === 'code')
9397
8804
  return { expectedKinds: ['pr-created'], agentId: task.agentId };
9398
8805
  if (task.phase === undefined) {
@@ -9401,27 +8808,6 @@ export class AgentManager {
9401
8808
  }
9402
8809
  return undefined;
9403
8810
  }
9404
- mapServerTaskToExpectedWatcher(task) {
9405
- const isSpec = isSpecStagePhase(task.phase);
9406
- if (task.status === 'review' && task.qaAgentId) {
9407
- return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
9408
- }
9409
- if (task.status === 'fixing' && task.agentId) {
9410
- return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
9411
- }
9412
- if (task.status === 'in_progress' && task.agentId) {
9413
- if (task.phase === 'research')
9414
- return { expectedKinds: ['spec-done'], agentId: task.agentId };
9415
- if (task.phase === 'code') {
9416
- return { expectedKinds: ['code-done'], agentId: task.agentId };
9417
- }
9418
- return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
9419
- }
9420
- if (task.status === 'approved' && task.agentId) {
9421
- return { expectedKinds: ['code-ready'], agentId: task.agentId };
9422
- }
9423
- return undefined;
9424
- }
9425
8811
  async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
9426
8812
  const task = await this.taskStore.get(taskId);
9427
8813
  if (!task?.signalToken)
@@ -9446,7 +8832,7 @@ export class AgentManager {
9446
8832
  const input = {
9447
8833
  title: old.title,
9448
8834
  description: old.description,
9449
- preferredAgentId: old.preferredAgentId,
8835
+ preferredAgentId: this.resolveRetryPreferredAgentId(old),
9450
8836
  };
9451
8837
  if (old.images?.length) {
9452
8838
  input.images = await this.readStagedImages(old.id, old.images);
@@ -9457,6 +8843,27 @@ export class AgentManager {
9457
8843
  }
9458
8844
  return this.createAndStartTask(old.projectId, input);
9459
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
+ }
9460
8867
  async editTask(taskId, patch) {
9461
8868
  return this.withTaskLock(async () => {
9462
8869
  const task = await this.taskStore.get(taskId);
@@ -9476,7 +8883,6 @@ export class AgentManager {
9476
8883
  task.devAgentId = '';
9477
8884
  delete task.phase;
9478
8885
  delete task.qaAgentId;
9479
- delete task.researchAgentId;
9480
8886
  }
9481
8887
  else {
9482
8888
  const cfg = this.getAgentConfig(patch.preferredAgentId);
@@ -9486,28 +8892,18 @@ export class AgentManager {
9486
8892
  throw new ApiError(400, `Agent not in project ${task.projectId}`);
9487
8893
  }
9488
8894
  if (!TASK_OWNER_ROLES.has(cfg.role))
9489
- throw new ApiError(400, `Agent is not dev or research role`);
8895
+ throw new ApiError(400, 'Agent is not a dev agent');
9490
8896
  const group = this.findAgentGroup(cfg.id);
9491
8897
  const dev = group?.find(agent => agent.role === 'dev');
9492
8898
  if (!dev)
9493
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`);
9494
8903
  task.preferredAgentId = patch.preferredAgentId;
9495
8904
  task.devAgentId = dev.id;
9496
- if (cfg.role === 'research') {
9497
- task.phase = 'research';
9498
- task.researchAgentId = cfg.id;
9499
- }
9500
- else {
9501
- delete task.phase;
9502
- delete task.researchAgentId;
9503
- }
9504
- const qaId = group?.find(agent => agent.role === 'qa')?.id;
9505
- if (qaId) {
9506
- task.qaAgentId = qaId;
9507
- }
9508
- else {
9509
- delete task.qaAgentId;
9510
- }
8905
+ task.qaAgentId = qa.id;
8906
+ delete task.phase;
9511
8907
  }
9512
8908
  }
9513
8909
  task.updatedAt = new Date().toISOString();
@@ -9520,72 +8916,32 @@ export class AgentManager {
9520
8916
  if (!task || !task.prNumber) {
9521
8917
  throw new Error(`mergePr: no PR number for task ${taskId}`);
9522
8918
  }
9523
- if (task.reviewMode === 'git') {
9524
- const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
9525
- if (!expectedHeadSha)
9526
- throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
9527
- await this.platformConfirmMerge(taskId, {
9528
- expectedHeadSha,
9529
- ...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
9530
- });
9531
- return;
9532
- }
9533
8919
  const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
9534
- if (!expectedHeadSha || !PLATFORM_HEAD_SHA_RE.test(expectedHeadSha)) {
9535
- throw new Error(`mergePr: no valid expected head sha for server task ${taskId}`);
9536
- }
9537
- 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
+ });
9538
8926
  }
9539
8927
  async markTaskComplete(taskId) {
9540
8928
  const peek = await this.taskStore.get(taskId);
9541
8929
  if (!peek)
9542
8930
  throw new ApiError(404, `Task ${taskId} not found`);
9543
- if (peek.status === 'ready' || peek.status === 'merge-ready') {
8931
+ if (peek.status === 'merge-ready') {
9544
8932
  return this.confirmHumanGate(taskId);
9545
8933
  }
9546
- const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
8934
+ const task = await this.claimCompleteGate(taskId, ['max_rounds']);
9547
8935
  try {
9548
- const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
9549
- if (!serverApprovedRetry && task.status !== 'max_rounds') {
8936
+ if (task.status !== 'max_rounds') {
9550
8937
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
9551
8938
  }
9552
8939
  if (isSpecStagePhase(task.phase)) {
9553
8940
  throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
9554
8941
  }
9555
- if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
8942
+ if (!task.prNumber || !task.branch) {
9556
8943
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
9557
8944
  }
9558
- if (serverApprovedRetry) {
9559
- const publishState = this.checkPublishInFlight(taskId, task.publishDispatchedAt);
9560
- if (publishState === 'live') {
9561
- throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
9562
- }
9563
- if (publishState === 'delivered') {
9564
- throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
9565
- `retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
9566
- }
9567
- const afterDone = this.resolveAfterDone(task);
9568
- if (afterDone === null) {
9569
- throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
9570
- }
9571
- await this.dispatchServerAfterDone(taskId, afterDone);
9572
- return (await this.taskStore.get(taskId));
9573
- }
9574
- if (task.reviewMode === 'server') {
9575
- const afterDone = await this.commitServerAfterDone(taskId);
9576
- if (afterDone === null) {
9577
- const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
9578
- if (!done)
9579
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9580
- await this.releaseTaskAgents(taskId);
9581
- return (await this.taskStore.get(taskId));
9582
- }
9583
- const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
9584
- if (!approved)
9585
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9586
- await this.dispatchServerAfterDone(taskId, afterDone);
9587
- return (await this.taskStore.get(taskId));
9588
- }
9589
8945
  for (const agentId of [task.agentId, task.qaAgentId]) {
9590
8946
  if (!agentId)
9591
8947
  continue;
@@ -9966,15 +9322,6 @@ export class AgentManager {
9966
9322
  stopPhaseSignalWatcher(taskId) {
9967
9323
  this.phaseSignalWatcher?.stop(taskId);
9968
9324
  }
9969
- checkPublishInFlight(taskId, publishDispatchedAt) {
9970
- if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
9971
- if (!this.phaseSignalWatcher.isRecovered(taskId) || publishDispatchedAt)
9972
- return 'live';
9973
- this.phaseSignalWatcher.stop(taskId);
9974
- return false;
9975
- }
9976
- return publishDispatchedAt ? 'delivered' : false;
9977
- }
9978
9325
  async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
9979
9326
  if (!this.phaseSignalWatcher)
9980
9327
  return true;
@@ -10012,7 +9359,6 @@ export class AgentManager {
10012
9359
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
10013
9360
  ...(opts.replaceFromToken ? { replaceFromToken: opts.replaceFromToken } : {}),
10014
9361
  ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
10015
- ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
10016
9362
  ...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
10017
9363
  });
10018
9364
  }
@@ -10021,14 +9367,12 @@ export class AgentManager {
10021
9367
  return false;
10022
9368
  }
10023
9369
  }
10024
- async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
9370
+ async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
10025
9371
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
10026
9372
  skipSnapshot,
10027
9373
  // Every post-dispatch arm follows a freshly injected prompt: the previous
10028
- // question is superseded, its watermark must not carry over (fix/publish
10029
- // continuations included).
9374
+ // question is superseded, its watermark must not carry over.
10030
9375
  needInputMode: 'fresh',
10031
- ...(onReadFile ? { onReadFile } : {}),
10032
9376
  });
10033
9377
  if (!armed)
10034
9378
  await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
@@ -10037,14 +9381,12 @@ export class AgentManager {
10037
9381
  const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
10038
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 });
10039
9383
  }
10040
- dispatchTokenFields(task, signalToken) {
10041
- const wantsPrCreated = task.reviewMode === 'git'
10042
- && this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
10043
- return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
9384
+ dispatchTokenFields(signalToken) {
9385
+ return { signalToken, pendingPrSignalToken: signalToken };
10044
9386
  }
10045
9387
  async rearmGitReconciliationWatcher(taskId, opts = {}) {
10046
9388
  const task = await this.taskStore.get(taskId);
10047
- if (!task || task.reviewMode !== 'git' || task.status !== 'review')
9389
+ if (!task || task.status !== 'review')
10048
9390
  return;
10049
9391
  if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
10050
9392
  return;
@@ -10060,21 +9402,20 @@ export class AgentManager {
10060
9402
  const result = await this.withTaskLock(async () => {
10061
9403
  const task = await this.taskStore.get(taskId);
10062
9404
  if (!task)
10063
- return { token: newToken, armed: false, isGit: false };
10064
- const isGit = task.reviewMode === 'git';
10065
- if (isGit && guard === undefined) {
10066
- 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');
10067
9408
  }
10068
- if (guard && (task.status !== guard.expectedStatus
10069
- || (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken))) {
10070
- 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 };
10071
9412
  }
10072
9413
  await this.taskStore.set({ ...task, signalToken: newToken, updatedAt: new Date().toISOString() });
10073
9414
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
10074
9415
  needInputMode: 'fresh',
10075
- ...(isGit ? { replaceScope: 'agent' } : {}),
9416
+ replaceScope: 'agent',
10076
9417
  });
10077
- return { token: newToken, armed, isGit };
9418
+ return { token: newToken, armed };
10078
9419
  });
10079
9420
  return { token: result.token, armed: result.armed };
10080
9421
  }
@@ -10085,13 +9426,19 @@ export class AgentManager {
10085
9426
  const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
10086
9427
  fromStatus: ['review', 'in_progress', 'fixing'],
10087
9428
  ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
9429
+ ...(opts.expectedSignalToken !== undefined ? { expectSignalToken: opts.expectedSignalToken } : {}),
10088
9430
  }, {
10089
9431
  phase: 'spec',
10090
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 } : {}),
10091
9437
  });
10092
9438
  if (!transition)
10093
9439
  return null;
10094
9440
  const parked = transition.task;
9441
+ const qaAgentId = requireTaskQaAgentId(parked, 'parkTaskAtSpecReady');
10095
9442
  const parkedGeneration = {
10096
9443
  status: parked.status,
10097
9444
  phase: parked.phase,
@@ -10112,17 +9459,15 @@ export class AgentManager {
10112
9459
  });
10113
9460
  }
10114
9461
  }
10115
- if (parked.qaAgentId) {
10116
- const released = await this.releaseAgentForTask(parked.qaAgentId, taskId, 'idle', {
10117
- expectedTask: parkedGeneration,
10118
- })
10119
- .catch(() => false);
10120
- if (!released) {
10121
- await this.emitIntervention(parked.projectId, parked.qaAgentId, taskId, {
10122
- phase: 'spec-ready-qa-release-failed',
10123
- qaAgentId: parked.qaAgentId,
10124
- });
10125
- }
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
+ });
10126
9471
  }
10127
9472
  return parked;
10128
9473
  }
@@ -10135,12 +9480,17 @@ export class AgentManager {
10135
9480
  const fresh = await this.taskStore.get(taskId);
10136
9481
  if (!fresh)
10137
9482
  throw new ApiError(404, `Task ${taskId} not found`);
10138
- const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
10139
- if (fresh.status !== 'spec-ready' && !specMaxRounds) {
10140
- 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
+ }
10141
9488
  }
10142
- if (verdict === 'archive' && !fresh.researchAgentId) {
10143
- 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
+ }
10144
9494
  }
10145
9495
  if (this.specVerdictInFlight.has(taskId)) {
10146
9496
  throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
@@ -10149,322 +9499,274 @@ export class AgentManager {
10149
9499
  return fresh;
10150
9500
  });
10151
9501
  try {
10152
- return await this.executeSpecVerdict(task, verdict, trimmed);
9502
+ return await this.executeGitSpecVerdict(task, verdict, trimmed);
10153
9503
  }
10154
9504
  finally {
10155
9505
  this.specVerdictInFlight.delete(taskId);
10156
9506
  }
10157
9507
  }
10158
- async executeSpecVerdict(task, verdict, trimmed) {
10159
- const taskId = task.id;
10160
- const store = this.getReviewStore();
10161
- if (!store)
10162
- throw new ApiError(500, 'Review store unavailable');
10163
- const round = task.specReviewRound ?? 1;
10164
- const at = new Date().toISOString();
10165
- const roundData = await store.getRound(taskId, 'spec', round);
10166
- if (!roundData || roundData.phase !== 'spec') {
10167
- throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
10168
- }
10169
- const base = roundData;
10170
- if (verdict === 'archive') {
10171
- await store.putRound(taskId, 'spec', {
10172
- ...base,
10173
- userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
10174
- });
10175
- const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
10176
- if (!transition)
10177
- throw new ApiError(409, `Task ${taskId} changed while archiving`);
10178
- this.stopPhaseSignalWatcher(taskId);
10179
- const participants = new Set([
10180
- task.agentId,
10181
- task.devAgentId,
10182
- task.qaAgentId,
10183
- task.researchAgentId,
10184
- ].filter((id) => typeof id === 'string' && id !== ''));
10185
- for (const agentId of participants) {
10186
- const state = await this.agentStore.get(agentId);
10187
- if (state?.taskId !== taskId)
10188
- continue;
10189
- const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
10190
- if (!released) {
10191
- await this.emitIntervention(task.projectId, agentId, taskId, {
10192
- phase: 'spec-archive-agent-release-failed',
10193
- agentId,
10194
- });
10195
- }
10196
- }
10197
- return (await this.taskStore.get(taskId));
10198
- }
10199
- if (verdict === 'approve') {
10200
- await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
10201
- let result;
10202
- try {
10203
- result = await this.transitionToCodePhase(taskId);
10204
- }
10205
- catch (err) {
10206
- if (err instanceof ApiError)
10207
- throw err;
10208
- const message = err instanceof Error ? err.message : String(err);
10209
- const after = await this.taskStore.get(taskId);
10210
- if (after?.status === 'failed') {
10211
- const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
10212
- throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
10213
- + 'The task has been marked failed; use Retry to run it as a fresh task.');
10214
- }
10215
- if (after?.phase === 'code' && after.status === 'in_progress') {
10216
- throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
10217
- + 'The task already moved to the code phase and the dev is held; '
10218
- + 'Resume the dev agent to redeliver the prompt.');
10219
- }
10220
- throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
10221
- + 'The task was rolled back to await the verdict; fix the dev Workdir '
10222
- + '(Resume the dev first if it is still held), then retry the verdict.');
10223
- }
10224
- if (!result) {
10225
- const after = await this.taskStore.get(taskId);
10226
- if (after?.phase === 'code' && after.status === 'in_progress') {
10227
- throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
10228
- + 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
10229
- + '(retrying the verdict will be rejected).');
10230
- }
10231
- throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
10232
- }
10233
- return result;
10234
- }
10235
- if (!trimmed)
10236
- throw new ApiError(400, 'comments is required for request-changes');
10237
- const priorFindings = base.findings?.findings ?? [];
10238
- const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
10239
- const mergedFindings = {
10240
- round,
10241
- verdict: 'request-changes',
10242
- findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
10243
- };
10244
- const rejectedRound = {
10245
- ...base,
10246
- findings: mergedFindings,
10247
- userDecision: { verdict, comments: trimmed, at },
10248
- };
10249
- // 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
10250
- // holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
10251
- // 因配置热更新出现余量,打回前也必须先恢复 holder
10252
- const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
10253
- const claim = task.status === 'max_rounds' || round >= cap
10254
- ? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
10255
- : null;
10256
- 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');
10257
9516
  try {
10258
- await store.putRound(taskId, 'spec', rejectedRound);
10259
- dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
9517
+ validateCommentBody(body);
10260
9518
  }
10261
9519
  catch (err) {
10262
- if (err instanceof ApiError)
10263
- throw err;
10264
- const message = err instanceof Error ? err.message : String(err);
10265
- const after = await this.taskStore.get(taskId);
10266
- if (after?.status === 'failed') {
10267
- const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
10268
- throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
10269
- + 'The task has been marked failed with the rejection kept on record; '
10270
- + 'use Retry to run it as a fresh task.');
10271
- }
10272
- if (after?.status === 'fixing') {
10273
- throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
10274
- + 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
10275
- }
10276
- throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
10277
- + 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
9520
+ throw new ApiError(400, `Invalid spec verdict comment: ${err instanceof Error ? err.message : String(err)}`);
10278
9521
  }
10279
- finally {
10280
- if (!dispatched)
10281
- await this.recoverSpecRejectionState(taskId, claim, base);
10282
- }
10283
- if (!dispatched)
10284
- throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
10285
- return dispatched;
9522
+ return body;
10286
9523
  }
10287
- async claimSpecFixDispatch(taskId, opts) {
10288
- return await this.withTaskLock(async () => {
10289
- const fresh = await this.taskStore.get(taskId);
10290
- if (!fresh)
10291
- throw new ApiError(404, `Task ${taskId} not found`);
10292
- if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
10293
- throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
10294
- }
10295
- const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
10296
- if (!holder || !this.getAgentConfig(holder)) {
10297
- throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
10298
- + 'restore the agent config or cancel the task');
10299
- }
10300
- const originalAgentId = fresh.agentId;
10301
- const originalCounter = fresh.maxRoundsContinues ?? 0;
10302
- let counterAfter = originalCounter;
10303
- if (opts.extendCapToFit !== null) {
10304
- // cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
10305
- counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
10306
- fresh.maxRoundsContinues = counterAfter;
10307
- }
10308
- fresh.agentId = holder;
10309
- fresh.updatedAt = new Date().toISOString();
10310
- await this.taskStore.set(fresh);
10311
- return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
10312
- });
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';
10313
9530
  }
10314
- // 补偿以 claim 时的 status+counter+holder 为代际证明:任务已被推进(fixing/cancelled/新扩轮)
10315
- // 就放弃恢复——尤其派发把任务带进 held fixing 时,打回 round 必须保留给后续 Resume 重派
10316
- async recoverSpecRejectionState(taskId, claim, baseRound) {
10317
- const store = this.getReviewStore();
10318
- await this.withTaskLock(async () => {
10319
- const fresh = await this.taskStore.get(taskId);
10320
- if (!fresh)
10321
- return;
10322
- if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
10323
- console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: task moved to ${fresh.status}`);
10324
- return;
10325
- }
10326
- if (claim) {
10327
- if ((fresh.maxRoundsContinues ?? 0) !== claim.counterAfter
10328
- || fresh.agentId !== claim.holderWritten) {
10329
- console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: `
10330
- + `continues=${fresh.maxRoundsContinues ?? 0} agentId=${fresh.agentId} `
10331
- + `(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)
10332
9566
  return;
10333
- }
10334
- fresh.maxRoundsContinues = claim.originalCounter;
10335
- fresh.agentId = claim.originalAgentId;
10336
- fresh.updatedAt = new Date().toISOString();
10337
- await this.taskStore.set(fresh);
10338
- }
10339
- await store?.putRound(taskId, 'spec', baseRound);
10340
- }).catch(err => {
10341
- console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
10342
- });
10343
- }
10344
- async submitCodeVerdict(taskId, comments) {
10345
- const trimmed = comments?.trim();
10346
- if (!trimmed) {
10347
- throw new ApiError(400, 'comments is required for request-changes');
10348
- }
10349
- const task = await this.withTaskLock(async () => {
10350
- const fresh = await this.taskStore.get(taskId);
10351
- if (!fresh)
10352
- throw new ApiError(404, `Task ${taskId} not found`);
10353
- if (fresh.status !== 'ready') {
10354
- throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
9567
+ task = settled;
10355
9568
  }
10356
- if (isSpecStagePhase(fresh.phase)) {
10357
- 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}`);
10358
9572
  }
10359
- if (fresh.reviewMode !== 'server') {
10360
- 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}`);
10361
9582
  }
10362
- if (!fresh.agentId) {
10363
- 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
+ });
10364
9591
  }
10365
- if (this.markCompleteInFlight.has(taskId)) {
10366
- 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
+ }
10367
9609
  }
10368
- if (this.codeVerdictInFlight.has(taskId)) {
10369
- 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`);
10370
9612
  }
10371
- this.codeVerdictInFlight.add(taskId);
10372
- return fresh;
10373
- });
10374
- try {
10375
- return await this.executeCodeVerdict(task, trimmed);
9613
+ const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
9614
+ if (!settled)
9615
+ return;
9616
+ task = settled;
10376
9617
  }
10377
- finally {
10378
- 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`);
10379
9625
  }
10380
9626
  }
10381
- async executeCodeVerdict(task, trimmed) {
10382
- const taskId = task.id;
10383
- const store = this.getReviewStore();
10384
- if (!store)
10385
- throw new ApiError(500, 'Review store unavailable');
10386
- const round = Math.max(task.reviewRound, 1);
10387
- const at = new Date().toISOString();
10388
- // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
10389
- const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
10390
- if (round >= cap) {
10391
- throw new ApiError(409, `Task ${taskId} has reached the code review round cap (${cap}); `
10392
- + 'confirm completion, or cancel the task');
10393
- }
10394
- const roundData = await store.getRound(taskId, 'code', round);
10395
- // QA 项目经 autoApproveCode 到达 ready 时从未存轮;补底轮次保证 userDecision 留痕与 fix 闭环
10396
- const base = roundData ?? await this.synthesizeEmptyCodeRound(task, round, at);
10397
- // runCodeFixSubmission 按 Math.max(reviewRound,1) 读轮、recheck 按 reviewRound+1 存新轮;
10398
- // reviewRound 停在 0 会让 recheck 覆写本轮,故落库为 1 对齐两侧
10399
- if (task.reviewRound === 0) {
10400
- await this.withTaskLock(async () => {
10401
- const fresh = await this.taskStore.get(taskId);
10402
- if (!fresh)
10403
- return;
10404
- fresh.reviewRound = 1;
10405
- fresh.updatedAt = new Date().toISOString();
10406
- 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,
10407
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`);
10408
9724
  }
10409
- const priorFindings = base.findings?.findings ?? [];
10410
- const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
10411
- const mergedFindings = {
10412
- round,
10413
- verdict: 'request-changes',
10414
- findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
10415
- };
10416
- await store.putRound(taskId, 'code', {
10417
- ...base,
10418
- findings: mergedFindings,
10419
- userDecision: { verdict: 'request-changes', comments: trimmed, at },
10420
- });
10421
- const dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
10422
- if (!dispatched)
10423
- throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
10424
- return dispatched;
10425
- }
10426
- async synthesizeEmptyCodeRound(task, round, at) {
10427
- const empty = { round, phase: 'code', content: '', startedAt: at };
10428
- const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
10429
- if (!dev || !task.agentId)
10430
- return empty;
10431
- // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
10432
- try {
10433
- await this.refreshWorkdirCacheFor(task.agentId);
10434
- const content = await this.getReviewTransport().readContent(task, dev, 'code');
10435
- return {
10436
- round,
10437
- phase: 'code',
10438
- content: content.content,
10439
- ...(content.diffstat ? { diffstat: content.diffstat } : {}),
10440
- ...(content.baseSha ? { baseSha: content.baseSha } : {}),
10441
- 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 },
10442
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;
10443
9758
  }
10444
- catch (err) {
10445
- console.warn(`[AgentManager] submitCodeVerdict: code content read failed for ${task.id}; using empty round:`, err);
10446
- return empty;
10447
- }
10448
- }
10449
- isResearchHandoff(task, devAgentId) {
10450
- return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
9759
+ await this.deliverSpecVerdictOutbox(task.id, entry.key);
9760
+ return (await this.taskStore.get(task.id)) ?? task;
10451
9761
  }
10452
- dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
10453
- const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
9762
+ dispatchCodePhasePrompt(task, devAgentId, signalToken) {
10454
9763
  const dispatchOpts = {
10455
9764
  signalToken,
10456
- specDocuments,
10457
- ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
10458
- armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
9765
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, 'pr-created', signalToken, { needInputMode: 'fresh' }),
10459
9766
  };
10460
- return this.isResearchHandoff(task, devAgentId)
10461
- ? this.startSession(task.id, devAgentId, 'code', {
10462
- ...dispatchOpts,
10463
- preserveBindingOnFailure: true,
10464
- })
10465
- : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
9767
+ return this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
10466
9768
  }
10467
- async transitionToCodePhase(taskId, expectedTask) {
9769
+ async transitionToCodePhase(taskId, expectedTask, approval) {
10468
9770
  const task = await this.withTaskLock(async () => {
10469
9771
  const current = await this.taskStore.get(taskId);
10470
9772
  if (expectedTask && current && !taskMatchesGeneration(current, expectedTask))
@@ -10474,6 +9776,7 @@ export class AgentManager {
10474
9776
  if (!task)
10475
9777
  return null;
10476
9778
  const devAgentId = task.devAgentId;
9779
+ const qaAgentId = requireTaskQaAgentId(task, 'transitionToCodePhase');
10477
9780
  const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
10478
9781
  if (!devAgentId || dev?.role !== 'dev') {
10479
9782
  await this.emitIntervention(task.projectId, task.agentId, taskId, {
@@ -10482,17 +9785,6 @@ export class AgentManager {
10482
9785
  });
10483
9786
  return null;
10484
9787
  }
10485
- const round = task.specReviewRound ?? 1;
10486
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
10487
- if (!stored || stored.phase !== 'spec') {
10488
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10489
- phase: 'code-spec-round-missing',
10490
- round,
10491
- });
10492
- return null;
10493
- }
10494
- const researchAgentId = task.researchAgentId;
10495
- const researchHandoff = this.isResearchHandoff(task, devAgentId);
10496
9788
  const releaseParticipant = async (agentId, failurePhase) => {
10497
9789
  const released = await this.releaseAgentIfBound(agentId, taskId, {
10498
9790
  allowAwaitingHuman: true,
@@ -10504,11 +9796,7 @@ export class AgentManager {
10504
9796
  }
10505
9797
  return released;
10506
9798
  };
10507
- if (researchHandoff && researchAgentId) {
10508
- if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
10509
- return null;
10510
- }
10511
- if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
9799
+ if (!await releaseParticipant(qaAgentId, 'code-phase-qa-release-failed')) {
10512
9800
  return null;
10513
9801
  }
10514
9802
  const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
@@ -10528,7 +9816,32 @@ export class AgentManager {
10528
9816
  const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
10529
9817
  fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
10530
9818
  ...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
10531
- }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
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
+ });
10532
9845
  if (!transition) {
10533
9846
  if (!devBoundBefore) {
10534
9847
  try {
@@ -10547,7 +9860,7 @@ export class AgentManager {
10547
9860
  this.stopPhaseSignalWatcher(taskId);
10548
9861
  let started = false;
10549
9862
  try {
10550
- started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
9863
+ started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken);
10551
9864
  }
10552
9865
  catch (err) {
10553
9866
  if (err instanceof DispatchTerminalError) {
@@ -10568,7 +9881,7 @@ export class AgentManager {
10568
9881
  + 'moved on; leaving its status as-is');
10569
9882
  }
10570
9883
  else if (!devBoundBefore) {
10571
- // 转码前 dev 未持有本任务(如 research handoff / max_rounds 重占用):任务已退回
9884
+ // 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
10572
9885
  // verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
10573
9886
  const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
10574
9887
  .catch(() => false);
@@ -10589,768 +9902,16 @@ export class AgentManager {
10589
9902
  if (!started) {
10590
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);
10591
9904
  await this.emitIntervention(task.projectId, devAgentId, taskId, {
10592
- phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
9905
+ phase: 'code-resume-failed',
10593
9906
  devAgentId,
10594
9907
  });
10595
9908
  return null;
10596
9909
  }
10597
9910
  // 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
10598
9911
  // startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
10599
- if (!researchHandoff) {
10600
- await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
10601
- }
9912
+ await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
10602
9913
  return await this.taskStore.get(taskId);
10603
9914
  }
10604
- async dispatchServerReviewToQa(taskId, opts) {
10605
- const dispatchPhase = opts.phase === 'spec'
10606
- ? 'server-spec-review'
10607
- : (opts.recheck ? 'server-recheck' : 'server-review');
10608
- const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10609
- const claim = await this.withTaskLock(async () => {
10610
- // 终闸与 newToken 铸发同临界区:settling 中仅结算 handler 链(settle permit)
10611
- // 或当前互斥 FIFO owner(手工先入者)可开新 pass。
10612
- if (this.phaseSignalWatcher?.isSettling(taskId)
10613
- && !this.phaseSignalWatcher.settlePermitMatches(taskId, opts.settlePermit)
10614
- && !this.phaseSignalWatcher.exclusiveOwnerMatches(taskId, opts.settlePermit)) {
10615
- throw new ApiError(409, `Task ${taskId} has a review completion settling; retry after it lands`, 'review-settling');
10616
- }
10617
- const task = await this.taskStore.get(taskId);
10618
- if (!task)
10619
- throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
10620
- if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
10621
- throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
10622
- }
10623
- if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
10624
- throw new ApiError(409, `Task ${taskId} review generation changed before server dispatch`, 'dispatch-superseded');
10625
- }
10626
- const qaId = task.qaAgentId;
10627
- if (!qaId) {
10628
- if (!opts.callerOwnsConsumedSignalFailure) {
10629
- const entryKind = task.status === 'fixing'
10630
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10631
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10632
- await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
10633
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10634
- phase: 'server-review-no-qa-partner',
10635
- devAgentId: task.agentId,
10636
- });
10637
- }
10638
- return null;
10639
- }
10640
- if (this.getAgentConfig(qaId)?.role !== 'qa') {
10641
- if (!opts.callerOwnsConsumedSignalFailure) {
10642
- const entryKind = task.status === 'fixing'
10643
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10644
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10645
- await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
10646
- await this.emitIntervention(task.projectId, task.agentId, taskId, {
10647
- phase: 'server-review-qa-unavailable',
10648
- qaAgentId: qaId,
10649
- });
10650
- }
10651
- return null;
10652
- }
10653
- const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
10654
- const qaBinding = await this.agentStore.get(qaId);
10655
- const bumpRound = shouldBumpServerReviewRound(opts.bumpRound, task, qaBinding);
10656
- const newRound = opts.continuation
10657
- ? Math.max(roundField, 1)
10658
- : bumpRound
10659
- ? roundField + 1
10660
- : roundField;
10661
- if (newRound < 1) {
10662
- throw new Error(`dispatchServerReviewToQa: task ${taskId} has no current ${opts.phase} review round to redispatch`);
10663
- }
10664
- return {
10665
- qaId,
10666
- devAgentId: task.agentId,
10667
- projectId: task.projectId,
10668
- newToken: createSignalToken(),
10669
- newRound,
10670
- originalStatus: task.status,
10671
- originalToken: task.signalToken,
10672
- originalRound: roundField,
10673
- originalBatchIndex: task.batchIndex,
10674
- originalBatchTotal: task.batchTotal,
10675
- originalPhase: task.phase,
10676
- originalReviewCheckoutMode: task.reviewCheckoutMode,
10677
- };
10678
- });
10679
- if (!claim)
10680
- return null;
10681
- const { qaId, devAgentId, projectId, newToken, newRound } = claim;
10682
- const rollback = async (installedPass) => {
10683
- try {
10684
- return (await this.transitionTaskStatus(taskId, claim.originalStatus, {
10685
- fromStatus: [installedPass.status],
10686
- expectPhase: installedPass.phase,
10687
- expectSignalToken: installedPass.signalToken,
10688
- expectTask: installedPass,
10689
- }, {
10690
- signalToken: claim.originalToken,
10691
- batchIndex: claim.originalBatchIndex,
10692
- batchTotal: claim.originalBatchTotal,
10693
- phase: claim.originalPhase,
10694
- // fresh 派发在 install 时清过 mode:不还原会把仍在跑的 base pass 降级成 unknown
10695
- reviewCheckoutMode: claim.originalReviewCheckoutMode,
10696
- ...(opts.phase === 'spec'
10697
- ? { specReviewRound: claim.originalRound }
10698
- : { reviewRound: claim.originalRound }),
10699
- })) !== null;
10700
- }
10701
- catch (err) {
10702
- console.error(`[AgentManager] dispatchServerReviewToQa rollback failed for ${taskId}:`, err);
10703
- return false;
10704
- }
10705
- };
10706
- const rearmEntrySignal = async () => {
10707
- if (opts.callerOwnsConsumedSignalFailure)
10708
- return;
10709
- // 从 review 重派(手工发起):入口信号早已被消费,dev 停驻等待中,无可重挂
10710
- if (claim.originalStatus === 'review')
10711
- return;
10712
- const entryKind = claim.originalStatus === 'fixing'
10713
- ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
10714
- : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
10715
- await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
10716
- };
10717
- let acquiredLockToken;
10718
- const ownAcquire = () => acquiredLockToken === undefined
10719
- ? {}
10720
- : { expectedLockToken: acquiredLockToken };
10721
- const releaseOwnAcquire = async (context) => {
10722
- try {
10723
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle', ownAcquire());
10724
- if (!released) {
10725
- console.warn(`[AgentManager] dispatchServerReviewToQa: QA release refused for ${qaId}/${taskId} (${context})`);
10726
- }
10727
- return released;
10728
- }
10729
- catch (err) {
10730
- console.error(`[AgentManager] dispatchServerReviewToQa: QA release failed for ${qaId}/${taskId} (${context}):`, err);
10731
- return false;
10732
- }
10733
- };
10734
- if (!opts.continuation) {
10735
- const prevQa = await this.agentStore.get(qaId);
10736
- if (prevQa?.taskId === taskId) {
10737
- if (prevQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(prevQa)) {
10738
- throw new ApiError(409, `QA agent ${qaId} is awaiting human (${prevQa.awaitingPhase ?? 'unknown phase'}); resolve that hold before redispatching server review`, 'dispatch-unsupported');
10739
- }
10740
- // 手工重派:上一 pass 的 QA 仍绑定本任务,先释放再重新 acquire
10741
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
10742
- ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
10743
- ...(prevQa.lockToken !== undefined ? { expectedLockToken: prevQa.lockToken } : {}),
10744
- ...(prevQa.status === 'awaiting_human'
10745
- ? {
10746
- allowAwaitingHuman: true,
10747
- expectedHold: {
10748
- phase: prevQa.awaitingPhase,
10749
- since: prevQa.awaitingSince,
10750
- nonce: prevQa.awaitingNonce,
10751
- },
10752
- }
10753
- : {}),
10754
- });
10755
- if (!released) {
10756
- await rearmEntrySignal();
10757
- return null;
10758
- }
10759
- opts.onSideEffect?.();
10760
- }
10761
- const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase, {
10762
- onAcquired: (lockToken) => {
10763
- acquiredLockToken = lockToken;
10764
- opts.onSideEffect?.();
10765
- },
10766
- });
10767
- if (!acquired) {
10768
- await rearmEntrySignal();
10769
- if (!opts.callerOwnsConsumedSignalFailure) {
10770
- await this.emitIntervention(projectId, qaId, taskId, {
10771
- phase: 'server-review-qa-acquire-failed',
10772
- qaAgentId: qaId,
10773
- });
10774
- }
10775
- return null;
10776
- }
10777
- if (devAgentId) {
10778
- const devOk = await this.markAgentWaiting(devAgentId, taskId, {
10779
- ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
10780
- });
10781
- if (!devOk) {
10782
- await releaseOwnAcquire('dev park failed');
10783
- await rearmEntrySignal();
10784
- if (!opts.callerOwnsConsumedSignalFailure) {
10785
- await this.emitIntervention(projectId, devAgentId, taskId, {
10786
- phase: 'server-review-dev-park-failed',
10787
- devAgentId,
10788
- });
10789
- }
10790
- return null;
10791
- }
10792
- }
10793
- }
10794
- else {
10795
- const qaState = await this.agentStore.get(qaId);
10796
- if (qaState?.taskId !== taskId) {
10797
- if (!opts.callerOwnsConsumedSignalFailure) {
10798
- await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
10799
- await this.emitIntervention(projectId, qaId, taskId, {
10800
- phase: 'server-review-continuation-qa-not-bound',
10801
- qaAgentId: qaId,
10802
- });
10803
- }
10804
- return null;
10805
- }
10806
- }
10807
- const roundPatch = opts.phase === 'spec'
10808
- ? { specReviewRound: newRound, phase: 'spec' }
10809
- : { reviewRound: newRound };
10810
- const transition = await this.transitionTaskStatus(taskId, 'review', {
10811
- fromStatus: [claim.originalStatus],
10812
- expectPhase: claim.originalPhase,
10813
- expectSignalToken: claim.originalToken,
10814
- ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
10815
- }, {
10816
- signalToken: newToken,
10817
- reviewDispatchedAt: new Date().toISOString(),
10818
- ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
10819
- ...(opts.batch
10820
- ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
10821
- : { batchIndex: undefined, batchTotal: undefined }),
10822
- // Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
10823
- // so a crash in this window recovers conservatively (no read-file) rather than
10824
- // re-arming it for what might be a head-mode review.
10825
- ...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
10826
- ...roundPatch,
10827
- });
10828
- if (!transition) {
10829
- if (!opts.continuation) {
10830
- await releaseOwnAcquire('pass transition rejected');
10831
- }
10832
- if (!opts.callerOwnsConsumedSignalFailure) {
10833
- await this.emitIntervention(projectId, qaId, taskId, {
10834
- phase: 'server-review-transition-failed',
10835
- qaAgentId: qaId,
10836
- });
10837
- }
10838
- return null;
10839
- }
10840
- opts.onSideEffect?.();
10841
- const installedPass = taskGenerationGuard(transition.task);
10842
- const passStillCurrent = async () => {
10843
- const current = await this.taskStore.get(taskId);
10844
- return current !== null && taskMatchesGeneration(current, installedPass);
10845
- };
10846
- let dispatchedCheckoutMode;
10847
- const sessionOpts = {
10848
- bypassTaskStatusGate: true,
10849
- signalToken: newToken,
10850
- dispatchPassToken: newToken,
10851
- guardBeforeInject: passStillCurrent,
10852
- serverContent: opts.content,
10853
- ...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
10854
- ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
10855
- ...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
10856
- ...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
10857
- ...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
10858
- ...(opts.batch ? { serverBatch: opts.batch } : {}),
10859
- ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
10860
- ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
10861
- ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
10862
- armBeforeInject: (ctx) => this.withTaskLock(async () => {
10863
- const current = await this.taskStore.get(taskId);
10864
- if (!current || !taskMatchesGeneration(current, installedPass))
10865
- return false;
10866
- dispatchedCheckoutMode = ctx.serverReviewCheckout;
10867
- const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
10868
- return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
10869
- needInputMode: 'fresh',
10870
- ...(allowReadFile
10871
- ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
10872
- : {}),
10873
- });
10874
- }),
10875
- };
10876
- const rearmConsumedSignal = async () => {
10877
- if (opts.callerOwnsConsumedSignalFailure)
10878
- return;
10879
- if (opts.continuation) {
10880
- await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
10881
- }
10882
- else {
10883
- await rearmEntrySignal();
10884
- }
10885
- };
10886
- let started = false;
10887
- try {
10888
- // 批次轮早于 head 物化且其持久化 mode 不可信(改名丢失/尽力而为),续传恒重发 base。
10889
- started = opts.continuation
10890
- ? await this.continueSession(taskId, qaId, dispatchPhase, { ...sessionOpts, serverReviewCheckout: 'base' })
10891
- : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
10892
- }
10893
- catch (err) {
10894
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
10895
- if (err instanceof DispatchTerminalError) {
10896
- await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, {
10897
- ...ownAcquire(),
10898
- expectedTask: installedPass,
10899
- });
10900
- }
10901
- else if (err instanceof EnsureSessionError && err.partial.handled) {
10902
- }
10903
- else {
10904
- const rolledBack = await rollback(installedPass);
10905
- if (rolledBack && !opts.continuation) {
10906
- await releaseOwnAcquire('session dispatch threw');
10907
- }
10908
- if (rolledBack)
10909
- await rearmConsumedSignal();
10910
- }
10911
- throw err;
10912
- }
10913
- if (!started) {
10914
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
10915
- const rolledBack = await rollback(installedPass);
10916
- if (rolledBack && !opts.continuation) {
10917
- await releaseOwnAcquire('session did not start');
10918
- }
10919
- if (rolledBack)
10920
- await rearmConsumedSignal();
10921
- if (rolledBack && !opts.callerOwnsConsumedSignalFailure) {
10922
- await this.emitIntervention(projectId, qaId, taskId, {
10923
- phase: 'server-review-start-failed',
10924
- qaAgentId: qaId,
10925
- });
10926
- }
10927
- return null;
10928
- }
10929
- // Persist the checkout mode so base-mode recovery can re-arm read-file.
10930
- if (opts.phase !== 'spec' && !opts.continuation) {
10931
- try {
10932
- const persisted = await this.updateTaskIfStatus(taskId, installedPass.status, { reviewCheckoutMode: dispatchedCheckoutMode }, installedPass);
10933
- if (!persisted) {
10934
- console.warn(`[AgentManager] review checkout mode for ${taskId} was superseded before persistence; skipping stale write`);
10935
- return await this.taskStore.get(taskId);
10936
- }
10937
- }
10938
- catch (err) {
10939
- const message = err instanceof Error ? err.message : String(err);
10940
- console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
10941
- await this.emitIntervention(projectId, qaId, taskId, {
10942
- phase: 'review-checkout-mode-persist-failed',
10943
- error: message,
10944
- note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
10945
- });
10946
- }
10947
- }
10948
- return await this.taskStore.get(taskId);
10949
- }
10950
- async dispatchServerFixToDev(taskId, _findingsJson, opts = {}) {
10951
- const claim = await this.withTaskLock(async () => {
10952
- const task = await this.taskStore.get(taskId);
10953
- if (!task)
10954
- throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
10955
- if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
10956
- throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
10957
- }
10958
- if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
10959
- throw new ApiError(409, `Task ${taskId} review generation changed before server fix dispatch`, 'dispatch-superseded');
10960
- }
10961
- if (!task.agentId)
10962
- throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
10963
- return {
10964
- devAgentId: task.agentId,
10965
- qaAgentId: task.qaAgentId,
10966
- projectId: task.projectId,
10967
- newToken: createSignalToken(),
10968
- taskPhase: task.phase,
10969
- currentReviewRound: task.reviewRound,
10970
- currentSpecRound: task.specReviewRound,
10971
- originalStatus: task.status,
10972
- originalToken: task.signalToken,
10973
- entryGeneration: taskGenerationGuard(task),
10974
- };
10975
- });
10976
- if (!claim)
10977
- return null;
10978
- const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound, entryGeneration, } = claim;
10979
- const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
10980
- const rearmReviewedSignal = async (expectedTask = entryGeneration) => {
10981
- if (opts.callerOwnsConsumedSignalFailure)
10982
- return;
10983
- if (!qaAgentId)
10984
- return;
10985
- const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10986
- await this.withTaskLock(async () => {
10987
- const current = await this.taskStore.get(taskId);
10988
- if (!current?.signalToken || !taskMatchesGeneration(current, expectedTask))
10989
- return;
10990
- await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, {
10991
- skipSnapshot: true,
10992
- });
10993
- });
10994
- };
10995
- const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
10996
- let acquiredDevLockToken;
10997
- const releaseFreshAcquire = async (devLockToken) => {
10998
- if (devBoundBefore)
10999
- return;
11000
- try {
11001
- const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
11002
- expectedTask: entryGeneration,
11003
- expectedLockToken: devLockToken,
11004
- });
11005
- if (!released) {
11006
- console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev `
11007
- + `${devAgentId} for ${taskId} was refused`);
11008
- }
11009
- }
11010
- catch (err) {
11011
- console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev `
11012
- + `${devAgentId} for ${taskId} failed:`, err);
11013
- }
11014
- };
11015
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback', {
11016
- onAcquired: (lockToken) => { acquiredDevLockToken = lockToken; },
11017
- });
11018
- if (!acquired) {
11019
- await rearmReviewedSignal();
11020
- if (!opts.callerOwnsConsumedSignalFailure) {
11021
- await this.emitIntervention(projectId, devAgentId, taskId, {
11022
- phase: 'server-fix-dev-acquire-failed',
11023
- devAgentId,
11024
- });
11025
- }
11026
- return null;
11027
- }
11028
- const devLockToken = acquiredDevLockToken;
11029
- if (!devLockToken) {
11030
- throw new Error(`dispatchServerFixToDev: acquireAgentForTask did not return the Dev lock token for ${devAgentId}`);
11031
- }
11032
- if (qaAgentId) {
11033
- const released = await this.releaseAgentIfBound(qaAgentId, taskId, { expectedTask: entryGeneration })
11034
- .catch((err) => {
11035
- console.error(`[AgentManager] dispatchServerFixToDev: releasing QA ${qaAgentId} for ${taskId} failed:`, err);
11036
- return false;
11037
- });
11038
- if (!released) {
11039
- await releaseFreshAcquire(devLockToken);
11040
- await rearmReviewedSignal();
11041
- if (!opts.callerOwnsConsumedSignalFailure) {
11042
- await this.emitIntervention(projectId, qaAgentId, taskId, {
11043
- phase: 'server-fix-qa-release-failed',
11044
- qaAgentId,
11045
- });
11046
- }
11047
- return null;
11048
- }
11049
- }
11050
- const transition = await this.transitionTaskStatus(taskId, 'fixing', {
11051
- fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'],
11052
- expectSignalToken: claim.originalToken,
11053
- expectTask: entryGeneration,
11054
- expectAgentLock: { agentId: devAgentId, token: devLockToken },
11055
- }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
11056
- if (!transition) {
11057
- await releaseFreshAcquire(devLockToken);
11058
- if (!opts.callerOwnsConsumedSignalFailure) {
11059
- await this.emitIntervention(projectId, devAgentId, taskId, {
11060
- phase: 'server-fix-transition-failed',
11061
- devAgentId,
11062
- });
11063
- }
11064
- return null;
11065
- }
11066
- const installedFix = taskGenerationGuard(transition.task);
11067
- const fixingPassStillCurrent = async () => {
11068
- const current = await this.taskStore.get(taskId);
11069
- return current !== null && taskMatchesGeneration(current, installedFix);
11070
- };
11071
- const rollbackToEntry = async () => {
11072
- try {
11073
- const rolledBack = await this.transitionTaskStatus(taskId, claim.originalStatus, {
11074
- fromStatus: ['fixing'],
11075
- expectTask: installedFix,
11076
- expectAgentLock: { agentId: devAgentId, token: devLockToken },
11077
- }, { signalToken: claim.originalToken });
11078
- if (!rolledBack) {
11079
- console.warn(`[AgentManager] dispatchServerFixToDev rollback for ${taskId} was superseded `
11080
- + `by a newer task or Dev lock generation`);
11081
- }
11082
- return rolledBack?.task ?? null;
11083
- }
11084
- catch (err) {
11085
- console.error(`[AgentManager] dispatchServerFixToDev rollback failed for ${taskId}:`, err);
11086
- return null;
11087
- }
11088
- };
11089
- const cleanupUndeliveredFix = async () => {
11090
- const rolledBack = await rollbackToEntry();
11091
- if (!rolledBack)
11092
- return false;
11093
- const rolledBackGeneration = taskGenerationGuard(rolledBack);
11094
- const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
11095
- expectedTask: rolledBackGeneration,
11096
- expectedLockToken: devLockToken,
11097
- }).catch((err) => {
11098
- console.error(`[AgentManager] dispatchServerFixToDev: releasing dev ${devAgentId} after rollback failed:`, err);
11099
- return false;
11100
- });
11101
- if (!released) {
11102
- console.error(`[AgentManager] dispatchServerFixToDev: releasing dev ${devAgentId} after rollback was refused`);
11103
- }
11104
- await rearmReviewedSignal(rolledBackGeneration);
11105
- return true;
11106
- };
11107
- let resumed = false;
11108
- try {
11109
- const feedbackPhase = taskPhase === 'spec' ? 'spec' : 'code';
11110
- const feedbackRound = feedbackPhase === 'spec'
11111
- ? (currentSpecRound ?? 1)
11112
- : Math.max(claim.currentReviewRound, 1);
11113
- const stored = await this.reviewStore?.getRound(taskId, feedbackPhase, feedbackRound);
11114
- if (!stored?.findings) {
11115
- throw new Error(`dispatchServerFixToDev: persisted aggregate findings missing for ${taskId}/${feedbackPhase}/${feedbackRound}`);
11116
- }
11117
- const findingsJson = JSON.stringify(stored.findings);
11118
- const findingsDigest = reviewFindingsDigest(stored.findings);
11119
- resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
11120
- bypassTaskStatusGate: true,
11121
- signalToken: newToken,
11122
- serverPriorFindings: findingsJson,
11123
- serverFindingsDigest: findingsDigest,
11124
- armBeforeInject: () => this.withTaskLock(async () => {
11125
- const current = await this.taskStore.get(taskId);
11126
- if (!current || !taskMatchesGeneration(current, installedFix))
11127
- return false;
11128
- return this.setupPhaseSignalWatcher(taskId, devAgentId, expectedKind, newToken, {
11129
- needInputMode: 'fresh',
11130
- });
11131
- }),
11132
- guardBeforeInject: fixingPassStillCurrent,
11133
- ...(taskPhase === 'spec' && currentSpecRound !== undefined
11134
- ? { currentSpecRound }
11135
- : {}),
11136
- });
11137
- }
11138
- catch (err) {
11139
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
11140
- if (err instanceof DispatchTerminalError) {
11141
- await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err, {
11142
- expectedTask: installedFix,
11143
- expectedLockToken: devLockToken,
11144
- });
11145
- }
11146
- else if (err instanceof EnsureSessionError && err.partial.handled) {
11147
- }
11148
- else {
11149
- await cleanupUndeliveredFix();
11150
- }
11151
- throw err;
11152
- }
11153
- if (!resumed) {
11154
- this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
11155
- const cleaned = await cleanupUndeliveredFix();
11156
- if (cleaned && !opts.callerOwnsConsumedSignalFailure) {
11157
- await this.emitIntervention(projectId, devAgentId, taskId, {
11158
- phase: 'server-fix-resume-failed',
11159
- devAgentId,
11160
- });
11161
- }
11162
- return null;
11163
- }
11164
- return await this.taskStore.get(taskId);
11165
- }
11166
- projectRepoKey(projectId) {
11167
- const repo = this.getProjectConfig(projectId)?.repo;
11168
- if (!repo)
11169
- return null;
11170
- if (isGitHubRepo(repo))
11171
- return `gh:${repoSlug(repo).toLowerCase()}`;
11172
- const parsed = parseGitRemote(repo);
11173
- if (parsed)
11174
- return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
11175
- return `raw:${repo.trim()}`;
11176
- }
11177
- async computeCodeInterdiff(taskId, round) {
11178
- const store = this.getReviewStore();
11179
- if (!store || !Number.isInteger(round) || round < 2)
11180
- return { ok: false, reason: 'no-anchor' };
11181
- const [cur, prev] = await Promise.all([
11182
- store.getRound(taskId, 'code', round),
11183
- store.getRound(taskId, 'code', round - 1),
11184
- ]);
11185
- const curSha = cur?.headSha;
11186
- const prevSha = prev?.headSha;
11187
- if (!curSha || !prevSha)
11188
- return { ok: false, reason: 'no-anchor' };
11189
- const task = await this.taskStore.get(taskId);
11190
- if (!task?.agentId)
11191
- return { ok: false, reason: 'released' };
11192
- const agent = this.getAgentConfig(task.agentId);
11193
- const agentState = await this.agentStore.get(task.agentId);
11194
- // A rebound agent's Workdir checkout belongs to its new task — its git state describes
11195
- // another branch, so the interdiff would be meaningless. Same skip rule as
11196
- // findLineageViolation: the Workdir must still be bound to THIS task.
11197
- if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
11198
- return { ok: false, reason: 'released' };
11199
- }
11200
- await this.refreshWorkdirCacheFor(task.agentId);
11201
- const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
11202
- return { ok: true, diff };
11203
- }
11204
- async findLineageViolation(taskId, baseSha) {
11205
- const task = await this.taskStore.get(taskId);
11206
- if (!task?.agentId)
11207
- return null;
11208
- const agent = this.getAgentConfig(task.agentId);
11209
- const agentState = await this.agentStore.get(task.agentId);
11210
- // A rebound agent's Workdir checkout belongs to its new task — checking it would
11211
- // produce verdicts about the wrong branch. Skip; the dispatch path's own
11212
- // binding guards (acquire) surface the real failure.
11213
- if (agentState?.taskId !== taskId)
11214
- return null;
11215
- const workdir = agentState.workdir;
11216
- if (!agent || !workdir)
11217
- return null;
11218
- const runner = this.createRunnerFor(agent);
11219
- let base = baseSha;
11220
- if (!base) {
11221
- // Callers without a baseSha (publish) run long after the review diff was
11222
- // read; refresh origin/HEAD first or upstream commits merged since then
11223
- // would linger in base..HEAD and flag tasks that leak nothing.
11224
- const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
11225
- if (fetch.exitCode !== 0) {
11226
- throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
11227
- }
11228
- const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
11229
- if (mb.exitCode !== 0) {
11230
- throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
11231
- }
11232
- base = mb.stdout.trim();
11233
- }
11234
- // Projects pointing at the same repo share one store and branch namespace,
11235
- // so candidates are scoped by repo identity, not by projectId.
11236
- const selfRepoKey = this.projectRepoKey(task.projectId);
11237
- if (!selfRepoKey)
11238
- return null;
11239
- const tasks = await this.taskStore.list();
11240
- const candidates = tasks
11241
- .filter(t => t.id !== taskId
11242
- && !TERMINAL_STATUSES.includes(t.status)
11243
- && this.projectRepoKey(t.projectId) === selfRepoKey)
11244
- .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
11245
- if (candidates.length === 0)
11246
- return null;
11247
- return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
11248
- }
11249
- async dispatchServerAfterDone(taskId, kind) {
11250
- const task = await this.taskStore.get(taskId);
11251
- if (!task)
11252
- throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
11253
- const devAgentId = task.agentId;
11254
- if (!devAgentId)
11255
- throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
11256
- // 幂等兜底:主路径已在 approve 前经 commitServerAfterDone 落盘 afterDone+binding;
11257
- // 重启/重试直连本函数时,这里补齐(binding 不可变,已在则短路)。
11258
- await this.commitServerAfterDone(taskId);
11259
- let violation;
11260
- try {
11261
- violation = await this.findLineageViolation(taskId);
11262
- }
11263
- catch (err) {
11264
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
11265
- phase: 'server-after-done-lineage-check-failed',
11266
- error: err instanceof Error ? err.message : String(err),
11267
- note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
11268
- });
11269
- return null;
11270
- }
11271
- if (violation) {
11272
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
11273
- phase: 'server-after-done-lineage-violation',
11274
- offendingTaskId: violation.taskId,
11275
- offendingBranch: violation.branch,
11276
- offendingSha: violation.sha,
11277
- 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.',
11278
- });
11279
- return null;
11280
- }
11281
- const branch = task.branch ?? BRANCH_PREFIX + taskId;
11282
- const originalToken = task.signalToken;
11283
- const newToken = createSignalToken();
11284
- await this.updateTask(taskId, { signalToken: newToken });
11285
- const rollbackToken = async () => {
11286
- await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
11287
- .catch(() => undefined);
11288
- };
11289
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
11290
- if (!acquired) {
11291
- await rollbackToken();
11292
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
11293
- return null;
11294
- }
11295
- await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
11296
- let resumed = false;
11297
- try {
11298
- resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
11299
- bypassTaskStatusGate: true,
11300
- signalToken: newToken,
11301
- serverAfterDone: { kind, branch },
11302
- });
11303
- }
11304
- catch (err) {
11305
- if (err instanceof DispatchTerminalError) {
11306
- await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
11307
- }
11308
- else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
11309
- await rollbackToken();
11310
- }
11311
- throw err;
11312
- }
11313
- if (!resumed) {
11314
- await rollbackToken();
11315
- await this.emitIntervention(task.projectId, devAgentId, taskId, {
11316
- phase: 'server-after-done-resume-failed',
11317
- devAgentId,
11318
- note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
11319
- });
11320
- return null;
11321
- }
11322
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
11323
- return await this.taskStore.get(taskId);
11324
- }
11325
- async handleReadFileRequest(taskId, qaAgentId, req) {
11326
- const task = await this.taskStore.get(taskId);
11327
- if (!task)
11328
- return;
11329
- const dev = this.getAgentConfig(task.agentId);
11330
- if (!dev)
11331
- return;
11332
- await this.refreshWorkdirCacheFor(task.agentId);
11333
- let body;
11334
- try {
11335
- const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
11336
- body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
11337
- }
11338
- catch (err) {
11339
- const reason = err instanceof Error ? err.message : String(err);
11340
- body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
11341
- }
11342
- const qaState = await this.agentStore.get(qaAgentId);
11343
- if (qaState?.taskId !== taskId) {
11344
- console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
11345
- return;
11346
- }
11347
- try {
11348
- await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
11349
- }
11350
- catch (err) {
11351
- console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
11352
- }
11353
- }
11354
9915
  async injectTextToAgent(agentId, text, opts = {}) {
11355
9916
  const cfg = this.getAgentConfig(agentId);
11356
9917
  if (!cfg)
@@ -11409,35 +9970,13 @@ export class AgentManager {
11409
9970
  }
11410
9971
  }
11411
9972
  async confirmHumanGate(taskId) {
11412
- const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
9973
+ const task = await this.claimCompleteGate(taskId, ['merge-ready']);
11413
9974
  try {
11414
9975
  const project = this.getProjectConfig(task.projectId);
11415
9976
  const mergeAuto = project?.merge === 'auto';
11416
- const afterDone = this.resolveAfterDone(task);
11417
- if (task.status === 'merge-ready') {
11418
- if (mergeAuto && task.prNumber) {
11419
- if (!task.latestHeadSha) {
11420
- throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
11421
- }
11422
- await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
11423
- matchHeadSha: task.latestHeadSha,
11424
- }));
11425
- await this.eventBus.emit({
11426
- id: '',
11427
- type: 'pr.merged',
11428
- timestamp: new Date().toISOString(),
11429
- projectId: task.projectId,
11430
- agentId: task.agentId,
11431
- taskId: task.id,
11432
- data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
11433
- });
11434
- return (await this.taskStore.get(taskId));
11435
- }
11436
- return this.finishTaskAsDone(taskId);
11437
- }
11438
- if (afterDone === 'pr' && mergeAuto && task.prNumber) {
9977
+ if (mergeAuto && task.prNumber) {
11439
9978
  if (!task.latestHeadSha) {
11440
- 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`);
11441
9980
  }
11442
9981
  await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
11443
9982
  matchHeadSha: task.latestHeadSha,
@@ -11453,13 +9992,6 @@ export class AgentManager {
11453
9992
  });
11454
9993
  return (await this.taskStore.get(taskId));
11455
9994
  }
11456
- if (afterDone === 'branch' && mergeAuto && task.branch) {
11457
- await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
11458
- const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
11459
- if (merged)
11460
- await this.releaseTaskAgents(taskId);
11461
- return (await this.taskStore.get(taskId));
11462
- }
11463
9995
  return this.finishTaskAsDone(taskId);
11464
9996
  }
11465
9997
  finally {
@@ -11477,9 +10009,6 @@ export class AgentManager {
11477
10009
  if (this.markCompleteInFlight.has(taskId)) {
11478
10010
  throw new ApiError(409, `Task ${taskId} is already being completed`);
11479
10011
  }
11480
- if (this.codeVerdictInFlight.has(taskId)) {
11481
- throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
11482
- }
11483
10012
  this.markCompleteInFlight.add(taskId);
11484
10013
  return fresh;
11485
10014
  });
@@ -11490,7 +10019,7 @@ export class AgentManager {
11490
10019
  }
11491
10020
  catch (err) {
11492
10021
  const message = err instanceof Error ? err.message : String(err);
11493
- if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
10022
+ if (err instanceof PlatformMergeRecheckError && task.status === 'merge-ready') {
11494
10023
  let retreated = false;
11495
10024
  if (err.kind === 'pending-feedback') {
11496
10025
  retreated = await this.withTaskLock(async () => {
@@ -11564,7 +10093,7 @@ export class AgentManager {
11564
10093
  }
11565
10094
  }
11566
10095
  async finishTaskAsDone(taskId) {
11567
- const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
10096
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['merge-ready'] });
11568
10097
  if (!done)
11569
10098
  throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
11570
10099
  await this.releaseTaskAgents(taskId);
@@ -11604,49 +10133,6 @@ export class AgentManager {
11604
10133
  })().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
11605
10134
  return true;
11606
10135
  }
11607
- repoMergeQueue = new Map();
11608
- async ffMergeBranch(task) {
11609
- const dev = this.getAgentConfig(task.agentId);
11610
- if (!dev)
11611
- throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
11612
- const state = await this.agentStore.get(task.agentId);
11613
- const workdir = state?.workdir;
11614
- if (!workdir)
11615
- throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
11616
- const branch = task.branch ?? BRANCH_PREFIX + task.id;
11617
- const runner = this.createRunnerFor(dev);
11618
- const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
11619
- const run = prev.then(async () => {
11620
- // 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.
11621
- const guard = `${canonicalSelfGuard(workdir)} && `;
11622
- const cd = `cd ${shellQuote(workdir)} && `;
11623
- const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
11624
- const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
11625
- if (db.exitCode !== 0 || defaultBranch === '') {
11626
- throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
11627
- }
11628
- const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
11629
- if (fetch.exitCode !== 0) {
11630
- throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
11631
- }
11632
- if (task.latestHeadSha) {
11633
- const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
11634
- if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
11635
- throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
11636
- `!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
11637
- }
11638
- }
11639
- else {
11640
- throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
11641
- }
11642
- const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
11643
- if (push.exitCode !== 0) {
11644
- throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
11645
- }
11646
- });
11647
- this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
11648
- await run;
11649
- }
11650
10136
  }
11651
10137
  function buildAgentIndex(config) {
11652
10138
  const index = new Map();