baxian 1.0.3 → 1.2.0

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 (86) hide show
  1. package/dist/agent/diff-split.d.ts +10 -0
  2. package/dist/agent/diff-split.d.ts.map +1 -0
  3. package/dist/agent/diff-split.js +83 -0
  4. package/dist/agent/diff-split.js.map +1 -0
  5. package/dist/agent/manager.d.ts +75 -12
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +1121 -320
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +7 -1
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +37 -11
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +29 -11
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +38 -8
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/prompt.d.ts +15 -2
  18. package/dist/agent/prompt.d.ts.map +1 -1
  19. package/dist/agent/prompt.js +250 -52
  20. package/dist/agent/prompt.js.map +1 -1
  21. package/dist/agent/repo-store.d.ts +0 -1
  22. package/dist/agent/repo-store.d.ts.map +1 -1
  23. package/dist/agent/repo-store.js +0 -25
  24. package/dist/agent/repo-store.js.map +1 -1
  25. package/dist/agent/review-transport.d.ts +36 -0
  26. package/dist/agent/review-transport.d.ts.map +1 -0
  27. package/dist/agent/review-transport.js +246 -0
  28. package/dist/agent/review-transport.js.map +1 -0
  29. package/dist/agent/worktree.d.ts +2 -0
  30. package/dist/agent/worktree.d.ts.map +1 -1
  31. package/dist/agent/worktree.js +26 -0
  32. package/dist/agent/worktree.js.map +1 -1
  33. package/dist/api/agents.d.ts.map +1 -1
  34. package/dist/api/agents.js +4 -0
  35. package/dist/api/agents.js.map +1 -1
  36. package/dist/api/tasks.d.ts.map +1 -1
  37. package/dist/api/tasks.js +8 -0
  38. package/dist/api/tasks.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +8 -1
  41. package/dist/cli.js.map +1 -1
  42. package/dist/config/loader.d.ts.map +1 -1
  43. package/dist/config/loader.js +3 -0
  44. package/dist/config/loader.js.map +1 -1
  45. package/dist/config/validator.js +23 -0
  46. package/dist/config/validator.js.map +1 -1
  47. package/dist/event/handlers.d.ts.map +1 -1
  48. package/dist/event/handlers.js +33 -451
  49. package/dist/event/handlers.js.map +1 -1
  50. package/dist/event/server-handlers.d.ts +4 -0
  51. package/dist/event/server-handlers.d.ts.map +1 -0
  52. package/dist/event/server-handlers.js +835 -0
  53. package/dist/event/server-handlers.js.map +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/shared/constants.d.ts +7 -1
  58. package/dist/shared/constants.d.ts.map +1 -1
  59. package/dist/shared/constants.js +26 -8
  60. package/dist/shared/constants.js.map +1 -1
  61. package/dist/shared/types.d.ts +64 -2
  62. package/dist/shared/types.d.ts.map +1 -1
  63. package/dist/skills/server-feedback/SKILL.md +34 -0
  64. package/dist/skills/server-recheck/SKILL.md +30 -0
  65. package/dist/skills/server-review/SKILL.md +43 -0
  66. package/dist/skills/server-spec-review/SKILL.md +31 -0
  67. package/dist/state/index.d.ts +1 -0
  68. package/dist/state/index.d.ts.map +1 -1
  69. package/dist/state/index.js +1 -0
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/state/review-store.d.ts +13 -0
  72. package/dist/state/review-store.d.ts.map +1 -0
  73. package/dist/state/review-store.js +92 -0
  74. package/dist/state/review-store.js.map +1 -0
  75. package/dist/state/snapshot.js +1 -1
  76. package/dist/state/snapshot.js.map +1 -1
  77. package/dist/state/task-store.d.ts.map +1 -1
  78. package/dist/state/task-store.js +1 -0
  79. package/dist/state/task-store.js.map +1 -1
  80. package/dist/terminal/attach.d.ts.map +1 -1
  81. package/dist/terminal/attach.js +8 -2
  82. package/dist/terminal/attach.js.map +1 -1
  83. package/dist/web/assets/index-OtgjyQI1.js +4 -0
  84. package/dist/web/index.html +1 -1
  85. package/package.json +1 -1
  86. package/dist/web/assets/index-ByNjLidI.js +0 -4
@@ -12,6 +12,7 @@ import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu,
12
12
  import { WorktreeManager } from './worktree.js';
13
13
  import { RepoStore, createRepoStoreCache } from './repo-store.js';
