baxian 1.2.57 → 1.2.58

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 (118) hide show
  1. package/dist/agent/branch.d.ts +6 -1
  2. package/dist/agent/branch.d.ts.map +1 -1
  3. package/dist/agent/branch.js +45 -9
  4. package/dist/agent/branch.js.map +1 -1
  5. package/dist/agent/manager.d.ts +67 -5
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +1224 -92
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +2 -0
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +28 -1
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/tmux.d.ts +8 -1
  14. package/dist/agent/tmux.d.ts.map +1 -1
  15. package/dist/agent/tmux.js +61 -1
  16. package/dist/agent/tmux.js.map +1 -1
  17. package/dist/api/projects.js +18 -3
  18. package/dist/api/projects.js.map +1 -1
  19. package/dist/config/normalizer.d.ts.map +1 -1
  20. package/dist/config/normalizer.js +1 -0
  21. package/dist/config/normalizer.js.map +1 -1
  22. package/dist/config/validator.d.ts +3 -1
  23. package/dist/config/validator.d.ts.map +1 -1
  24. package/dist/config/validator.js +104 -7
  25. package/dist/config/validator.js.map +1 -1
  26. package/dist/event/handlers.d.ts.map +1 -1
  27. package/dist/event/handlers.js +157 -47
  28. package/dist/event/handlers.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +8 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/platform/body-digest.d.ts +4 -0
  33. package/dist/platform/body-digest.d.ts.map +1 -0
  34. package/dist/platform/body-digest.js +13 -0
  35. package/dist/platform/body-digest.js.map +1 -0
  36. package/dist/platform/command-renderer.d.ts +22 -0
  37. package/dist/platform/command-renderer.d.ts.map +1 -0
  38. package/dist/platform/command-renderer.js +82 -0
  39. package/dist/platform/command-renderer.js.map +1 -0
  40. package/dist/platform/comment-cursor.d.ts +36 -0
  41. package/dist/platform/comment-cursor.d.ts.map +1 -0
  42. package/dist/platform/comment-cursor.js +235 -0
  43. package/dist/platform/comment-cursor.js.map +1 -0
  44. package/dist/platform/driver-spec.d.ts +7 -0
  45. package/dist/platform/driver-spec.d.ts.map +1 -0
  46. package/dist/platform/driver-spec.js +475 -0
  47. package/dist/platform/driver-spec.js.map +1 -0
  48. package/dist/platform/field-mapper.d.ts +7 -0
  49. package/dist/platform/field-mapper.d.ts.map +1 -0
  50. package/dist/platform/field-mapper.js +117 -0
  51. package/dist/platform/field-mapper.js.map +1 -0
  52. package/dist/platform/git-driver.d.ts +55 -0
  53. package/dist/platform/git-driver.d.ts.map +1 -0
  54. package/dist/platform/git-driver.js +171 -0
  55. package/dist/platform/git-driver.js.map +1 -0
  56. package/dist/platform/manifest.d.ts +12 -0
  57. package/dist/platform/manifest.d.ts.map +1 -0
  58. package/dist/platform/manifest.js +57 -0
  59. package/dist/platform/manifest.js.map +1 -0
  60. package/dist/platform/markers.d.ts +46 -0
  61. package/dist/platform/markers.d.ts.map +1 -0
  62. package/dist/platform/markers.js +121 -0
  63. package/dist/platform/markers.js.map +1 -0
  64. package/dist/platform/platform-poller.d.ts +67 -0
  65. package/dist/platform/platform-poller.d.ts.map +1 -0
  66. package/dist/platform/platform-poller.js +632 -0
  67. package/dist/platform/platform-poller.js.map +1 -0
  68. package/dist/platform/plugin-registry.d.ts +31 -0
  69. package/dist/platform/plugin-registry.d.ts.map +1 -0
  70. package/dist/platform/plugin-registry.js +238 -0
  71. package/dist/platform/plugin-registry.js.map +1 -0
  72. package/dist/platform/plugin-roots.d.ts +3 -0
  73. package/dist/platform/plugin-roots.d.ts.map +1 -0
  74. package/dist/platform/plugin-roots.js +11 -0
  75. package/dist/platform/plugin-roots.js.map +1 -0
  76. package/dist/platform/plugins/github/baxian-plugin.json +8 -0
  77. package/dist/platform/plugins/github/driver.json +134 -0
  78. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +99 -0
  79. package/dist/platform/response-parser.d.ts +6 -0
  80. package/dist/platform/response-parser.d.ts.map +1 -0
  81. package/dist/platform/response-parser.js +92 -0
  82. package/dist/platform/response-parser.js.map +1 -0
  83. package/dist/platform/row-schema.d.ts +12 -0
  84. package/dist/platform/row-schema.d.ts.map +1 -0
  85. package/dist/platform/row-schema.js +127 -0
  86. package/dist/platform/row-schema.js.map +1 -0
  87. package/dist/platform/startup.d.ts +16 -0
  88. package/dist/platform/startup.d.ts.map +1 -0
  89. package/dist/platform/startup.js +52 -0
  90. package/dist/platform/startup.js.map +1 -0
  91. package/dist/platform/types.d.ts +56 -0
  92. package/dist/platform/types.d.ts.map +1 -0
  93. package/dist/platform/types.js +32 -0
  94. package/dist/platform/types.js.map +1 -0
  95. package/dist/platform/verdict-engine.d.ts +54 -0
  96. package/dist/platform/verdict-engine.d.ts.map +1 -0
  97. package/dist/platform/verdict-engine.js +125 -0
  98. package/dist/platform/verdict-engine.js.map +1 -0
  99. package/dist/shared/constants.d.ts +2 -0
  100. package/dist/shared/constants.d.ts.map +1 -1
  101. package/dist/shared/constants.js +3 -0
  102. package/dist/shared/constants.js.map +1 -1
  103. package/dist/shared/git-url.d.ts +8 -0
  104. package/dist/shared/git-url.d.ts.map +1 -1
  105. package/dist/shared/git-url.js +38 -0
  106. package/dist/shared/git-url.js.map +1 -1
  107. package/dist/shared/types.d.ts +17 -1
  108. package/dist/shared/types.d.ts.map +1 -1
  109. package/dist/shared/types.js.map +1 -1
  110. package/dist/state/agent-store.js +2 -0
  111. package/dist/state/agent-store.js.map +1 -1
  112. package/dist/state/task-store.d.ts.map +1 -1
  113. package/dist/state/task-store.js +20 -2
  114. package/dist/state/task-store.js.map +1 -1
  115. package/dist/web/assets/index-DskVWe7e.js +13 -0
  116. package/dist/web/index.html +1 -1
  117. package/package.json +1 -1
  118. package/dist/web/assets/index-3Ypk8Gfo.js +0 -13
@@ -147,6 +147,16 @@ const RUNTIME_LIVENESS_SAMPLES = 3;
147
147
  export function canDispatchWithBinding(binding) {
148
148
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
149
149
  }
150
+ // 派发期 hold:prompt 从未注入、pane 无在途回合,重派安全;ack_unknown 一类不在此列
151
+ export const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
152
+ 'checkout-preparation-failed',
153
+ 'dirty-workdir',
154
+ ]);
155
+ export function isRecoverableQaDispatchHold(binding) {
156
+ return binding?.status === 'awaiting_human'
157
+ && binding.awaitingPhase != null
158
+ && RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
159
+ }
150
160
  const TURN_COMPLETED_AWAITING_PHASES = new Set();
151
161
  const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
152
162
  function isCancelCleanupHold(binding) {
@@ -163,12 +173,24 @@ function cancelPhaseDowngrades(prev, next) {
163
173
  return cancelPhaseRank(next) < cancelPhaseRank(prev);
164
174
  }
165
175
  const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
176
+ const CHECKOUT_HOLD_PHASES = new Set([
177
+ 'dirty-workdir',
178
+ 'checkout-preparation-failed',
179
+ 'branch-cleanup-pending',
180
+ ]);
166
181
  function shouldReleaseHeldBinding(state, boundTask) {
167
182
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
168
183
  return false;
169
184
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
170
185
  const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
171
- return !boundTask || taskIsTerminal || turnCompleted;
186
+ // 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
187
+ // (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
188
+ const staleCodeHandoff = state.awaitingPhase != null
189
+ && CHECKOUT_HOLD_PHASES.has(state.awaitingPhase)
190
+ && !!boundTask
191
+ && (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
192
+ && boundTask.agentId !== state.id;
193
+ return !boundTask || taskIsTerminal || turnCompleted || staleCodeHandoff;
172
194
  }
173
195
  export class AgentManager {
174
196
  config;
@@ -1493,17 +1515,43 @@ export class AgentManager {
1493
1515
  `(expected ${expectedTaskId}, got ${state.taskId}); skipping`);
1494
1516
  return false;
1495
1517
  }
1518
+ if (opts.expectedLockToken !== undefined && state.lockToken !== opts.expectedLockToken) {
1519
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} lock generation changed ` +
1520
+ `(binding re-acquired by a successor); skipping release`);
1521
+ return false;
1522
+ }
1496
1523
  if (isCancelCleanupHold(state) && !opts.fromCancelCleanup) {
1497
1524
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} ${state.awaitingPhase} (cancel-cleanup hold); refusing auto-release`);
1498
1525
  return false;
1499
1526
  }
1500
1527
  const boundTask = await this.taskStore.get(expectedTaskId);
1528
+ if (opts.expectedTask
1529
+ && boundTask
1530
+ && (boundTask.status !== opts.expectedTask.status
1531
+ || boundTask.phase !== opts.expectedTask.phase
1532
+ || boundTask.signalToken !== opts.expectedTask.signalToken)) {
1533
+ console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
1534
+ return false;
1535
+ }
1501
1536
  if (state.status === 'awaiting_human' && !opts.allowAwaitingHuman) {
1502
1537
  if (!shouldReleaseHeldBinding(state, boundTask)) {
1503
1538
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} is awaiting_human (${state.awaitingPhase}); refusing to release`);
1504
1539
  return false;
1505
1540
  }
1506
1541
  }
1542
+ // expectedHold 是 hold 世代快照的严格 CAS(全 undefined 表示“期望无 hold”):
1543
+ // hold 消失(被并发 Resume 清掉)或换代同样意味着接管,不得继续释放
1544
+ const matchesExpectedHold = (binding) => {
1545
+ const held = binding.status === 'awaiting_human';
1546
+ return (held ? binding.awaitingPhase : undefined) === opts.expectedHold.phase
1547
+ && (held ? binding.awaitingSince : undefined) === opts.expectedHold.since
1548
+ && (held ? binding.awaitingNonce : undefined) === opts.expectedHold.nonce;
1549
+ };
1550
+ // 世代门前置:hold 换代时任何 pane/workdir 副作用都不得发生(末尾 CAS 只兜最后窗口)
1551
+ if (opts.expectedHold && !matchesExpectedHold(state)) {
1552
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed or cleared before release; refusing`);
1553
+ return false;
1554
+ }
1507
1555
  if (mode === 'waiting' && (!boundTask || !ACTIVE_TASK_STATUSES.has(boundTask.status))) {
1508
1556
  console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} is not active; skipping waiting transition`);
1509
1557
  return false;
@@ -1518,10 +1566,19 @@ export class AgentManager {
1518
1566
  }
1519
1567
  const now = new Date().toISOString();
1520
1568
  if (mode === 'waiting') {
1569
+ let preservedNewerHold = false;
1521
1570
  await this.agentStore.update(agentId, (latest) => {
1522
1571
  if (!latest)
1523
1572
  return AGENT_STORE_NOOP;
1524
1573
  if (opts.clearAwaitingHuman && latest.status === 'awaiting_human') {
1574
+ // The hold-generation CAS lives inside the atomic update: a hold written after any pre-read survives.
1575
+ if (opts.expectedHold
1576
+ && (latest.awaitingPhase !== opts.expectedHold.phase
1577
+ || latest.awaitingSince !== opts.expectedHold.since
1578
+ || latest.awaitingNonce !== opts.expectedHold.nonce)) {
1579
+ preservedNewerHold = true;
1580
+ return AGENT_STORE_NOOP;
1581
+ }
1525
1582
  const cleared = {
1526
1583
  id: latest.id,
1527
1584
  projectId: latest.projectId,
@@ -1540,6 +1597,10 @@ export class AgentManager {
1540
1597
  updatedAt: now,
1541
1598
  };
1542
1599
  });
1600
+ if (preservedNewerHold) {
1601
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
1602
+ return false;
1603
+ }
1543
1604
  return true;
1544
1605
  }
1545
1606
  let sessionConfirmedAbsent = false;
@@ -1547,6 +1608,7 @@ export class AgentManager {
1547
1608
  const releaseWorkdir = state.workdir;
1548
1609
  const runner = this.createRunnerFor(cfg);
1549
1610
  const tmux = new TmuxManager(runner);
1611
+ const holdMatchesExpected = (binding) => !opts.expectedHold || matchesExpectedHold(binding);
1550
1612
  const hold = async (reason) => {
1551
1613
  const latest = await this.agentStore.get(agentId);
1552
1614
  if (!latest
@@ -1555,18 +1617,27 @@ export class AgentManager {
1555
1617
  || !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
1556
1618
  return false;
1557
1619
  }
1620
+ // 释放中被写入的新 hold(如 ack_unknown)比 cleanup 失败信息更关键,不得覆盖
1621
+ if (!holdMatchesExpected(latest)) {
1622
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; not overwriting with branch-cleanup-pending`);
1623
+ return false;
1624
+ }
1558
1625
  const pendingAt = new Date().toISOString();
