baxian 2.0.0 → 2.0.1

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 (133) hide show
  1. package/dist/agent/branch.d.ts +0 -1
  2. package/dist/agent/branch.d.ts.map +1 -1
  3. package/dist/agent/branch.js.map +1 -1
  4. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  5. package/dist/agent/dispatch-reconciler.js +10 -5
  6. package/dist/agent/dispatch-reconciler.js.map +1 -1
  7. package/dist/agent/manager.d.ts +42 -13
  8. package/dist/agent/manager.d.ts.map +1 -1
  9. package/dist/agent/manager.js +568 -165
  10. package/dist/agent/manager.js.map +1 -1
  11. package/dist/agent/net-exec.d.ts +3 -0
  12. package/dist/agent/net-exec.d.ts.map +1 -1
  13. package/dist/agent/net-exec.js +6 -0
  14. package/dist/agent/net-exec.js.map +1 -1
  15. package/dist/agent/pane-streamer-manager.d.ts +6 -6
  16. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  17. package/dist/agent/pane-streamer-manager.js.map +1 -1
  18. package/dist/agent/pane-streamer.d.ts +2 -2
  19. package/dist/agent/pane-streamer.d.ts.map +1 -1
  20. package/dist/agent/pane-streamer.js +46 -36
  21. package/dist/agent/pane-streamer.js.map +1 -1
  22. package/dist/agent/phase-signal-watcher.d.ts +15 -1
  23. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  24. package/dist/agent/phase-signal-watcher.js +113 -6
  25. package/dist/agent/phase-signal-watcher.js.map +1 -1
  26. package/dist/agent/phase-signal.d.ts +7 -0
  27. package/dist/agent/phase-signal.d.ts.map +1 -1
  28. package/dist/agent/phase-signal.js +14 -5
  29. package/dist/agent/phase-signal.js.map +1 -1
  30. package/dist/agent/prompt.d.ts +0 -5
  31. package/dist/agent/prompt.d.ts.map +1 -1
  32. package/dist/agent/prompt.js +5 -18
  33. package/dist/agent/prompt.js.map +1 -1
  34. package/dist/agent/repo-store.d.ts +4 -1
  35. package/dist/agent/repo-store.d.ts.map +1 -1
  36. package/dist/agent/repo-store.js +33 -7
  37. package/dist/agent/repo-store.js.map +1 -1
  38. package/dist/agent/root-agent-runtime.d.ts +80 -0
  39. package/dist/agent/root-agent-runtime.d.ts.map +1 -0
  40. package/dist/agent/root-agent-runtime.js +635 -0
  41. package/dist/agent/root-agent-runtime.js.map +1 -0
  42. package/dist/agent/root-recovery-coordinator.d.ts +125 -0
  43. package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
  44. package/dist/agent/root-recovery-coordinator.js +1579 -0
  45. package/dist/agent/root-recovery-coordinator.js.map +1 -0
  46. package/dist/agent/runner.d.ts +1 -0
  47. package/dist/agent/runner.d.ts.map +1 -1
  48. package/dist/agent/runner.js +103 -7
  49. package/dist/agent/runner.js.map +1 -1
  50. package/dist/agent/tmux-probe-poller.d.ts +1 -0
  51. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  52. package/dist/agent/tmux-probe-poller.js +16 -10
  53. package/dist/agent/tmux-probe-poller.js.map +1 -1
  54. package/dist/agent/tmux.d.ts +3 -0
  55. package/dist/agent/tmux.d.ts.map +1 -1
  56. package/dist/agent/tmux.js +11 -5
  57. package/dist/agent/tmux.js.map +1 -1
  58. package/dist/api/agents.d.ts.map +1 -1
  59. package/dist/api/agents.js +42 -1
  60. package/dist/api/agents.js.map +1 -1
  61. package/dist/api/config.d.ts.map +1 -1
  62. package/dist/api/config.js +77 -7
  63. package/dist/api/config.js.map +1 -1
  64. package/dist/api/hosts.d.ts.map +1 -1
  65. package/dist/api/hosts.js +68 -13
  66. package/dist/api/hosts.js.map +1 -1
  67. package/dist/api/projects.d.ts.map +1 -1
  68. package/dist/api/projects.js +23 -7
  69. package/dist/api/projects.js.map +1 -1
  70. package/dist/app.d.ts +2 -0
  71. package/dist/app.d.ts.map +1 -1
  72. package/dist/app.js +6 -0
  73. package/dist/app.js.map +1 -1
  74. package/dist/cli.d.ts +1 -1
  75. package/dist/cli.d.ts.map +1 -1
  76. package/dist/cli.js +49 -7
  77. package/dist/cli.js.map +1 -1
  78. package/dist/config/loader.d.ts.map +1 -1
  79. package/dist/config/loader.js +22 -1
  80. package/dist/config/loader.js.map +1 -1
  81. package/dist/config/normalizer.d.ts.map +1 -1
  82. package/dist/config/normalizer.js +18 -0
  83. package/dist/config/normalizer.js.map +1 -1
  84. package/dist/config/validator.d.ts.map +1 -1
  85. package/dist/config/validator.js +194 -23
  86. package/dist/config/validator.js.map +1 -1
  87. package/dist/event/handlers.d.ts.map +1 -1
  88. package/dist/event/handlers.js +0 -3
  89. package/dist/event/handlers.js.map +1 -1
  90. package/dist/event/server-handlers.d.ts.map +1 -1
  91. package/dist/event/server-handlers.js +201 -128
  92. package/dist/event/server-handlers.js.map +1 -1
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +45 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/platform/platform-poller.d.ts.map +1 -1
  97. package/dist/platform/platform-poller.js +1 -5
  98. package/dist/platform/platform-poller.js.map +1 -1
  99. package/dist/shared/constants.d.ts +7 -1
  100. package/dist/shared/constants.d.ts.map +1 -1
  101. package/dist/shared/constants.js +5 -0
  102. package/dist/shared/constants.js.map +1 -1
  103. package/dist/shared/types.d.ts +25 -2
  104. package/dist/shared/types.d.ts.map +1 -1
  105. package/dist/shared/types.js +18 -0
  106. package/dist/shared/types.js.map +1 -1
  107. package/dist/skills/baxian-pr-feedback/SKILL.md +2 -4
  108. package/dist/skills/baxian-pr-recheck/SKILL.md +2 -0
  109. package/dist/skills/baxian-pr-review/SKILL.md +2 -0
  110. package/dist/skills/baxian-research/SKILL.md +1 -1
  111. package/dist/skills/baxian-server-feedback/SKILL.md +1 -1
  112. package/dist/skills/baxian-server-review/SKILL.md +2 -2
  113. package/dist/skills/baxian-signals/SKILL.md +6 -0
  114. package/dist/state/init.d.ts.map +1 -1
  115. package/dist/state/init.js +1 -0
  116. package/dist/state/init.js.map +1 -1
  117. package/dist/state/lock.d.ts +6 -0
  118. package/dist/state/lock.d.ts.map +1 -1
  119. package/dist/state/lock.js +8 -0
  120. package/dist/state/lock.js.map +1 -1
  121. package/dist/state/root-recovery-store.d.ts +107 -0
  122. package/dist/state/root-recovery-store.d.ts.map +1 -0
  123. package/dist/state/root-recovery-store.js +400 -0
  124. package/dist/state/root-recovery-store.js.map +1 -0
  125. package/dist/state/task-store.d.ts.map +1 -1
  126. package/dist/state/task-store.js +7 -3
  127. package/dist/state/task-store.js.map +1 -1
  128. package/dist/terminal/attach.d.ts +3 -3
  129. package/dist/terminal/attach.d.ts.map +1 -1
  130. package/dist/terminal/attach.js.map +1 -1
  131. package/dist/terminal/stream-ws.d.ts.map +1 -1
  132. package/dist/terminal/stream-ws.js.map +1 -1
  133. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { join, normalize } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