14
14
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
15
+ import { ReviewTransport } from './review-transport.js';
15
16
  import { buildPromptInline, buildPostMergeCleanupPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
16
17
  import { ApiError } from '../errors.js';
17
18
  export class EnsureSessionError extends Error {
@@ -73,7 +74,7 @@ function agentRuntimeKindFor(agent) {
73
74
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
74
75
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
75
76
  // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
76
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'spec-fix']);
77
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
77
78
  export function canDispatchWithBinding(binding) {
78
79
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
79
80
  }
@@ -115,6 +116,8 @@ export class AgentManager {
115
116
  postApproveStore;
116
117
  phaseSignalWatcher;
117
118
  errorRecordStore;
119
+ reviewStore;
120
+ reviewTransportInstance;
118
121
  dispatchAckTimeoutMs;
119
122
  dispatchSettleTimeoutMs;
120
123
  // Re-send Enter after this long of continuous post-paste idle — recovers a swallowed first Enter
@@ -129,6 +132,7 @@ export class AgentManager {
129
132
  runtimeMenuPollIntervalMs = 10_000;
130
133
  compactIdleWaitMs = 5 * 60_000;
131
134
  compactIdlePollMs = 2_000;
135
+ manualCompactWaitMs = 5_000;
132
136
  postMergeFetchTimeoutMs = 60_000;
133
137
  postMergeBranchTimeoutMs = 10_000;
134
138
  // taskIds with in-flight manual review — second concurrent POST gets 409.
@@ -140,6 +144,7 @@ export class AgentManager {
140
144
  // agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
141
145
  // 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
142
146
  deletionInFlight = new Set();
147
+ compactInFlight = new Set();
143
148
  constructor(deps) {
144
149
  this.config = deps.config;
145
150
  this.agentStore = deps.agentStore;
@@ -160,6 +165,7 @@ export class AgentManager {
160
165
  resolveAgent: (id) => this.getAgentConfig(id),
161
166
  })
162
167
  : undefined);
168
+ this.reviewStore = deps.reviewStore;
163
169
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
164
170
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
165
171
  this.agentIndex = buildAgentIndex(deps.config);
@@ -176,6 +182,36 @@ export class AgentManager {
176
182
  this.taskMutationQueue = next.catch(() => undefined);
177
183
  return next;
178
184
  }
185
+ getReviewStore() {
186
+ return this.reviewStore;
187
+ }
188
+ // Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
189
+ // config — `??` would swallow it and reroute an already-decided task (PR #288).
190
+ resolveAfterDone(task) {
191
+ if (task.afterDone !== undefined)
192
+ return task.afterDone;
193
+ return this.config.review.afterDone ?? null;
194
+ }
195
+ getReviewTransport() {
196
+ this.reviewTransportInstance ??= new ReviewTransport({
197
+ createRunnerFor: (agent) => this.createRunnerFor(agent),
198
+ resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
199
+ });
200
+ return this.reviewTransportInstance;
201
+ }
202
+ // ReviewTransport resolves worktrees synchronously; agentStore reads are async.
203
+ // The cache is refreshed by callers (server handlers) before transport use via
204
+ // refreshWorktreeCacheFor — a stale entry only costs one refresh round-trip.
205
+ bindingWorktreeCache = new Map();
206
+ async refreshWorktreeCacheFor(agentId) {
207
+ const state = await this.agentStore.get(agentId);
208
+ if (state?.worktreePath) {
209
+ this.bindingWorktreeCache.set(agentId, state.worktreePath);
210
+ return state.worktreePath;
211
+ }
212
+ this.bindingWorktreeCache.delete(agentId);
213
+ return undefined;
214
+ }
179
215
  async safeEmit(event) {
180
216
  try {
181
217
  await this.eventBus.emit(event);
@@ -940,7 +976,10 @@ export class AgentManager {
940
976
  throw new Error(`Unknown agent: ${agentId}`);
941
977
  const state = await this.agentStore.get(agentId);
942
978
  const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
943
- const reentryPhases = new Set(['fix', 'post-approve', 'spec-fix', 'code']);
979
+ const reentryPhases = new Set([
980
+ 'fix', 'post-approve', 'code',
981
+ 'server-feedback', 'server-after-done',
982
+ ]);
944
983
  const sameTaskReentry = state?.taskId === taskId &&
945
984
  !state.creationToken &&
946
985
  state.status !== 'awaiting_human' &&
@@ -1178,6 +1217,28 @@ export class AgentManager {
1178
1217
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} dialog resolved but bound task ${state.taskId} still active (crash window) — prompt was never injected; refusing Resume. Operator should cancel the task or DELETE the agent.`);
1179
1218
  return { resumed: false, releasedBinding: false };
1180
1219
  }
1220
+ // code-dispatch-failed: the code-phase prompt never reached the pane (spec
1221
+ // approval already transitioned the task). Resume = clear the hold AND
1222
+ // redispatch the code prompt (outside this lock) — without the redispatch
1223
+ // the task would stay in_progress with nothing running (PR #288).
1224
+ if (state.awaitingPhase === 'code-dispatch-failed'
1225
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1226
+ && state.taskId) {
1227
+ const now2 = new Date().toISOString();
1228
+ await this.agentStore.update(agentId, (existing) => {
1229
+ if (!existing)
1230
+ return AGENT_STORE_NOOP;
1231
+ return {
1232
+ ...existing,
1233
+ status: 'ok',
1234
+ awaitingPhase: undefined,
1235
+ awaitingReason: undefined,
1236
+ awaitingSince: undefined,
1237
+ updatedAt: now2,
1238
+ };
1239
+ });
1240
+ return { resumed: true, releasedBinding: false, redispatchCodeTaskId: state.taskId };
1241
+ }
1181
1242
  // signal-arm-failed: the prompt was already dispatched but its pane-signal watcher never
1182
1243
  // armed. Resume here would only flip status→ok WITHOUT rebuilding the watcher (Resume has no
1183
1244
  // re-arm path), so the prompt's signal would still have no consumer — silent deadlock again.
@@ -1243,7 +1304,20 @@ export class AgentManager {
1243
1304
  });
1244
1305
  return { resumed: true, releasedBinding: shouldReleaseBinding };
1245
1306
  });
1246
- return result;
1307
+ // Outside the task lock: continueSession takes it internally.
1308
+ if (result.redispatchCodeTaskId) {
1309
+ try {
1310
+ const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
1311
+ if (!resumed) {
1312
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1313
+ }
1314
+ }
1315
+ catch (err) {
1316
+ console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
1317
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume failed; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1318
+ }
1319
+ }
1320
+ return { resumed: result.resumed, releasedBinding: result.releasedBinding };
1247
1321
  }
1248
1322
  async interruptPaneAndWaitReady(state, cfg) {
1249
1323
  const runner = this.createRunnerFor(cfg);
@@ -1337,6 +1411,11 @@ export class AgentManager {
1337
1411
  const out = [];
1338
1412
  for (const t of tasks) {
1339
1413
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1414
+ // Human gates are decision states, not running work: an absent agent
1415
+ // session must not terminally fail a task whose published PR/branch
1416
+ // would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
1417
+ if (t.status === 'ready' || t.status === 'merge-ready')
1418
+ continue;
1340
1419
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
1341
1420
  t.status = 'failed';
1342
1421
  t.updatedAt = new Date().toISOString();
@@ -1667,6 +1746,7 @@ export class AgentManager {
1667
1746
  expectedKinds: 'pr-merge-ready',
1668
1747
  token: completion.token,
1669
1748
  skipSnapshot,
1749
+ recovered: true,
1670
1750
  });
1671
1751
  }
1672
1752
  catch (err) {
@@ -1674,8 +1754,8 @@ export class AgentManager {
1674
1754
  }
1675
1755
  }
1676
1756
  }
1677
- // skipSnapshot=true: scrollback 里的 signal 重启后不应再触发。
1678
- // 只对 spec verdict / spec-fixed emit intervention — spec-created 在 develop
1757
+ // snapshot 扫描按协议族决定:server 协议(含全模式 spec 阶段)恢复时必扫,github code 阶段仅 review/fixing 扫。
1758
+ // 只对 spec verdict / spec-fixed emit intervention — spec-done 在 develop
1679
1759
  // prompt 里是 optional, 报警会让所有 in_progress task 噪音化。
1680
1760
  // expectedKinds 必须覆盖 dispatch 时实际 set up 的 kind 集,否则真信号无法匹配。
1681
1761
  async setupRecoveredSpecSignals() {
@@ -1690,20 +1770,26 @@ export class AgentManager {
1690
1770
  continue;
1691
1771
  const { expectedKinds, agentId } = mapped;
1692
1772
  // Only spec verdict / spec-fixed / PR verdict warrant an intervention —
1693
- // optional kinds (spec-created, pr-created in develop) would spam every
1773
+ // optional kinds (spec-done, pr-created in develop) would spam every
1694
1774
  // in_progress task on restart.
1695
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-approved|spec-changes-requested'
1775
+ const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
1696
1776
  : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
1697
1777
  : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
1698
1778
  : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
1699
1779
  : undefined;
1700
- // Scan the pane snapshot for one-shot completion signals the agent may have
1701
- // echoed before the server persisted/consumed it (lost on restart otherwise,
1702
- // since the agent does not re-emit): PR verdict (review) and pr-fixed (code
1703
- // fixing). Both handlers are idempotent under replay (token + status gates),
1704
- // and token rotation still rejects stale ones. Other phases keep
1705
- // skipSnapshot=true their handlers aren't as cleanly replay-safe.
1706
- const scanSnapshotOnRecover = task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing');
1780
+ // spec 阶段恒为 server 协议(无 poller 兜底);code 阶段才按 reviewMode 区分。
1781
+ // Scan pane snapshot on recover for signals the agent emitted before the
1782
+ // server consumed them (lost on restart; agent won't re-emit).
1783
+ // github code states (review/fixing): replay-safe handlers token + status
1784
+ // gates reject duplicates; PR verdict & pr-fixed covered.
1785
+ // github pre-spec (phase undefined, in_progress): spec-done has only the pane
1786
+ // channel (pr-created has a poller backstop, scanning it is idempotent).
1787
+ // server protocol incl. all-mode spec phase: no poller backstop, pane is the
1788
+ // only signal channel; handlers equally replay-safe via same gates.
1789
+ const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
1790
+ const scanSnapshotOnRecover = isServerProtocol
1791
+ || (task.phase === undefined && task.status === 'in_progress')
1792
+ || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
1707
1793
  try {
1708
1794
  await this.phaseSignalWatcher.start({
1709
1795
  taskId: task.id,
@@ -1712,6 +1798,10 @@ export class AgentManager {
1712
1798
  expectedKinds,
1713
1799
  token: task.signalToken,
1714
1800
  skipSnapshot: !scanSnapshotOnRecover,
1801
+ recovered: true,
1802
+ ...(isServerProtocol && task.status === 'review'
1803
+ ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
1804
+ : {}),
1715
1805
  });
1716
1806
  if (interventionKindLabel) {
1717
1807
  await this.safeEmit({
@@ -1987,6 +2077,7 @@ export class AgentManager {
1987
2077
  reviewRound: 0,
1988
2078
  status: 'pending',
1989
2079
  branch: BRANCH_PREFIX + taskId,
2080
+ reviewMode: this.config.review.mode ?? 'github',
1990
2081
  createdAt: now,
1991
2082
  updatedAt: now,
1992
2083
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2015,6 +2106,7 @@ export class AgentManager {
2015
2106
  reviewRound: 0,
2016
2107
  status: 'pending',
2017
2108
  branch: BRANCH_PREFIX + taskId,
2109
+ reviewMode: this.config.review.mode ?? 'github',
2018
2110
  createdAt: now,
2019
2111
  updatedAt: now,
2020
2112
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2047,6 +2139,7 @@ export class AgentManager {
2047
2139
  reviewRound: 0,
2048
2140
  status: 'pending',
2049
2141
  branch: BRANCH_PREFIX + taskId,
2142
+ reviewMode: this.config.review.mode ?? 'github',
2050
2143
  createdAt: now,
2051
2144
  updatedAt: now,
2052
2145
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2078,6 +2171,7 @@ export class AgentManager {
2078
2171
  reviewRound: 0,
2079
2172
  status: 'in_progress',
2080
2173
  branch: BRANCH_PREFIX + taskId,
2174
+ reviewMode: this.config.review.mode ?? 'github',
2081
2175
  createdAt: now,
2082
2176
  updatedAt: now,
2083
2177
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2161,13 +2255,16 @@ export class AgentManager {
2161
2255
  return null;
2162
2256
  }
2163
2257
  // 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
2164
- // spec-created/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2258
+ // spec-done/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2259
+ // Kinds derive from the task's frozen reviewMode — a hot mode flip during the
2260
+ // startSession window must not desync the armed kinds from the sent prompt.
2261
+ const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
2165
2262
  try {
2166
- await this.armPostDispatchSignalOrHold(taskId, agentId, ['spec-created', 'pr-created'], signalToken);
2263
+ await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
2167
2264
  }
2168
2265
  catch (armErr) {
2169
2266
  console.error(`[AgentManager] createAndStartTask arm failed for task=${taskId}:`, armErr);
2170
- await this.holdAgentForUnarmedSignal(taskId, agentId, ['spec-created', 'pr-created'])
2267
+ await this.holdAgentForUnarmedSignal(taskId, agentId, initialKinds)
2171
2268
  .catch((holdErr) => {
2172
2269
  console.error(`[AgentManager] createAndStartTask hold-after-arm-failure failed for task=${taskId}:`, holdErr);
2173
2270
  });
@@ -2206,12 +2303,83 @@ export class AgentManager {
2206
2303
  const paneId = state?.paneId;
2207
2304
  if (!paneId)
2208
2305
  throw new ApiError(409, `Agent ${agentId} has no live session`);
2209
- const runner = this.createRunnerFor(cfg);
2210
- const path = agentHostPath(agentId, imageFilename(ext));
2211
- await writeImageToHost(runner, path, bytes);
2212
- const tmux = new TmuxManager(runner);
2213
- await tmux.injectPrompt(paneId, `${path} `, agentId);
2214
- return { path };
2306
+ // 写文件→粘贴全程持有 pane 互斥:写文件可能卡住,恢复后的粘贴若落进
2307
+ // compact C-c→/compact 窗口会把路径拼进指令提交。
2308
+ if (!this.tryAcquireCompactGuard(agentId)) {
2309
+ throw new ApiError(409, `Agent ${agentId} compact or upload in progress; retry shortly`);
2310
+ }
2311
+ try {
2312
+ const runner = this.createRunnerFor(cfg);
2313
+ const path = agentHostPath(agentId, imageFilename(ext));
2314
+ await writeImageToHost(runner, path, bytes);
2315
+ const tmux = new TmuxManager(runner);
2316
+ await tmux.injectPrompt(paneId, `${path} `, agentId);
2317
+ return { path };
2318
+ }
2319
+ finally {
2320
+ this.compactInFlight.delete(agentId);
2321
+ }
2322
+ }
2323
+ // busy 时注入会把指令拼进正在运行的回合,宁可 409。
2324
+ async compactAgent(agentId) {
2325
+ const cfg = this.getAgentConfig(agentId);
2326
+ if (!cfg)
2327
+ throw new ApiError(404, `Unknown agent: ${agentId}`);
2328
+ if (!this.tryAcquireCompactGuard(agentId)) {
2329
+ throw new ApiError(409, `Agent ${agentId} compact or upload already in progress`);
2330
+ }
2331
+ let guardHandedOff = false;
2332
+ try {
2333
+ const state = await this.agentStore.get(agentId);
2334
+ const paneId = state?.paneId;
2335
+ if (!paneId)
2336
+ throw new ApiError(409, `Agent ${agentId} has no live session`);
2337
+ const taskIdAtStart = state.taskId;
2338
+ const updatedAtAtStart = state.updatedAt;
2339
+ // updatedAt 拦同任务 phase 派发(paneId/taskId 均不变,派发 paste 前必写 state);
2340
+ // 快照变了决不注入——C-c 会打断刚注入的 prompt。
2341
+ const assertSessionUnchanged = async () => {
2342
+ const now = await this.agentStore.get(agentId);
2343
+ if (!now
2344
+ || now.paneId !== paneId
2345
+ || now.taskId !== taskIdAtStart
2346
+ || now.updatedAt !== updatedAtAtStart) {
2347
+ throw new ApiError(409, `Agent ${agentId} session changed while waiting; compact aborted`);
2348
+ }
2349
+ };
2350
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
2351
+ const waitReady = async () => {
2352
+ try {
2353
+ await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
2354
+ }
2355
+ catch (err) {
2356
+ const detail = err instanceof Error ? err.message : String(err);
2357
+ throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
2358
+ }
2359
+ };
2360
+ await waitReady();
2361
+ await assertSessionUnchanged();
2362
+ // 残留草稿会被「草稿/compact」连带提交;C-c 清线后再发。
2363
+ await tmux.sendKeysToPane(paneId, 'C-c');
2364
+ await waitReady();
2365
+ await assertSessionUnchanged();
2366
+ await tmux.sendKeysLiteral(paneId, '/compact');
2367
+ await tmux.sendEnter(paneId);
2368
+ // 压缩仍在运行:guard 交给后台尾随等待,runtime 回到 idle 才释放,
2369
+ // 否则紧随的上传/派发会粘进压缩中的 pane。
2370
+ guardHandedOff = true;
2371
+ void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
2372
+ .catch(err => {
2373
+ console.warn(`[AgentManager] compactAgent(${agentId}) post-/compact idle wait failed:`, err);
2374
+ })
2375
+ .finally(() => {
2376
+ this.compactInFlight.delete(agentId);
2377
+ });
2378
+ }
2379
+ finally {
2380
+ if (!guardHandedOff)
2381
+ this.compactInFlight.delete(agentId);
2382
+ }
2215
2383
  }
2216
2384
  async persistTaskImages(taskId, images) {
2217
2385
  const dir = join(this.imageStagingRoot, taskId);
@@ -2263,12 +2431,10 @@ export class AgentManager {
2263
2431
  }
2264
2432
  return hostPaths;
2265
2433
  }
2266
- // Dev-facing deliverable phases (initial + rework) all carry the task's uploaded images, since
2267
- // the image is a persistent task input the dev needs while producing or revising the spec/code —
2268
- // and a fresh runtime (restart/recovery) loses the original context. `develop` flows through
2269
- // startSession; `code`/`fix`/`spec-fix` through continueSession both call this. Excluded:
2270
- // QA phases (review/recheck/spec-review) and post-approve (feedback-processing; if it needs
2271
- // changes baxian routes to `fix`, which carries the image) (task-055).
2434
+ // Dev-facing deliverable phases all carry the task's uploaded images, since the image is a
2435
+ // persistent task input the dev needs while producing or revising the spec/code — and a fresh
2436
+ // runtime (restart/recovery) loses the original context. IMAGE_DISPATCH_PHASES 中的后续阶段经
2437
+ // continueSession 触发此方法;QA 阶段和 post-approve 不传图(task-055)。
2272
2438
  async imagePathsForDispatch(runner, task, phase) {
2273
2439
  if (!IMAGE_DISPATCH_PHASES.has(phase))
2274
2440
  return [];
@@ -2365,7 +2531,7 @@ export class AgentManager {
2365
2531
  console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
2366
2532
  }
2367
2533
  if (started) {
2368
- await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, ['spec-created', 'pr-created'], signalToken);
2534
+ await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
2369
2535
  const refreshed = await this.taskStore.get(claimed.id);
2370
2536
  return { task: refreshed ?? claimed };
2371
2537
  }
@@ -2454,9 +2620,12 @@ export class AgentManager {
2454
2620
  const baseRef = agent.workdir
2455
2621
  ? undefined
2456
2622
  : await this.resolveAutoBaseRef(runner, workdir);
2457
- const worktreePath = phase === 'review' || phase === 'recheck' || phase === 'spec-review'
2458
- ? await worktree.createDetached(workdir, taskId, task.branch)
2459
- : await worktree.create(workdir, taskId, baseRef);
2623
+ const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2624
+ const worktreePath = isServerQaPhase
2625
+ ? await worktree.createDetachedAtBase(workdir, taskId)
2626
+ : phase === 'review' || phase === 'recheck'
2627
+ ? await worktree.createDetached(workdir, taskId, task.branch)
2628
+ : await worktree.create(workdir, taskId, baseRef);
2460
2629
  // Persist worktreePath now so a crash before set-running leaves a recoverable trail.
2461
2630
  await this.agentStore.update(agentId, (stateNow) => {
2462
2631
  if (!stateNow || stateNow.taskId !== taskId)
@@ -2480,6 +2649,8 @@ export class AgentManager {
2480
2649
  const reuseInjectedSkills = ensure.freshRuntime
2481
2650
  ? null
2482
2651
  : reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
2652
+ // develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
2653
+ const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2483
2654
  let prompt;
2484
2655
  try {
2485
2656
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
@@ -2489,11 +2660,17 @@ export class AgentManager {
2489
2660
  agent,
2490
2661
  worktreePath,
2491
2662
  skillRegistry: this.skillRegistry,
2663
+ hasQaPartner,
2492
2664
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2493
2665
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2494
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2495
2666
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2496
2667
  ...(imagePaths.length ? { imagePaths } : {}),
2668
+ ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2669
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2670
+ ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
2671
+ ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
2672
+ ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
2673
+ ...(opts.contentTruncated ? { contentTruncated: true } : {}),
2497
2674
  });
2498
2675
  }
2499
2676
  catch (err) {
@@ -2658,7 +2835,27 @@ export class AgentManager {
2658
2835
  throw err;
2659
2836
  }
2660
2837
  }
2661
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, _runtime) {
2838
+ // 注入方必须持有 pane 互斥:compact 侧的快照校验关不死「校验→按键」
2839
+ // 之间的 async 边界,竞态只能在这里关死。
2840
+ async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
2841
+ const before = await this.agentStore.get(agentId);
2842
+ await this.acquireCompactGuard(agentId);
2843
+ try {
2844
+ // guard 等待期间任务可能被 Cancel(释放绑定)或会话重建;过期派发
2845
+ // 决不落 pane。无快照(direct 调用)时跳过——真实派发必有绑定。
2846
+ if (before) {
2847
+ const now = await this.agentStore.get(agentId);
2848
+ if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
2849
+ throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
2850
+ }
2851
+ }
2852
+ return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime);
2853
+ }
2854
+ finally {
2855
+ this.compactInFlight.delete(agentId);
2856
+ }
2857
+ }
2858
+ async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, _runtime) {
2662
2859
  await tmux.injectPrompt(paneId, prompt, agentId);
2663
2860
  let baseline;
2664
2861
  try {
@@ -2844,9 +3041,15 @@ export class AgentManager {
2844
3041
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
2845
3042
  : {}),
2846
3043
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2847
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2848
3044
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2849
3045
  ...(imagePaths.length ? { imagePaths } : {}),
3046
+ ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
3047
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
3048
+ ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
3049
+ ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
3050
+ ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
3051
+ ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
3052
+ ...(opts.contentTruncated ? { contentTruncated: opts.contentTruncated } : {}),
2850
3053
  });
2851
3054
  }
2852
3055
  catch (err) {
@@ -3235,6 +3438,13 @@ export class AgentManager {
3235
3438
  async cancelTask(taskId) {
3236
3439
  let devToRelease;
3237
3440
  let qaToRelease;
3441
+ // Server-mode ready gate may have already published remote artifacts
3442
+ // (pushed branch / open PR). Capture before flipping to cancelled so the
3443
+ // post-lock cleanup can retire them instead of orphaning (PR #288).
3444
+ // mayBeInFlight: approved+marker means the publish prompt may STILL be
3445
+ // running — retirement must wait for the dev interrupt or the in-flight
3446
+ // push/pr-create would recreate the artifacts right after cleanup.
3447
+ let publishedCleanup;
3238
3448
  this.phaseSignalWatcher?.stop(taskId);
3239
3449
  const result = await this.withTaskLock(async () => {
3240
3450
  const task = await this.taskStore.get(taskId);
@@ -3252,6 +3462,37 @@ export class AgentManager {
3252
3462
  devToRelease = task.agentId;
3253
3463
  if (task.qaAgentId)
3254
3464
  qaToRelease = task.qaAgentId;
3465
+ // approved + publishDispatchedAt = the publish prompt reached the pane, so
3466
+ // remote artifacts may already exist even though code-ready never landed
3467
+ // (dispatch crash, or the reviewed-head mismatch gate refused ready —
3468
+ // whose documented exit is exactly this Cancel).
3469
+ // Truthy (not !== undefined): sanitizeTask passes hand-edited nulls through.
3470
+ const publishedAtGate = task.status === 'ready'
3471
+ || (task.status === 'approved' && !!task.publishDispatchedAt);
3472
+ if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
3473
+ const afterDone = this.resolveAfterDone(task);
3474
+ if (afterDone !== null && task.branch) {
3475
+ publishedCleanup = {
3476
+ afterDone,
3477
+ branch: task.branch,
3478
+ ...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
3479
+ devAgentId: task.agentId,
3480
+ // ready = code-ready consumed, publish finished; approved = no
3481
+ // completion signal yet, the publish may still be running.
3482
+ mayBeInFlight: task.status === 'approved',
3483
+ };
3484
+ }
3485
+ }
3486
+ else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
3487
+ // GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
3488
+ publishedCleanup = {
3489
+ afterDone: 'pr',
3490
+ branch: task.branch,
3491
+ prNumber: task.prNumber,
3492
+ devAgentId: task.agentId,
3493
+ mayBeInFlight: false,
3494
+ };
3495
+ }
3255
3496
  const now = new Date().toISOString();
3256
3497
  task.status = 'cancelled';
3257
3498
  task.updatedAt = now;
@@ -3266,7 +3507,13 @@ export class AgentManager {
3266
3507
  });
3267
3508
  return task;
3268
3509
  });
3269
- // 唯一允许打断 agent 会话的入口(用户主动 Cancel)。
3510
+ // 唯一允许打断 agent 会话的入口(用户主动 Cancel)。Interrupt BEFORE remote
3511
+ // retirement: an in-flight publish prompt would re-push the branch / re-open
3512
+ // the PR right after cleanup, and a cancelled task gets no second pass.
3513
+ // Only a successful interrupt PROVES the pane stopped — skipped paths
3514
+ // (config hot-removed: the pane outlives the config; state gone; rebound)
3515
+ // leave an in-flight publish possible.
3516
+ let devStopConfirmed = false;
3270
3517
  for (const id of [devToRelease, qaToRelease]) {
3271
3518
  if (!id)
3272
3519
  continue;
@@ -3285,6 +3532,8 @@ export class AgentManager {
3285
3532
  await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but C-c / REPL ready check failed; agent may still be running. Attach via web terminal to verify, then Resume or Delete.');
3286
3533
  continue;
3287
3534
  }
3535
+ if (id === publishedCleanup?.devAgentId)
3536
+ devStopConfirmed = true;
3288
3537
  try {
3289
3538
  // allowAwaitingHuman: cancelTask 是显式回收入口,agent 之前可能因 ack_unknown 等被标 Held,
3290
3539
  // 用户主动 Cancel 应允许跨过 awaiting_human gate 清理 binding;release 默认 gate 是为了
@@ -3295,6 +3544,65 @@ export class AgentManager {
3295
3544
  console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
3296
3545
  }
3297
3546
  }
3547
+ // Best-effort remote retirement for a cancelled published gate: close the PR
3548
+ // and delete the pushed branch so they don't outlive the task. Failures only
3549
+ // warn + intervene — cancel must not be blocked by remote faults.
3550
+ if (publishedCleanup) {
3551
+ if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
3552
+ // No proof the publish prompt stopped; cleaning now would race its
3553
+ // push/pr-create. Leave the artifacts to the operator.
3554
+ await this.safeEmit({
3555
+ id: '',
3556
+ type: 'human.intervention',
3557
+ timestamp: new Date().toISOString(),
3558
+ projectId: result.projectId,
3559
+ taskId,
3560
+ data: {
3561
+ phase: 'cancel-published-artifact-cleanup-skipped',
3562
+ afterDone: publishedCleanup.afterDone,
3563
+ branch: publishedCleanup.branch,
3564
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3565
+ reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3566
+ },
3567
+ });
3568
+ return result;
3569
+ }
3570
+ const project = this.getProjectConfig(result.projectId);
3571
+ try {
3572
+ if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
3573
+ const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
3574
+ `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
3575
+ if (close.exitCode !== 0)
3576
+ throw new Error(close.stderr.trim() || close.stdout.trim());
3577
+ }
3578
+ else {
3579
+ const dev = this.getAgentConfig(publishedCleanup.devAgentId);
3580
+ const state = await this.agentStore.get(publishedCleanup.devAgentId);
3581
+ if (dev && state?.repoPath) {
3582
+ const del = await this.createRunnerFor(dev).exec(`cd ${shellQuote(state.repoPath)} && git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3583
+ if (del.exitCode !== 0)
3584
+ throw new Error(del.stderr.trim() || del.stdout.trim());
3585
+ }
3586
+ }
3587
+ }
3588
+ catch (err) {
3589
+ console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
3590
+ await this.safeEmit({
3591
+ id: '',
3592
+ type: 'human.intervention',
3593
+ timestamp: new Date().toISOString(),
3594
+ projectId: result.projectId,
3595
+ taskId,
3596
+ data: {
3597
+ phase: 'cancel-published-artifact-cleanup-failed',
3598
+ afterDone: publishedCleanup.afterDone,
3599
+ branch: publishedCleanup.branch,
3600
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3601
+ error: err instanceof Error ? err.message : String(err),
3602
+ },
3603
+ });
3604
+ }
3605
+ }
3298
3606
  return result;
3299
3607
  }
3300
3608
  // task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
@@ -3342,6 +3650,11 @@ export class AgentManager {
3342
3650
  const task = await this.taskStore.get(taskId);
3343
3651
  if (!task)
3344
3652
  throw new ApiError(404, `Task ${taskId} not found`);
3653
+ // Server-mode tasks review via the exchange protocol; routing one into the
3654
+ // legacy GitHub review flow would cross-contaminate the state machines.
3655
+ if (task.reviewMode === 'server') {
3656
+ throw new ApiError(409, `Task ${taskId} uses server review mode; legacy Call review is not applicable`);
3657
+ }
3345
3658
  // spec-phase max_rounds escapes via Retry/Cancel only. Call review dispatches the
3346
3659
  // CODE-review protocol, but review.submitted early-returns for spec phase — so a direct
3347
3660
  // /tasks/:id/review here would transition the task to review + bind QA, yet its verdict
@@ -3554,6 +3867,55 @@ export class AgentManager {
3554
3867
  if (task.phase === 'spec') {
3555
3868
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
3556
3869
  }
3870
+ // Server-mode continue: grant one round past the cap, then re-run the server
3871
+ // fix protocol from the stored findings — no PR exists at this point (PR #288).
3872
+ if (task.reviewMode === 'server') {
3873
+ if (!task.agentId) {
3874
+ throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
3875
+ }
3876
+ const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
3877
+ if (!stored?.findings) {
3878
+ throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
3879
+ }
3880
+ // Re-check + grant under the task lock: the entry checks above ran lock-free,
3881
+ // so a concurrent mark-complete may have claimed the gate since (the
3882
+ // claimCompleteGate comment promises Continue re-checks under the same lock).
3883
+ await this.withTaskLock(async () => {
3884
+ if (this.markCompleteInFlight.has(taskId)) {
3885
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3886
+ }
3887
+ const fresh = await this.taskStore.get(taskId);
3888
+ if (!fresh || fresh.status !== 'max_rounds') {
3889
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
3890
+ }
3891
+ fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
3892
+ fresh.updatedAt = new Date().toISOString();
3893
+ await this.taskStore.set(fresh);
3894
+ });
3895
+ let dispatched = null;
3896
+ try {
3897
+ dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
3898
+ }
3899
+ finally {
3900
+ // The grant is only spent when the fix prompt actually reached the dev.
3901
+ // Decrement (not restore-snapshot): a snapshot write-back would also
3902
+ // erase a concurrent Continue's grant.
3903
+ if (!dispatched) {
3904
+ await this.withTaskLock(async () => {
3905
+ const fresh = await this.taskStore.get(taskId);
3906
+ if (!fresh)
3907
+ return;
3908
+ fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
3909
+ fresh.updatedAt = new Date().toISOString();
3910
+ await this.taskStore.set(fresh);
3911
+ }).catch(() => undefined);
3912
+ }
3913
+ }
3914
+ if (!dispatched) {
3915
+ throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
3916
+ }
3917
+ return dispatched;
3918
+ }
3557
3919
  if (!task.prNumber || !task.branch) {
3558
3920
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
3559
3921
  }
@@ -3667,7 +4029,7 @@ export class AgentManager {
3667
4029
  }
3668
4030
  // General case: rolled-back task may still want a watcher matching its
3669
4031
  // current (restored) state — develop dispatch still waiting on
3670
- // spec-created/pr-created, recheck still waiting on verdict, etc.
4032
+ // spec-done/pr-created, recheck still waiting on verdict, etc.
3671
4033
  const restored = await this.taskStore.get(taskId);
3672
4034
  if (!restored || !restored.signalToken)
3673
4035
  return;
@@ -3690,9 +4052,19 @@ export class AgentManager {
3690
4052
  // Single source of truth for "what watcher should this task have, given its
3691
4053
  // current state". Used by both setupRecoveredSpecSignals (restart recovery)
3692
4054
  // and rollbackDispatchReviewPhase1 (manual dispatch failure).
4055
+ // Dev's first prompt offers the spec-first or straight-to-code path; the arm
4056
+ // must accept both completion signals for the task's protocol family.
4057
+ devInitialSignalKinds(reviewMode) {
4058
+ const mode = reviewMode ?? this.config.review.mode ?? 'github';
4059
+ return mode === 'server'
4060
+ ? ['spec-done', 'code-done']
4061
+ : ['spec-done', 'pr-created'];
4062
+ }
3693
4063
  mapTaskStateToExpectedWatcher(task) {
4064
+ if (task.reviewMode === 'server')
4065
+ return this.mapServerTaskToExpectedWatcher(task);
3694
4066
  if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
3695
- return { expectedKinds: ['spec-approved', 'spec-changes-requested'], agentId: task.qaAgentId };
4067
+ return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
3696
4068
  }
3697
4069
  if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
3698
4070
  return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
@@ -3702,7 +4074,7 @@ export class AgentManager {
3702
4074
  return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
3703
4075
  }
3704
4076
  if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
3705
- return { expectedKinds: ['spec-created', 'pr-created'], agentId: task.agentId };
4077
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
3706
4078
  }
3707
4079
  if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
3708
4080
  return { expectedKinds: ['pr-created'], agentId: task.agentId };
@@ -3714,14 +4086,35 @@ export class AgentManager {
3714
4086
  }
3715
4087
  return undefined;
3716
4088
  }
4089
+ // Recovery mapping for server-mode tasks: the watcher is the ONLY verdict
4090
+ // channel (no poller backstop), so every awaiting state must re-arm on restart.
4091
+ mapServerTaskToExpectedWatcher(task) {
4092
+ const isSpec = task.phase === 'spec';
4093
+ if (task.status === 'review' && task.qaAgentId) {
4094
+ return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
4095
+ }
4096
+ if (task.status === 'fixing' && task.agentId) {
4097
+ return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
4098
+ }
4099
+ if (task.status === 'in_progress' && task.agentId) {
4100
+ if (task.phase === 'code')
4101
+ return { expectedKinds: ['code-done'], agentId: task.agentId };
4102
+ return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
4103
+ }
4104
+ if (task.status === 'approved' && task.agentId) {
4105
+ return { expectedKinds: ['code-ready'], agentId: task.agentId };
4106
+ }
4107
+ return undefined;
4108
+ }
3717
4109
  // Public re-establish helper for in-band recoveries that don't rotate the token
3718
4110
  // (e.g. handler reject path: agent's next emit must still match current
3719
- // task.signalToken, so rotating would strand it).
4111
+ // task.signalToken, so rotating would strand it). Returns whether a watcher
4112
+ // armed; callers that consumed a signal must hold on false or it has no consumer.
3720
4113
  async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
3721
4114
  const task = await this.taskStore.get(taskId);
3722
4115
  if (!task?.signalToken)
3723
- return;
3724
- await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
4116
+ return false;
4117
+ return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
3725
4118
  }
3726
4119
  async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
3727
4120
  if (!agentId)
@@ -3838,28 +4231,83 @@ export class AgentManager {
3838
4231
  // cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
3839
4232
  // cleanup + /compact + release). Same path the poller drives when it detects the merge.
3840
4233
  async markTaskComplete(taskId) {
3841
- const task = await this.taskStore.get(taskId);
3842
- if (!task)
4234
+ const peek = await this.taskStore.get(taskId);
4235
+ if (!peek)
3843
4236
  throw new ApiError(404, `Task ${taskId} not found`);
3844
- if (task.status !== 'max_rounds') {
3845
- throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
3846
- }
3847
- // spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
3848
- // endpoint too so a direct API call / older client can't merge a spec cap through here.
3849
- if (task.phase === 'spec') {
3850
- throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
3851
- }
3852
- if (!task.prNumber || !task.branch) {
3853
- throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
3854
- }
3855
- // Claim the task for the whole merge window. markCompleteInFlight blocks Cancel /
3856
- // Call review / Continue (all re-check it under the task lock) so they can't act on
3857
- // the same max_rounds snapshot and interleave with the irreversible `gh pr merge`.
3858
- if (this.markCompleteInFlight.has(taskId)) {
3859
- throw new ApiError(409, `Task ${taskId} is already being completed`);
3860
- }
3861
- this.markCompleteInFlight.add(taskId);
4237
+ // Human gate (spec §10): ready / merge-ready confirm runs its own completion
4238
+ // matrix (with its own lock-claimed gate); the legacy max_rounds path below
4239
+ // is untouched.
4240
+ if (peek.status === 'ready' || peek.status === 'merge-ready') {
4241
+ return this.confirmHumanGate(taskId);
4242
+ }
4243
+ // Claim under the task lock the whole merge window. markCompleteInFlight
4244
+ // blocks Cancel / Call review / Continue (all re-check it under the same
4245
+ // lock) so they can't act on the same snapshot and interleave with the
4246
+ // irreversible `gh pr merge` (or, server mode, the publish dispatch).
4247
+ const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
3862
4248
  try {
4249
+ // Server-mode publish retry: a failed afterDone dispatch leaves the task
4250
+ // 'approved' with dev released — mark-complete re-runs the publish (PR #288).
4251
+ const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
4252
+ if (!serverApprovedRetry && task.status !== 'max_rounds') {
4253
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4254
+ }
4255
+ // spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
4256
+ // endpoint too so a direct API call / older client can't merge a spec cap through here.
4257
+ if (task.phase === 'spec') {
4258
+ throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
4259
+ }
4260
+ if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
4261
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
4262
+ }
4263
+ if (serverApprovedRetry) {
4264
+ // A live code-ready watcher armed by a real dispatch means the publish
4265
+ // prompt IS running — a retry would inject a second prompt and rotate
4266
+ // the token under it. A RECOVERED watcher is weaker evidence, but
4267
+ // publishDispatchedAt persists delivery across restarts: set = the
4268
+ // prompt reached the pane before the restart (still in flight, 409);
4269
+ // cleared = the dispatch failed and this approved state is retryable —
4270
+ // stop the recovered watch and let the retry own the dispatch (PR #288).
4271
+ if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
4272
+ if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
4273
+ throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
4274
+ }
4275
+ this.phaseSignalWatcher.stop(taskId);
4276
+ }
4277
+ else if (task.publishDispatchedAt) {
4278
+ throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
4279
+ `retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
4280
+ }
4281
+ // task.afterDone was snapshotted when the approve verdict routed it.
4282
+ const afterDone = this.resolveAfterDone(task);
4283
+ if (afterDone === null) {
4284
+ throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
4285
+ }
4286
+ await this.dispatchServerAfterDone(taskId, afterDone);
4287
+ return (await this.taskStore.get(taskId));
4288
+ }
4289
+ // Server-mode capped task, human accepts as-is: no PR exists yet — run the
4290
+ // afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
4291
+ // Inside the in-flight claim so a concurrent Continue can't act on the same
4292
+ // max_rounds snapshot and release dev mid-publish.
4293
+ if (task.reviewMode === 'server') {
4294
+ // Max_rounds never routed an approve verdict — snapshot afterDone NOW so
4295
+ // the eventual ready-confirm uses this decision, not future hot config.
4296
+ const afterDone = this.config.review.afterDone ?? null;
4297
+ await this.updateTask(taskId, { afterDone });
4298
+ if (afterDone === null) {
4299
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
4300
+ if (!done)
4301
+ throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
4302
+ await this.releaseTaskAgents(taskId);
4303
+ return (await this.taskStore.get(taskId));
4304
+ }
4305
+ const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
4306
+ if (!approved)
4307
+ throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
4308
+ await this.dispatchServerAfterDone(taskId, afterDone);
4309
+ return (await this.taskStore.get(taskId));
4310
+ }
3863
4311
  // Held-agent check AFTER claiming (the claim blocks a new continueDevRound from starting),
3864
4312
  // and re-reading agent state here catches a continue that Held an agent in the window just
3865
4313
  // before our claim. dispatchPostMergeCleanup early-returns on awaiting_human, so merging with
@@ -4060,6 +4508,28 @@ export class AgentManager {
4060
4508
  }
4061
4509
  }
4062
4510
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4511
+ // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
4512
+ // 且 finally 会误删对方的 guard 放穿后续请求。
4513
+ await this.acquireCompactGuard(agentId);
4514
+ try {
4515
+ await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt);
4516
+ }
4517
+ finally {
4518
+ this.compactInFlight.delete(agentId);
4519
+ }
4520
+ }
4521
+ async acquireCompactGuard(agentId) {
4522
+ while (!this.tryAcquireCompactGuard(agentId)) {
4523
+ await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4524
+ }
4525
+ }
4526
+ tryAcquireCompactGuard(agentId) {
4527
+ if (this.compactInFlight.has(agentId))
4528
+ return false;
4529
+ this.compactInFlight.add(agentId);
4530
+ return true;
4531
+ }
4532
+ async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4063
4533
  const bindingStillOurs = async () => {
4064
4534
  const s = await this.agentStore.get(agentId);
4065
4535
  return !!s && s.taskId === originalTaskId && s.paneId === paneId;
@@ -4132,18 +4602,12 @@ export class AgentManager {
4132
4602
  stopPhaseSignalWatcher(taskId) {
4133
4603
  this.phaseSignalWatcher?.stop(taskId);
4134
4604
  }
4135
- // Backwards-compat alias for spec-only call sites (recovery, transitions
4136
- // that already named the kind). New callers should use setupPhaseSignalWatcher
4137
- // directly with the right expectedKinds.
4138
- stopSpecSignalWatcher(taskId) {
4139
- this.stopPhaseSignalWatcher(taskId);
4140
- }
4141
4605
  // Prompt build (via task.signalToken) and watcher must share the same token.
4142
4606
  // Returns whether dispatch may safely proceed. False ONLY when a configured watcher failed
4143
4607
  // to arm — the dangerous case where a same-identity verdict would have no consumer. When no
4144
4608
  // watcher subsystem is configured at all (poller-only deployment) the poller is the verdict
4145
4609
  // path, so this returns true and does not block. Best-effort callers ignore the result.
4146
- async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
4610
+ async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
4147
4611
  if (!this.phaseSignalWatcher)
4148
4612
  return true;
4149
4613
  const task = await this.taskStore.get(taskId);
@@ -4157,6 +4621,7 @@ export class AgentManager {
4157
4621
  expectedKinds,
4158
4622
  token,
4159
4623
  skipSnapshot,
4624
+ ...(onReadFile ? { onReadFile } : {}),
4160
4625
  });
4161
4626
  }
4162
4627
  catch (err) {
@@ -4167,8 +4632,8 @@ export class AgentManager {
4167
4632
  // Arm a watcher for a signal the just-dispatched prompt will emit, then hold the agent if it
4168
4633
  // could not arm. Used by post-dispatch arms (develop/spec/code phases) whose pane only exists
4169
4634
  // after dispatch, so they can't gate before sending the prompt the way verdict dispatch does.
4170
- async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
4171
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot);
4635
+ async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
4636
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot, onReadFile);
4172
4637
  if (!armed)
4173
4638
  await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
4174
4639
  }
@@ -4220,56 +4685,125 @@ export class AgentManager {
4220
4685
  if (mapped)
4221
4686
  await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
4222
4687
  }
4223
- async readSpecReviewFile(taskId, fileName) {
4688
+ async transitionToCodePhase(taskId) {
4224
4689
  const task = await this.taskStore.get(taskId);
4225
4690
  if (!task)
4226
4691
  return null;
4227
- if (!task.branch) {
4228
- throw new Error(`readSpecReviewFile: task ${taskId} has no branch`);
4229
- }
4230
- const project = this.getProjectConfig(task.projectId);
4231
- if (!project) {
4232
- throw new Error(`readSpecReviewFile: unknown project ${task.projectId}`);
4233
- }
4234
- const dev = this.getAgentConfig(task.agentId);
4235
- if (!dev) {
4236
- throw new Error(`readSpecReviewFile: task ${taskId} has no dev agent bound`);
4692
+ const devAgentId = task.agentId;
4693
+ if (!devAgentId)
4694
+ return null;
4695
+ // Rotate token for the code phase so dev's pr-created signal is fresh; old
4696
+ // spec token must not survive into a different expected-kind set.
4697
+ const newToken = createSignalToken();
4698
+ // Atomic transition + persist: 旧版先 transition 再 updateTask, 中间崩溃 task 卡在
4699
+ // (phase='spec', status='in_progress') — setupRecoveredSpecSignals 三个 case 都不匹配,
4700
+ // freshness gate 也拒所有 spec.* event, 任务 stranded 无 auto-recovery。
4701
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing'] }, { phase: 'code', signalToken: newToken });
4702
+ if (!transition)
4703
+ return null;
4704
+ this.stopPhaseSignalWatcher(taskId);
4705
+ // Best-effort arm (NOT hold-on-failure): this runs before the code prompt is dispatched
4706
+ // (acquire + continueSession below), so holding here would block that reentry. And pr-created
4707
+ // is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
4708
+ // a missed pane watcher only costs one poll cycle of latency, never a stuck task.
4709
+ // Server mode has NO poller backstop: an unarmed code-done watcher means the
4710
+ // dev's completion signal would have no consumer — fail closed and hold.
4711
+ const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
4712
+ const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
4713
+ if (!codeArmed && task.reviewMode === 'server') {
4714
+ await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
4715
+ return null;
4237
4716
  }
4238
- const runner = this.createRunnerFor(dev);
4239
- const store = this.createRepoStore(dev, project, runner);
4240
- const workdir = await this.resolveWorkdir(dev, await this.agentStore.get(dev.id))
4241
- ?? await store.ensure();
4242
- const filePath = `.baxian/spec-review/${fileName}`;
4243
- return store.readFileFromBranch(workdir, task.branch, filePath);
4244
- }
4245
- async dispatchSpecReviewToQa(taskId) {
4246
- // Phase 1 (lock): validate + decide qa + compute newToken/newRound (无 mutation, 无 park)。
4247
- // 关键约束:task 不能在 startSession 之前被改 — startSession 内部调用
4248
- // buildPromptInline,prompt 必须看到的是新 token 和新 round;这里只 *计算*,
4249
- // 真正写回 task 放到 Phase 3。
4250
- const claim = await this.withTaskLock(async () => {
4251
- const task = await this.taskStore.get(taskId);
4252
- if (!task)
4253
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} not found`);
4254
- if (!task.branch)
4255
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} has no branch`);
4256
- // Stale spec-created guard: 一旦 task 离开 pre-spec 阶段 (phase='code' 或其他
4257
- // 非 'spec'/undefined 值),迟到的 spec-created signal 不应再 dispatch review。
4258
- // 允许 phase==='spec' 是预留 dev 在 fix-complete 后再 emit spec-created 的扩展点。
4259
- if (task.phase !== undefined && task.phase !== 'spec') {
4717
+ if (task.qaAgentId) {
4718
+ // release 失败留 stale qa binding → emit intervention 让其可见。
4719
+ const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
4720
+ .catch(() => false);
4721
+ if (!released) {
4260
4722
  await this.safeEmit({
4261
4723
  id: '',
4262
4724
  type: 'human.intervention',
4263
4725
  timestamp: new Date().toISOString(),
4264
4726
  projectId: task.projectId,
4265
- agentId: task.agentId,
4727
+ agentId: task.qaAgentId,
4266
4728
  taskId,
4267
- data: { phase: 'spec-created-stale-after-code', taskPhase: task.phase },
4729
+ data: { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId },
4268
4730
  });
4269
- return null;
4270
4731
  }
4271
- const qa = this.findQaPartner(task.agentId);
4272
- if (!qa) {
4732
+ }
4733
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
4734
+ if (!acquired) {
4735
+ // Task already shows phase='code' in_progress with the code-done watcher
4736
+ // armed, and server mode has no poller backstop — without a hold the dev
4737
+ // never receives the code prompt and the task dead-ends. The
4738
+ // code-dispatch-failed hold gives Resume a redispatch path.
4739
+ await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Dev could not be acquired for the code phase after spec approval; the task looks in_progress but the code prompt was never dispatched. Resume the agent to redispatch or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
4740
+ await this.safeEmit({
4741
+ id: '',
4742
+ type: 'human.intervention',
4743
+ timestamp: new Date().toISOString(),
4744
+ projectId: task.projectId,
4745
+ agentId: devAgentId,
4746
+ taskId,
4747
+ data: { phase: 'code-dev-acquire-failed', devAgentId },
4748
+ });
4749
+ return null;
4750
+ }
4751
+ let resumed = false;
4752
+ try {
4753
+ resumed = await this.continueSession(taskId, devAgentId, 'code');
4754
+ }
4755
+ catch (err) {
4756
+ // 同 dispatchServerReviewToQa/dispatchServerFixToDev:DispatchTerminalError 委托给 failTaskForDispatchError
4757
+ // (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
4758
+ if (err instanceof DispatchTerminalError) {
4759
+ await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
4760
+ }
4761
+ else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
4762
+ // Task already shows phase='code' in_progress but the prompt never landed
4763
+ // and there is no retry entry — hold explicitly instead of dead-ending.
4764
+ 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);
4765
+ }
4766
+ console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
4767
+ throw err;
4768
+ }
4769
+ if (!resumed) {
4770
+ 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);
4771
+ await this.safeEmit({
4772
+ id: '',
4773
+ type: 'human.intervention',
4774
+ timestamp: new Date().toISOString(),
4775
+ projectId: task.projectId,
4776
+ agentId: devAgentId,
4777
+ taskId,
4778
+ data: { phase: 'code-resume-failed', devAgentId },
4779
+ });
4780
+ return null;
4781
+ }
4782
+ return await this.taskStore.get(taskId);
4783
+ }
4784
+ // ── Server review mode (spec: docs/spec/server-review-mode.md) ──────────────
4785
+ async dispatchServerReviewToQa(taskId, opts) {
4786
+ const dispatchPhase = opts.phase === 'spec'
4787
+ ? 'server-spec-review'
4788
+ : (opts.recheck ? 'server-recheck' : 'server-review');
4789
+ const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
4790
+ const claim = await this.withTaskLock(async () => {
4791
+ const task = await this.taskStore.get(taskId);
4792
+ if (!task)
4793
+ throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
4794
+ // spec 阶段恒为 server 中转;code 阶段仍 server-only。
4795
+ if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
4796
+ throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
4797
+ }
4798
+ const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
4799
+ if (!qaId) {
4800
+ // Config validation rejects qa-less server pairs, but a hot-removed QA
4801
+ // can still land here — re-arm the consumed entry signal so the task
4802
+ // is recoverable once a QA is configured again.
4803
+ const entryKind = task.status === 'fixing'
4804
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
4805
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
4806
+ await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
4273
4807
  await this.safeEmit({
4274
4808
  id: '',
4275
4809
  type: 'human.intervention',
@@ -4277,83 +4811,103 @@ export class AgentManager {
4277
4811
  projectId: task.projectId,
4278
4812
  agentId: task.agentId,
4279
4813
  taskId,
4280
- data: { phase: 'spec-review-no-qa-partner', devAgentId: task.agentId },
4814
+ data: { phase: 'server-review-no-qa-partner', devAgentId: task.agentId },
4281
4815
  });
4282
4816
  return null;
4283
4817
  }
4284
- // 记录入口 status fix-then-review 重派 (fromStatus 'fixing') 时,
4285
- // spawn 失败 rollback 不能无差别回 in_progress;必须回到原 status 以保留 spec phase。
4286
- // transitionTaskStatus 的 fromStatus 守门已限定为这三种之一; 其他 status 不会走到这里。
4287
- const isReviewEntry = task.status === 'in_progress'
4288
- || task.status === 'fixing'
4289
- || task.status === 'pending';
4290
- if (!isReviewEntry)
4291
- return null;
4818
+ const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
4292
4819
  return {
4293
- qaId: qa.id,
4820
+ qaId,
4294
4821
  devAgentId: task.agentId,
4295
4822
  projectId: task.projectId,
4296
4823
  newToken: createSignalToken(),
4297
- newRound: (task.specReviewRound ?? 0) + 1,
4824
+ newRound: opts.continuation ? Math.max(roundField, 1) : roundField + 1,
4298
4825
  originalStatus: task.status,
4299
- // 记录原 spec-created token — pre-spec entry rollback 时 restore,
4300
- // 让 dev 后续 spec-created signal (with 原 token) 经 handler freshness gate 通过 → auto retry。
4301
4826
  originalToken: task.signalToken,
4302
- // 回滚时 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4303
- originalRound: task.specReviewRound,
4827
+ originalRound: roundField,
4828
+ originalBatchIndex: task.batchIndex,
4829
+ originalBatchTotal: task.batchTotal,
4830
+ originalPhase: task.phase,
4304
4831
  };
4305
4832
  });
4306
4833
  if (!claim)
4307
4834
  return null;
4308
- const { qaId, devAgentId, projectId, newToken, newRound, originalStatus, originalToken, originalRound } = claim;
4309
- // Phase 2a: acquire QA 失败时 dev 还未 park,直接 return 即可。
4310
- const acquired = await this.acquireAgentForTask(qaId, taskId, 'spec-review');
4311
- if (!acquired) {
4312
- await this.safeEmit({
4313
- id: '',
4314
- type: 'human.intervention',
4315
- timestamp: new Date().toISOString(),
4316
- projectId,
4317
- agentId: qaId,
4318
- taskId,
4319
- data: { phase: 'spec-review-qa-acquire-failed', qaAgentId: qaId },
4320
- });
4321
- return null;
4322
- }
4323
- // Phase 2b: dev gate — park dev so it stops editing the spec while QA reviews。
4324
- // 顺序在 acquireQA 之后:避免 QA 失败时 dev parked 但 task 仍 in_progress,
4325
- // 无任何后续 dispatch dev 拉出 waiting (即 dev 永久挂起)。
4326
- if (devAgentId) {
4327
- const devOk = await this.markAgentWaiting(devAgentId, taskId);
4328
- if (!devOk) {
4329
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4330
- .catch(() => undefined);
4835
+ const { qaId, devAgentId, projectId, newToken, newRound } = claim;
4836
+ // continueSession failure after the transition would otherwise strand the
4837
+ // task in 'review' with a fresh token nobody will ever signal (PR #288).
4838
+ const rollback = async () => {
4839
+ await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
4840
+ signalToken: claim.originalToken,
4841
+ batchIndex: claim.originalBatchIndex,
4842
+ batchTotal: claim.originalBatchTotal,
4843
+ // spec transition 写入 phase:'spec';github 首轮失败若不还原,dev 直发
4844
+ // pr-created 会被 legacy freshness gate 拒(设计 §2)。
4845
+ phase: claim.originalPhase,
4846
+ ...(opts.phase === 'spec'
4847
+ ? { specReviewRound: claim.originalRound }
4848
+ : { reviewRound: claim.originalRound }),
4849
+ }).catch(() => undefined);
4850
+ };
4851
+ // The entry signal (code/spec-done|fixed) was already consumed by the
4852
+ // watcher; a pre-transition failure must re-arm it with the unrotated token
4853
+ // or the agent's re-emit after the operator fixes availability has no
4854
+ // consumer (PR #288).
4855
+ const rearmEntrySignal = async () => {
4856
+ const entryKind = claim.originalStatus === 'fixing'
4857
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
4858
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
4859
+ await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
4860
+ };
4861
+ if (!opts.continuation) {
4862
+ const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
4863
+ if (!acquired) {
4864
+ await rearmEntrySignal();
4331
4865
  await this.safeEmit({
4332
4866
  id: '',
4333
4867
  type: 'human.intervention',
4334
4868
  timestamp: new Date().toISOString(),
4335
4869
  projectId,
4336
- agentId: devAgentId,
4870
+ agentId: qaId,
4337
4871
  taskId,
4338
- data: { phase: 'spec-review-dev-park-failed', devAgentId },
4872
+ data: { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId },
4339
4873
  });
4340
4874
  return null;
4341
4875
  }
4876
+ if (devAgentId) {
4877
+ const devOk = await this.markAgentWaiting(devAgentId, taskId);
4878
+ if (!devOk) {
4879
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
4880
+ await rearmEntrySignal();
4881
+ await this.safeEmit({
4882
+ id: '',
4883
+ type: 'human.intervention',
4884
+ timestamp: new Date().toISOString(),
4885
+ projectId,
4886
+ agentId: devAgentId,
4887
+ taskId,
4888
+ data: { phase: 'server-review-dev-park-failed', devAgentId },
4889
+ });
4890
+ return null;
4891
+ }
4892
+ }
4342
4893
  }
4343
- // Phase 2c (lock): atomic transition + persist newToken/newRound/phase/qaAgentId.
4344
- // 必须在 startSession 之前;若顺序反过来,startSession 之后崩溃但 transition 没做时,
4345
- // setupRecoveredSpecSignals 会读旧 status/token 推断错 kind/token,新 signal 无法匹配 → 链路死。
4346
- const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'pending'] }, {
4347
- specReviewRound: newRound,
4894
+ const roundPatch = opts.phase === 'spec'
4895
+ ? { specReviewRound: newRound, phase: 'spec' }
4896
+ : { reviewRound: newRound };
4897
+ const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
4348
4898
  signalToken: newToken,
4349
- phase: 'spec',
4350
4899
  qaAgentId: qaId,
4900
+ reviewDispatchedAt: new Date().toISOString(),
4901
+ ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
4902
+ ...(opts.batch
4903
+ ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
4904
+ : { batchIndex: undefined, batchTotal: undefined }),
4905
+ ...roundPatch,
4351
4906
  });
4352
4907
  if (!transition) {
4353
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4354
- .catch(() => undefined);
4355
- // 不 re-acquire dev: markAgentWaiting (mode='waiting') 仅 bump updatedAt,
4356
- // dev 仍 bound 到 task; develop phase 不在 reentry 集合, 重 acquire 必返回 false (dead code)。
4908
+ if (!opts.continuation) {
4909
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
4910
+ }
4357
4911
  await this.safeEmit({
4358
4912
  id: '',
4359
4913
  type: 'human.intervention',
@@ -4361,45 +4915,63 @@ export class AgentManager {
4361
4915
  projectId,
4362
4916
  agentId: qaId,
4363
4917
  taskId,
4364
- data: { phase: 'spec-review-transition-failed', qaAgentId: qaId },
4918
+ data: { phase: 'server-review-transition-failed', qaAgentId: qaId },
4365
4919
  });
4366
4920
  return null;
4367
4921
  }
4368
- // Phase 2d: startSession 用显式 newToken/newRound 透传到 prompt。
4369
- // 失败时回滚 transition + 清新 persist 字段,避免 task 留在 review 但 qa 无 session 的 stuck。
4370
- // 不调 acquireAgentForTask(dev, 'develop'):markAgentWaiting 走 mode='waiting' 仅 bump updatedAt
4371
- // (不清 binding 也不真正 park REPL),dev 仍 bound 到 task;且 develop phase 不在
4372
- // canDispatchWithBinding 的 reentry 集合,重 acquire 必返回 false — 是 dead code。
4922
+ // First dispatch creates the QA's base-detached worktree (startSession);
4923
+ // batch continuations reuse the live session + worktree (continueSession).
4924
+ const sessionOpts = {
4925
+ bypassTaskStatusGate: true,
4926
+ signalToken: newToken,
4927
+ serverContent: opts.content,
4928
+ ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
4929
+ ...(opts.contentTruncated ? { contentTruncated: true } : {}),
4930
+ ...(opts.batch ? { serverBatch: opts.batch } : {}),
4931
+ ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
4932
+ ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
4933
+ ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
4934
+ };
4935
+ // A continuation consumed the QA's reviewed signal (not the dev's entry
4936
+ // signal): rollback restores the prior slice's review/token, so re-arm the
4937
+ // reviewed watcher — the QA's re-emit replays the stored batch findings and
4938
+ // resumes the next-slice dispatch (PR #288).
4939
+ const rearmConsumedSignal = async () => {
4940
+ if (opts.continuation) {
4941
+ await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
4942
+ }
4943
+ else {
4944
+ await rearmEntrySignal();
4945
+ }
4946
+ };
4373
4947
  let started = false;
4374
4948
  try {
4375
- started = await this.startSession(taskId, qaId, 'spec-review', {
4376
- bypassTaskStatusGate: true,
4377
- signalToken: newToken,
4378
- currentSpecRound: newRound,
4379
- });
4949
+ started = opts.continuation
4950
+ ? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
4951
+ : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
4380
4952
  }
4381
4953
  catch (err) {
4382
- // DispatchTerminalError 都委托给 failTaskForDispatchError:ack_unknown 会保留绑定走
4383
- // markAwaitingHuman,其他 reason(prompt_too_large 等非 transient)让 task 进 failed
4384
- // 而不是 rollback 让 cron 反复 retry。其他异常(瞬时 / 不明)才走 rollback + release。
4385
4954
  if (err instanceof DispatchTerminalError) {
4386
- await this.failTaskForDispatchError(taskId, 'spec-review', qaId, err);
4955
+ await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
4387
4956
  }
4388
4957
  else if (err instanceof EnsureSessionError && err.partial.handled) {
4389
- // handleDialogPendingFromRuntime Held + fail task + release partners;跳过 rollback + release,
4390
- // 否则 boundTask terminal 让 release gate 放行清掉仍卡 dialog 的 pane lock。
4958
+ // handleDialogPendingFromRuntime already held + failed + released.
4391
4959
  }
4392
4960
  else {
4393
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4394
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4395
- .catch(() => undefined);
4961
+ await rollback();
4962
+ if (!opts.continuation) {
4963
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
4964
+ }
4965
+ await rearmConsumedSignal();
4396
4966
  }
4397
4967
  throw err;
4398
4968
  }
4399
4969
  if (!started) {
4400
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4401
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4402
- .catch(() => undefined);
4970
+ await rollback();
4971
+ if (!opts.continuation) {
4972
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
4973
+ }
4974
+ await rearmConsumedSignal();
4403
4975
  await this.safeEmit({
4404
4976
  id: '',
4405
4977
  type: 'human.intervention',
@@ -4407,75 +4979,75 @@ export class AgentManager {
4407
4979
  projectId,
4408
4980
  agentId: qaId,
4409
4981
  taskId,
4410
- data: { phase: 'spec-review-start-failed', qaAgentId: qaId },
4982
+ data: { phase: 'server-review-start-failed', qaAgentId: qaId },
4411
4983
  });
4412
4984
  return null;
4413
4985
  }
4414
- // Phase 3: set up watcher。spec-created 已被消费,先 tear down 防止 dev 之后无关 signal 误触发。
4415
- // QA echoes exactly one verdict signal set up both kinds, the first match wins.
4416
- this.stopSpecSignalWatcher(taskId);
4417
- await this.armPostDispatchSignalOrHold(taskId, qaId, ['spec-approved', 'spec-changes-requested'], newToken);
4986
+ this.stopPhaseSignalWatcher(taskId);
4987
+ await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
4418
4988
  return await this.taskStore.get(taskId);
4419
4989
  }
4420
- // startSession 失败回滚:
4421
- // - pre-spec entry: restore originalToken 让 dev 后续 spec-created signal 经 freshness gate 通过 → auto retry。
4422
- // - fixing entry: 保留 phase='spec' + qaAgentId(否则 spec.* freshness gate 全 fail),清 token 防 stale。
4423
- // round 必须 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4424
- async rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound) {
4425
- if (originalStatus === 'fixing') {
4426
- await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: undefined, specReviewRound: originalRound });
4427
- return;
4428
- }
4429
- await this.transitionTaskStatus(taskId, originalStatus, { fromStatus: ['review'] }, {
4430
- signalToken: originalToken,
4431
- phase: undefined,
4432
- qaAgentId: undefined,
4433
- specReviewRound: originalRound,
4434
- });
4435
- }
4436
- async dispatchSpecFixToDev(taskId, findings) {
4437
- // Phase 1 (lock): validate + phase guard + decide newToken。
4438
- // fix 是同 round 的 dev 处理 QA findings,round 不递增;只刷新 token 让 prompt + watcher 唯一识别本轮 fix。
4990
+ async dispatchServerFixToDev(taskId, findingsJson) {
4439
4991
  const claim = await this.withTaskLock(async () => {
4440
4992
  const task = await this.taskStore.get(taskId);
4441
4993
  if (!task)
4442
- throw new Error(`dispatchSpecFixToDev: task ${taskId} not found`);
4443
- const devAgentId = task.agentId;
4444
- if (!devAgentId) {
4445
- throw new Error(`dispatchSpecFixToDev: task ${taskId} has no dev agent`);
4446
- }
4447
- // 离开 spec 阶段的 task 不应再被 spec-fix dispatch 击中 (defense in depth — handler 也 gate)。
4448
- if (task.phase !== 'spec') {
4449
- await this.safeEmit({
4450
- id: '',
4451
- type: 'human.intervention',
4452
- timestamp: new Date().toISOString(),
4453
- projectId: task.projectId,
4454
- agentId: devAgentId,
4455
- taskId,
4456
- data: { phase: 'spec-fix-stale-phase', taskPhase: task.phase },
4457
- });
4458
- return null;
4994
+ throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
4995
+ if (task.reviewMode !== 'server' && task.phase !== 'spec') {
4996
+ throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
4459
4997
  }
4998
+ if (!task.agentId)
4999
+ throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
4460
5000
  return {
4461
- devAgentId,
5001
+ devAgentId: task.agentId,
4462
5002
  qaAgentId: task.qaAgentId,
4463
5003
  projectId: task.projectId,
4464
5004
  newToken: createSignalToken(),
4465
- currentRound: task.specReviewRound ?? 1,
5005
+ taskPhase: (task.phase ?? 'code'),
5006
+ currentSpecRound: task.specReviewRound,
5007
+ // Continue-one-round enters from max_rounds — failure must restore THAT,
5008
+ // not silently demote the human's pause decision to 'review' (PR #288).
5009
+ originalStatus: task.status,
5010
+ originalToken: task.signalToken,
4466
5011
  };
4467
5012
  });
4468
5013
  if (!claim)
4469
5014
  return null;
4470
- const { devAgentId, qaAgentId, projectId, newToken, currentRound } = claim;
5015
+ const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
5016
+ const rollbackToEntry = async () => {
5017
+ await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
5018
+ };
5019
+ const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
5020
+ // The QA's reviewed signal was consumed before this dispatch; pre-transition
5021
+ // failures must re-arm it (unrotated token) so a later re-emit is consumed.
5022
+ const rearmReviewedSignal = async () => {
5023
+ if (!qaAgentId)
5024
+ return;
5025
+ const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
5026
+ await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
5027
+ };
5028
+ // Dev BEFORE QA: releasing the QA first is irreversible (binding cleared,
5029
+ // worktree removed, schedulable elsewhere) — a dev acquire failure after it
5030
+ // would leave the review-parked task with no stably-bound agent to retry
5031
+ // from. With the dev secured first, both failure exits keep the QA bound.
5032
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
5033
+ if (!acquired) {
5034
+ await rearmReviewedSignal();
5035
+ await this.safeEmit({
5036
+ id: '',
5037
+ type: 'human.intervention',
5038
+ timestamp: new Date().toISOString(),
5039
+ projectId,
5040
+ agentId: devAgentId,
5041
+ taskId,
5042
+ data: { phase: 'server-fix-dev-acquire-failed', devAgentId },
5043
+ });
5044
+ return null;
5045
+ }
4471
5046
  if (qaAgentId) {
4472
- // release 失败留 stale qa binding,下一轮 acquireAgentForTask(qa) 必拒;abort + emit intervention。
4473
5047
  const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
4474
- .catch(err => {
4475
- console.warn(`[AgentManager] dispatchSpecFixToDev release qa=${qaAgentId} failed:`, err);
4476
- return false;
4477
- });
5048
+ .catch(() => false);
4478
5049
  if (!released) {
5050
+ await rearmReviewedSignal();
4479
5051
  await this.safeEmit({
4480
5052
  id: '',
4481
5053
  type: 'human.intervention',
@@ -4483,32 +5055,18 @@ export class AgentManager {
4483
5055
  projectId,
4484
5056
  agentId: qaAgentId,
4485
5057
  taskId,
4486
- data: { phase: 'spec-fix-qa-release-failed', qaAgentId },
5058
+ data: { phase: 'server-fix-qa-release-failed', qaAgentId },
4487
5059
  });
4488
5060
  return null;
4489
5061
  }
4490
5062
  }
4491
- // Phase 2a: acquire dev。
4492
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'spec-fix');
4493
- if (!acquired) {
4494
- await this.safeEmit({
4495
- id: '',
4496
- type: 'human.intervention',
4497
- timestamp: new Date().toISOString(),
4498
- projectId,
4499
- agentId: devAgentId,
4500
- taskId,
4501
- data: { phase: 'spec-fix-dev-acquire-failed', devAgentId },
4502
- });
4503
- return null;
4504
- }
4505
- // Phase 2b (lock): atomic transition + persist newToken/phase。
4506
- // 必须在 continueSession 之前;否则崩溃后 setupRecoveredSpecSignals 读旧 token,
4507
- // 与 dev 输出的 newToken signal 不匹配 → 链路死。
4508
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: newToken, phase: 'spec' });
5063
+ // max_rounds entry = human "continue one round" via continueDevRound.
5064
+ const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
4509
5065
  if (!transition) {
4510
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4511
- .catch(() => undefined);
5066
+ // Refusal = the task left review/max_rounds concurrently (cancel / fail /
5067
+ // mark-complete publish). Ownership moved with it — releasing dev here
5068
+ // would strip a binding the winning chain may be actively using (e.g. a
5069
+ // publish prompt running in the pane); its own cleanup releases the dev.
4512
5070
  await this.safeEmit({
4513
5071
  id: '',
4514
5072
  type: 'human.intervention',
@@ -4516,42 +5074,42 @@ export class AgentManager {
4516
5074
  projectId,
4517
5075
  agentId: devAgentId,
4518
5076
  taskId,
4519
- data: { phase: 'spec-fix-transition-failed', devAgentId },
5077
+ data: { phase: 'server-fix-transition-failed', devAgentId },
4520
5078
  });
4521
5079
  return null;
4522
5080
  }
4523
- // Phase 2c: continueSession 透传 newToken + currentRound 给 prompt。
4524
- // 失败时回滚 transition + 清新 token,避免 task 留在 fixing 但 dev 无 spec-fix prompt 的 stuck。
4525
5081
  let resumed = false;
4526
5082
  try {
4527
- resumed = await this.continueSession(taskId, devAgentId, 'spec-fix', {
4528
- specFindings: findings,
4529
- signalToken: newToken,
4530
- currentSpecRound: currentRound,
5083
+ resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
4531
5084
  bypassTaskStatusGate: true,
5085
+ signalToken: newToken,
5086
+ serverPriorFindings: findingsJson,
5087
+ ...(taskPhase === 'spec' && currentSpecRound !== undefined
5088
+ ? { currentSpecRound }
5089
+ : {}),
4532
5090
  });
4533
5091
  }
4534
5092
  catch (err) {
4535
- // 同 spec-review:DispatchTerminalError 走 failTaskForDispatchError 统一处理
4536
- // (ack_unknown → markAwaitingHuman,其他 reason → release + task failed)。
4537
5093
  if (err instanceof DispatchTerminalError) {
4538
- await this.failTaskForDispatchError(taskId, 'spec-fix', devAgentId, err);
5094
+ await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
4539
5095
  }
4540
5096
  else if (err instanceof EnsureSessionError && err.partial.handled) {
4541
- // handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release。
5097
+ // handled upstream
4542
5098
  }
4543
5099
  else {
4544
- await this.rollbackSpecFixTransition(taskId);
4545
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4546
- .catch(() => undefined);
5100
+ await rollbackToEntry();
5101
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5102
+ // Rollback restored review/old-token, but the QA's reviewed signal was
5103
+ // consumed — without a subscriber its re-emit can never retry the fix
5104
+ // dispatch (PR #288).
5105
+ await rearmReviewedSignal();
4547
5106
  }
4548
- console.error(`[AgentManager] dispatchSpecFixToDev continueSession(dev=${devAgentId}) failed:`, err);
4549
5107
  throw err;
4550
5108
  }
4551
5109
  if (!resumed) {
4552
- await this.rollbackSpecFixTransition(taskId);
4553
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4554
- .catch(() => undefined);
5110
+ await rollbackToEntry();
5111
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5112
+ await rearmReviewedSignal();
4555
5113
  await this.safeEmit({
4556
5114
  id: '',
4557
5115
  type: 'human.intervention',
@@ -4559,59 +5117,34 @@ export class AgentManager {
4559
5117
  projectId,
4560
5118
  agentId: devAgentId,
4561
5119
  taskId,
4562
- data: { phase: 'spec-fix-resume-failed', devAgentId },
5120
+ data: { phase: 'server-fix-resume-failed', devAgentId },
4563
5121
  });
4564
5122
  return null;
4565
5123
  }
4566
- // Phase 3: set up watcher。
4567
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'spec-fixed', newToken);
5124
+ await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
4568
5125
  return await this.taskStore.get(taskId);
4569
5126
  }
4570
- // continueSession 失败回滚:fixing → review + 清新 token。
4571
- // 保留 phase='spec' 与 qaAgentId — 失败后 review 状态需要人工 retry 或重新 dispatch。
4572
- async rollbackSpecFixTransition(taskId) {
4573
- await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['fixing'] }, { signalToken: undefined });
4574
- }
4575
- async transitionToCodePhase(taskId) {
5127
+ async dispatchServerAfterDone(taskId, kind) {
4576
5128
  const task = await this.taskStore.get(taskId);
4577
5129
  if (!task)
4578
- return null;
5130
+ throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
4579
5131
  const devAgentId = task.agentId;
4580
5132
  if (!devAgentId)
4581
- return null;
4582
- // Rotate token for the code phase so dev's pr-created signal is fresh; old
4583
- // spec token must not survive into a different expected-kind set.
5133
+ throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
5134
+ const branch = task.branch ?? BRANCH_PREFIX + taskId;
5135
+ const originalToken = task.signalToken;
4584
5136
  const newToken = createSignalToken();
4585
- // Atomic transition + persist: 旧版先 transition 再 updateTask, 中间崩溃 task 卡在
4586
- // (phase='spec', status='in_progress')setupRecoveredSpecSignals 三个 case 都不匹配,
4587
- // freshness gate 也拒所有 spec.* event, 任务 stranded auto-recovery。
4588
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing'] }, { phase: 'code', signalToken: newToken });
4589
- if (!transition)
4590
- return null;
4591
- this.stopPhaseSignalWatcher(taskId);
4592
- // Best-effort arm (NOT hold-on-failure): this runs before the code prompt is dispatched
4593
- // (acquire + continueSession below), so holding here would block that reentry. And pr-created
4594
- // is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
4595
- // a missed pane watcher only costs one poll cycle of latency, never a stuck task.
4596
- await this.setupPhaseSignalWatcher(taskId, devAgentId, 'pr-created', newToken);
4597
- if (task.qaAgentId) {
4598
- // release 失败留 stale qa binding → emit intervention 让其可见。
4599
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
4600
- .catch(() => false);
4601
- if (!released) {
4602
- await this.safeEmit({
4603
- id: '',
4604
- type: 'human.intervention',
4605
- timestamp: new Date().toISOString(),
4606
- projectId: task.projectId,
4607
- agentId: task.qaAgentId,
4608
- taskId,
4609
- data: { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId },
4610
- });
4611
- }
4612
- }
4613
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5137
+ await this.updateTask(taskId, { signalToken: newToken });
5138
+ // The publish prompt never reached the pane restore the pre-rotation token
5139
+ // (so recovery still matches the pre-dispatch arm) and clear the delivery
5140
+ // marker so retry knows this approved state is preemptible (PR #288).
5141
+ const rollbackToken = async () => {
5142
+ await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
5143
+ .catch(() => undefined);
5144
+ };
5145
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
4614
5146
  if (!acquired) {
5147
+ await rollbackToken();
4615
5148
  await this.safeEmit({
4616
5149
  id: '',
4617
5150
  type: 'human.intervention',
@@ -4619,24 +5152,37 @@ export class AgentManager {
4619
5152
  projectId: task.projectId,
4620
5153
  agentId: devAgentId,
4621
5154
  taskId,
4622
- data: { phase: 'code-dev-acquire-failed', devAgentId },
5155
+ data: { phase: 'server-after-done-dev-acquire-failed', devAgentId },
4623
5156
  });
4624
5157
  return null;
4625
5158
  }
5159
+ // Pessimistic delivery marker BEFORE the irreversible paste: every failure
5160
+ // path below clears it. The remaining crash window (marker written, paste
5161
+ // never ran) fails CLOSED — retry 409s on a publish that never started and
5162
+ // the operator escapes via Cancel — instead of the old window's fail-open
5163
+ // double publish (paste ran, marker missing, retry re-pastes) (PR #288).
5164
+ await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
4626
5165
  let resumed = false;
4627
5166
  try {
4628
- resumed = await this.continueSession(taskId, devAgentId, 'code');
5167
+ resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
5168
+ bypassTaskStatusGate: true,
5169
+ signalToken: newToken,
5170
+ serverAfterDone: { kind, branch },
5171
+ });
4629
5172
  }
4630
5173
  catch (err) {
4631
- // 同 spec-review/spec-fix:DispatchTerminalError 委托给 failTaskForDispatchError
4632
- // (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
4633
5174
  if (err instanceof DispatchTerminalError) {
4634
- await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
5175
+ await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
5176
+ }
5177
+ else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5178
+ // Keep dev BOUND — its worktree holds the reviewed (unpushed) commits.
5179
+ // mark-complete retries the publish via server-after-done same-task reentry.
5180
+ await rollbackToken();
4635
5181
  }
4636
- console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
4637
5182
  throw err;
4638
5183
  }