1559
1626
  if (boundTask && cfg.role === 'dev') {
1560
1627
  boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
1561
1628
  boundTask.updatedAt = pendingAt;
1562
1629
  await this.taskStore.set(boundTask);
1563
1630
  }
1564
- await this.agentStore.update(agentId, (latest) => {
1565
- if (!latest || latest.taskId !== expectedTaskId || latest.lockToken !== lockToken) {
1631
+ let wrote = false;
1632
+ await this.agentStore.update(agentId, (latest2) => {
1633
+ if (!latest2 || latest2.taskId !== expectedTaskId || latest2.lockToken !== lockToken) {
1566
1634
  return AGENT_STORE_NOOP;
1567
1635
  }
1636
+ if (!holdMatchesExpected(latest2))
1637
+ return AGENT_STORE_NOOP;
1638
+ wrote = true;
1568
1639
  return {
1569
- ...latest,
1640
+ ...latest2,
1570
1641
  status: 'awaiting_human',
1571
1642
  awaitingPhase: 'branch-cleanup-pending',
1572
1643
  awaitingReason: reason,
@@ -1574,6 +1645,10 @@ export class AgentManager {
1574
1645
  updatedAt: pendingAt,
1575
1646
  };
1576
1647
  });
1648
+ if (!wrote) {
1649
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} branch-cleanup-pending hold not written (binding or hold changed)`);
1650
+ return false;
1651
+ }
1577
1652
  await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
1578
1653
  phase: 'branch-cleanup-pending',
1579
1654
  reason,
@@ -1608,6 +1683,14 @@ export class AgentManager {
1608
1683
  sessionConfirmedAbsent = true;
1609
1684
  }
1610
1685
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1686
+ // 探测/等待期间 markAwaitingHuman 不受本锁串行;进入 checkout 变更前按当前盘面重验世代
1687
+ if (opts.expectedHold) {
1688
+ const beforeBranchOps = await this.agentStore.get(agentId);
1689
+ if (!beforeBranchOps || !matchesExpectedHold(beforeBranchOps)) {
1690
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed before checkout cleanup; aborting release`);
1691
+ return false;
1692
+ }
1693
+ }
1611
1694
  if (cfg.role === 'dev' && boundTask) {
1612
1695
  const branches = new BranchManager(runner);
1613
1696
  const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
@@ -1651,6 +1734,13 @@ export class AgentManager {
1651
1734
  delete boundTask.branchCleanupSkipped;
1652
1735
  cleanupStateChanged = true;
1653
1736
  }
1737
+ if (cleanup.remoteTipSha) {
1738
+ boundTask.branchLocalCleaned = {
1739
+ remoteTipSha: cleanup.remoteTipSha,
1740
+ updatedAt: new Date().toISOString(),
1741
+ };
1742
+ cleanupStateChanged = true;
1743
+ }
1654
1744
  }
1655
1745
  if (cleanupStateChanged) {
1656
1746
  boundTask.updatedAt = new Date().toISOString();
@@ -1677,6 +1767,7 @@ export class AgentManager {
1677
1767
  else {
1678
1768
  await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1679
1769
  }
1770
+ let holdGenerationChanged = false;
1680
1771
  await this.agentStore.update(agentId, (existing) => {
1681
1772
  if (!existing)
1682
1773
  return AGENT_STORE_NOOP;
@@ -1685,6 +1776,11 @@ export class AgentManager {
1685
1776
  || (state.workdir !== undefined && existing.workdir !== state.workdir)) {
1686
1777
  return AGENT_STORE_NOOP;
1687
1778
  }
1779
+ // 世代 CAS 在原子写入内判定:预读后被重写或清除的 hold 都不得被这次释放清掉
1780
+ if (opts.expectedHold && !matchesExpectedHold(existing)) {
1781
+ holdGenerationChanged = true;
1782
+ return AGENT_STORE_NOOP;
1783
+ }
1688
1784
  return {
1689
1785
  id: existing.id,
1690
1786
  projectId: existing.projectId,
@@ -1694,9 +1790,25 @@ export class AgentManager {
1694
1790
  updatedAt: now,
1695
1791
  };
1696
1792
  });
1793
+ if (holdGenerationChanged) {
1794
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
1795
+ return false;
1796
+ }
1697
1797
  return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1698
1798
  });
1699
1799
  }
1800
+ async clearBranchLocalCleaned(taskId) {
1801
+ await this.withTaskLock(async () => {
1802
+ const fresh = await this.taskStore.get(taskId);
1803
+ if (!fresh?.branchLocalCleaned)
1804
+ return;
1805
+ delete fresh.branchLocalCleaned;
1806
+ fresh.updatedAt = new Date().toISOString();
1807
+ await this.taskStore.set(fresh);
1808
+ }).catch(err => {
1809
+ console.warn(`[AgentManager] clearing branchLocalCleaned(${taskId}) failed:`, err);
1810
+ });
1811
+ }
1700
1812
  async releaseAgentIfBound(agentId, expectedTaskId, opts) {
1701
1813
  const state = await this.agentStore.get(agentId);
1702
1814
  if (state?.taskId !== expectedTaskId)
@@ -1908,14 +2020,20 @@ export class AgentManager {
1908
2020
  this.phaseSignalWatcher?.rearmNeedInput(agentId);
1909
2021
  await this.setAgentNeedInput(agentId, false);
1910
2022
  }
1911
- async clearAwaitingHuman(agentId) {
2023
+ async clearAwaitingHuman(agentId, opts = {}) {
1912
2024
  const now = new Date().toISOString();
1913
2025
  let cleared = false;
1914
2026
  await this.agentStore.update(agentId, (latest) => {
1915
2027
  if (!latest || latest.status !== 'awaiting_human')
1916
2028
  return AGENT_STORE_NOOP;
2029
+ if (opts.expectedHold
2030
+ && (latest.awaitingPhase !== opts.expectedHold.phase
2031
+ || latest.awaitingSince !== opts.expectedHold.since
2032
+ || latest.awaitingNonce !== opts.expectedHold.nonce)) {
2033
+ return AGENT_STORE_NOOP;
2034
+ }
1917
2035
  cleared = true;
1918
- const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, ...readyState } = latest;
2036
+ const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, awaitingNonce: _awaitingNonce, ...readyState } = latest;
1919
2037
  return {
1920
2038
  ...readyState,
1921
2039
  updatedAt: now,
@@ -1931,6 +2049,16 @@ export class AgentManager {
1931
2049
  await this.agentStore.update(agentId, (existing) => {
1932
2050
  if (!existing)
1933
2051
  return AGENT_STORE_NOOP;
2052
+ if (opts.expectedHold) {
2053
+ const heldPhase = existing.status === 'awaiting_human' ? existing.awaitingPhase : undefined;
2054
+ const heldSince = existing.status === 'awaiting_human' ? existing.awaitingSince : undefined;
2055
+ const heldNonce = existing.status === 'awaiting_human' ? existing.awaitingNonce : undefined;
2056
+ if (heldPhase !== opts.expectedHold.phase
2057
+ || heldSince !== opts.expectedHold.since
2058
+ || heldNonce !== opts.expectedHold.nonce) {
2059
+ return AGENT_STORE_NOOP;
2060
+ }
2061
+ }
1934
2062
  if (opts.expectedCreationToken !== undefined) {
1935
2063
  const expected = opts.expectedCreationToken;
1936
2064
  const actual = existing.creationToken ?? null;
@@ -1943,6 +2071,10 @@ export class AgentManager {
1943
2071
  if (actualTask !== expectedTask)
1944
2072
  return AGENT_STORE_NOOP;
1945
2073
  }
2074
+ // 锁代 CAS:绑定被 successor re-acquire 后不得把工作中的 agent 挂回旧 hold
2075
+ if (opts.expectedLockToken !== undefined && existing.lockToken !== opts.expectedLockToken) {
2076
+ return AGENT_STORE_NOOP;
2077
+ }
1946
2078
  if (isCancelCleanupHold(existing)) {
1947
2079
  if (!CANCEL_CLEANUP_HOLD_PHASES.has(phase))
1948
2080
  return AGENT_STORE_NOOP;
@@ -1959,15 +2091,17 @@ export class AgentManager {
1959
2091
  awaitingPhase: phase,
1960
2092
  awaitingReason: reason,
1961
2093
  awaitingSince: now,
2094
+ awaitingNonce: randomBytes(8).toString('hex'),
1962
2095
  updatedAt: now,
1963
2096
  };
1964
2097
  });
1965
2098
  if (!wrote)
1966
- return;
2099
+ return false;
1967
2100
  await this.emitIntervention(projectId, agentId, taskId, {
1968
2101
  phase,
1969
2102
  reason,
1970
2103
  });
2104
+ return true;
1971
2105
  }
1972
2106
  async markAwaitingIfAckUnknown(agentId, err, expectedTaskId) {
1973
2107
  if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
@@ -2018,7 +2152,69 @@ export class AgentManager {
2018
2152
  return { resumed: false, releasedBinding: false, reason };
2019
2153
  }
2020
2154
  if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
2021
- && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
2155
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
2156
+ // verdict 门禁状态没有在途派发:清掉 hold 后由 spec verdict 重新走完整转码,Resume 放行
2157
+ && boundTask.status !== 'spec-ready' && boundTask.status !== 'max_rounds') {
2158
+ if (state.taskId && boundTask.qaAgentId === agentId) {
2159
+ // 停驻的 review pass 已死(任务离开 review):重派会伪造新 pass,只清 hold 并释放绑定;
2160
+ // 快照 hold/task 世代供释放路径 CAS,且不得借用 cancel-cleanup 的绕行凭据
2161
+ if (boundTask.status !== 'review') {
2162
+ return {
2163
+ resumed: true,
2164
+ releasedBinding: false,
2165
+ releaseHeldQa: {
2166
+ taskId: state.taskId,
2167
+ expectedHold: {
2168
+ phase: state.awaitingPhase,
2169
+ since: state.awaitingSince,
2170
+ nonce: state.awaitingNonce,
2171
+ },
2172
+ expectedTask: {
2173
+ status: boundTask.status,
2174
+ phase: boundTask.phase,
2175
+ signalToken: boundTask.signalToken,
2176
+ },
2177
+ },
2178
+ projectId: state.projectId,
2179
+ previousPhase: state.awaitingPhase,
2180
+ };
2181
+ }
2182
+ const nowQa = new Date().toISOString();
2183
+ let clearedHold = false;
2184
+ await this.agentStore.update(agentId, (existing) => {
2185
+ if (!existing || existing.taskId !== state.taskId)
2186
+ return AGENT_STORE_NOOP;
2187
+ if (existing.awaitingPhase !== state.awaitingPhase
2188
+ || existing.awaitingSince !== state.awaitingSince
2189
+ || existing.awaitingNonce !== state.awaitingNonce) {
2190
+ return AGENT_STORE_NOOP;
2191
+ }
2192
+ clearedHold = true;
2193
+ return {
2194
+ ...existing,
2195
+ status: 'ok',
2196
+ awaitingPhase: undefined,
2197
+ awaitingReason: undefined,
2198
+ awaitingSince: undefined,
2199
+ awaitingNonce: undefined,
2200
+ updatedAt: nowQa,
2201
+ };
2202
+ });
2203
+ if (!clearedHold) {
2204
+ const reason = `Agent ${agentId} hold changed while resuming; re-check the agent state and Resume again.`;
2205
+ console.warn(`[AgentManager] resumeAgent: ${reason}`);
2206
+ return { resumed: false, releasedBinding: false, reason };
2207
+ }
2208
+ return {
2209
+ resumed: true,
2210
+ releasedBinding: false,
2211
+ redispatchReviewTaskId: state.taskId,
2212
+ reviewPassToken: boundTask.signalToken,
2213
+ heldLockToken: state.lockToken,
2214
+ projectId: state.projectId,
2215
+ previousPhase: state.awaitingPhase,
2216
+ };
2217
+ }
2022
2218
  const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
2023
2219
  'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
2024
2220
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
@@ -2117,7 +2313,7 @@ export class AgentManager {
2117
2313
  if (result.redispatchCodeTaskId) {
2118
2314
  try {
2119
2315
  const task = await this.taskStore.get(result.redispatchCodeTaskId);
2120
- const round = task?.specReviewRound;
2316
+ const round = task ? (task.specReviewRound ?? 1) : undefined;
2121
2317
  const stored = round === undefined
2122
2318
  ? null
2123
2319
  : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
@@ -2131,6 +2327,12 @@ export class AgentManager {
2131
2327
  await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
2132
2328
  return { resumed: false, releasedBinding: false, reason };
2133
2329
  }
2330
+ // 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
2331
+ // 会让下一次 recover 把已送达的 code prompt 再判成未送达
2332
+ if (!this.isResearchHandoff(task, agentId)) {
2333
+ const lockToken = (await this.agentStore.get(agentId))?.lockToken;
2334
+ await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
2335
+ }
2134
2336
  }
2135
2337
  catch (err) {
2136
2338
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
@@ -2139,6 +2341,126 @@ export class AgentManager {
2139
2341
  return { resumed: false, releasedBinding: false, reason };
2140
2342
  }
2141
2343
  }
2344
+ if (result.releaseHeldQa) {
2345
+ const { taskId: heldTaskId, expectedHold, expectedTask } = result.releaseHeldQa;
2346
+ const released = await this.releaseAgentForTask(agentId, heldTaskId, 'idle', {
2347
+ allowAwaitingHuman: true,
2348
+ expectedHold,
2349
+ expectedTask,
2350
+ });
2351
+ if (!released) {
2352
+ const refreshed = await this.agentStore.get(agentId).catch((readErr) => {
2353
+ console.warn(`[AgentManager] resumeAgent post-release read failed for ${agentId}:`, readErr);
2354
+ return null;
2355
+ });
2356
+ return {
2357
+ resumed: false,
2358
+ releasedBinding: false,
2359
+ reason: refreshed?.awaitingReason
2360
+ ?? `Agent ${agentId} could not safely release task ${heldTaskId}; its pass or hold moved on — re-check the agent state and Resume again.`,
2361
+ };
2362
+ }
2363
+ await this.emitIntervention(result.projectId, agentId, heldTaskId, {
2364
+ phase: 'resumed',
2365
+ previousPhase: result.previousPhase,
2366
+ releasedBinding: true,
2367
+ });
2368
+ return { resumed: true, releasedBinding: true };
2369
+ }
2370
+ if (result.redispatchReviewTaskId) {
2371
+ try {
2372
+ await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
2373
+ bumpRound: false,
2374
+ fromStatus: ['review'],
2375
+ ...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
2376
+ });
2377
+ }
2378
+ catch (err) {
2379
+ console.error(`[AgentManager] resumeAgent review redispatch failed for ${agentId}:`, err);
2380
+ const reason = `QA review redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
2381
+ const reviewTaskId = result.redispatchReviewTaskId;
2382
+ // 锁代未变(未被 successor re-acquire)才允许补挂;markAwaitingHuman 内的 CAS 原子复核同一条件
2383
+ const restoreHoldIfStillOurs = async (phase, holdReason) => {
2384
+ const after = await this.agentStore.get(agentId);
2385
+ if (after?.taskId === reviewTaskId
2386
+ && after.status !== 'awaiting_human'
2387
+ && after.lockToken === result.heldLockToken) {
2388
+ await this.markAwaitingHuman(agentId, phase, holdReason, {
2389
+ expectedTaskId: reviewTaskId,
2390
+ ...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
2391
+ });
2392
+ }
2393
+ };
2394
+ // handled 只代表 startSession 已尝试落 hold;落库可能已失败,必须核实并按需补挂
2395
+ if (err instanceof EnsureSessionError && err.partial.handled) {
2396
+ try {
2397
+ await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
2398
+ return { resumed: false, releasedBinding: false, reason };
2399
+ }
2400
+ catch (holdErr) {
2401
+ const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
2402
+ console.error(`[AgentManager] resumeAgent could not verify/restore the handled hold for ${agentId}:`, holdErr);
2403
+ return {
2404
+ resumed: false,
2405
+ releasedBinding: false,
2406
+ reason: `${reason}; verifying/restoring the hold also failed: ${holdMsg} — the agent may be missing its awaiting_human flag, inspect it manually`,
2407
+ };
2408
+ }
2409
+ }
2410
+ // ack_unknown:prompt 可能已在跑,必须恢复 ack hold 的可见性,而非按普通失败补挂 checkout hold
2411
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
2412
+ try {
2413
+ await restoreHoldIfStillOurs('dispatch-failed:ack_unknown', `${err.message}. Prompt may still be running in the pane; verify before resuming.`);
2414
+ return { resumed: false, releasedBinding: false, reason };
2415
+ }
2416
+ catch (holdErr) {
2417
+ const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
2418
+ console.error(`[AgentManager] resumeAgent could not restore the ack_unknown hold for ${agentId}:`, holdErr);
2419
+ return {
2420
+ resumed: false,
2421
+ releasedBinding: false,
2422
+ reason: `${reason}; restoring the dispatch-failed:ack_unknown hold also failed: ${holdMsg} — prompt state is unknown and the agent is NOT flagged awaiting_human, inspect it manually`,
2423
+ };
2424
+ }
2425
+ }
2426
+ // pass 被接管(token 轮换或任务离开 review)说明该 pass 已死,补挂会把新 pass 的 QA 打成 hold
2427
+ let passSuperseded = false;
2428
+ let recoveryReadFailed = false;
2429
+ try {
2430
+ const taskNow = await this.taskStore.get(reviewTaskId);
2431
+ passSuperseded = taskNow?.status !== 'review'
2432
+ || (result.reviewPassToken !== undefined && taskNow.signalToken !== result.reviewPassToken);
2433
+ }
2434
+ catch (readErr) {
2435
+ recoveryReadFailed = true;
2436
+ console.error(`[AgentManager] resumeAgent recovery task read failed for ${agentId}:`, readErr);
2437
+ }
2438
+ if (passSuperseded)
2439
+ return { resumed: false, releasedBinding: false, reason };
2440
+ try {
2441
+ if (recoveryReadFailed) {
2442
+ // 读失败时无法证明接管,优先恢复可见性:锁代/绑定 CAS 全部由 markAwaitingHuman 原子判定
2443
+ await this.markAwaitingHuman(agentId, result.previousPhase ?? 'checkout-preparation-failed', reason, {
2444
+ expectedTaskId: reviewTaskId,
2445
+ ...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
2446
+ });
2447
+ }
2448
+ else {
2449
+ await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
2450
+ }
2451
+ return { resumed: false, releasedBinding: false, reason };
2452
+ }
2453
+ catch (holdErr) {
2454
+ const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
2455
+ console.error(`[AgentManager] resumeAgent could not restore the QA hold for ${agentId}:`, holdErr);
2456
+ return {
2457
+ resumed: false,
2458
+ releasedBinding: false,
2459
+ reason: `${reason}; restoring the hold also failed: ${holdMsg} — the agent is NOT flagged awaiting_human, inspect it manually`,
2460
+ };
2461
+ }
2462
+ }
2463
+ }
2142
2464
  return {
2143
2465
  resumed: result.resumed,
2144
2466
  releasedBinding: result.releasedBinding,
@@ -2495,41 +2817,339 @@ export class AgentManager {
2495
2817
  };
2496
2818
  });
2497
2819
  }
2498
- async redispatchResearchAfterReplRestart(agentId, taskId) {
2820
+ // Holds only the pass captured in the task tuple: a successor pass (rotated token/phase/status) is never overwritten.
2821
+ async holdReplayFailureIfCurrent(agentId, taskAtEntry, holdPhase, reason, entryHold) {
2822
+ return this.withTaskLock(async () => {
2823
+ const fresh = await this.taskStore.get(taskAtEntry.id);
2824
+ if (fresh?.agentId !== agentId
2825
+ || fresh.status !== taskAtEntry.status
2826
+ || fresh.phase !== taskAtEntry.phase
2827
+ || fresh.signalToken !== taskAtEntry.signalToken)
2828
+ return false;
2829
+ if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
2830
+ expectedTaskId: taskAtEntry.id,
2831
+ expectedHold: entryHold,
2832
+ }))
2833
+ return true;
2834
+ // Entry hold gone (Resume raced): write over the empty generation, never over a foreign hold.
2835
+ if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
2836
+ expectedTaskId: taskAtEntry.id,
2837
+ expectedHold: { phase: undefined, since: undefined, nonce: undefined },
2838
+ }))
2839
+ return true;
2840
+ const now = await this.agentStore.get(agentId);
2841
+ return now?.status === 'awaiting_human';
2842
+ });
2843
+ }
2844
+ async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
2499
2845
  const agent = this.getAgentConfig(agentId);
2500
2846
  const task = await this.taskStore.get(taskId);
2501
- if (!agent || agent.role !== 'research' || !task)
2847
+ if (!agent || !task || !TASK_OWNER_ROLES.has(agent.role))
2502
2848
  return false;
2503
- if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
2849
+ if (task.agentId !== agentId)
2504
2850
  return false;
2505
- let phase;
2506
- let expectedKind;
2507
- let findings;
2508
- if (task.phase === 'research' && task.status === 'in_progress') {
2509
- phase = 'research';
2510
- expectedKind = 'spec-done';
2511
- }
2512
- else if (task.phase === 'spec' && task.status === 'fixing') {
2513
- const round = task.specReviewRound ?? 1;
2514
- const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2515
- if (!stored?.findings)
2851
+ const participantId = agent.role === 'research' ? task.researchAgentId : task.devAgentId;
2852
+ if (participantId !== agentId)
2853
+ return false;
2854
+ const bindingAtEntry = await this.agentStore.get(agentId);
2855
+ const entryHold = {
2856
+ phase: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingPhase : undefined,
2857
+ since: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingSince : undefined,
2858
+ nonce: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingNonce : undefined,
2859
+ };
2860
+ // Concurrent passes can advance the task mid-replay; every replay gate re-checks this snapshot.
2861
+ const taskStillCurrent = async () => {
2862
+ const fresh = await this.taskStore.get(taskId);
2863
+ return fresh?.agentId === agentId
2864
+ && fresh.status === task.status
2865
+ && fresh.phase === task.phase
2866
+ && fresh.signalToken === task.signalToken;
2867
+ };
2868
+ // Hold ops share one task-mutation slot with a tuple re-check, and CAS on the entry hold generation.
2869
+ const clearEntryHold = async () => this.withTaskLock(async () => {
2870
+ if (!(await taskStillCurrent()))
2871
+ return false;
2872
+ if (entryHold.phase === undefined) {
2873
+ const now = await this.agentStore.get(agentId);
2874
+ return now?.status !== 'awaiting_human';
2875
+ }
2876
+ return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
2877
+ });
2878
+ const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
2879
+ // skipSnapshot: stale scrollback literals belong to the aborted runtime; onlyReplaceOwnToken keeps a successor's watcher out of reach.
2880
+ const fencedReplay = (expectedKinds, token, stillCurrent) => {
2881
+ const abortStaleReplay = async () => {
2882
+ if (await stillCurrent())
2883
+ return true;
2884
+ this.phaseSignalWatcher?.stopIfToken(taskId, token);
2885
+ return false;
2886
+ };
2887
+ return {
2888
+ armBeforeInject: async () => {
2889
+ if (!(await stillCurrent()))
2890
+ return false;
2891
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
2892
+ skipSnapshot: true,
2893
+ onlyReplaceOwnToken: true,
2894
+ });
2895
+ if (!armed) {
2896
+ if (!(await stillCurrent()))
2897
+ return false;
2898
+ // Own-generation arm failure: throwing routes the caller into the resumable hold.
2899
+ throw new Error(`replay signal watcher failed to arm for task ${taskId}; Restart REPL again or cancel the task`);
2900
+ }
2901
+ return abortStaleReplay();
2902
+ },
2903
+ guardBeforeInject: abortStaleReplay,
2904
+ };
2905
+ };
2906
+ try {
2907
+ if (task.status === 'approved' && agent.role === 'dev') {
2908
+ return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
2909
+ }
2910
+ const mapped = this.mapTaskStateToExpectedWatcher(task);
2911
+ if (!mapped || mapped.agentId !== agentId)
2516
2912
  return false;
2517
- phase = 'server-feedback';
2518
- expectedKind = 'spec-fixed';
2519
- findings = JSON.stringify(stored.findings);
2913
+ if (!task.signalToken) {
2914
+ return await holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-pass but the task has no signal token to replay the prompt with; cancel the task or re-dispatch it.');
2915
+ }
2916
+ const signalToken = task.signalToken;
2917
+ const replayOpts = {
2918
+ signalToken,
2919
+ preserveDispatchOutputs: true,
2920
+ expectedHold: { phase: undefined, since: undefined, nonce: undefined },
2921
+ ...fencedReplay(mapped.expectedKinds, signalToken, taskStillCurrent),
2922
+ };
2923
+ const replayInitialBootstrap = async (phase, opts) => {
2924
+ // This hold means the prompt WAS delivered and only the marker clear failed; replaying would run it twice.
2925
+ if (entryHold.phase === 'bootstrap-marker-clear-failed') {
2926
+ return holdReplayFailure('restart-redispatch-failed', 'REPL restarted while the initial prompt was already delivered (its bootstrap marker clear had failed); replaying would dispatch the task twice. Resume and verify the Workdir, or cancel the task.');
2927
+ }
2928
+ const bindingBefore = await this.agentStore.get(agentId);
2929
+ if (!(await clearEntryHold()))
2930
+ return false;
2931
+ const resumed = await this.continueSession(taskId, agentId, phase, opts);
2932
+ if (resumed && bindingBefore?.bootstrappingTaskId === taskId) {
2933
+ await this.finalizeReplayedBootstrap(agentId, taskId, phase, bindingBefore.lockToken);
2934
+ }
2935
+ return resumed;
2936
+ };
2937
+ if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
2938
+ return await replayInitialBootstrap('research', replayOpts);
2939
+ }
2940
+ if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
2941
+ // An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
2942
+ const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
2943
+ return await replayInitialBootstrap('develop', {
2944
+ ...replayOpts,
2945
+ ...(delivered ? { allowDirtyWorkdir: true } : {}),
2946
+ });
2947
+ }
2948
+ if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
2949
+ const state = await this.agentStore.get(agentId);
2950
+ // A no-QA auto-approved spec never writes specReviewRound back; the code dispatch defaults to round 1.
2951
+ const round = task.specReviewRound ?? 1;
2952
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2953
+ if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
2954
+ 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.');
2955
+ }
2956
+ if (!(await clearEntryHold()))
2957
+ return false;
2958
+ return await this.continueSession(taskId, agentId, 'code', {
2959
+ ...replayOpts,
2960
+ allowDirtyWorkdir: true,
2961
+ specDocuments: stored.documents,
2962
+ ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2963
+ });
2964
+ }
2965
+ if (task.status === 'fixing' && task.phase === 'spec') {
2966
+ const round = task.specReviewRound ?? 1;
2967
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2968
+ if (!stored?.findings) {
2969
+ 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.`);
2970
+ }
2971
+ if (!(await clearEntryHold()))
2972
+ return false;
2973
+ return await this.continueSession(taskId, agentId, 'server-feedback', {
2974
+ ...replayOpts,
2975
+ ...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
2976
+ serverPriorFindings: JSON.stringify(stored.findings),
2977
+ ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2978
+ });
2979
+ }
2980
+ // A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
2981
+ if (task.status === 'fixing'
2982
+ && (task.phase === 'code' || task.phase === undefined)
2983
+ && agent.role === 'dev') {
2984
+ if (task.reviewMode === 'server') {
2985
+ const round = Math.max(task.reviewRound, 1);
2986
+ const stored = await this.reviewStore?.getRound(taskId, 'code', round);
2987
+ if (!stored?.findings) {
2988
+ 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.`);
2989
+ }
2990
+ if (!(await clearEntryHold()))
2991
+ return false;
2992
+ return await this.continueSession(taskId, agentId, 'server-feedback', {
2993
+ ...replayOpts,
2994
+ allowDirtyWorkdir: true,
2995
+ serverPriorFindings: JSON.stringify(stored.findings),
2996
+ });
2997
+ }
2998
+ if (!(await clearEntryHold()))
2999
+ return false;
3000
+ return await this.continueSession(taskId, agentId, 'fix', {
3001
+ ...replayOpts,
3002
+ allowDirtyWorkdir: true,
3003
+ });
3004
+ }
3005
+ return false;
3006
+ }
3007
+ catch (err) {
3008
+ // A post-entry throw may land after clearEntryHold; only a tuple-checked hold on the live generation is safe.
3009
+ const message = err instanceof Error ? err.message : String(err);
3010
+ if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
3011
+ return true;
3012
+ }
3013
+ throw err;
3014
+ }
3015
+ }
3016
+ // approved hosts two live holder passes — GitHub post-approve (PostApproveStore) and server after-done (task fields); a parked pre-publish holder stays on the waiting path.
3017
+ async replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, holdGen) {
3018
+ const taskId = task.id;
3019
+ const { clearEntryHold, holdReplayFailure } = holdGen;
3020
+ if (task.reviewMode === 'server') {
3021
+ if (!task.publishDispatchedAt)
3022
+ return false;
3023
+ if (!task.signalToken) {
3024
+ 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.');
3025
+ }
3026
+ const token = task.signalToken;
3027
+ const afterDone = this.resolveAfterDone(task);
3028
+ if (!afterDone) {
3029
+ 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.');
3030
+ }
3031
+ let violation;
3032
+ try {
3033
+ violation = await this.findLineageViolation(taskId);
3034
+ }
3035
+ catch (err) {
3036
+ const message = err instanceof Error ? err.message : String(err);
3037
+ 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.`);
3038
+ }
3039
+ if (violation) {
3040
+ 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.`);
3041
+ }
3042
+ if (!(await clearEntryHold()))
3043
+ return false;
3044
+ return this.continueSession(taskId, agentId, 'server-after-done', {
3045
+ signalToken: token,
3046
+ preserveDispatchOutputs: true,
3047
+ expectedHold: { phase: undefined, since: undefined, nonce: undefined },
3048
+ allowDirtyWorkdir: true,
3049
+ serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
3050
+ ...fencedReplay(['code-ready'], token, taskStillCurrent),
3051
+ });
3052
+ }
3053
+ if (task.postApproveRevoked) {
3054
+ return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
3055
+ 'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
3056
+ }
3057
+ const stored = await this.getPostApproveCompletion(taskId);
3058
+ let completion;
3059
+ if (stored) {
3060
+ if (!task.postApproveHeadSha) {
3061
+ return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but the stored completion cannot be tied to a persisted approved head; re-request the review verdict or cancel the task.');
3062
+ }
3063
+ if (stored.approvedHeadSha !== task.postApproveHeadSha) {
3064
+ // Provably a dead episode's residue: retire it by token, then rebuild for the current head.
3065
+ await this.clearPostApproveCompletionIfMatches(taskId, stored.token);
3066
+ completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
3067
+ }
3068
+ else {
3069
+ completion = { token: stored.token, rebuilt: false };
3070
+ }
2520
3071
  }
2521
3072
  else {
3073
+ completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
3074
+ }
3075
+ if (completion === 'held')
3076
+ return true;
3077
+ if (completion === 'stale')
2522
3078
  return false;
3079
+ const resolvedCompletion = completion;
3080
+ const completionStillCurrent = async () => {
3081
+ const fresh = await this.taskStore.get(taskId);
3082
+ return fresh?.agentId === agentId
3083
+ && fresh.status === task.status
3084
+ && fresh.phase === task.phase
3085
+ && fresh.signalToken === task.signalToken
3086
+ && fresh.postApproveRevoked === undefined
3087
+ && fresh.postApproveHeadSha === task.postApproveHeadSha
3088
+ && (await this.getPostApproveCompletion(taskId))?.token === resolvedCompletion.token;
3089
+ };
3090
+ if (!(await completionStillCurrent()))
3091
+ return false;
3092
+ if (!(await clearEntryHold()))
3093
+ return false;
3094
+ let resumed = false;
3095
+ try {
3096
+ resumed = await this.continueSession(taskId, agentId, 'post-approve', {
3097
+ signalToken: completion.token,
3098
+ preserveDispatchOutputs: true,
3099
+ expectedHold: { phase: undefined, since: undefined, nonce: undefined },
3100
+ allowDirtyWorkdir: true,
3101
+ ...fencedReplay(['pr-merge-ready'], completion.token, completionStillCurrent),
3102
+ });
2523
3103
  }
2524
- await this.clearAwaitingHuman(agentId);
2525
- return this.continueSession(taskId, agentId, phase, {
2526
- bypassTaskStatusGate: true,
2527
- signalToken: task.signalToken,
2528
- preserveDispatchOutputs: true,
2529
- ...(findings ? { serverPriorFindings: findings } : {}),
2530
- ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2531
- armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
3104
+ catch (err) {
3105
+ if (completion.rebuilt)
3106
+ await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3107
+ throw err;
3108
+ }
3109
+ if (!resumed && completion.rebuilt) {
3110
+ await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3111
+ const fresh = await this.taskStore.get(taskId);
3112
+ const episodeIntact = fresh?.agentId === agentId
3113
+ && fresh.status === task.status
3114
+ && fresh.phase === task.phase
3115
+ && fresh.signalToken === task.signalToken
3116
+ && fresh.postApproveRevoked === undefined
3117
+ && fresh.postApproveHeadSha === task.postApproveHeadSha;
3118
+ if (!episodeIntact)
3119
+ return false;
3120
+ return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass and the rebuilt prompt could not be delivered; Resume then Restart REPL to retry, or cancel the task.');
3121
+ }
3122
+ return resumed;
3123
+ }
3124
+ // A bare hold would loop (Resume clears it, the next restart misses the record again), so rebuild from the persisted approved head.
3125
+ async rebuildPostApproveCompletion(agentId, task, holdReplayFailure) {
3126
+ // latestHeadSha drifts with pushes/mismatch probes; only the head persisted at dispatch is provably approved.
3127
+ const approvedHeadSha = task.postApproveHeadSha;
3128
+ if (!approvedHeadSha) {
3129
+ const held = await holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but neither its completion context nor a provably approved head are persisted; cancel the task or re-request the review verdict.');
3130
+ return held ? 'held' : 'stale';
3131
+ }
3132
+ const token = createSignalToken();
3133
+ // Conditional create: the in-lock re-read rejects any revoke/dispatch landed since the snapshot.
3134
+ const created = await this.withTaskLock(async () => {
3135
+ const fresh = await this.taskStore.get(task.id);
3136
+ if (!fresh
3137
+ || fresh.agentId !== agentId
3138
+ || fresh.status !== 'approved'
3139
+ || fresh.phase !== task.phase
3140
+ || fresh.signalToken !== task.signalToken
3141
+ || fresh.postApproveRevoked !== undefined
3142
+ || fresh.postApproveHeadSha !== approvedHeadSha) {
3143
+ return false;
3144
+ }
3145
+ if (await this.postApproveStore.get(task.id))
3146
+ return false;
3147
+ await this.postApproveStore.set(task.id, { token, approvedHeadSha });
3148
+ return true;
2532
3149
  });
3150
+ if (!created)
3151
+ return 'stale';
3152
+ return { token, rebuilt: true };
2533
3153
  }
2534
3154
  prepareRemoveTargets(agentId) {
2535
3155
  const cfg = this.getAgentConfig(agentId);
@@ -2709,14 +3329,27 @@ export class AgentManager {
2709
3329
  async getPostApproveCompletion(taskId) {
2710
3330
  return this.postApproveStore.get(taskId);
2711
3331
  }
2712
- async setPostApproveCompletion(taskId, value) {
2713
- await this.withTaskLock(async () => {
2714
- await this.postApproveStore.set(taskId, value);
2715
- if (!this.phaseSignalWatcher)
2716
- return;
3332
+ // Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
3333
+ async setPostApproveCompletion(taskId, value, opts = {}) {
3334
+ return this.withTaskLock(async () => {
2717
3335
  const task = await this.taskStore.get(taskId);
3336
+ if (task) {
3337
+ if (task.status !== 'approved')
3338
+ return false;
3339
+ if (task.postApproveRevoked && !opts.clearRevocation)
3340
+ return false;
3341
+ if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
3342
+ return false;
3343
+ }
3344
+ await this.postApproveStore.set(taskId, value);
2718
3345
  if (!task)
2719
- return;
3346
+ return true;
3347
+ if (task.postApproveRevoked) {
3348
+ const { postApproveRevoked: _revoked, ...rest } = task;
3349
+ await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3350
+ }
3351
+ if (!this.phaseSignalWatcher)
3352
+ return true;
2720
3353
  await this.startPhaseSignalWatch({
2721
3354
  taskId,
2722
3355
  projectId: task.projectId,
@@ -2724,6 +3357,7 @@ export class AgentManager {
2724
3357
  expectedKinds: 'pr-merge-ready',
2725
3358
  token: value.token,
2726
3359
  });
3360
+ return true;
2727
3361
  });
2728
3362
  }
2729
3363
  async clearPostApproveCompletion(taskId) {
@@ -2732,6 +3366,46 @@ export class AgentManager {
2732
3366
  this.phaseSignalWatcher?.stop(taskId);
2733
3367
  });
2734
3368
  }
3369
+ // CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
3370
+ async updatePostApproveCompletionIfToken(taskId, expectedToken, patch) {
3371
+ return this.withTaskLock(async () => {
3372
+ const task = await this.taskStore.get(taskId);
3373
+ if (task?.postApproveRevoked)
3374
+ return false;
3375
+ const current = await this.postApproveStore.get(taskId);
3376
+ if (!current || current.token !== expectedToken)
3377
+ return false;
3378
+ await this.postApproveStore.set(taskId, { ...current, ...patch });
3379
+ return true;
3380
+ });
3381
+ }
3382
+ // Deliberate clear: the persisted marker keeps a restart replay from rebuilding the completion and bypassing the block.
3383
+ async revokePostApproveCompletion(taskId, reason, opts = {}) {
3384
+ return this.withTaskLock(async () => {
3385
+ const task = await this.taskStore.get(taskId);
3386
+ if (!task || task.status !== 'approved')
3387
+ return false;
3388
+ // Head binds the revoke to one approved episode; an unpersisted head cannot disprove it, so blocking stays fail closed.
3389
+ if (opts.expectedHeadSha !== undefined
3390
+ && task.postApproveHeadSha !== undefined
3391
+ && task.postApproveHeadSha !== opts.expectedHeadSha)
3392
+ return false;
3393
+ if (opts.expectedToken !== undefined) {
3394
+ const current = await this.postApproveStore.get(taskId);
3395
+ if (current?.token !== opts.expectedToken)
3396
+ return false;
3397
+ }
3398
+ // Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
3399
+ await this.taskStore.set({
3400
+ ...task,
3401
+ postApproveRevoked: { reason, at: new Date().toISOString() },
3402
+ updatedAt: new Date().toISOString(),
3403
+ });
3404
+ await this.postApproveStore.clear(taskId);
3405
+ this.phaseSignalWatcher?.stop(taskId);
3406
+ return true;
3407
+ });
3408
+ }
2735
3409
  async clearPostApproveCompletionIfMatches(taskId, token) {
2736
3410
  return this.withTaskLock(async () => {
2737
3411
  const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
@@ -2740,14 +3414,67 @@ export class AgentManager {
2740
3414
  return cleared;
2741
3415
  });
2742
3416
  }
3417
+ // Marker/token/head/pending are re-checked inside the committing critical section, so a concurrent revoke never loses.
3418
+ async completeApprovedPassToMergeReady(taskId, expectedToken) {
3419
+ return this.withTaskLock(async () => {
3420
+ const task = await this.taskStore.get(taskId);
3421
+ if (!task || task.status !== 'approved' || task.postApproveRevoked) {
3422
+ return { refused: 'stale' };
3423
+ }
3424
+ const completion = await this.postApproveStore.get(taskId);
3425
+ if (!completion
3426
+ || completion.token !== expectedToken
3427
+ || task.postApproveHeadSha === undefined
3428
+ || completion.approvedHeadSha !== task.postApproveHeadSha) {
3429
+ return { refused: 'stale' };
3430
+ }
3431
+ if (completion.pendingRedispatch)
3432
+ return { refused: 'pending' };
3433
+ delete task.postApproveHeadSha;
3434
+ Object.assign(task, {
3435
+ status: 'merge-ready',
3436
+ latestHeadSha: completion.approvedHeadSha,
3437
+ updatedAt: new Date().toISOString(),
3438
+ });
3439
+ await this.taskStore.set(task);
3440
+ await this.postApproveStore.clear(taskId);
3441
+ this.phaseSignalWatcher?.stop(taskId);
3442
+ return { task };
3443
+ });
3444
+ }
2743
3445
  async setupRecoveredPostApproveSignals() {
2744
- if (!this.phaseSignalWatcher)
2745
- return;
2746
3446
  const tasks = await this.taskStore.list({ status: 'approved' });
2747
3447
  for (const task of tasks) {
3448
+ if (task.postApproveRevoked) {
3449
+ // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
3450
+ try {
3451
+ await this.postApproveStore.clear(task.id);
3452
+ }
3453
+ catch (err) {
3454
+ console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
3455
+ }
3456
+ continue;
3457
+ }
2748
3458
  const completion = await this.postApproveStore.get(task.id);
2749
3459
  if (!completion)
2750
3460
  continue;
3461
+ if (task.postApproveHeadSha === undefined) {
3462
+ // A completion the task head cannot vouch for is another episode's residue until proven otherwise.
3463
+ console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} has a stored completion but no persisted approved head; not re-arming`);
3464
+ continue;
3465
+ }
3466
+ if (completion.approvedHeadSha !== task.postApproveHeadSha) {
3467
+ try {
3468
+ await this.postApproveStore.clear(task.id);
3469
+ console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
3470
+ }
3471
+ catch (err) {
3472
+ console.warn(`[AgentManager] setupRecoveredPostApproveSignals: stale completion cleanup failed for task=${task.id}:`, err);
3473
+ }
3474
+ continue;
3475
+ }
3476
+ if (!this.phaseSignalWatcher)
3477
+ continue;
2751
3478
  try {
2752
3479
  await this.startPhaseSignalWatch({
2753
3480
  taskId: task.id,
@@ -2924,6 +3651,20 @@ export class AgentManager {
2924
3651
  task.updatedAt = new Date().toISOString();
2925
3652
  await this.taskStore.set(task);
2926
3653
  }
3654
+ async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
3655
+ return this.withTaskLock(async () => {
3656
+ const task = await this.taskStore.get(taskId);
3657
+ if (!task || task.status !== expectedStatus)
3658
+ return false;
3659
+ if ('signalToken' in alsoExpect && task.signalToken !== alsoExpect.signalToken)
3660
+ return false;
3661
+ if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
3662
+ return false;
3663
+ Object.assign(task, updates, { updatedAt: new Date().toISOString() });
3664
+ await this.taskStore.set(task);
3665
+ return true;
3666
+ });
3667
+ }
2927
3668
  async updateTask(taskId, updates) {
2928
3669
  await this.withTaskLock(async () => {
2929
3670
  const task = await this.taskStore.get(taskId);
@@ -2943,11 +3684,18 @@ export class AgentManager {
2943
3684
  return null;
2944
3685
  if (!guard.fromStatus.includes(previousStatus))
2945
3686
  return null;
3687
+ if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
3688
+ return null;
2946
3689
  if (patch?.branch && patch.branch !== task.branch) {
2947
3690
  const existing = await this.findTaskByBranch(patch.branch, task.projectId);
2948
3691
  if (existing && existing.id !== taskId)
2949
3692
  return null;
2950
3693
  }
3694
+ // The revocation marker and approved head scope to the approved episode they were created in.
3695
+ if (previousStatus === 'approved' && toStatus !== 'approved') {
3696
+ delete task.postApproveRevoked;
3697
+ delete task.postApproveHeadSha;
3698
+ }
2951
3699
  Object.assign(task, patch ?? {}, {
2952
3700
  status: toStatus,
2953
3701
  updatedAt: new Date().toISOString(),
@@ -3752,7 +4500,11 @@ export class AgentManager {
3752
4500
  else {
3753
4501
  if (!task.branch)
3754
4502
  throw new Error(`Task ${taskId} has no task branch`);
3755
- await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true);
4503
+ await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true, task.branchLocalCleaned
4504
+ ? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
4505
+ : {});
4506
+ if (task.branchLocalCleaned)
4507
+ await this.clearBranchLocalCleaned(taskId);
3756
4508
  }
3757
4509
  await assertOwner();
3758
4510
  if (!ensure.freshRuntime) {
@@ -3997,7 +4749,7 @@ export class AgentManager {
3997
4749
  throw err;
3998
4750
  }
3999
4751
  }
4000
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
4752
+ async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
4001
4753
  const before = await this.agentStore.get(agentId);
4002
4754
  const beforeLockToken = before?.taskId
4003
4755
  ? await this.resolveTaskLockToken(before, before.taskId)
@@ -4046,13 +4798,16 @@ export class AgentManager {
4046
4798
  }
4047
4799
  : undefined;
4048
4800
  await revalidate?.();
4049
- return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate);
4801
+ if (guardBeforePaste && !(await guardBeforePaste())) {
4802
+ return { acked: false, composerDelivered: false, aborted: true };
4803
+ }
4804
+ return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
4050
4805
  }
4051
4806
  finally {
4052
4807
  this.compactInFlight.delete(agentId);
4053
4808
  }
4054
4809
  }
4055
- async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate) {
4810
+ async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
4056
4811
  // 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
4057
4812
  const preTitle = await tmux.readPaneTitle(paneId);
4058
4813
  const preFrame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
@@ -4062,15 +4817,87 @@ export class AgentManager {
4062
4817
  if (staticBusy && await this.paneHasLiveTurn(tmux, paneId, runtime)) {
4063
4818
  throw new Error(`pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
4064
4819
  }
4065
- await tmux.clearComposerDraft(paneId);
4066
4820
  await revalidate?.();
4067
- await tmux.injectPrompt(paneId, prompt, agentId);
4821
+ if (guardBeforePaste) {
4822
+ // A stale replay must not touch the pane at all: the guard runs before the first composer scrub.
4823
+ if (!(await guardBeforePaste())) {
4824
+ return { acked: false, composerDelivered: false, aborted: true };
4825
+ }
4826
+ const staged = await tmux.stagePromptBuffer(paneId, prompt, agentId);
4827
+ // Fence + scrub + paste share one task-mutation-queue slot, so a rotation lands before or after, never between.
4828
+ let pasted = false;
4829
+ try {
4830
+ pasted = await this.withTaskLock(async () => {
4831
+ if (!(await guardBeforePaste()))
4832
+ return false;
4833
+ await tmux.clearComposerDraft(paneId);
4834
+ await tmux.pasteStagedBuffer(paneId, staged.buf);
4835
+ return true;
4836
+ });
4837
+ }
4838
+ catch (pasteErr) {
4839
+ // Buffer still present proves the paste never landed; missing/unprobeable fails closed to a composer scrub.
4840
+ let bufferConsumed = true;
4841
+ try {
4842
+ await tmux.dropStagedBuffer(staged.buf);
4843
+ bufferConsumed = false;
4844
+ }
4845
+ catch (dropErr) {
4846
+ console.warn(`[AgentManager] staged buffer ${staged.buf} not confirmed dropped after failed paste (missing or unprobeable); failing closed to a composer scrub:`, dropErr);
4847
+ }
4848
+ if (bufferConsumed) {
4849
+ try {
4850
+ await tmux.clearComposerDraft(paneId);
4851
+ }
4852
+ catch (clearErr) {
4853
+ console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
4854
+ }
4855
+ }
4856
+ throw pasteErr;
4857
+ }
4858
+ if (!pasted) {
4859
+ try {
4860
+ await tmux.dropStagedBuffer(staged.buf);
4861
+ }
4862
+ catch (err) {
4863
+ console.warn(`[AgentManager] staged buffer ${staged.buf} cleanup failed:`, err);
4864
+ }
4865
+ return { acked: false, composerDelivered: false, aborted: true };
4866
+ }
4867
+ }
4868
+ else {
4869
+ await tmux.clearComposerDraft(paneId);
4870
+ await revalidate?.();
4871
+ await tmux.injectPrompt(paneId, prompt, agentId);
4872
+ }
4068
4873
  let baseline;
4069
4874
  let baselineTitle = '';
4070
4875
  try {
4071
4876
  baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
4072
4877
  baselineTitle = await tmux.readPaneTitle(paneId);
4073
- await tmux.sendEnter(paneId);
4878
+ if (guardBeforePaste) {
4879
+ // The Enter is the actual submission; it takes the same fenced queue slot.
4880
+ const submitted = await this.withTaskLock(async () => {
4881
+ if (!(await guardBeforePaste()))
4882
+ return false;
4883
+ await tmux.sendEnter(paneId);
4884
+ return true;
4885
+ });
4886
+ if (!submitted) {
4887
+ try {
4888
+ await tmux.clearComposerDraft(paneId);
4889
+ }
4890
+ catch (err) {
4891
+ // Fail closed: a submit-ready stale prompt could not be confirmed gone.
4892
+ const message = err instanceof Error ? err.message : String(err);
4893
+ throw new Error(`fence-rejected prompt could not be scrubbed from the composer of pane ${paneId}; verify the pane before reuse: ${message}`);
4894
+ }
4895
+ return { acked: false, composerDelivered: false, aborted: true };
4896
+ }
4897
+ }
4898
+ else {
4899
+ await tmux.sendEnter(paneId);
4900
+ }
4074
4901
  }
4075
4902
  catch (preAckErr) {
4076
4903
  if (await this.clearComposerForReuse(tmux, paneId, agentId))
@@ -4078,11 +4905,24 @@ export class AgentManager {
4078
4905
  const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
4079
4906
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
4080
4907
  }
4908
+ let staleDuringResend = false;
4081
4909
  try {
4082
4910
  await tmux.waitSubmitAck(paneId, baseline, runtime, {
4083
4911
  timeoutMs: this.dispatchAckTimeoutMs,
4084
4912
  baselineTitle,
4085
- resend: () => tmux.sendEnter(paneId),
4913
+ resend: guardBeforePaste
4914
+ ? async () => {
4915
+ // A resend is a fresh submission of whatever sits in the composer; it takes the same fence.
4916
+ const resent = await this.withTaskLock(async () => {
4917
+ if (!(await guardBeforePaste()))
4918
+ return false;
4919
+ await tmux.sendEnter(paneId);
4920
+ return true;
4921
+ });
4922
+ if (!resent)
4923
+ staleDuringResend = true;
4924
+ }
4925
+ : () => tmux.sendEnter(paneId),
4086
4926
  resendIntervalMs: this.dispatchAckResendIntervalMs,
4087
4927
  });
4088
4928
  return { acked: true, composerDelivered: true };
@@ -4092,6 +4932,16 @@ export class AgentManager {
4092
4932
  if (!(err instanceof Error && /runtime ack timeout/.test(err.message))) {
4093
4933
  throw new DispatchTerminalError('ack_unknown', `ack_unknown for pane ${paneId}: ${message}`);
4094
4934
  }
4935
+ if (staleDuringResend) {
4936
+ try {
4937
+ await tmux.clearComposerDraft(paneId);
4938
+ }
4939
+ catch (scrubErr) {
4940
+ const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
4941
+ throw new Error(`fence-rejected resend left an unverified composer on pane ${paneId}; verify the pane before reuse: ${scrubMessage}`);
4942
+ }
4943
+ return { acked: false, composerDelivered: false, aborted: true };
4944
+ }
4095
4945
  console.warn(`[AgentManager] dispatch ack timeout for pane ${paneId} agent ${agentId}: ${message}`);
4096
4946
  const state = await this.agentStore.get(agentId).catch(() => null);
4097
4947
  await this.emitIntervention(state?.projectId ?? '', agentId, state?.taskId, {
@@ -4201,7 +5051,7 @@ export class AgentManager {
4201
5051
  const reason = `Workdir changed during continueSession: ` +
4202
5052
  `before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
4203
5053
  console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4204
- await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
5054
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
4205
5055
  return false;
4206
5056
  }
4207
5057
  const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
@@ -4217,11 +5067,22 @@ export class AgentManager {
4217
5067
  }
4218
5068
  if (agent.role === 'dev' && task.branch) {
4219
5069
  const branches = new BranchManager(runner);
4220
- await branches.assertClean(verifiedWorkdir);
5070
+ if (!opts.allowDirtyWorkdir)
5071
+ await branches.assertClean(verifiedWorkdir);
4221
5072
  const actualRef = await branches.currentRef(verifiedWorkdir);
4222
5073
  if (actualRef !== `refs/heads/${task.branch}`) {
4223
- throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
4224
- `expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
5074
+ // dev 可能已被释放过(如 spec max_rounds 停驻默认分支);switchToTaskBranch 自带
5075
+ // assertClean,脏树时抛 DirtyWorkdirError fail-closed,不会覆盖未提交的改动
5076
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
5077
+ await branches.switchToTaskBranch(verifiedWorkdir, taskId, task.branch, task.branchCreatedByBaxian === true, {
5078
+ requireExistingWork: true,
5079
+ ...(task.branchLocalCleaned
5080
+ ? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
5081
+ : {}),
5082
+ });
5083
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
5084
+ if (task.branchLocalCleaned)
5085
+ await this.clearBranchLocalCleaned(taskId);
4225
5086
  }
4226
5087
  }
4227
5088
  else if (agent.role === 'research') {
@@ -4264,6 +5125,7 @@ export class AgentManager {
4264
5125
  agent,
4265
5126
  workdir: verifiedWorkdir,
4266
5127
  skillRegistry: this.skillRegistry,
5128
+ hasQaPartner: task.qaAgentId !== undefined,
4267
5129
  ...(signalToken ? { signalToken } : {}),
4268
5130
  ...(useIncrementalNudge
4269
5131
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
@@ -4315,17 +5177,29 @@ export class AgentManager {
4315
5177
  const reason = `Workdir changed before prompt injection: ` +
4316
5178
  `verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
4317
5179
  console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4318
- await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
5180
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
4319
5181
  return false;
4320
5182
  }
4321
5183
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
4322
- if (phase === 'post-approve') {
4323
- const completionFresh = await this.getPostApproveCompletion(taskId);
4324
- if (!completionFresh || completionFresh.token !== signalToken) {
4325
- console.warn(`[AgentManager] continueSession[post-approve]: token changed before paste for task ${taskId}; skipping`);
4326
- return false;
4327
- }
5184
+ // A revoke marks before clearing and a push transitions before clearing; every half-window must abort the paste.
5185
+ const postApprovePassStillLive = async () => {
5186
+ const [taskFresh, completionFresh] = await Promise.all([
5187
+ this.taskStore.get(taskId),
5188
+ this.getPostApproveCompletion(taskId),
5189
+ ]);
5190
+ return taskFresh?.agentId === agentId
5191
+ && taskFresh.status === 'approved'
5192
+ && taskFresh.postApproveRevoked === undefined
5193
+ && taskFresh.postApproveHeadSha !== undefined
5194
+ && completionFresh !== null
5195
+ && completionFresh.token === signalToken
5196
+ && completionFresh.approvedHeadSha === taskFresh.postApproveHeadSha;
5197
+ };
5198
+ if (phase === 'post-approve' && !(await postApprovePassStillLive())) {
5199
+ console.warn(`[AgentManager] continueSession[post-approve]: pass revoked or rotated before paste for task ${taskId}; skipping`);
5200
+ return false;
4328
5201
  }
5202
+ const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
4329
5203
  if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
4330
5204
  return false;
4331
5205
  }
@@ -4356,11 +5230,19 @@ export class AgentManager {
4356
5230
  const reason = `Workdir changed before prompt injection: ` +
4357
5231
  `verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
4358
5232
  console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4359
- await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
5233
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
4360
5234
  return false;
4361
5235
  }
4362
5236
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
4363
- await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
5237
+ if (guardBeforeInject && !(await guardBeforeInject())) {
5238
+ console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
5239
+ return false;
5240
+ }
5241
+ const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
5242
+ if (delivery.aborted) {
5243
+ console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
5244
+ return false;
5245
+ }
4364
5246
  return true;
4365
5247
  }
4366
5248
  async rollbackUndeliveredBootstrap(state) {
@@ -4445,6 +5327,49 @@ export class AgentManager {
4445
5327
  return { ...rest, updatedAt: new Date().toISOString() };
4446
5328
  });
4447
5329
  }
5330
+ // continueSession never runs startSession's post-ack finalization; a surviving marker would make recover() roll the running task back to pending.
5331
+ async finalizeReplayedBootstrap(agentId, taskId, phase, expectedLockToken) {
5332
+ let cleared = false;
5333
+ let deliveredWorkdir;
5334
+ try {
5335
+ await this.agentStore.update(agentId, (latest) => {
5336
+ if (!latest
5337
+ || latest.taskId !== taskId
5338
+ || latest.bootstrappingTaskId !== taskId
5339
+ || latest.lockToken !== expectedLockToken) {
5340
+ return AGENT_STORE_NOOP;
5341
+ }
5342
+ cleared = true;
5343
+ deliveredWorkdir = latest.workdir;
5344
+ const { bootstrappingTaskId: _delivered, ...rest } = latest;
5345
+ return { ...rest, updatedAt: new Date().toISOString() };
5346
+ });
5347
+ }
5348
+ catch (err) {
5349
+ console.warn(`[AgentManager] replay: clearing bootstrap marker for task=${taskId} failed:`, err);
5350
+ await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', 'Prompt was replayed but clearing the in-flight bootstrap marker failed; held so recovery does ' +
5351
+ 'not roll back an already-running task. Verify the agent via web terminal, then Resume.', { expectedTaskId: taskId }).catch((holdErr) => {
5352
+ console.warn(`[AgentManager] replay: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
5353
+ });
5354
+ return;
5355
+ }
5356
+ if (!cleared)
5357
+ return;
5358
+ try {
5359
+ await this.eventBus.emit({
5360
+ id: '',
5361
+ type: 'session.started',
5362
+ timestamp: new Date().toISOString(),
5363
+ projectId: this.getAgentConfig(agentId)?.projectId ?? '',
5364
+ agentId,
5365
+ taskId,
5366
+ data: { phase, ...(deliveredWorkdir !== undefined ? { workdir: deliveredWorkdir } : {}) },
5367
+ });
5368
+ }
5369
+ catch (err) {
5370
+ console.warn(`[AgentManager] replay: session.started emit failed for task=${taskId}:`, err);
5371
+ }
5372
+ }
4448
5373
  async releaseOrphanedLocks(states) {
4449
5374
  const bindings = new Map(states.map(state => [state.id, state]));
4450
5375
  const claims = await this.lockManager.listClaims();
@@ -4966,13 +5891,38 @@ export class AgentManager {
4966
5891
  ? 'review'
4967
5892
  : 'recheck';
4968
5893
  const prevQa = await this.agentStore.get(qaId);
5894
+ const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
4969
5895
  if (prevQa?.taskId === taskId) {
4970
- await this.releaseAgentForTask(qaId, taskId, 'idle');
5896
+ if (prevQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(prevQa)) {
5897
+ throw new ApiError(409, `QA agent ${qaId} is awaiting human (${prevQa.awaitingPhase ?? 'unknown phase'}); resolve that hold before redispatching review`);
5898
+ }
5899
+ const released = await this.releaseAgentIfBound(qaId, taskId, prevQa.status === 'awaiting_human'
5900
+ ? {
5901
+ allowAwaitingHuman: true,
5902
+ expectedHold: {
5903
+ phase: prevQa.awaitingPhase,
5904
+ since: prevQa.awaitingSince,
5905
+ nonce: prevQa.awaitingNonce,
5906
+ },
5907
+ }
5908
+ : undefined);
5909
+ if (!released) {
5910
+ const after = await this.agentStore.get(qaId);
5911
+ const holdNote = after?.status === 'awaiting_human'
5912
+ ? ` (awaiting_human: ${after.awaitingPhase ?? 'unknown phase'})`
5913
+ : '';
5914
+ throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
5915
+ }
4971
5916
  }
4972
5917
  const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
4973
5918
  if (!acquired) {
4974
5919
  throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
4975
5920
  }
5921
+ const acquiredClaim = await this.lockManager.claimOf(qaId);
5922
+ const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
5923
+ const releaseOwnAcquire = acquiredLockToken !== undefined
5924
+ ? { expectedLockToken: acquiredLockToken }
5925
+ : {};
4976
5926
  let devParked = false;
4977
5927
  if (!isTerminal && devAgentId) {
4978
5928
  const devState = await this.agentStore.get(devAgentId);
@@ -4983,7 +5933,7 @@ export class AgentManager {
4983
5933
  return false;
4984
5934
  });
4985
5935
  if (!devOk) {
4986
- await this.releaseAgentForTask(qaId, taskId, 'idle')
5936
+ await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire)
4987
5937
  .catch(() => undefined);
4988
5938
  throw new ApiError(500, `Cannot park dev ${devAgentId} into waiting for manual QA review (task status=${taskStatusAtClaim}); QA released`);
4989
5939
  }
@@ -4998,7 +5948,8 @@ export class AgentManager {
4998
5948
  reviewDispatchedAt: pre?.reviewDispatchedAt,
4999
5949
  };
5000
5950
  const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
5001
- const bumpRound = opts.bumpRound !== false;
5951
+ // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
5952
+ const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
5002
5953
  // 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
5003
5954
  // 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
5004
5955
  const abortDispatch = async (dispatchToken) => {
@@ -5011,13 +5962,18 @@ export class AgentManager {
5011
5962
  let transitionToken;
5012
5963
  if (!isTerminalAtClaim) {
5013
5964
  const reviewAnchor = await this.fetchPrHeadSha(taskId).catch(() => undefined);
5014
- const preDispatched = await this.transitionTaskStatus(taskId, 'review', { fromStatus: [taskStatusAtClaim] }, {
5965
+ const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
5966
+ fromStatus: [taskStatusAtClaim],
5967
+ // 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
5968
+ ...(opts.expectSignalToken !== undefined ? { expectSignalToken: opts.expectSignalToken } : {}),
5969
+ }, {
5015
5970
  reviewHeadAnchorSha: reviewAnchor,
5016
5971
  reviewDispatchedAt: new Date().toISOString(),
5017
5972
  signalToken: createSignalToken(),
5018
5973
  });
5019
5974
  if (!preDispatched) {
5020
- await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5975
+ // fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
5976
+ await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
5021
5977
  if (devParked)
5022
5978
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
5023
5979
  throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
@@ -5068,11 +6024,17 @@ export class AgentManager {
5068
6024
  });
5069
6025
  }
5070
6026
  catch (err) {
5071
- if (await this.markAwaitingIfAckUnknown(qaId, err, taskId)) {
5072
- }
5073
- else if (err instanceof EnsureSessionError && err.partial.handled) {
6027
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
6028
+ // prompt 可能已注入:不回滚 armed pass、不释放 QA;hold 持久化失败也不得吞掉 ack_unknown 分类
6029
+ try {
6030
+ await this.markAwaitingIfAckUnknown(qaId, err, taskId);
6031
+ }
6032
+ catch (holdErr) {
6033
+ console.error(`[AgentManager] dispatchReviewToQa could not persist the ack_unknown hold for ${qaId}:`, holdErr);
6034
+ }
6035
+ throw err;
5074
6036
  }
5075
- else {
6037
+ if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5076
6038
  await abortDispatch(armedToken);
5077
6039
  }
5078
6040
  throw err;
@@ -5372,7 +6334,9 @@ export class AgentManager {
5372
6334
  const task = await this.taskStore.get(taskId);
5373
6335
  if (!task?.signalToken)
5374
6336
  return false;
5375
- return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
6337
+ return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, {
6338
+ skipSnapshot: opts.skipSnapshot ?? false,
6339
+ });
5376
6340
  }
5377
6341
  async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
5378
6342
  if (!agentId)
@@ -5868,7 +6832,7 @@ export class AgentManager {
5868
6832
  }
5869
6833
  return publishDispatchedAt ? 'delivered' : false;
5870
6834
  }
5871
- async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
6835
+ async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
5872
6836
  if (!this.phaseSignalWatcher)
5873
6837
  return true;
5874
6838
  const task = await this.taskStore.get(taskId);
@@ -5881,8 +6845,9 @@ export class AgentManager {
5881
6845
  agentId,
5882
6846
  expectedKinds,
5883
6847
  token,
5884
- skipSnapshot,
5885
- ...(onReadFile ? { onReadFile } : {}),
6848
+ skipSnapshot: opts.skipSnapshot ?? false,
6849
+ ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
6850
+ ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
5886
6851
  });
5887
6852
  }
5888
6853
  catch (err) {
@@ -5891,7 +6856,10 @@ export class AgentManager {
5891
6856
  }
5892
6857
  }
5893
6858
  async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
5894
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot, onReadFile);
6859
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
6860
+ skipSnapshot,
6861
+ ...(onReadFile ? { onReadFile } : {}),
6862
+ });
5895
6863
  if (!armed)
5896
6864
  await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
5897
6865
  }
@@ -5980,8 +6948,9 @@ export class AgentManager {
5980
6948
  const fresh = await this.taskStore.get(taskId);
5981
6949
  if (!fresh)
5982
6950
  throw new ApiError(404, `Task ${taskId} not found`);
5983
- if (fresh.status !== 'spec-ready') {
5984
- throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
6951
+ const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
6952
+ if (fresh.status !== 'spec-ready' && !specMaxRounds) {
6953
+ throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
5985
6954
  }
5986
6955
  if (verdict === 'archive' && !fresh.researchAgentId) {
5987
6956
  throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
@@ -6016,7 +6985,7 @@ export class AgentManager {
6016
6985
  ...base,
6017
6986
  userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
6018
6987
  });
6019
- const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
6988
+ const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
6020
6989
  if (!transition)
6021
6990
  throw new ApiError(409, `Task ${taskId} changed while archiving`);
6022
6991
  this.stopPhaseSignalWatcher(taskId);
@@ -6042,18 +7011,40 @@ export class AgentManager {
6042
7011
  }
6043
7012
  if (verdict === 'approve') {
6044
7013
  await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
6045
- const result = await this.transitionToCodePhase(taskId);
7014
+ let result;
7015
+ try {
7016
+ result = await this.transitionToCodePhase(taskId);
7017
+ }
7018
+ catch (err) {
7019
+ if (err instanceof ApiError)
7020
+ throw err;
7021
+ const message = err instanceof Error ? err.message : String(err);
7022
+ const after = await this.taskStore.get(taskId);
7023
+ if (after?.status === 'failed') {
7024
+ const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
7025
+ throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
7026
+ + 'The task has been marked failed; use Retry to run it as a fresh task.');
7027
+ }
7028
+ if (after?.phase === 'code' && after.status === 'in_progress') {
7029
+ throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
7030
+ + 'The task already moved to the code phase and the dev is held; '
7031
+ + 'Resume the dev agent to redeliver the prompt.');
7032
+ }
7033
+ throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
7034
+ + 'The task was rolled back to await the verdict; fix the dev Workdir '
7035
+ + '(Resume the dev first if it is still held), then retry the verdict.');
7036
+ }
6046
7037
  if (!result) {
7038
+ const after = await this.taskStore.get(taskId);
7039
+ if (after?.phase === 'code' && after.status === 'in_progress') {
7040
+ throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
7041
+ + 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
7042
+ + '(retrying the verdict will be rejected).');
7043
+ }
6047
7044
  throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
6048
7045
  }
6049
7046
  return result;
6050
7047
  }
6051
- // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
6052
- const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
6053
- if (round >= cap) {
6054
- throw new ApiError(409, `Task ${taskId} has reached the spec review round cap (${cap}); `
6055
- + 'approve the spec, or cancel the task and retry with a refined description');
6056
- }
6057
7048
  if (!trimmed)
6058
7049
  throw new ApiError(400, 'comments is required for request-changes');
6059
7050
  const priorFindings = base.findings?.findings ?? [];
@@ -6063,16 +7054,106 @@ export class AgentManager {
6063
7054
  verdict: 'request-changes',
6064
7055
  findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
6065
7056
  };
6066
- await store.putRound(taskId, 'spec', {
7057
+ const rejectedRound = {
6067
7058
  ...base,
6068
7059
  findings: mergedFindings,
6069
7060
  userDecision: { verdict, comments: trimmed, at },
6070
- });
6071
- const dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
7061
+ };
7062
+ // 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
7063
+ // holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
7064
+ // 因配置热更新出现余量,打回前也必须先恢复 holder
7065
+ const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
7066
+ const claim = task.status === 'max_rounds' || round >= cap
7067
+ ? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
7068
+ : null;
7069
+ let dispatched = null;
7070
+ try {
7071
+ await store.putRound(taskId, 'spec', rejectedRound);
7072
+ dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
7073
+ }
7074
+ catch (err) {
7075
+ if (err instanceof ApiError)
7076
+ throw err;
7077
+ const message = err instanceof Error ? err.message : String(err);
7078
+ const after = await this.taskStore.get(taskId);
7079
+ if (after?.status === 'failed') {
7080
+ const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
7081
+ throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
7082
+ + 'The task has been marked failed with the rejection kept on record; '
7083
+ + 'use Retry to run it as a fresh task.');
7084
+ }
7085
+ if (after?.status === 'fixing') {
7086
+ throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
7087
+ + 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
7088
+ }
7089
+ throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
7090
+ + 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
7091
+ }
7092
+ finally {
7093
+ if (!dispatched)
7094
+ await this.recoverSpecRejectionState(taskId, claim, base);
7095
+ }
6072
7096
  if (!dispatched)
6073
7097
  throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
6074
7098
  return dispatched;
6075
7099
  }
7100
+ async claimSpecFixDispatch(taskId, opts) {
7101
+ return await this.withTaskLock(async () => {
7102
+ const fresh = await this.taskStore.get(taskId);
7103
+ if (!fresh)
7104
+ throw new ApiError(404, `Task ${taskId} not found`);
7105
+ if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
7106
+ throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
7107
+ }
7108
+ const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
7109
+ if (!holder || !this.getAgentConfig(holder)) {
7110
+ throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
7111
+ + 'restore the agent config or cancel the task');
7112
+ }
7113
+ const originalAgentId = fresh.agentId;
7114
+ const originalCounter = fresh.maxRoundsContinues ?? 0;
7115
+ let counterAfter = originalCounter;
7116
+ if (opts.extendCapToFit !== null) {
7117
+ // cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
7118
+ counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
7119
+ fresh.maxRoundsContinues = counterAfter;
7120
+ }
7121
+ fresh.agentId = holder;
7122
+ fresh.updatedAt = new Date().toISOString();
7123
+ await this.taskStore.set(fresh);
7124
+ return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
7125
+ });
7126
+ }
7127
+ // 补偿以 claim 时的 status+counter+holder 为代际证明:任务已被推进(fixing/cancelled/新扩轮)
7128
+ // 就放弃恢复——尤其派发把任务带进 held fixing 时,打回 round 必须保留给后续 Resume 重派
7129
+ async recoverSpecRejectionState(taskId, claim, baseRound) {
7130
+ const store = this.getReviewStore();
7131
+ await this.withTaskLock(async () => {
7132
+ const fresh = await this.taskStore.get(taskId);
7133
+ if (!fresh)
7134
+ return;
7135
+ if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
7136
+ console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: task moved to ${fresh.status}`);
7137
+ return;
7138
+ }
7139
+ if (claim) {
7140
+ if ((fresh.maxRoundsContinues ?? 0) !== claim.counterAfter
7141
+ || fresh.agentId !== claim.holderWritten) {
7142
+ console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: `
7143
+ + `continues=${fresh.maxRoundsContinues ?? 0} agentId=${fresh.agentId} `
7144
+ + `(expected ${claim.counterAfter}/${claim.holderWritten})`);
7145
+ return;
7146
+ }
7147
+ fresh.maxRoundsContinues = claim.originalCounter;
7148
+ fresh.agentId = claim.originalAgentId;
7149
+ fresh.updatedAt = new Date().toISOString();
7150
+ await this.taskStore.set(fresh);
7151
+ }
7152
+ await store?.putRound(taskId, 'spec', baseRound);
7153
+ }).catch(err => {
7154
+ console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
7155
+ });
7156
+ }
6076
7157
  async submitCodeVerdict(taskId, comments) {
6077
7158
  const trimmed = comments?.trim();
6078
7159
  if (!trimmed) {
@@ -6235,6 +7316,7 @@ export class AgentManager {
6235
7316
  if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
6236
7317
  return null;
6237
7318
  }
7319
+ const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
6238
7320
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
6239
7321
  if (!acquired) {
6240
7322
  await this.emitIntervention(task.projectId, devAgentId, taskId, {
@@ -6243,8 +7325,10 @@ export class AgentManager {
6243
7325
  });
6244
7326
  return null;
6245
7327
  }
7328
+ const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
6246
7329
  const newToken = createSignalToken();
6247
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
7330
+ // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
7331
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken, maxRoundsContinues: 0 });
6248
7332
  if (!transition) {
6249
7333
  await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
6250
7334
  .catch(() => undefined);
@@ -6259,6 +7343,33 @@ export class AgentManager {
6259
7343
  if (err instanceof DispatchTerminalError) {
6260
7344
  await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
6261
7345
  }
7346
+ else if (err instanceof EnsureSessionError && err.partial.handled
7347
+ && (task.status === 'spec-ready' || task.status === 'max_rounds')) {
7348
+ // dev 已被 checkout/dialog hold 且 prompt 未送达;resumeAgent 不重派这些 hold,
7349
+ // 任务停在 in_progress 会同时封死 Resume 与 verdict——退回 verdict 门禁状态
7350
+ const rolledBack = await this.transitionTaskStatus(taskId, task.status, { fromStatus: ['in_progress'] }, {
7351
+ ...(task.phase !== undefined ? { phase: task.phase } : {}),
7352
+ agentId: task.agentId,
7353
+ ...(task.signalToken ? { signalToken: task.signalToken } : {}),
7354
+ maxRoundsContinues: task.maxRoundsContinues ?? 0,
7355
+ });
7356
+ if (!rolledBack) {
7357
+ console.warn(`[AgentManager] transitionToCodePhase(${taskId}): handled dispatch failure but the task `
7358
+ + 'moved on; leaving its status as-is');
7359
+ }
7360
+ else if (!devBoundBefore) {
7361
+ // 转码前 dev 未持有本任务(如 research handoff / max_rounds 重占用):任务已退回
7362
+ // verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
7363
+ const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
7364
+ .catch(() => false);
7365
+ if (!released) {
7366
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
7367
+ phase: 'code-rollback-dev-release-failed',
7368
+ devAgentId,
7369
+ });
7370
+ }
7371
+ }
7372
+ }
6262
7373
  else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
6263
7374
  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);