6
6
  import { tmuxInstallHint } from './preflight.js';
7
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
7
+ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, taskGenerationGuard, 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';
@@ -28,6 +28,14 @@ import { ApiError } from '../errors.js';
28
28
  import { prepareConfig } from '../config/loader.js';
29
29
  import { projectAfterDone, projectReviewMode, resolveProjectTool } from '../config/validator.js';
30
30
  import { SHA_HEX_SOURCE } from '../platform/types.js';
31
+ // 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
32
+ const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
33
+ export function isBenignDispatchConflict(err) {
34
+ return err instanceof ApiError
35
+ && err.status === 409
36
+ && err.code !== undefined
37
+ && BENIGN_DISPATCH_CONFLICT_CODES.has(err.code);
38
+ }
31
39
  export class EnsureSessionError extends Error {
32
40
  partial;
33
41
  constructor(partial, message) {
@@ -185,6 +193,13 @@ export function isRecoverableQaDispatchHold(binding) {
185
193
  && binding.awaitingPhase != null
186
194
  && RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
187
195
  }
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
+ }
188
203
  const TURN_COMPLETED_AWAITING_PHASES = new Set();
189
204
  const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
190
205
  function isCancelCleanupHold(binding) {
@@ -366,6 +381,42 @@ export class AgentManager {
366
381
  getReviewStore() {
367
382
  return this.reviewStore;
368
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
+ }
369
420
  setServerReviewDriver(driver) {
370
421
  this.serverReviewDriver = driver;
371
422
  }
@@ -382,11 +433,14 @@ export class AgentManager {
382
433
  const project = this.getProjectConfig(projectId);
383
434
  return project ? projectAfterDone(this.config, project) : (this.config.review.afterDone ?? null);
384
435
  }
385
- async commitServerAfterDone(taskId) {
436
+ async commitServerAfterDone(taskId, expectedTask) {
386
437
  return this.withTaskLock(async () => {
387
438
  const task = await this.taskStore.get(taskId);
388
439
  if (!task)
389
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
+ }
390
444
  if (task.afterDone === 'pr' && task.platformBinding === undefined) {
391
445
  throw new Error(`platform binding missing for task ${task.id}; refusing to reconstruct it from live config`);
392
446
  }
@@ -1814,9 +1868,13 @@ export class AgentManager {
1814
1868
  }
1815
1869
  if (opts.expectedTask
1816
1870
  && boundTask
1817
- && (boundTask.status !== opts.expectedTask.status
1818
- || boundTask.phase !== opts.expectedTask.phase
1819
- || boundTask.signalToken !== opts.expectedTask.signalToken)) {
1871
+ && (('status' in opts.expectedTask && boundTask.status !== opts.expectedTask.status)
1872
+ || ('phase' in opts.expectedTask && boundTask.phase !== opts.expectedTask.phase)
1873
+ || ('signalToken' in opts.expectedTask && boundTask.signalToken !== opts.expectedTask.signalToken)
1874
+ || ('agentId' in opts.expectedTask && boundTask.agentId !== opts.expectedTask.agentId)
1875
+ || ('reviewRound' in opts.expectedTask && boundTask.reviewRound !== opts.expectedTask.reviewRound)
1876
+ || ('specReviewRound' in opts.expectedTask
1877
+ && boundTask.specReviewRound !== opts.expectedTask.specReviewRound))) {
1820
1878
  console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
1821
1879
  return false;
1822
1880
  }
@@ -2930,6 +2988,9 @@ export class AgentManager {
2930
2988
  status: boundTask.status,
2931
2989
  phase: boundTask.phase,
2932
2990
  signalToken: boundTask.signalToken,
2991
+ agentId: boundTask.agentId,
2992
+ reviewRound: boundTask.reviewRound,
2993
+ specReviewRound: boundTask.specReviewRound,
2933
2994
  },
2934
2995
  heldLockToken: state.lockToken,
2935
2996
  projectId: state.projectId,
@@ -3106,6 +3167,7 @@ export class AgentManager {
3106
3167
  fromStatus: ['review'],
3107
3168
  expectPhase: reviewPass.phase,
3108
3169
  expectSignalToken: reviewPass.signalToken,
3170
+ expectedTask: reviewPass,
3109
3171
  ...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
3110
3172
  onQaAcquired: (lockToken) => { recoveryLockToken = lockToken; },
3111
3173
  });
@@ -3163,9 +3225,7 @@ export class AgentManager {
3163
3225
  let recoveryReadFailed = false;
3164
3226
  try {
3165
3227
  const taskNow = await this.taskStore.get(reviewTaskId);
3166
- passSuperseded = taskNow?.status !== reviewPass.status
3167
- || taskNow.phase !== reviewPass.phase
3168
- || taskNow.signalToken !== reviewPass.signalToken;
3228
+ passSuperseded = !taskNow || !taskMatchesGeneration(taskNow, reviewPass);
3169
3229
  }
3170
3230
  catch (readErr) {
3171
3231
  recoveryReadFailed = true;
@@ -3386,9 +3446,16 @@ export class AgentManager {
3386
3446
  ...(opts.expectedReviewDispatch !== undefined
3387
3447
  ? { expectReviewDispatch: opts.expectedReviewDispatch }
3388
3448
  : {}),
3449
+ ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
3450
+ ...(opts.expectedLockToken !== undefined
3451
+ ? { expectAgentLock: { agentId, token: opts.expectedLockToken } }
3452
+ : {}),
3389
3453
  });
3390
3454
  if (!transitioned
3391
- && (opts.expectedPostApproveEpisode !== undefined || opts.expectedReviewDispatch !== undefined)) {
3455
+ && (opts.expectedPostApproveEpisode !== undefined
3456
+ || opts.expectedReviewDispatch !== undefined
3457
+ || opts.expectedTask !== undefined
3458
+ || opts.expectedLockToken !== undefined)) {
3392
3459
  console.warn(`[AgentManager] failTaskForDispatchError: dispatch generation for ${taskId} was superseded; skipping stale failure`);
3393
3460
  return;
3394
3461
  }
@@ -3732,11 +3799,14 @@ export class AgentManager {
3732
3799
  retireOldOnce();
3733
3800
  }
3734
3801
  }
3735
- async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
3802
+ async redispatchTaskPromptAfterReplRestart(agentId, taskId, expected) {
3736
3803
  const agent = this.getAgentConfig(agentId);
3737
3804
  const loadedTask = await this.taskStore.get(taskId);
3738
3805
  if (!agent || !loadedTask || !TASK_OWNER_ROLES.has(agent.role))
3739
3806
  return false;
3807
+ if (expected && !taskMatchesGeneration(loadedTask, expected))
3808
+ return false;
3809
+ const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
3740
3810
  let task = loadedTask;
3741
3811
  if (task.agentId !== agentId)
3742
3812
  return false;
@@ -3754,7 +3824,8 @@ export class AgentManager {
3754
3824
  return fresh?.agentId === agentId
3755
3825
  && fresh.status === task.status
3756
3826
  && fresh.phase === task.phase
3757
- && fresh.signalToken === signalToken;
3827
+ && fresh.signalToken === signalToken
3828
+ && rootGenerationMatches(fresh, signalToken);
3758
3829
  };
3759
3830
  const clearEntryHold = async () => this.withTaskLock(async () => {
3760
3831
  if (!(await taskStillCurrent()))
@@ -3765,10 +3836,10 @@ export class AgentManager {
3765
3836
  }
3766
3837
  return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
3767
3838
  });
3768
- const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
3839
+ const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold, async (fresh) => rootGenerationMatches(fresh, task.signalToken));
3769
3840
  try {
3770
3841
  if (task.status === 'approved' && agent.role === 'dev') {
3771
- return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
3842
+ return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
3772
3843
  }
3773
3844
  if (task.serverSignalRecovery) {
3774
3845
  const recovery = task.serverSignalRecovery;
@@ -3809,7 +3880,8 @@ export class AgentManager {
3809
3880
  if (fresh?.agentId !== agentId
3810
3881
  || fresh.status !== task.status
3811
3882
  || fresh.phase !== task.phase
3812
- || fresh.signalToken !== signalToken)
3883
+ || fresh.signalToken !== signalToken
3884
+ || !rootGenerationMatches(fresh, signalToken))
3813
3885
  return false;
3814
3886
  const rotatePendingPr = mapped.expectedKinds.includes('pr-created')
3815
3887
  && fresh.pendingPrSignalToken === signalToken;
@@ -3822,7 +3894,7 @@ export class AgentManager {
3822
3894
  return true;
3823
3895
  },
3824
3896
  stillCurrent: taskStillCurrent,
3825
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
3897
+ holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
3826
3898
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
3827
3899
  ...opts,
3828
3900
  signalToken: newToken,
@@ -3921,10 +3993,49 @@ export class AgentManager {
3921
3993
  throw err;
3922
3994
  }
3923
3995
  }
3996
+ async redispatchCurrentTaskPhase(taskId, expected) {
3997
+ const current = await this.taskStore.get(taskId);
3998
+ if (!current || !taskMatchesGeneration(current, expected))
3999
+ return 'stale';
4000
+ if (current.status === 'review') {
4001
+ let changedByRedispatch = false;
4002
+ try {
4003
+ await this.dispatchReviewToQa(taskId, {
4004
+ fromStatus: ['review'],
4005
+ bumpRound: false,
4006
+ expectSignalToken: current.signalToken,
4007
+ expectPhase: current.phase,
4008
+ expectedTask: expected,
4009
+ onSideEffect: () => { changedByRedispatch = true; },
4010
+ });
4011
+ return 'dispatched';
4012
+ }
4013
+ catch (err) {
4014
+ if (!changedByRedispatch && isBenignDispatchConflict(err))
4015
+ return 'stale';
4016
+ if (!changedByRedispatch
4017
+ && err instanceof ApiError
4018
+ && err.code === 'dispatch-unsupported') {
4019
+ return 'unsupported';
4020
+ }
4021
+ if (changedByRedispatch)
4022
+ throw err;
4023
+ const fresh = await this.taskStore.get(taskId);
4024
+ if (!fresh || !taskMatchesGeneration(fresh, expected))
4025
+ return 'stale';
4026
+ throw err;
4027
+ }
4028
+ }
4029
+ const redispatched = await this.redispatchTaskPromptAfterReplRestart(current.agentId, taskId, expected);
4030
+ if (redispatched)
4031
+ return 'dispatched';
4032
+ const fresh = await this.taskStore.get(taskId);
4033
+ return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
4034
+ }
3924
4035
  // approved hosts two live holder passes — post-approve and server after-done; a parked pre-publish holder stays on the waiting path.
3925
4036
  async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
3926
4037
  const taskId = task.id;
3927
- const { clearEntryHold, holdReplayFailure } = holdGen;
4038
+ const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
3928
4039
  if (task.reviewMode === 'server') {
3929
4040
  if (!task.publishDispatchedAt)
3930
4041
  return false;
@@ -3954,7 +4065,8 @@ export class AgentManager {
3954
4065
  return fresh?.agentId === agentId
3955
4066
  && fresh.status === task.status
3956
4067
  && fresh.phase === task.phase
3957
- && fresh.signalToken === signalToken;
4068
+ && fresh.signalToken === signalToken
4069
+ && rootGenerationMatches(fresh, signalToken);
3958
4070
  };
3959
4071
  return this.runRotatedReplay({
3960
4072
  agentId,
@@ -3966,13 +4078,14 @@ export class AgentManager {
3966
4078
  if (fresh?.agentId !== agentId
3967
4079
  || fresh.status !== task.status
3968
4080
  || fresh.phase !== task.phase
3969
- || fresh.signalToken !== token)
4081
+ || fresh.signalToken !== token
4082
+ || !rootGenerationMatches(fresh, token))
3970
4083
  return false;
3971
4084
  await this.taskStore.set({ ...fresh, signalToken: newToken, updatedAt: new Date().toISOString() });
3972
4085
  return true;
3973
4086
  },
3974
4087
  stillCurrent,
3975
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
4088
+ holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
3976
4089
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'server-after-done', {
3977
4090
  signalToken: newToken,
3978
4091
  preserveDispatchOutputs: true,
@@ -4014,6 +4127,7 @@ export class AgentManager {
4014
4127
  && fresh.status === task.status
4015
4128
  && fresh.phase === task.phase
4016
4129
  && fresh.signalToken === task.signalToken
4130
+ && rootGenerationMatches(fresh, task.signalToken)
4017
4131
  && fresh.reviewMode === task.reviewMode
4018
4132
  && fresh.postApproveRevoked === undefined
4019
4133
  && fresh.postApproveHeadSha === task.postApproveHeadSha
@@ -4675,6 +4789,8 @@ export class AgentManager {
4675
4789
  const current = await this.taskStore.get(taskId);
4676
4790
  if (!current || current.reviewMode !== 'git' || TERMINAL_STATUSES.includes(current.status))
4677
4791
  return null;
4792
+ if (opts.expectedTask && !taskMatchesGeneration(current, opts.expectedTask))
4793
+ return null;
4678
4794
  if (!opts.fromStatus.includes(current.status))
4679
4795
  return null;
4680
4796
  if (current.reviewDispatch?.phase === 'uncertain')
@@ -4726,7 +4842,7 @@ export class AgentManager {
4726
4842
  return { task: next, previousStatus };
4727
4843
  });
4728
4844
  }
4729
- async claimGitReviewDispatch(taskId, expectedGeneration) {
4845
+ async claimGitReviewDispatch(taskId, expectedGeneration, expectedTask) {
4730
4846
  return this.withTaskLock(async () => {
4731
4847
  if (this.markCompleteInFlight.has(taskId))
4732
4848
  return null;
@@ -4734,6 +4850,8 @@ export class AgentManager {
4734
4850
  const lease = task?.reviewDispatch;
4735
4851
  if (!task || !lease || lease.phase !== 'pending')
4736
4852
  return null;
4853
+ if (expectedTask && !taskMatchesGeneration(task, expectedTask))
4854
+ return null;
4737
4855
  if (expectedGeneration !== undefined && lease.generation !== expectedGeneration)
4738
4856
  return null;
4739
4857
  if (!this.gitReviewLeaseMatches(task, lease))
@@ -5320,7 +5438,9 @@ export class AgentManager {
5320
5438
  return;
5321
5439
  }
5322
5440
  try {
5323
- await this.getReviewTransport().deleteResponse(dev);
5441
+ const deleted = await this.deleteReviewExchangeIfCurrent(task.id, dev, 'response', taskGenerationGuard(fresh), current);
5442
+ if (!deleted)
5443
+ return;
5324
5444
  }
5325
5445
  catch (err) {
5326
5446
  await hold(`server-${recovery.phase}-feedback-recovery-cleanup-failed`, `The rejected response was archived but could not be removed during recovery: `
@@ -5344,10 +5464,16 @@ export class AgentManager {
5344
5464
  const scanSnapshotOnRecover = isServerProtocol
5345
5465
  || (task.phase === undefined && task.status === 'in_progress')
5346
5466
  || (!specStage && (task.status === 'review' || task.status === 'fixing'));
5467
+ // 侧道读 dev workdir,对 head 评审会注入未验证内容——mode 不明时关侧道、发干预交人工。
5347
5468
  const allowRecoveredReadFile = task.status === 'review'
5348
5469
  && (specStage
5349
5470
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
5350
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;
5351
5477
  try {
5352
5478
  await this.startPhaseSignalWatch({
5353
5479
  taskId: task.id,
@@ -5362,12 +5488,29 @@ export class AgentManager {
5362
5488
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
5363
5489
  : {}),
5364
5490
  });
5365
- if (interventionKindLabel) {
5366
- await this.emitIntervention(task.projectId, agentId, task.id, {
5367
- phase: 'spec-signal-setup-during-recovery',
5368
- kind: interventionKindLabel,
5369
- note: 'Task is waiting for a spec signal after server recovery; if no signal arrives, the prompt may not have been fully delivered before the previous crash. Inspect the agent pane and consider manual retry or transition.',
5370
- });
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
+ }
5371
5514
  }
5372
5515
  }
5373
5516
  catch (err) {
@@ -5796,6 +5939,29 @@ export class AgentManager {
5796
5939
  return;
5797
5940
  }
5798
5941
  if (branch.headSha === undefined) {
5942
+ try {
5943
+ await this.eventBus.emit({
5944
+ id: `outbox:${task.id}:${intent.prNumber}:remote-branch-deleted:${generation}`,
5945
+ type: 'task.updated',
5946
+ timestamp: new Date().toISOString(),
5947
+ projectId: task.projectId,
5948
+ taskId: task.id,
5949
+ data: {
5950
+ status: task.status,
5951
+ operation: 'git-branch-delete',
5952
+ outcome: 'succeeded',
5953
+ generation,
5954
+ prNumber: intent.prNumber,
5955
+ branch: intent.branch,
5956
+ expectedHeadSha: intent.expectedHeadSha,
5957
+ remoteProjectId: intent.remoteProjectId,
5958
+ },
5959
+ });
5960
+ }
5961
+ catch (err) {
5962
+ await this.recordRemoteCleanupFailure(task, generation, 'persist', safeDriverErrorText(err));
5963
+ throw err;
5964
+ }
5799
5965
  await this.mutateRemoteCleanup(taskId, generation, () => undefined);
5800
5966
  return;
5801
5967
  }
@@ -5996,6 +6162,8 @@ export class AgentManager {
5996
6162
  return false;
5997
6163
  if ('phase' in alsoExpect && task.phase !== alsoExpect.phase)
5998
6164
  return false;
6165
+ if ('agentId' in alsoExpect && task.agentId !== alsoExpect.agentId)
6166
+ return false;
5999
6167
  if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
6000
6168
  return false;
6001
6169
  if ('specReviewRound' in alsoExpect && task.specReviewRound !== alsoExpect.specReviewRound)
@@ -6194,38 +6362,52 @@ export class AgentManager {
6194
6362
  }
6195
6363
  async transitionTaskStatus(taskId, toStatus, guard, patch) {
6196
6364
  return this.withTaskLock(async () => {
6197
- const task = await this.taskStore.get(taskId);
6198
- if (!task)
6199
- return null;
6200
- const previousStatus = task.status;
6201
- if (TERMINAL_STATUSES.includes(previousStatus))
6202
- return null;
6203
- if (!guard.fromStatus.includes(previousStatus))
6204
- return null;
6205
- if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
6206
- return null;
6207
- if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
6208
- return null;
6209
- if (guard.expectPostApproveEpisode !== undefined
6210
- && !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
6211
- return null;
6212
- if (guard.expectReviewDispatch !== undefined
6213
- && !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
6214
- return null;
6215
- if (patch?.branch && patch.branch !== task.branch) {
6216
- const existing = await this.findTaskByBranch(patch.branch, task.projectId);
6217
- if (existing && existing.id !== taskId)
6365
+ const transition = async () => {
6366
+ if (guard.expectAgentLock) {
6367
+ const binding = await this.agentStore.get(guard.expectAgentLock.agentId);
6368
+ if (binding?.taskId !== taskId
6369
+ || binding.lockToken !== guard.expectAgentLock.token)
6370
+ return null;
6371
+ }
6372
+ const task = await this.taskStore.get(taskId);
6373
+ if (!task)
6218
6374
  return null;
6219
- }
6220
- const next = this.stripGitStatusScopedState(task, toStatus);
6221
- if (previousStatus !== toStatus)
6222
- delete next.serverSignalRecovery;
6223
- Object.assign(next, patch ?? {}, {
6224
- status: toStatus,
6225
- updatedAt: new Date().toISOString(),
6226
- });
6227
- await this.taskStore.set(next);
6228
- return { task: next, previousStatus };
6375
+ const previousStatus = task.status;
6376
+ if (TERMINAL_STATUSES.includes(previousStatus))
6377
+ return null;
6378
+ if (!guard.fromStatus.includes(previousStatus))
6379
+ return null;
6380
+ if (guard.expectTask && !taskMatchesGeneration(task, guard.expectTask))
6381
+ return null;
6382
+ if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
6383
+ return null;
6384
+ if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
6385
+ return null;
6386
+ if (guard.expectPostApproveEpisode !== undefined
6387
+ && !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
6388
+ return null;
6389
+ if (guard.expectReviewDispatch !== undefined
6390
+ && !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
6391
+ return null;
6392
+ if (patch?.branch && patch.branch !== task.branch) {
6393
+ const existing = await this.findTaskByBranch(patch.branch, task.projectId);
6394
+ if (existing && existing.id !== taskId)
6395
+ return null;
6396
+ }
6397
+ const next = this.stripGitStatusScopedState(task, toStatus);
6398
+ if (previousStatus !== toStatus)
6399
+ delete next.serverSignalRecovery;
6400
+ Object.assign(next, patch ?? {}, {
6401
+ status: toStatus,
6402
+ updatedAt: new Date().toISOString(),
6403
+ });
6404
+ await this.taskStore.set(next);
6405
+ return { task: next, previousStatus };
6406
+ };
6407
+ if (!guard.expectAgentLock)
6408
+ return transition();
6409
+ const guarded = await this.lockManager.runIfOwner(guard.expectAgentLock.agentId, taskId, guard.expectAgentLock.token, transition);
6410
+ return guarded.ran ? guarded.value : null;
6229
6411
  });
6230
6412
  }
6231
6413
  createRunnerFor(agent) {
@@ -7379,11 +7561,6 @@ export class AgentManager {
7379
7561
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
7380
7562
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
7381
7563
  ...(imagePaths.length ? { imagePaths } : {}),
7382
- ...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
7383
- ...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
7384
- ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
7385
- ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
7386
- ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
7387
7564
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
7388
7565
  ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
7389
7566
  ...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
@@ -7933,9 +8110,6 @@ export class AgentManager {
7933
8110
  await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
7934
8111
  },
7935
8112
  });
7936
- const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
7937
- && opts.postApproveRedispatchCount > 0
7938
- && !ensure.freshRuntime;
7939
8113
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
7940
8114
  let payloadOpts = {};
7941
8115
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
@@ -7963,11 +8137,9 @@ export class AgentManager {
7963
8137
  hasQaPartner: task.qaAgentId !== undefined,
7964
8138
  ...(platformCli ? { platformCli } : {}),
7965
8139
  ...(signalToken ? { signalToken } : {}),
7966
- ...(useIncrementalNudge
7967
- ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
7968
- : {}),
7969
8140
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
7970
8141
  ...(imagePaths.length ? { imagePaths } : {}),
8142
+ ...(opts.serverReviewCheckout ? { serverReviewCheckout: opts.serverReviewCheckout } : {}),
7971
8143
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
7972
8144
  ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
7973
8145
  ...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
@@ -8732,10 +8904,11 @@ export class AgentManager {
8732
8904
  && this.gitReviewLeaseMatches(task, lease);
8733
8905
  }
8734
8906
  async dispatchGitReviewLease(taskId, opts = {}) {
8735
- const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration);
8907
+ const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration, opts.expectedTask);
8736
8908
  if (!claimed) {
8737
8909
  throw new ApiError(409, `Git review dispatch for ${taskId} was already claimed or superseded`, 'dispatch-superseded');
8738
8910
  }
8911
+ opts.onSideEffect?.();
8739
8912
  const { task, lease } = claimed;
8740
8913
  const qaId = task.qaAgentId;
8741
8914
  if (!qaId || !this.gitReviewQaConfigUsable(task)) {
@@ -8747,7 +8920,7 @@ export class AgentManager {
8747
8920
  if (previousQa?.taskId === taskId) {
8748
8921
  if (previousQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(previousQa)) {
8749
8922
  await this.resetGitReviewDispatchClaim(taskId, lease);
8750
- throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})`);
8923
+ throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
8751
8924
  }
8752
8925
  const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
8753
8926
  expectedTask: { status: 'review', phase: task.phase, signalToken: lease.signalToken },
@@ -8768,11 +8941,13 @@ export class AgentManager {
8768
8941
  await this.resetGitReviewDispatchClaim(taskId, lease);
8769
8942
  throw new ApiError(409, `QA agent ${qaId} could not be released for git review dispatch`);
8770
8943
  }
8944
+ opts.onSideEffect?.();
8771
8945
  }
8772
8946
  let acquiredLockToken;
8773
8947
  const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
8774
8948
  onAcquired: token => {
8775
8949
  acquiredLockToken = token;
8950
+ opts.onSideEffect?.();
8776
8951
  opts.onQaAcquired?.(token);
8777
8952
  },
8778
8953
  });
@@ -8867,6 +9042,9 @@ export class AgentManager {
8867
9042
  async dispatchReviewToQa(taskId, opts = {}) {
8868
9043
  let routeTask = await this.taskStore.get(taskId);
8869
9044
  if (routeTask?.reviewMode === 'git' && !isSpecStagePhase(routeTask.phase)) {
9045
+ if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
9046
+ throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
9047
+ }
8870
9048
  if (opts.fromStatus && !opts.fromStatus.includes(routeTask.status)) {
8871
9049
  throw new ApiError(409, `Task ${taskId} status is ${routeTask.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
8872
9050
  }
@@ -8876,6 +9054,9 @@ export class AgentManager {
8876
9054
  if (Object.hasOwn(opts, 'expectPhase') && routeTask.phase !== opts.expectPhase) {
8877
9055
  throw new ApiError(409, `Task ${taskId} review phase changed during redispatch`, 'dispatch-superseded');
8878
9056
  }
9057
+ if (!routeTask.qaAgentId || this.getAgentConfig(routeTask.qaAgentId)?.role !== 'qa') {
9058
+ throw new ApiError(409, `Task ${taskId} has no available QA participant`, 'dispatch-unsupported');
9059
+ }
8879
9060
  let lease = routeTask.reviewDispatch;
8880
9061
  if (lease?.phase === 'uncertain') {
8881
9062
  if (opts.confirmUncertainNotDelivered !== true) {
@@ -8897,17 +9078,32 @@ export class AgentManager {
8897
9078
  }
8898
9079
  routeTask = confirmed;
8899
9080
  lease = confirmed.reviewDispatch;
9081
+ opts.onSideEffect?.();
9082
+ if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
9083
+ throw new ApiError(409, `Task ${taskId} review generation changed during uncertain-delivery confirmation`, 'dispatch-superseded');
9084
+ }
8900
9085
  }
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');
9089
+ }
9090
+ const qaBinding = await this.agentStore.get(routeQaId);
9091
+ if (qaBinding?.taskId === taskId
9092
+ && qaBinding.status === 'awaiting_human'
9093
+ && !isRecoverableQaDispatchHold(qaBinding)) {
9094
+ throw new ApiError(409, `QA agent ${routeQaId} is awaiting human (${qaBinding.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
9095
+ }
9096
+ let dispatchExpectedTask = opts.expectedTask;
8901
9097
  if (lease === undefined) {
8902
9098
  if (routeTask.prNumber === undefined) {
8903
- throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`);
9099
+ throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`, 'dispatch-unsupported');
8904
9100
  }
8905
9101
  const verified = await this.platformVerifyPrBinding(taskId, routeTask.prNumber);
8906
9102
  if (!verified.ok) {
8907
- throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}`);
9103
+ throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}`, 'dispatch-unsupported');
8908
9104
  }
8909
9105
  if (!PLATFORM_HEAD_SHA_RE.test(verified.headSha)) {
8910
- throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head`);
9106
+ throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head`, 'dispatch-unsupported');
8911
9107
  }
8912
9108
  const begun = await this.beginGitReviewPass(taskId, {
8913
9109
  fromStatus: [routeTask.status],
@@ -8916,17 +9112,27 @@ export class AgentManager {
8916
9112
  ...(opts.qaPhase !== undefined ? { qaPhase: opts.qaPhase } : {}),
8917
9113
  expectSignalToken: routeTask.signalToken,
8918
9114
  expectPhase: routeTask.phase,
9115
+ ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
8919
9116
  patch: { latestHeadSha: verified.headSha },
8920
9117
  });
8921
9118
  if (!begun?.task.reviewDispatch) {
8922
9119
  throw new ApiError(409, `Task ${taskId} changed before git review pass creation`, 'dispatch-superseded');
8923
9120
  }
9121
+ opts.onSideEffect?.();
8924
9122
  lease = begun.task.reviewDispatch;
9123
+ if (opts.expectedTask) {
9124
+ dispatchExpectedTask = {
9125
+ ...opts.expectedTask,
9126
+ signalToken: begun.task.signalToken,
9127
+ };
9128
+ }
8925
9129
  }
8926
9130
  const dispatched = await this.dispatchGitReviewLease(taskId, {
8927
9131
  expectedGeneration: lease.generation,
9132
+ ...(dispatchExpectedTask !== undefined ? { expectedTask: dispatchExpectedTask } : {}),
8928
9133
  ...(opts.pendingBudget !== undefined ? { pendingBudget: opts.pendingBudget } : {}),
8929
9134
  ...(opts.onQaAcquired !== undefined ? { onQaAcquired: opts.onQaAcquired } : {}),
9135
+ ...(opts.onSideEffect !== undefined ? { onSideEffect: opts.onSideEffect } : {}),
8930
9136
  });
8931
9137
  opts.onPassArmed?.(lease.signalToken);
8932
9138
  return dispatched;
@@ -8938,12 +9144,19 @@ export class AgentManager {
8938
9144
  if (this.markCompleteInFlight.has(taskId)) {
8939
9145
  throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
8940
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
+ }
8941
9151
  const task = await this.taskStore.get(taskId);
8942
9152
  if (!task)
8943
9153
  throw new ApiError(404, `Task ${taskId} not found`);
8944
9154
  if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
8945
9155
  throw new ApiError(409, `Task ${taskId} review mode changed during dispatch; retry`, 'dispatch-superseded');
8946
9156
  }
9157
+ if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
9158
+ throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
9159
+ }
8947
9160
  if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
8948
9161
  throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
8949
9162
  }
@@ -8954,27 +9167,29 @@ export class AgentManager {
8954
9167
  throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (phase changed); aborting`, 'dispatch-superseded');
8955
9168
  }
8956
9169
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
8957
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
9170
+ throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`, 'dispatch-unsupported');
8958
9171
  }
8959
9172
  if (!this.serverReviewDriver) {
8960
- throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
9173
+ throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`, 'dispatch-unsupported');
8961
9174
  }
8962
9175
  if (task.status === 'in_progress' && task.phase === undefined) {
8963
- throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
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');
8964
9177
  }
8965
9178
  const qaAvailable = task.qaAgentId !== undefined && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
8966
9179
  if (!qaAvailable) {
8967
- throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
9180
+ throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`, 'dispatch-unsupported');
8968
9181
  }
8969
9182
  const isSpec = isSpecStagePhase(task.phase);
9183
+ const qaBinding = await this.agentStore.get(task.qaAgentId);
9184
+ const bumpRound = shouldBumpServerReviewRound(opts.bumpRound, task, qaBinding);
8970
9185
  const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
8971
9186
  const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
8972
- const nextRound = opts.bumpRound === false ? round : round + 1;
9187
+ const nextRound = bumpRound ? round + 1 : round;
8973
9188
  if (nextRound < 1) {
8974
- throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`);
9189
+ throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`, 'dispatch-unsupported');
8975
9190
  }
8976
9191
  if (nextRound > cap) {
8977
- throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`);
9192
+ throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`, 'dispatch-unsupported');
8978
9193
  }
8979
9194
  this.manualReviewInFlight.add(taskId);
8980
9195
  return {
@@ -8983,35 +9198,45 @@ export class AgentManager {
8983
9198
  status: task.status,
8984
9199
  phase: task.phase,
8985
9200
  signalToken: task.signalToken,
9201
+ agentId: task.agentId,
9202
+ reviewRound: task.reviewRound,
9203
+ specReviewRound: task.specReviewRound,
8986
9204
  },
8987
- bumpRound: opts.bumpRound,
9205
+ bumpRound,
9206
+ onSideEffect: opts.onSideEffect,
8988
9207
  };
8989
9208
  });
8990
9209
  return this.runManualServerReview(taskId, claim);
8991
9210
  }
8992
9211
  async runManualServerReview(taskId, claim) {
8993
9212
  try {
8994
- // 不在此处预释放旧 QA:可失败的准备工作(读 diff/spec、存轮次)失败时旧 pass 必须原样保留;
8995
- // 同任务的 QA 重新绑定由 dispatchServerReviewToQa 在派发前完成
8996
- const fresh = await this.taskStore.get(taskId);
8997
- if (!fresh
8998
- || fresh.status !== claim.reviewPass.status
8999
- || fresh.phase !== claim.reviewPass.phase
9000
- || fresh.signalToken !== claim.reviewPass.signalToken
9001
- || !MANUAL_SERVER_REVIEW_STATUSES.includes(fresh.status)) {
9002
- throw new ApiError(409, `Task ${taskId} changed during manual review dispatch; aborting`);
9003
- }
9004
- const dispatched = claim.isSpec
9005
- ? claim.bumpRound === false
9006
- ? await this.serverReviewDriver.dispatchSpecReview(fresh, { bumpRound: false })
9007
- : await this.serverReviewDriver.dispatchSpecReview(fresh)
9008
- : claim.bumpRound === false
9009
- ? await this.serverReviewDriver.dispatchCodeReview(fresh, { bumpRound: false })
9010
- : await this.serverReviewDriver.dispatchCodeReview(fresh);
9011
- if (!dispatched) {
9012
- throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
9013
- }
9014
- return (await this.taskStore.get(taskId));
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();
9015
9240
  }
9016
9241
  finally {
9017
9242
  this.manualReviewInFlight.delete(taskId);
@@ -9857,27 +10082,49 @@ export class AgentManager {
9857
10082
  const task = await this.taskStore.get(taskId);
9858
10083
  if (!task)
9859
10084
  return null;
9860
- const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
10085
+ const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
10086
+ fromStatus: ['review', 'in_progress', 'fixing'],
10087
+ ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
10088
+ }, {
9861
10089
  phase: 'spec',
9862
10090
  ...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
9863
10091
  });
9864
10092
  if (!transition)
9865
10093
  return null;
10094
+ const parked = transition.task;
10095
+ const parkedGeneration = {
10096
+ status: parked.status,
10097
+ phase: parked.phase,
10098
+ signalToken: parked.signalToken,
10099
+ agentId: parked.agentId,
10100
+ reviewRound: parked.reviewRound,
10101
+ specReviewRound: parked.specReviewRound,
10102
+ };
9866
10103
  this.stopPhaseSignalWatcher(taskId);
9867
- if (task.agentId) {
9868
- const devOk = await this.markAgentWaiting(task.agentId, taskId).catch(() => false);
10104
+ if (parked.agentId) {
10105
+ const devOk = await this.markAgentWaiting(parked.agentId, taskId, {
10106
+ expectedTask: parkedGeneration,
10107
+ }).catch(() => false);
9869
10108
  if (!devOk) {
9870
- await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'spec-ready-dev-park-failed', devAgentId: task.agentId });
10109
+ await this.emitIntervention(parked.projectId, parked.agentId, taskId, {
10110
+ phase: 'spec-ready-dev-park-failed',
10111
+ devAgentId: parked.agentId,
10112
+ });
9871
10113
  }
9872
10114
  }
9873
- if (task.qaAgentId) {
9874
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
10115
+ if (parked.qaAgentId) {
10116
+ const released = await this.releaseAgentForTask(parked.qaAgentId, taskId, 'idle', {
10117
+ expectedTask: parkedGeneration,
10118
+ })
9875
10119
  .catch(() => false);
9876
10120
  if (!released) {
9877
- await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'spec-ready-qa-release-failed', qaAgentId: task.qaAgentId });
10121
+ await this.emitIntervention(parked.projectId, parked.qaAgentId, taskId, {
10122
+ phase: 'spec-ready-qa-release-failed',
10123
+ qaAgentId: parked.qaAgentId,
10124
+ });
9878
10125
  }
9879
10126
  }
9880
- return transition.task;
10127
+ return parked;
9881
10128
  }
9882
10129
  async submitSpecVerdict(taskId, verdict, comments) {
9883
10130
  const trimmed = comments?.trim();
@@ -10217,8 +10464,13 @@ export class AgentManager {
10217
10464
  })
10218
10465
  : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
10219
10466
  }
10220
- async transitionToCodePhase(taskId) {
10221
- const task = await this.taskStore.get(taskId);
10467
+ async transitionToCodePhase(taskId, expectedTask) {
10468
+ const task = await this.withTaskLock(async () => {
10469
+ const current = await this.taskStore.get(taskId);
10470
+ if (expectedTask && current && !taskMatchesGeneration(current, expectedTask))
10471
+ return null;
10472
+ return current;
10473
+ });
10222
10474
  if (!task)
10223
10475
  return null;
10224
10476
  const devAgentId = task.devAgentId;
@@ -10242,7 +10494,10 @@ export class AgentManager {
10242
10494
  const researchAgentId = task.researchAgentId;
10243
10495
  const researchHandoff = this.isResearchHandoff(task, devAgentId);
10244
10496
  const releaseParticipant = async (agentId, failurePhase) => {
10245
- const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
10497
+ const released = await this.releaseAgentIfBound(agentId, taskId, {
10498
+ allowAwaitingHuman: true,
10499
+ ...(expectedTask !== undefined ? { expectedTask } : {}),
10500
+ })
10246
10501
  .catch(() => false);
10247
10502
  if (!released) {
10248
10503
  await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
@@ -10257,7 +10512,10 @@ export class AgentManager {
10257
10512
  return null;
10258
10513
  }
10259
10514
  const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
10260
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
10515
+ let acquiredLockToken;
10516
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code', {
10517
+ onAcquired: token => { acquiredLockToken = token; },
10518
+ });
10261
10519
  if (!acquired) {
10262
10520
  await this.emitIntervention(task.projectId, devAgentId, taskId, {
10263
10521
  phase: 'code-dev-acquire-failed',
@@ -10265,13 +10523,25 @@ export class AgentManager {
10265
10523
  });
10266
10524
  return null;
10267
10525
  }
10268
- const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
10269
10526
  const newToken = createSignalToken();
10270
10527
  // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
10271
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
10528
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
10529
+ fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
10530
+ ...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
10531
+ }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
10272
10532
  if (!transition) {
10273
- await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
10274
- .catch(() => undefined);
10533
+ if (!devBoundBefore) {
10534
+ try {
10535
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
10536
+ allowAwaitingHuman: true,
10537
+ ...(expectedTask !== undefined ? { expectedTask } : {}),
10538
+ ...(acquiredLockToken !== undefined ? { expectedLockToken: acquiredLockToken } : {}),
10539
+ });
10540
+ }
10541
+ catch (err) {
10542
+ console.warn(`[AgentManager] transitionToCodePhase(${taskId}): stale-generation acquire rollback failed:`, err);
10543
+ }
10544
+ }
10275
10545
  return null;
10276
10546
  }
10277
10547
  this.stopPhaseSignalWatcher(taskId);
@@ -10337,12 +10607,22 @@ export class AgentManager {
10337
10607
  : (opts.recheck ? 'server-recheck' : 'server-review');
10338
10608
  const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10339
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
+ }
10340
10617
  const task = await this.taskStore.get(taskId);
10341
10618
  if (!task)
10342
10619
  throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
10343
10620
  if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
10344
10621
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
10345
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
+ }
10346
10626
  const qaId = task.qaAgentId;
10347
10627
  if (!qaId) {
10348
10628
  if (!opts.callerOwnsConsumedSignalFailure) {
@@ -10371,11 +10651,13 @@ export class AgentManager {
10371
10651
  return null;
10372
10652
  }
10373
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);
10374
10656
  const newRound = opts.continuation
10375
10657
  ? Math.max(roundField, 1)
10376
- : opts.bumpRound === false
10377
- ? roundField
10378
- : roundField + 1;
10658
+ : bumpRound
10659
+ ? roundField + 1
10660
+ : roundField;
10379
10661
  if (newRound < 1) {
10380
10662
  throw new Error(`dispatchServerReviewToQa: task ${taskId} has no current ${opts.phase} review round to redispatch`);
10381
10663
  }
@@ -10391,6 +10673,7 @@ export class AgentManager {
10391
10673
  originalBatchIndex: task.batchIndex,
10392
10674
  originalBatchTotal: task.batchTotal,
10393
10675
  originalPhase: task.phase,
10676
+ originalReviewCheckoutMode: task.reviewCheckoutMode,
10394
10677
  };
10395
10678
  });
10396
10679
  if (!claim)
@@ -10402,11 +10685,14 @@ export class AgentManager {
10402
10685
  fromStatus: [installedPass.status],
10403
10686
  expectPhase: installedPass.phase,
10404
10687
  expectSignalToken: installedPass.signalToken,
10688
+ expectTask: installedPass,
10405
10689
  }, {
10406
10690
  signalToken: claim.originalToken,
10407
10691
  batchIndex: claim.originalBatchIndex,
10408
10692
  batchTotal: claim.originalBatchTotal,
10409
10693
  phase: claim.originalPhase,
10694
+ // fresh 派发在 install 时清过 mode:不还原会把仍在跑的 base pass 降级成 unknown
10695
+ reviewCheckoutMode: claim.originalReviewCheckoutMode,
10410
10696
  ...(opts.phase === 'spec'
10411
10697
  ? { specReviewRound: claim.originalRound }
10412
10698
  : { reviewRound: claim.originalRound }),
@@ -10448,17 +10734,35 @@ export class AgentManager {
10448
10734
  if (!opts.continuation) {
10449
10735
  const prevQa = await this.agentStore.get(qaId);
10450
10736
  if (prevQa?.taskId === taskId) {
10451
- // 手工重派:上一 pass QA 仍绑定本任务,先释放再重新 acquire(与 github 手工路径一致)
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
10452
10741
  const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
10742
+ ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
10453
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
+ : {}),
10454
10754
  });
10455
10755
  if (!released) {
10456
10756
  await rearmEntrySignal();
10457
10757
  return null;
10458
10758
  }
10759
+ opts.onSideEffect?.();
10459
10760
  }
10460
10761
  const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase, {
10461
- onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
10762
+ onAcquired: (lockToken) => {
10763
+ acquiredLockToken = lockToken;
10764
+ opts.onSideEffect?.();
10765
+ },
10462
10766
  });
10463
10767
  if (!acquired) {
10464
10768
  await rearmEntrySignal();
@@ -10471,7 +10775,9 @@ export class AgentManager {
10471
10775
  return null;
10472
10776
  }
10473
10777
  if (devAgentId) {
10474
- const devOk = await this.markAgentWaiting(devAgentId, taskId);
10778
+ const devOk = await this.markAgentWaiting(devAgentId, taskId, {
10779
+ ...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
10780
+ });
10475
10781
  if (!devOk) {
10476
10782
  await releaseOwnAcquire('dev park failed');
10477
10783
  await rearmEntrySignal();
@@ -10505,6 +10811,7 @@ export class AgentManager {
10505
10811
  fromStatus: [claim.originalStatus],
10506
10812
  expectPhase: claim.originalPhase,
10507
10813
  expectSignalToken: claim.originalToken,
10814
+ ...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
10508
10815
  }, {
10509
10816
  signalToken: newToken,
10510
10817
  reviewDispatchedAt: new Date().toISOString(),
@@ -10530,15 +10837,18 @@ export class AgentManager {
10530
10837
  }
10531
10838
  return null;
10532
10839
  }
10533
- const installedPass = {
10534
- status: transition.task.status,
10535
- phase: transition.task.phase,
10536
- signalToken: transition.task.signalToken,
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);
10537
10845
  };
10538
10846
  let dispatchedCheckoutMode;
10539
10847
  const sessionOpts = {
10540
10848
  bypassTaskStatusGate: true,
10541
10849
  signalToken: newToken,
10850
+ dispatchPassToken: newToken,
10851
+ guardBeforeInject: passStillCurrent,
10542
10852
  serverContent: opts.content,
10543
10853
  ...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
10544
10854
  ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
@@ -10549,7 +10859,10 @@ export class AgentManager {
10549
10859
  ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
10550
10860
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
10551
10861
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
10552
- armBeforeInject: (ctx) => {
10862
+ armBeforeInject: (ctx) => this.withTaskLock(async () => {
10863
+ const current = await this.taskStore.get(taskId);
10864
+ if (!current || !taskMatchesGeneration(current, installedPass))
10865
+ return false;
10553
10866
  dispatchedCheckoutMode = ctx.serverReviewCheckout;
10554
10867
  const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
10555
10868
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
@@ -10558,7 +10871,7 @@ export class AgentManager {
10558
10871
  ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
10559
10872
  : {}),
10560
10873
  });
10561
- },
10874
+ }),
10562
10875
  };
10563
10876
  const rearmConsumedSignal = async () => {
10564
10877
  if (opts.callerOwnsConsumedSignalFailure)
@@ -10572,20 +10885,24 @@ export class AgentManager {
10572
10885
  };
10573
10886
  let started = false;
10574
10887
  try {
10888
+ // 批次轮早于 head 物化且其持久化 mode 不可信(改名丢失/尽力而为),续传恒重发 base。
10575
10889
  started = opts.continuation
10576
- ? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
10890
+ ? await this.continueSession(taskId, qaId, dispatchPhase, { ...sessionOpts, serverReviewCheckout: 'base' })
10577
10891
  : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
10578
10892
  }
10579
10893
  catch (err) {
10580
- this.stopPhaseSignalWatcher(taskId);
10894
+ this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
10581
10895
  if (err instanceof DispatchTerminalError) {
10582
- await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, ownAcquire());
10896
+ await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, {
10897
+ ...ownAcquire(),
10898
+ expectedTask: installedPass,
10899
+ });
10583
10900
  }
10584
10901
  else if (err instanceof EnsureSessionError && err.partial.handled) {
10585
10902
  }
10586
10903
  else {
10587
10904
  const rolledBack = await rollback(installedPass);
10588
- if (!opts.continuation) {
10905
+ if (rolledBack && !opts.continuation) {
10589
10906
  await releaseOwnAcquire('session dispatch threw');
10590
10907
  }
10591
10908
  if (rolledBack)
@@ -10594,14 +10911,14 @@ export class AgentManager {
10594
10911
  throw err;
10595
10912
  }
10596
10913
  if (!started) {
10597
- this.stopPhaseSignalWatcher(taskId);
10914
+ this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
10598
10915
  const rolledBack = await rollback(installedPass);
10599
- if (!opts.continuation) {
10916
+ if (rolledBack && !opts.continuation) {
10600
10917
  await releaseOwnAcquire('session did not start');
10601
10918
  }
10602
10919
  if (rolledBack)
10603
10920
  await rearmConsumedSignal();
10604
- if (!opts.callerOwnsConsumedSignalFailure) {
10921
+ if (rolledBack && !opts.callerOwnsConsumedSignalFailure) {
10605
10922
  await this.emitIntervention(projectId, qaId, taskId, {
10606
10923
  phase: 'server-review-start-failed',
10607
10924
  qaAgentId: qaId,
@@ -10612,7 +10929,11 @@ export class AgentManager {
10612
10929
  // Persist the checkout mode so base-mode recovery can re-arm read-file.
10613
10930
  if (opts.phase !== 'spec' && !opts.continuation) {
10614
10931
  try {
10615
- await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
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
+ }
10616
10937
  }
10617
10938
  catch (err) {
10618
10939
  const message = err instanceof Error ? err.message : String(err);
@@ -10634,6 +10955,9 @@ export class AgentManager {
10634
10955
  if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
10635
10956
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
10636
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
+ }
10637
10961
  if (!task.agentId)
10638
10962
  throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
10639
10963
  return {
@@ -10646,34 +10970,51 @@ export class AgentManager {
10646
10970
  currentSpecRound: task.specReviewRound,
10647
10971
  originalStatus: task.status,
10648
10972
  originalToken: task.signalToken,
10973
+ entryGeneration: taskGenerationGuard(task),
10649
10974
  };
10650
10975
  });
10651
10976
  if (!claim)
10652
10977
  return null;
10653
- const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
10654
- const rollbackToEntry = async () => {
10655
- await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
10656
- };
10978
+ const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound, entryGeneration, } = claim;
10657
10979
  const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
10658
- const rearmReviewedSignal = async () => {
10980
+ const rearmReviewedSignal = async (expectedTask = entryGeneration) => {
10659
10981
  if (opts.callerOwnsConsumedSignalFailure)
10660
10982
  return;
10661
10983
  if (!qaAgentId)
10662
10984
  return;
10663
10985
  const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
10664
- await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
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
+ });
10665
10994
  };
10666
10995
  const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
10667
- const releaseFreshAcquire = async () => {
10996
+ let acquiredDevLockToken;
10997
+ const releaseFreshAcquire = async (devLockToken) => {
10668
10998
  if (devBoundBefore)
10669
10999
  return;
10670
- const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
10671
- .catch(() => false);
10672
- if (!released) {
10673
- console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
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);
10674
11013
  }
10675
11014
  };
10676
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
11015
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback', {
11016
+ onAcquired: (lockToken) => { acquiredDevLockToken = lockToken; },
11017
+ });
10677
11018
  if (!acquired) {
10678
11019
  await rearmReviewedSignal();
10679
11020
  if (!opts.callerOwnsConsumedSignalFailure) {
@@ -10684,11 +11025,18 @@ export class AgentManager {
10684
11025
  }
10685
11026
  return null;
10686
11027
  }
11028
+ const devLockToken = acquiredDevLockToken;
11029
+ if (!devLockToken) {
11030
+ throw new Error(`dispatchServerFixToDev: acquireAgentForTask did not return the Dev lock token for ${devAgentId}`);
11031
+ }
10687
11032
  if (qaAgentId) {
10688
- const released = await this.releaseAgentIfBound(qaAgentId, taskId)
10689
- .catch(() => false);
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
+ });
10690
11038
  if (!released) {
10691
- await releaseFreshAcquire();
11039
+ await releaseFreshAcquire(devLockToken);
10692
11040
  await rearmReviewedSignal();
10693
11041
  if (!opts.callerOwnsConsumedSignalFailure) {
10694
11042
  await this.emitIntervention(projectId, qaAgentId, taskId, {
@@ -10699,9 +11047,14 @@ export class AgentManager {
10699
11047
  return null;
10700
11048
  }
10701
11049
  }
10702
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
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() });
10703
11056
  if (!transition) {
10704
- await releaseFreshAcquire();
11057
+ await releaseFreshAcquire(devLockToken);
10705
11058
  if (!opts.callerOwnsConsumedSignalFailure) {
10706
11059
  await this.emitIntervention(projectId, devAgentId, taskId, {
10707
11060
  phase: 'server-fix-transition-failed',
@@ -10710,6 +11063,47 @@ export class AgentManager {
10710
11063
  }
10711
11064
  return null;
10712
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
+ };
10713
11107
  let resumed = false;
10714
11108
  try {
10715
11109
  const feedbackPhase = taskPhase === 'spec' ? 'spec' : 'code';
@@ -10727,29 +11121,39 @@ export class AgentManager {
10727
11121
  signalToken: newToken,
10728
11122
  serverPriorFindings: findingsJson,
10729
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,
10730
11133
  ...(taskPhase === 'spec' && currentSpecRound !== undefined
10731
11134
  ? { currentSpecRound }
10732
11135
  : {}),
10733
11136
  });
10734
11137
  }
10735
11138
  catch (err) {
11139
+ this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
10736
11140
  if (err instanceof DispatchTerminalError) {
10737
- await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
11141
+ await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err, {
11142
+ expectedTask: installedFix,
11143
+ expectedLockToken: devLockToken,
11144
+ });
10738
11145
  }
10739
11146
  else if (err instanceof EnsureSessionError && err.partial.handled) {
10740
11147
  }
10741
11148
  else {
10742
- await rollbackToEntry();
10743
- await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
10744
- await rearmReviewedSignal();
11149
+ await cleanupUndeliveredFix();
10745
11150
  }
10746
11151
  throw err;
10747
11152
  }
10748
11153
  if (!resumed) {
10749
- await rollbackToEntry();
10750
- await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
10751
- await rearmReviewedSignal();
10752
- if (!opts.callerOwnsConsumedSignalFailure) {
11154
+ this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
11155
+ const cleaned = await cleanupUndeliveredFix();
11156
+ if (cleaned && !opts.callerOwnsConsumedSignalFailure) {
10753
11157
  await this.emitIntervention(projectId, devAgentId, taskId, {
10754
11158
  phase: 'server-fix-resume-failed',
10755
11159
  devAgentId,
@@ -10757,7 +11161,6 @@ export class AgentManager {
10757
11161
  }
10758
11162
  return null;
10759
11163
  }
10760
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
10761
11164
  return await this.taskStore.get(taskId);
10762
11165
  }
10763
11166
  projectRepoKey(projectId) {