4639
5184
  if (!resumed) {
5185
+ await rollbackToken();
4640
5186
  await this.safeEmit({
4641
5187
  id: '',
4642
5188
  type: 'human.intervention',
@@ -4644,12 +5190,267 @@ export class AgentManager {
4644
5190
  projectId: task.projectId,
4645
5191
  agentId: devAgentId,
4646
5192
  taskId,
4647
- data: { phase: 'code-resume-failed', devAgentId },
5193
+ data: {
5194
+ phase: 'server-after-done-resume-failed',
5195
+ devAgentId,
5196
+ note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
5197
+ },
4648
5198
  });
4649
5199
  return null;
4650
5200
  }
5201
+ await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
4651
5202
  return await this.taskStore.get(taskId);
4652
5203
  }
5204
+ // QA asked for file context during a server-mode review. Read from the DEV
5205
+ // worktree (the QA worktree sits on the base branch) and paste into QA's pane.
5206
+ async handleReadFileRequest(taskId, qaAgentId, req) {
5207
+ const task = await this.taskStore.get(taskId);
5208
+ if (!task)
5209
+ return;
5210
+ const dev = this.getAgentConfig(task.agentId);
5211
+ if (!dev)
5212
+ return;
5213
+ await this.refreshWorktreeCacheFor(task.agentId);
5214
+ let body;
5215
+ try {
5216
+ const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
5217
+ body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
5218
+ }
5219
+ catch (err) {
5220
+ const reason = err instanceof Error ? err.message : String(err);
5221
+ body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
5222
+ }
5223
+ // The read ran async — QA may have submitted its verdict and been released
5224
+ // or rebound meanwhile. Never paste old-task content into a new task's pane.
5225
+ const qaState = await this.agentStore.get(qaAgentId);
5226
+ if (qaState?.taskId !== taskId) {
5227
+ console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
5228
+ return;
5229
+ }
5230
+ try {
5231
+ await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
5232
+ }
5233
+ catch (err) {
5234
+ console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
5235
+ }
5236
+ }
5237
+ // Plain text paste + submit into a live agent pane (no skills, no ack protocol).
5238
+ async injectTextToAgent(agentId, text, opts = {}) {
5239
+ const cfg = this.getAgentConfig(agentId);
5240
+ if (!cfg)
5241
+ throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
5242
+ await this.acquireCompactGuard(agentId);
5243
+ try {
5244
+ // 锁内重读:guard 等待期间绑定可能已易主,过期文本决不落 pane。
5245
+ const state = await this.agentStore.get(agentId);
5246
+ if (opts.expectedTaskId !== undefined && state?.taskId !== opts.expectedTaskId) {
5247
+ throw new Error(`injectTextToAgent: agent ${agentId} no longer bound to ${opts.expectedTaskId}`);
5248
+ }
5249
+ const paneId = state?.paneId;
5250
+ if (!paneId)
5251
+ throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5252
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5253
+ await tmux.injectPrompt(paneId, text, agentId);
5254
+ await tmux.sendEnter(paneId);
5255
+ }
5256
+ finally {
5257
+ this.compactInFlight.delete(agentId);
5258
+ }
5259
+ }
5260
+ // Human gate confirm (spec §10): executes the configured completion for
5261
+ // ready (server mode) / merge-ready (github mode) tasks.
5262
+ async confirmHumanGate(taskId) {
5263
+ // Claim under the task lock: a Cancel racing this read can no longer flip the
5264
+ // task to cancelled (and retire its artifacts) while we proceed on a stale
5265
+ // gate snapshot — cancelTask checks the in-flight flag inside the same lock.
5266
+ const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
5267
+ try {
5268
+ const project = this.getProjectConfig(task.projectId);
5269
+ const mergeAuto = project?.merge === 'auto';
5270
+ // Snapshot from verdict time — a hot config flip between publish and
5271
+ // confirm must not reroute an already-published artifact (PR #288).
5272
+ const afterDone = this.resolveAfterDone(task);
5273
+ if (task.status === 'merge-ready') {
5274
+ if (mergeAuto && task.prNumber) {
5275
+ // Guard on the post-approve head persisted at the merge-ready transition.
5276
+ if (!task.latestHeadSha) {
5277
+ throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
5278
+ }
5279
+ await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
5280
+ matchHeadSha: task.latestHeadSha,
5281
+ }));
5282
+ await this.eventBus.emit({
5283
+ id: '',
5284
+ type: 'pr.merged',
5285
+ timestamp: new Date().toISOString(),
5286
+ projectId: task.projectId,
5287
+ agentId: task.agentId,
5288
+ taskId: task.id,
5289
+ data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
5290
+ });
5291
+ return (await this.taskStore.get(taskId));
5292
+ }
5293
+ return this.finishTaskAsDone(taskId);
5294
+ }
5295
+ // status === 'ready' (server mode)
5296
+ if (afterDone === 'pr' && mergeAuto && task.prNumber) {
5297
+ // Reviewed-head guard is mandatory here — publish fail-closes on capture,
5298
+ // so a missing sha means tampered/legacy state, not a soft fallback.
5299
+ if (!task.latestHeadSha) {
5300
+ throw new ApiError(409, `Task ${taskId} has no reviewed head recorded; cannot safely merge`);
5301
+ }
5302
+ await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
5303
+ matchHeadSha: task.latestHeadSha,
5304
+ }));
5305
+ // pr.merged's fromStatus now includes 'ready' — let the handler own the
5306
+ // merged transition + full cleanup chain (branch delete, /compact, release).
5307
+ await this.eventBus.emit({
5308
+ id: '',
5309
+ type: 'pr.merged',
5310
+ timestamp: new Date().toISOString(),
5311
+ projectId: task.projectId,
5312
+ agentId: task.agentId,
5313
+ taskId: task.id,
5314
+ data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
5315
+ });
5316
+ return (await this.taskStore.get(taskId));
5317
+ }
5318
+ if (afterDone === 'branch' && mergeAuto && task.branch) {
5319
+ await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
5320
+ const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
5321
+ if (merged)
5322
+ await this.releaseTaskAgents(taskId);
5323
+ return (await this.taskStore.get(taskId));
5324
+ }
5325
+ return this.finishTaskAsDone(taskId);
5326
+ }
5327
+ finally {
5328
+ this.markCompleteInFlight.delete(taskId);
5329
+ }
5330
+ }
5331
+ // Atomic gate claim: re-read + status check + markCompleteInFlight.add under
5332
+ // the task lock, so confirm and cancel serialize on the same snapshot.
5333
+ async claimCompleteGate(taskId, statuses) {
5334
+ return this.withTaskLock(async () => {
5335
+ const fresh = await this.taskStore.get(taskId);
5336
+ if (!fresh)
5337
+ throw new ApiError(404, `Task ${taskId} not found`);
5338
+ if (!statuses.includes(fresh.status)) {
5339
+ throw new ApiError(409, `Task ${taskId} is not awaiting confirmation (status=${fresh.status})`);
5340
+ }
5341
+ if (this.markCompleteInFlight.has(taskId)) {
5342
+ throw new ApiError(409, `Task ${taskId} is already being completed`);
5343
+ }
5344
+ this.markCompleteInFlight.add(taskId);
5345
+ return fresh;
5346
+ });
5347
+ }
5348
+ // Merge failures keep the gate: transient gh/network errors retry via another
5349
+ // Confirm, a stale head resolves via Cancel or an external decision — terminal
5350
+ // 'failed' would orphan the published PR/branch outside the task flow (PR #288).
5351
+ async executeConfirmMerge(task, merge) {
5352
+ try {
5353
+ await merge();
5354
+ }
5355
+ catch (err) {
5356
+ const message = err instanceof Error ? err.message : String(err);
5357
+ await this.safeEmit({
5358
+ id: '',
5359
+ type: 'human.intervention',
5360
+ timestamp: new Date().toISOString(),
5361
+ projectId: task.projectId,
5362
+ taskId: task.id,
5363
+ data: {
5364
+ phase: 'confirm-merge-failed',
5365
+ gate: task.status,
5366
+ error: message,
5367
+ note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
5368
+ },
5369
+ });
5370
+ throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
5371
+ }
5372
+ }
5373
+ async finishTaskAsDone(taskId) {
5374
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
5375
+ if (!done)
5376
+ throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
5377
+ await this.releaseTaskAgents(taskId);
5378
+ return (await this.taskStore.get(taskId));
5379
+ }
5380
+ // Terminal-state resource release shared by done/merged(branch)/failed confirm
5381
+ // paths: stop the watcher, release dev+qa (worktree removal rides releaseAgentForTask).
5382
+ async releaseTaskAgents(taskId) {
5383
+ this.phaseSignalWatcher?.stop(taskId);
5384
+ const task = await this.taskStore.get(taskId);
5385
+ if (!task)
5386
+ return;
5387
+ for (const id of [task.agentId, task.qaAgentId]) {
5388
+ if (!id)
5389
+ continue;
5390
+ const state = await this.agentStore.get(id);
5391
+ if (!state || state.taskId !== taskId)
5392
+ continue;
5393
+ await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
5394
+ .catch(err => {
5395
+ console.warn(`[AgentManager] confirm release ${id} failed:`, err);
5396
+ });
5397
+ }
5398
+ }
5399
+ // afterDone:'branch' + merge:'auto' — fast-forward the remote default branch
5400
+ // to the reviewed branch ref-to-ref (`git push origin origin/bx/X:main`).
5401
+ // Never touches the repo working tree, and a plain push is ff-only by default:
5402
+ // a non-ff base is rejected by the remote and a human must rebase/decide (spec §6).
5403
+ repoMergeQueue = new Map();
5404
+ async ffMergeBranch(task) {
5405
+ const dev = this.getAgentConfig(task.agentId);
5406
+ if (!dev)
5407
+ throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
5408
+ const state = await this.agentStore.get(task.agentId);
5409
+ const repoPath = state?.repoPath;
5410
+ if (!repoPath)
5411
+ throw new Error(`ffMergeBranch: no repoPath for agent ${task.agentId}`);
5412
+ const branch = task.branch ?? BRANCH_PREFIX + task.id;
5413
+ const runner = this.createRunnerFor(dev);
5414
+ const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
5415
+ const run = prev.then(async () => {
5416
+ const cd = `cd ${shellQuote(repoPath)} && `;
5417
+ const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
5418
+ const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5419
+ if (db.exitCode !== 0 || defaultBranch === '') {
5420
+ // A silent 'main' fallback would push the reviewed branch onto the wrong
5421
+ // ref for repos whose default branch differs (PR #288).
5422
+ throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
5423
+ }
5424
+ const fetch = await runner.exec(`${cd}git fetch origin`);
5425
+ if (fetch.exitCode !== 0) {
5426
+ throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
5427
+ }
5428
+ // Reviewed-head guard (branch path): refuse if origin/<branch> moved after
5429
+ // the gate — symmetric with the pr path's --match-head-commit (PR #288).
5430
+ if (task.latestHeadSha) {
5431
+ const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
5432
+ if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
5433
+ throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
5434
+ `!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
5435
+ }
5436
+ }
5437
+ else {
5438
+ throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
5439
+ }
5440
+ const push = await runner.exec(`${cd}git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
5441
+ if (push.exitCode !== 0) {
5442
+ throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5443
+ }
5444
+ // The merge has landed; branch deletion is cleanup — a transient failure
5445
+ // here must not flip an already-merged task to failed (PR #288).
5446
+ const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
5447
+ if (del.exitCode !== 0) {
5448
+ console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
5449
+ }
5450
+ });
5451
+ this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
5452
+ await run;
5453
+ }
4653
5454
  }
4654
5455
  function buildAgentIndex(config) {
4655
5456
  const index = new Map();