6264
7375
  }
@@ -6273,6 +7384,11 @@ export class AgentManager {
6273
7384
  });
6274
7385
  return null;
6275
7386
  }
7387
+ // 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
7388
+ // startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
7389
+ if (!researchHandoff) {
7390
+ await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
7391
+ }
6276
7392
  return await this.taskStore.get(taskId);
6277
7393
  }
6278
7394
  async dispatchServerReviewToQa(taskId, opts) {
@@ -6415,7 +7531,11 @@ export class AgentManager {
6415
7531
  armBeforeInject: (ctx) => {
6416
7532
  dispatchedCheckoutMode = ctx.serverReviewCheckout;
6417
7533
  const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
6418
- return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
7534
+ return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
7535
+ ...(allowReadFile
7536
+ ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
7537
+ : {}),
7538
+ });
6419
7539
  },
6420
7540
  };
6421
7541
  const rearmConsumedSignal = async () => {
@@ -6509,6 +7629,16 @@ export class AgentManager {
6509
7629
  const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
6510
7630
  await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
6511
7631
  };
7632
+ const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
7633
+ const releaseFreshAcquire = async () => {
7634
+ if (devBoundBefore)
7635
+ return;
7636
+ const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
7637
+ .catch(() => false);
7638
+ if (!released) {
7639
+ console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
7640
+ }
7641
+ };
6512
7642
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
6513
7643
  if (!acquired) {
6514
7644
  await rearmReviewedSignal();
@@ -6519,6 +7649,7 @@ export class AgentManager {
6519
7649
  const released = await this.releaseAgentIfBound(qaAgentId, taskId)
6520
7650
  .catch(() => false);
6521
7651
  if (!released) {
7652
+ await releaseFreshAcquire();
6522
7653
  await rearmReviewedSignal();
6523
7654
  await this.emitIntervention(projectId, qaAgentId, taskId, { phase: 'server-fix-qa-release-failed', qaAgentId });
6524
7655
  return null;
@@ -6526,6 +7657,7 @@ export class AgentManager {
6526
7657
  }
6527
7658
  const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
6528
7659
  if (!transition) {
7660
+ await releaseFreshAcquire();
6529
7661
  await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-transition-failed', devAgentId });
6530
7662
  return null;
6531
7663
  }