baxian 1.0.2 → 1.1.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 (78) 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 +66 -4
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +1070 -37
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +11 -2
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +53 -8
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +29 -2
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +34 -1
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/prompt.d.ts +14 -0
  18. package/dist/agent/prompt.d.ts.map +1 -1
  19. package/dist/agent/prompt.js +242 -13
  20. package/dist/agent/prompt.js.map +1 -1
  21. package/dist/agent/review-transport.d.ts +36 -0
  22. package/dist/agent/review-transport.d.ts.map +1 -0
  23. package/dist/agent/review-transport.js +246 -0
  24. package/dist/agent/review-transport.js.map +1 -0
  25. package/dist/agent/runner.js +3 -3
  26. package/dist/agent/runner.js.map +1 -1
  27. package/dist/agent/worktree.d.ts +1 -0
  28. package/dist/agent/worktree.d.ts.map +1 -1
  29. package/dist/agent/worktree.js +12 -0
  30. package/dist/agent/worktree.js.map +1 -1
  31. package/dist/api/hosts.d.ts.map +1 -1
  32. package/dist/api/hosts.js +11 -7
  33. package/dist/api/hosts.js.map +1 -1
  34. package/dist/api/tasks.d.ts.map +1 -1
  35. package/dist/api/tasks.js +8 -0
  36. package/dist/api/tasks.js.map +1 -1
  37. package/dist/config/loader.d.ts.map +1 -1
  38. package/dist/config/loader.js +6 -8
  39. package/dist/config/loader.js.map +1 -1
  40. package/dist/config/validator.js +23 -0
  41. package/dist/config/validator.js.map +1 -1
  42. package/dist/event/handlers.d.ts.map +1 -1
  43. package/dist/event/handlers.js +23 -21
  44. package/dist/event/handlers.js.map +1 -1
  45. package/dist/event/server-handlers.d.ts +4 -0
  46. package/dist/event/server-handlers.d.ts.map +1 -0
  47. package/dist/event/server-handlers.js +828 -0
  48. package/dist/event/server-handlers.js.map +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +5 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/shared/constants.d.ts +7 -1
  53. package/dist/shared/constants.d.ts.map +1 -1
  54. package/dist/shared/constants.js +26 -2
  55. package/dist/shared/constants.js.map +1 -1
  56. package/dist/shared/types.d.ts +64 -2
  57. package/dist/shared/types.d.ts.map +1 -1
  58. package/dist/skills/server-feedback/SKILL.md +32 -0
  59. package/dist/skills/server-recheck/SKILL.md +30 -0
  60. package/dist/skills/server-review/SKILL.md +43 -0
  61. package/dist/skills/server-spec-review/SKILL.md +31 -0
  62. package/dist/state/index.d.ts +1 -0
  63. package/dist/state/index.d.ts.map +1 -1
  64. package/dist/state/index.js +1 -0
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/state/review-store.d.ts +13 -0
  67. package/dist/state/review-store.d.ts.map +1 -0
  68. package/dist/state/review-store.js +92 -0
  69. package/dist/state/review-store.js.map +1 -0
  70. package/dist/state/snapshot.js +1 -1
  71. package/dist/state/snapshot.js.map +1 -1
  72. package/dist/state/task-store.d.ts.map +1 -1
  73. package/dist/state/task-store.js +1 -0
  74. package/dist/state/task-store.js.map +1 -1
  75. package/dist/web/assets/index-DE_xpPQe.js +4 -0
  76. package/dist/web/index.html +1 -1
  77. package/package.json +1 -1
  78. package/dist/web/assets/index-BfCCF072.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 {
@@ -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
@@ -160,6 +163,7 @@ export class AgentManager {
160
163
  resolveAgent: (id) => this.getAgentConfig(id),
161
164
  })
162
165
  : undefined);
166
+ this.reviewStore = deps.reviewStore;
163
167
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
164
168
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
165
169
  this.agentIndex = buildAgentIndex(deps.config);
@@ -176,6 +180,36 @@ export class AgentManager {
176
180
  this.taskMutationQueue = next.catch(() => undefined);
177
181
  return next;
178
182
  }
183
+ getReviewStore() {
184
+ return this.reviewStore;
185
+ }
186
+ // Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
187
+ // config — `??` would swallow it and reroute an already-decided task (PR #288).
188
+ resolveAfterDone(task) {
189
+ if (task.afterDone !== undefined)
190
+ return task.afterDone;
191
+ return this.config.review.afterDone ?? null;
192
+ }
193
+ getReviewTransport() {
194
+ this.reviewTransportInstance ??= new ReviewTransport({
195
+ createRunnerFor: (agent) => this.createRunnerFor(agent),
196
+ resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
197
+ });
198
+ return this.reviewTransportInstance;
199
+ }
200
+ // ReviewTransport resolves worktrees synchronously; agentStore reads are async.
201
+ // The cache is refreshed by callers (server handlers) before transport use via
202
+ // refreshWorktreeCacheFor — a stale entry only costs one refresh round-trip.
203
+ bindingWorktreeCache = new Map();
204
+ async refreshWorktreeCacheFor(agentId) {
205
+ const state = await this.agentStore.get(agentId);
206
+ if (state?.worktreePath) {
207
+ this.bindingWorktreeCache.set(agentId, state.worktreePath);
208
+ return state.worktreePath;
209
+ }
210
+ this.bindingWorktreeCache.delete(agentId);
211
+ return undefined;
212
+ }
179
213
  async safeEmit(event) {
180
214
  try {
181
215
  await this.eventBus.emit(event);
@@ -940,7 +974,10 @@ export class AgentManager {
940
974
  throw new Error(`Unknown agent: ${agentId}`);
941
975
  const state = await this.agentStore.get(agentId);
942
976
  const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
943
- const reentryPhases = new Set(['fix', 'post-approve', 'spec-fix', 'code']);
977
+ const reentryPhases = new Set([
978
+ 'fix', 'post-approve', 'spec-fix', 'code',
979
+ 'server-feedback', 'server-after-done',
980
+ ]);
944
981
  const sameTaskReentry = state?.taskId === taskId &&
945
982
  !state.creationToken &&
946
983
  state.status !== 'awaiting_human' &&
@@ -1178,6 +1215,28 @@ export class AgentManager {
1178
1215
  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
1216
  return { resumed: false, releasedBinding: false };
1180
1217
  }
1218
+ // code-dispatch-failed: the code-phase prompt never reached the pane (spec
1219
+ // approval already transitioned the task). Resume = clear the hold AND
1220
+ // redispatch the code prompt (outside this lock) — without the redispatch
1221
+ // the task would stay in_progress with nothing running (PR #288).
1222
+ if (state.awaitingPhase === 'code-dispatch-failed'
1223
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1224
+ && state.taskId) {
1225
+ const now2 = new Date().toISOString();
1226
+ await this.agentStore.update(agentId, (existing) => {
1227
+ if (!existing)
1228
+ return AGENT_STORE_NOOP;
1229
+ return {
1230
+ ...existing,
1231
+ status: 'ok',
1232
+ awaitingPhase: undefined,
1233
+ awaitingReason: undefined,
1234
+ awaitingSince: undefined,
1235
+ updatedAt: now2,
1236
+ };
1237
+ });
1238
+ return { resumed: true, releasedBinding: false, redispatchCodeTaskId: state.taskId };
1239
+ }
1181
1240
  // signal-arm-failed: the prompt was already dispatched but its pane-signal watcher never
1182
1241
  // armed. Resume here would only flip status→ok WITHOUT rebuilding the watcher (Resume has no
1183
1242
  // re-arm path), so the prompt's signal would still have no consumer — silent deadlock again.
@@ -1243,7 +1302,20 @@ export class AgentManager {
1243
1302
  });
1244
1303
  return { resumed: true, releasedBinding: shouldReleaseBinding };
1245
1304
  });
1246
- return result;
1305
+ // Outside the task lock: continueSession takes it internally.
1306
+ if (result.redispatchCodeTaskId) {
1307
+ try {
1308
+ const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
1309
+ if (!resumed) {
1310
+ 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);
1311
+ }
1312
+ }
1313
+ catch (err) {
1314
+ console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
1315
+ 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);
1316
+ }
1317
+ }
1318
+ return { resumed: result.resumed, releasedBinding: result.releasedBinding };
1247
1319
  }
1248
1320
  async interruptPaneAndWaitReady(state, cfg) {
1249
1321
  const runner = this.createRunnerFor(cfg);
@@ -1337,6 +1409,11 @@ export class AgentManager {
1337
1409
  const out = [];
1338
1410
  for (const t of tasks) {
1339
1411
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1412
+ // Human gates are decision states, not running work: an absent agent
1413
+ // session must not terminally fail a task whose published PR/branch
1414
+ // would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
1415
+ if (t.status === 'ready' || t.status === 'merge-ready')
1416
+ continue;
1340
1417
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
1341
1418
  t.status = 'failed';
1342
1419
  t.updatedAt = new Date().toISOString();
@@ -1667,6 +1744,8 @@ export class AgentManager {
1667
1744
  expectedKinds: 'pr-merge-ready',
1668
1745
  token: completion.token,
1669
1746
  skipSnapshot,
1747
+ reviewMode: task.reviewMode ?? 'github',
1748
+ recovered: true,
1670
1749
  });
1671
1750
  }
1672
1751
  catch (err) {
@@ -1703,7 +1782,11 @@ export class AgentManager {
1703
1782
  // fixing). Both handlers are idempotent under replay (token + status gates),
1704
1783
  // and token rotation still rejects stale ones. Other phases keep
1705
1784
  // skipSnapshot=true — their handlers aren't as cleanly replay-safe.
1706
- const scanSnapshotOnRecover = task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing');
1785
+ // Server mode scans on EVERY recovered state: the pane signal is the only
1786
+ // verdict channel (no poller backstop) and all server handlers are
1787
+ // replay-safe (token/status gates + stored-data resumption).
1788
+ const scanSnapshotOnRecover = task.reviewMode === 'server'
1789
+ || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
1707
1790
  try {
1708
1791
  await this.phaseSignalWatcher.start({
1709
1792
  taskId: task.id,
@@ -1712,6 +1795,11 @@ export class AgentManager {
1712
1795
  expectedKinds,
1713
1796
  token: task.signalToken,
1714
1797
  skipSnapshot: !scanSnapshotOnRecover,
1798
+ reviewMode: task.reviewMode ?? 'github',
1799
+ recovered: true,
1800
+ ...(task.reviewMode === 'server' && task.status === 'review'
1801
+ ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
1802
+ : {}),
1715
1803
  });
1716
1804
  if (interventionKindLabel) {
1717
1805
  await this.safeEmit({
@@ -1987,6 +2075,7 @@ export class AgentManager {
1987
2075
  reviewRound: 0,
1988
2076
  status: 'pending',
1989
2077
  branch: BRANCH_PREFIX + taskId,
2078
+ reviewMode: this.config.review.mode ?? 'github',
1990
2079
  createdAt: now,
1991
2080
  updatedAt: now,
1992
2081
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2015,6 +2104,7 @@ export class AgentManager {
2015
2104
  reviewRound: 0,
2016
2105
  status: 'pending',
2017
2106
  branch: BRANCH_PREFIX + taskId,
2107
+ reviewMode: this.config.review.mode ?? 'github',
2018
2108
  createdAt: now,
2019
2109
  updatedAt: now,
2020
2110
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2047,6 +2137,7 @@ export class AgentManager {
2047
2137
  reviewRound: 0,
2048
2138
  status: 'pending',
2049
2139
  branch: BRANCH_PREFIX + taskId,
2140
+ reviewMode: this.config.review.mode ?? 'github',
2050
2141
  createdAt: now,
2051
2142
  updatedAt: now,
2052
2143
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2078,6 +2169,7 @@ export class AgentManager {
2078
2169
  reviewRound: 0,
2079
2170
  status: 'in_progress',
2080
2171
  branch: BRANCH_PREFIX + taskId,
2172
+ reviewMode: this.config.review.mode ?? 'github',
2081
2173
  createdAt: now,
2082
2174
  updatedAt: now,
2083
2175
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2162,12 +2254,15 @@ export class AgentManager {
2162
2254
  }
2163
2255
  // 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
2164
2256
  // spec-created/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2257
+ // Kinds derive from the task's frozen reviewMode — a hot mode flip during the
2258
+ // startSession window must not desync the armed kinds from the sent prompt.
2259
+ const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
2165
2260
  try {
2166
- await this.armPostDispatchSignalOrHold(taskId, agentId, ['spec-created', 'pr-created'], signalToken);
2261
+ await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
2167
2262
  }
2168
2263
  catch (armErr) {
2169
2264
  console.error(`[AgentManager] createAndStartTask arm failed for task=${taskId}:`, armErr);
2170
- await this.holdAgentForUnarmedSignal(taskId, agentId, ['spec-created', 'pr-created'])
2265
+ await this.holdAgentForUnarmedSignal(taskId, agentId, initialKinds)
2171
2266
  .catch((holdErr) => {
2172
2267
  console.error(`[AgentManager] createAndStartTask hold-after-arm-failure failed for task=${taskId}:`, holdErr);
2173
2268
  });
@@ -2365,7 +2460,7 @@ export class AgentManager {
2365
2460
  console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
2366
2461
  }
2367
2462
  if (started) {
2368
- await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, ['spec-created', 'pr-created'], signalToken);
2463
+ await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
2369
2464
  const refreshed = await this.taskStore.get(claimed.id);
2370
2465
  return { task: refreshed ?? claimed };
2371
2466
  }
@@ -2454,9 +2549,12 @@ export class AgentManager {
2454
2549
  const baseRef = agent.workdir
2455
2550
  ? undefined
2456
2551
  : 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);
2552
+ const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2553
+ const worktreePath = isServerQaPhase
2554
+ ? await worktree.createDetachedAtBase(workdir, taskId)
2555
+ : phase === 'review' || phase === 'recheck' || phase === 'spec-review'
2556
+ ? await worktree.createDetached(workdir, taskId, task.branch)
2557
+ : await worktree.create(workdir, taskId, baseRef);
2460
2558
  // Persist worktreePath now so a crash before set-running leaves a recoverable trail.
2461
2559
  await this.agentStore.update(agentId, (stateNow) => {
2462
2560
  if (!stateNow || stateNow.taskId !== taskId)
@@ -2494,6 +2592,12 @@ export class AgentManager {
2494
2592
  ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2495
2593
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2496
2594
  ...(imagePaths.length ? { imagePaths } : {}),
2595
+ ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2596
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2597
+ ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
2598
+ ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
2599
+ ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
2600
+ ...(opts.contentTruncated ? { contentTruncated: true } : {}),
2497
2601
  });
2498
2602
  }
2499
2603
  catch (err) {
@@ -2847,6 +2951,13 @@ export class AgentManager {
2847
2951
  ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2848
2952
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2849
2953
  ...(imagePaths.length ? { imagePaths } : {}),
2954
+ ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2955
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2956
+ ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
2957
+ ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
2958
+ ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
2959
+ ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
2960
+ ...(opts.contentTruncated ? { contentTruncated: opts.contentTruncated } : {}),
2850
2961
  });
2851
2962
  }
2852
2963
  catch (err) {
@@ -3235,6 +3346,13 @@ export class AgentManager {
3235
3346
  async cancelTask(taskId) {
3236
3347
  let devToRelease;
3237
3348
  let qaToRelease;
3349
+ // Server-mode ready gate may have already published remote artifacts
3350
+ // (pushed branch / open PR). Capture before flipping to cancelled so the
3351
+ // post-lock cleanup can retire them instead of orphaning (PR #288).
3352
+ // mayBeInFlight: approved+marker means the publish prompt may STILL be
3353
+ // running — retirement must wait for the dev interrupt or the in-flight
3354
+ // push/pr-create would recreate the artifacts right after cleanup.
3355
+ let publishedCleanup;
3238
3356
  this.phaseSignalWatcher?.stop(taskId);
3239
3357
  const result = await this.withTaskLock(async () => {
3240
3358
  const task = await this.taskStore.get(taskId);
@@ -3252,6 +3370,37 @@ export class AgentManager {
3252
3370
  devToRelease = task.agentId;
3253
3371
  if (task.qaAgentId)
3254
3372
  qaToRelease = task.qaAgentId;
3373
+ // approved + publishDispatchedAt = the publish prompt reached the pane, so
3374
+ // remote artifacts may already exist even though code-ready never landed
3375
+ // (dispatch crash, or the reviewed-head mismatch gate refused ready —
3376
+ // whose documented exit is exactly this Cancel).
3377
+ // Truthy (not !== undefined): sanitizeTask passes hand-edited nulls through.
3378
+ const publishedAtGate = task.status === 'ready'
3379
+ || (task.status === 'approved' && !!task.publishDispatchedAt);
3380
+ if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
3381
+ const afterDone = this.resolveAfterDone(task);
3382
+ if (afterDone !== null && task.branch) {
3383
+ publishedCleanup = {
3384
+ afterDone,
3385
+ branch: task.branch,
3386
+ ...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
3387
+ devAgentId: task.agentId,
3388
+ // ready = code-ready consumed, publish finished; approved = no
3389
+ // completion signal yet, the publish may still be running.
3390
+ mayBeInFlight: task.status === 'approved',
3391
+ };
3392
+ }
3393
+ }
3394
+ else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
3395
+ // GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
3396
+ publishedCleanup = {
3397
+ afterDone: 'pr',
3398
+ branch: task.branch,
3399
+ prNumber: task.prNumber,
3400
+ devAgentId: task.agentId,
3401
+ mayBeInFlight: false,
3402
+ };
3403
+ }
3255
3404
  const now = new Date().toISOString();
3256
3405
  task.status = 'cancelled';
3257
3406
  task.updatedAt = now;
@@ -3266,7 +3415,13 @@ export class AgentManager {
3266
3415
  });
3267
3416
  return task;
3268
3417
  });
3269
- // 唯一允许打断 agent 会话的入口(用户主动 Cancel)。
3418
+ // 唯一允许打断 agent 会话的入口(用户主动 Cancel)。Interrupt BEFORE remote
3419
+ // retirement: an in-flight publish prompt would re-push the branch / re-open
3420
+ // the PR right after cleanup, and a cancelled task gets no second pass.
3421
+ // Only a successful interrupt PROVES the pane stopped — skipped paths
3422
+ // (config hot-removed: the pane outlives the config; state gone; rebound)
3423
+ // leave an in-flight publish possible.
3424
+ let devStopConfirmed = false;
3270
3425
  for (const id of [devToRelease, qaToRelease]) {
3271
3426
  if (!id)
3272
3427
  continue;
@@ -3285,6 +3440,8 @@ export class AgentManager {
3285
3440
  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
3441
  continue;
3287
3442
  }
3443
+ if (id === publishedCleanup?.devAgentId)
3444
+ devStopConfirmed = true;
3288
3445
  try {
3289
3446
  // allowAwaitingHuman: cancelTask 是显式回收入口,agent 之前可能因 ack_unknown 等被标 Held,
3290
3447
  // 用户主动 Cancel 应允许跨过 awaiting_human gate 清理 binding;release 默认 gate 是为了
@@ -3295,6 +3452,65 @@ export class AgentManager {
3295
3452
  console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
3296
3453
  }
3297
3454
  }
3455
+ // Best-effort remote retirement for a cancelled published gate: close the PR
3456
+ // and delete the pushed branch so they don't outlive the task. Failures only
3457
+ // warn + intervene — cancel must not be blocked by remote faults.
3458
+ if (publishedCleanup) {
3459
+ if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
3460
+ // No proof the publish prompt stopped; cleaning now would race its
3461
+ // push/pr-create. Leave the artifacts to the operator.
3462
+ await this.safeEmit({
3463
+ id: '',
3464
+ type: 'human.intervention',
3465
+ timestamp: new Date().toISOString(),
3466
+ projectId: result.projectId,
3467
+ taskId,
3468
+ data: {
3469
+ phase: 'cancel-published-artifact-cleanup-skipped',
3470
+ afterDone: publishedCleanup.afterDone,
3471
+ branch: publishedCleanup.branch,
3472
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3473
+ reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3474
+ },
3475
+ });
3476
+ return result;
3477
+ }
3478
+ const project = this.getProjectConfig(result.projectId);
3479
+ try {
3480
+ if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
3481
+ const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
3482
+ `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
3483
+ if (close.exitCode !== 0)
3484
+ throw new Error(close.stderr.trim() || close.stdout.trim());
3485
+ }
3486
+ else {
3487
+ const dev = this.getAgentConfig(publishedCleanup.devAgentId);
3488
+ const state = await this.agentStore.get(publishedCleanup.devAgentId);
3489
+ if (dev && state?.repoPath) {
3490
+ const del = await this.createRunnerFor(dev).exec(`cd ${shellQuote(state.repoPath)} && git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3491
+ if (del.exitCode !== 0)
3492
+ throw new Error(del.stderr.trim() || del.stdout.trim());
3493
+ }
3494
+ }
3495
+ }
3496
+ catch (err) {
3497
+ console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
3498
+ await this.safeEmit({
3499
+ id: '',
3500
+ type: 'human.intervention',
3501
+ timestamp: new Date().toISOString(),
3502
+ projectId: result.projectId,
3503
+ taskId,
3504
+ data: {
3505
+ phase: 'cancel-published-artifact-cleanup-failed',
3506
+ afterDone: publishedCleanup.afterDone,
3507
+ branch: publishedCleanup.branch,
3508
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3509
+ error: err instanceof Error ? err.message : String(err),
3510
+ },
3511
+ });
3512
+ }
3513
+ }
3298
3514
  return result;
3299
3515
  }
3300
3516
  // task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
@@ -3342,6 +3558,11 @@ export class AgentManager {
3342
3558
  const task = await this.taskStore.get(taskId);
3343
3559
  if (!task)
3344
3560
  throw new ApiError(404, `Task ${taskId} not found`);
3561
+ // Server-mode tasks review via the exchange protocol; routing one into the
3562
+ // legacy GitHub review flow would cross-contaminate the state machines.
3563
+ if (task.reviewMode === 'server') {
3564
+ throw new ApiError(409, `Task ${taskId} uses server review mode; legacy Call review is not applicable`);
3565
+ }
3345
3566
  // spec-phase max_rounds escapes via Retry/Cancel only. Call review dispatches the
3346
3567
  // CODE-review protocol, but review.submitted early-returns for spec phase — so a direct
3347
3568
  // /tasks/:id/review here would transition the task to review + bind QA, yet its verdict
@@ -3554,6 +3775,55 @@ export class AgentManager {
3554
3775
  if (task.phase === 'spec') {
3555
3776
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
3556
3777
  }
3778
+ // Server-mode continue: grant one round past the cap, then re-run the server
3779
+ // fix protocol from the stored findings — no PR exists at this point (PR #288).
3780
+ if (task.reviewMode === 'server') {
3781
+ if (!task.agentId) {
3782
+ throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
3783
+ }
3784
+ const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
3785
+ if (!stored?.findings) {
3786
+ throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
3787
+ }
3788
+ // Re-check + grant under the task lock: the entry checks above ran lock-free,
3789
+ // so a concurrent mark-complete may have claimed the gate since (the
3790
+ // claimCompleteGate comment promises Continue re-checks under the same lock).
3791
+ await this.withTaskLock(async () => {
3792
+ if (this.markCompleteInFlight.has(taskId)) {
3793
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3794
+ }
3795
+ const fresh = await this.taskStore.get(taskId);
3796
+ if (!fresh || fresh.status !== 'max_rounds') {
3797
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
3798
+ }
3799
+ fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
3800
+ fresh.updatedAt = new Date().toISOString();
3801
+ await this.taskStore.set(fresh);
3802
+ });
3803
+ let dispatched = null;
3804
+ try {
3805
+ dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
3806
+ }
3807
+ finally {
3808
+ // The grant is only spent when the fix prompt actually reached the dev.
3809
+ // Decrement (not restore-snapshot): a snapshot write-back would also
3810
+ // erase a concurrent Continue's grant.
3811
+ if (!dispatched) {
3812
+ await this.withTaskLock(async () => {
3813
+ const fresh = await this.taskStore.get(taskId);
3814
+ if (!fresh)
3815
+ return;
3816
+ fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
3817
+ fresh.updatedAt = new Date().toISOString();
3818
+ await this.taskStore.set(fresh);
3819
+ }).catch(() => undefined);
3820
+ }
3821
+ }
3822
+ if (!dispatched) {
3823
+ throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
3824
+ }
3825
+ return dispatched;
3826
+ }
3557
3827
  if (!task.prNumber || !task.branch) {
3558
3828
  throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
3559
3829
  }
@@ -3690,7 +3960,17 @@ export class AgentManager {
3690
3960
  // Single source of truth for "what watcher should this task have, given its
3691
3961
  // current state". Used by both setupRecoveredSpecSignals (restart recovery)
3692
3962
  // and rollbackDispatchReviewPhase1 (manual dispatch failure).
3963
+ // Dev's first prompt offers the spec-first or straight-to-code path; the arm
3964
+ // must accept both completion signals for the task's protocol family.
3965
+ devInitialSignalKinds(reviewMode) {
3966
+ const mode = reviewMode ?? this.config.review.mode ?? 'github';
3967
+ return mode === 'server'
3968
+ ? ['spec-done', 'code-done']
3969
+ : ['spec-created', 'pr-created'];
3970
+ }
3693
3971
  mapTaskStateToExpectedWatcher(task) {
3972
+ if (task.reviewMode === 'server')
3973
+ return this.mapServerTaskToExpectedWatcher(task);
3694
3974
  if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
3695
3975
  return { expectedKinds: ['spec-approved', 'spec-changes-requested'], agentId: task.qaAgentId };
3696
3976
  }
@@ -3714,14 +3994,35 @@ export class AgentManager {
3714
3994
  }
3715
3995
  return undefined;
3716
3996
  }
3997
+ // Recovery mapping for server-mode tasks: the watcher is the ONLY verdict
3998
+ // channel (no poller backstop), so every awaiting state must re-arm on restart.
3999
+ mapServerTaskToExpectedWatcher(task) {
4000
+ const isSpec = task.phase === 'spec';
4001
+ if (task.status === 'review' && task.qaAgentId) {
4002
+ return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
4003
+ }
4004
+ if (task.status === 'fixing' && task.agentId) {
4005
+ return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
4006
+ }
4007
+ if (task.status === 'in_progress' && task.agentId) {
4008
+ if (task.phase === 'code')
4009
+ return { expectedKinds: ['code-done'], agentId: task.agentId };
4010
+ return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
4011
+ }
4012
+ if (task.status === 'approved' && task.agentId) {
4013
+ return { expectedKinds: ['code-ready'], agentId: task.agentId };
4014
+ }
4015
+ return undefined;
4016
+ }
3717
4017
  // Public re-establish helper for in-band recoveries that don't rotate the token
3718
4018
  // (e.g. handler reject path: agent's next emit must still match current
3719
- // task.signalToken, so rotating would strand it).
4019
+ // task.signalToken, so rotating would strand it). Returns whether a watcher
4020
+ // armed; callers that consumed a signal must hold on false or it has no consumer.
3720
4021
  async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
3721
4022
  const task = await this.taskStore.get(taskId);
3722
4023
  if (!task?.signalToken)
3723
- return;
3724
- await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
4024
+ return false;
4025
+ return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
3725
4026
  }
3726
4027
  async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
3727
4028
  if (!agentId)
@@ -3838,28 +4139,83 @@ export class AgentManager {
3838
4139
  // cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
3839
4140
  // cleanup + /compact + release). Same path the poller drives when it detects the merge.
3840
4141
  async markTaskComplete(taskId) {
3841
- const task = await this.taskStore.get(taskId);
3842
- if (!task)
4142
+ const peek = await this.taskStore.get(taskId);
4143
+ if (!peek)
3843
4144
  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);
4145
+ // Human gate (spec §10): ready / merge-ready confirm runs its own completion
4146
+ // matrix (with its own lock-claimed gate); the legacy max_rounds path below
4147
+ // is untouched.
4148
+ if (peek.status === 'ready' || peek.status === 'merge-ready') {
4149
+ return this.confirmHumanGate(taskId);
4150
+ }
4151
+ // Claim under the task lock the whole merge window. markCompleteInFlight
4152
+ // blocks Cancel / Call review / Continue (all re-check it under the same
4153
+ // lock) so they can't act on the same snapshot and interleave with the
4154
+ // irreversible `gh pr merge` (or, server mode, the publish dispatch).
4155
+ const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
3862
4156
  try {
4157
+ // Server-mode publish retry: a failed afterDone dispatch leaves the task
4158
+ // 'approved' with dev released — mark-complete re-runs the publish (PR #288).
4159
+ const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
4160
+ if (!serverApprovedRetry && task.status !== 'max_rounds') {
4161
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4162
+ }
4163
+ // spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
4164
+ // endpoint too so a direct API call / older client can't merge a spec cap through here.
4165
+ if (task.phase === 'spec') {
4166
+ throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
4167
+ }
4168
+ if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
4169
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
4170
+ }
4171
+ if (serverApprovedRetry) {
4172
+ // A live code-ready watcher armed by a real dispatch means the publish
4173
+ // prompt IS running — a retry would inject a second prompt and rotate
4174
+ // the token under it. A RECOVERED watcher is weaker evidence, but
4175
+ // publishDispatchedAt persists delivery across restarts: set = the
4176
+ // prompt reached the pane before the restart (still in flight, 409);
4177
+ // cleared = the dispatch failed and this approved state is retryable —
4178
+ // stop the recovered watch and let the retry own the dispatch (PR #288).
4179
+ if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
4180
+ if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
4181
+ throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
4182
+ }
4183
+ this.phaseSignalWatcher.stop(taskId);
4184
+ }
4185
+ else if (task.publishDispatchedAt) {
4186
+ throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
4187
+ `retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
4188
+ }
4189
+ // task.afterDone was snapshotted when the approve verdict routed it.
4190
+ const afterDone = this.resolveAfterDone(task);
4191
+ if (afterDone === null) {
4192
+ throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
4193
+ }
4194
+ await this.dispatchServerAfterDone(taskId, afterDone);
4195
+ return (await this.taskStore.get(taskId));
4196
+ }
4197
+ // Server-mode capped task, human accepts as-is: no PR exists yet — run the
4198
+ // afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
4199
+ // Inside the in-flight claim so a concurrent Continue can't act on the same
4200
+ // max_rounds snapshot and release dev mid-publish.
4201
+ if (task.reviewMode === 'server') {
4202
+ // Max_rounds never routed an approve verdict — snapshot afterDone NOW so
4203
+ // the eventual ready-confirm uses this decision, not future hot config.
4204
+ const afterDone = this.config.review.afterDone ?? null;
4205
+ await this.updateTask(taskId, { afterDone });
4206
+ if (afterDone === null) {
4207
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
4208
+ if (!done)
4209
+ throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
4210
+ await this.releaseTaskAgents(taskId);
4211
+ return (await this.taskStore.get(taskId));
4212
+ }
4213
+ const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
4214
+ if (!approved)
4215
+ throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
4216
+ await this.dispatchServerAfterDone(taskId, afterDone);
4217
+ return (await this.taskStore.get(taskId));
4218
+ }
3863
4219
  // Held-agent check AFTER claiming (the claim blocks a new continueDevRound from starting),
3864
4220
  // and re-reading agent state here catches a continue that Held an agent in the window just
3865
4221
  // before our claim. dispatchPostMergeCleanup early-returns on awaiting_human, so merging with
@@ -4143,7 +4499,7 @@ export class AgentManager {
4143
4499
  // to arm — the dangerous case where a same-identity verdict would have no consumer. When no
4144
4500
  // watcher subsystem is configured at all (poller-only deployment) the poller is the verdict
4145
4501
  // path, so this returns true and does not block. Best-effort callers ignore the result.
4146
- async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
4502
+ async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
4147
4503
  if (!this.phaseSignalWatcher)
4148
4504
  return true;
4149
4505
  const task = await this.taskStore.get(taskId);
@@ -4157,6 +4513,10 @@ export class AgentManager {
4157
4513
  expectedKinds,
4158
4514
  token,
4159
4515
  skipSnapshot,
4516
+ // Mode routing rides every arm automatically: the snapshot lives on the
4517
+ // task, so github-mode tasks keep their legacy event types untouched.
4518
+ reviewMode: task.reviewMode ?? 'github',
4519
+ ...(onReadFile ? { onReadFile } : {}),
4160
4520
  });
4161
4521
  }
4162
4522
  catch (err) {
@@ -4167,8 +4527,8 @@ export class AgentManager {
4167
4527
  // Arm a watcher for a signal the just-dispatched prompt will emit, then hold the agent if it
4168
4528
  // could not arm. Used by post-dispatch arms (develop/spec/code phases) whose pane only exists
4169
4529
  // 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);
4530
+ async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
4531
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot, onReadFile);
4172
4532
  if (!armed)
4173
4533
  await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
4174
4534
  }
@@ -4593,7 +4953,14 @@ export class AgentManager {
4593
4953
  // (acquire + continueSession below), so holding here would block that reentry. And pr-created
4594
4954
  // is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
4595
4955
  // a missed pane watcher only costs one poll cycle of latency, never a stuck task.
4596
- await this.setupPhaseSignalWatcher(taskId, devAgentId, 'pr-created', newToken);
4956
+ // Server mode has NO poller backstop: an unarmed code-done watcher means the
4957
+ // dev's completion signal would have no consumer — fail closed and hold.
4958
+ const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
4959
+ const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
4960
+ if (!codeArmed && task.reviewMode === 'server') {
4961
+ await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
4962
+ return null;
4963
+ }
4597
4964
  if (task.qaAgentId) {
4598
4965
  // release 失败留 stale qa binding → emit intervention 让其可见。
4599
4966
  const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
@@ -4612,6 +4979,11 @@ export class AgentManager {
4612
4979
  }
4613
4980
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
4614
4981
  if (!acquired) {
4982
+ // Task already shows phase='code' in_progress with the code-done watcher
4983
+ // armed, and server mode has no poller backstop — without a hold the dev
4984
+ // never receives the code prompt and the task dead-ends. The
4985
+ // code-dispatch-failed hold gives Resume a redispatch path.
4986
+ 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);
4615
4987
  await this.safeEmit({
4616
4988
  id: '',
4617
4989
  type: 'human.intervention',
@@ -4633,10 +5005,16 @@ export class AgentManager {
4633
5005
  if (err instanceof DispatchTerminalError) {
4634
5006
  await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
4635
5007
  }
5008
+ else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5009
+ // Task already shows phase='code' in_progress but the prompt never landed
5010
+ // and there is no retry entry — hold explicitly instead of dead-ending.
5011
+ 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);
5012
+ }
4636
5013
  console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
4637
5014
  throw err;
4638
5015
  }
4639
5016
  if (!resumed) {
5017
+ 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);
4640
5018
  await this.safeEmit({
4641
5019
  id: '',
4642
5020
  type: 'human.intervention',
@@ -4650,6 +5028,661 @@ export class AgentManager {
4650
5028
  }
4651
5029
  return await this.taskStore.get(taskId);
4652
5030
  }
5031
+ // ── Server review mode (spec: docs/spec/server-review-mode.md) ──────────────
5032
+ async dispatchServerReviewToQa(taskId, opts) {
5033
+ const dispatchPhase = opts.phase === 'spec'
5034
+ ? 'server-spec-review'
5035
+ : (opts.recheck ? 'server-recheck' : 'server-review');
5036
+ const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
5037
+ const claim = await this.withTaskLock(async () => {
5038
+ const task = await this.taskStore.get(taskId);
5039
+ if (!task)
5040
+ throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
5041
+ if (task.reviewMode !== 'server') {
5042
+ throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5043
+ }
5044
+ const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
5045
+ if (!qaId) {
5046
+ // Config validation rejects qa-less server pairs, but a hot-removed QA
5047
+ // can still land here — re-arm the consumed entry signal so the task
5048
+ // is recoverable once a QA is configured again.
5049
+ const entryKind = task.status === 'fixing'
5050
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
5051
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
5052
+ await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
5053
+ await this.safeEmit({
5054
+ id: '',
5055
+ type: 'human.intervention',
5056
+ timestamp: new Date().toISOString(),
5057
+ projectId: task.projectId,
5058
+ agentId: task.agentId,
5059
+ taskId,
5060
+ data: { phase: 'server-review-no-qa-partner', devAgentId: task.agentId },
5061
+ });
5062
+ return null;
5063
+ }
5064
+ const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
5065
+ return {
5066
+ qaId,
5067
+ devAgentId: task.agentId,
5068
+ projectId: task.projectId,
5069
+ newToken: createSignalToken(),
5070
+ newRound: opts.continuation ? Math.max(roundField, 1) : roundField + 1,
5071
+ originalStatus: task.status,
5072
+ originalToken: task.signalToken,
5073
+ originalRound: roundField,
5074
+ originalBatchIndex: task.batchIndex,
5075
+ originalBatchTotal: task.batchTotal,
5076
+ };
5077
+ });
5078
+ if (!claim)
5079
+ return null;
5080
+ const { qaId, devAgentId, projectId, newToken, newRound } = claim;
5081
+ // continueSession failure after the transition would otherwise strand the
5082
+ // task in 'review' with a fresh token nobody will ever signal (PR #288).
5083
+ const rollback = async () => {
5084
+ await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
5085
+ signalToken: claim.originalToken,
5086
+ batchIndex: claim.originalBatchIndex,
5087
+ batchTotal: claim.originalBatchTotal,
5088
+ ...(opts.phase === 'spec'
5089
+ ? { specReviewRound: claim.originalRound }
5090
+ : { reviewRound: claim.originalRound }),
5091
+ }).catch(() => undefined);
5092
+ };
5093
+ // The entry signal (code/spec-done|fixed) was already consumed by the
5094
+ // watcher; a pre-transition failure must re-arm it with the unrotated token
5095
+ // or the agent's re-emit after the operator fixes availability has no
5096
+ // consumer (PR #288).
5097
+ const rearmEntrySignal = async () => {
5098
+ const entryKind = claim.originalStatus === 'fixing'
5099
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
5100
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
5101
+ await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
5102
+ };
5103
+ if (!opts.continuation) {
5104
+ const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
5105
+ if (!acquired) {
5106
+ await rearmEntrySignal();
5107
+ await this.safeEmit({
5108
+ id: '',
5109
+ type: 'human.intervention',
5110
+ timestamp: new Date().toISOString(),
5111
+ projectId,
5112
+ agentId: qaId,
5113
+ taskId,
5114
+ data: { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId },
5115
+ });
5116
+ return null;
5117
+ }
5118
+ if (devAgentId) {
5119
+ const devOk = await this.markAgentWaiting(devAgentId, taskId);
5120
+ if (!devOk) {
5121
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5122
+ await rearmEntrySignal();
5123
+ await this.safeEmit({
5124
+ id: '',
5125
+ type: 'human.intervention',
5126
+ timestamp: new Date().toISOString(),
5127
+ projectId,
5128
+ agentId: devAgentId,
5129
+ taskId,
5130
+ data: { phase: 'server-review-dev-park-failed', devAgentId },
5131
+ });
5132
+ return null;
5133
+ }
5134
+ }
5135
+ }
5136
+ const roundPatch = opts.phase === 'spec'
5137
+ ? { specReviewRound: newRound, phase: 'spec' }
5138
+ : { reviewRound: newRound };
5139
+ const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
5140
+ signalToken: newToken,
5141
+ qaAgentId: qaId,
5142
+ reviewDispatchedAt: new Date().toISOString(),
5143
+ ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
5144
+ ...(opts.batch
5145
+ ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5146
+ : { batchIndex: undefined, batchTotal: undefined }),
5147
+ ...roundPatch,
5148
+ });
5149
+ if (!transition) {
5150
+ if (!opts.continuation) {
5151
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5152
+ }
5153
+ await this.safeEmit({
5154
+ id: '',
5155
+ type: 'human.intervention',
5156
+ timestamp: new Date().toISOString(),
5157
+ projectId,
5158
+ agentId: qaId,
5159
+ taskId,
5160
+ data: { phase: 'server-review-transition-failed', qaAgentId: qaId },
5161
+ });
5162
+ return null;
5163
+ }
5164
+ // First dispatch creates the QA's base-detached worktree (startSession);
5165
+ // batch continuations reuse the live session + worktree (continueSession).
5166
+ const sessionOpts = {
5167
+ bypassTaskStatusGate: true,
5168
+ signalToken: newToken,
5169
+ serverContent: opts.content,
5170
+ ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
5171
+ ...(opts.contentTruncated ? { contentTruncated: true } : {}),
5172
+ ...(opts.batch ? { serverBatch: opts.batch } : {}),
5173
+ ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
5174
+ ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5175
+ ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5176
+ };
5177
+ // A continuation consumed the QA's reviewed signal (not the dev's entry
5178
+ // signal): rollback restores the prior slice's review/token, so re-arm the
5179
+ // reviewed watcher — the QA's re-emit replays the stored batch findings and
5180
+ // resumes the next-slice dispatch (PR #288).
5181
+ const rearmConsumedSignal = async () => {
5182
+ if (opts.continuation) {
5183
+ await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
5184
+ }
5185
+ else {
5186
+ await rearmEntrySignal();
5187
+ }
5188
+ };
5189
+ let started = false;
5190
+ try {
5191
+ started = opts.continuation
5192
+ ? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
5193
+ : await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
5194
+ }
5195
+ catch (err) {
5196
+ if (err instanceof DispatchTerminalError) {
5197
+ await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
5198
+ }
5199
+ else if (err instanceof EnsureSessionError && err.partial.handled) {
5200
+ // handleDialogPendingFromRuntime already held + failed + released.
5201
+ }
5202
+ else {
5203
+ await rollback();
5204
+ if (!opts.continuation) {
5205
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5206
+ }
5207
+ await rearmConsumedSignal();
5208
+ }
5209
+ throw err;
5210
+ }
5211
+ if (!started) {
5212
+ await rollback();
5213
+ if (!opts.continuation) {
5214
+ await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5215
+ }
5216
+ await rearmConsumedSignal();
5217
+ await this.safeEmit({
5218
+ id: '',
5219
+ type: 'human.intervention',
5220
+ timestamp: new Date().toISOString(),
5221
+ projectId,
5222
+ agentId: qaId,
5223
+ taskId,
5224
+ data: { phase: 'server-review-start-failed', qaAgentId: qaId },
5225
+ });
5226
+ return null;
5227
+ }
5228
+ this.stopPhaseSignalWatcher(taskId);
5229
+ await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
5230
+ return await this.taskStore.get(taskId);
5231
+ }
5232
+ async dispatchServerFixToDev(taskId, findingsJson) {
5233
+ const claim = await this.withTaskLock(async () => {
5234
+ const task = await this.taskStore.get(taskId);
5235
+ if (!task)
5236
+ throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5237
+ if (task.reviewMode !== 'server') {
5238
+ throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5239
+ }
5240
+ if (!task.agentId)
5241
+ throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
5242
+ return {
5243
+ devAgentId: task.agentId,
5244
+ qaAgentId: task.qaAgentId,
5245
+ projectId: task.projectId,
5246
+ newToken: createSignalToken(),
5247
+ taskPhase: (task.phase ?? 'code'),
5248
+ currentSpecRound: task.specReviewRound,
5249
+ // Continue-one-round enters from max_rounds — failure must restore THAT,
5250
+ // not silently demote the human's pause decision to 'review' (PR #288).
5251
+ originalStatus: task.status,
5252
+ originalToken: task.signalToken,
5253
+ };
5254
+ });
5255
+ if (!claim)
5256
+ return null;
5257
+ const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
5258
+ const rollbackToEntry = async () => {
5259
+ await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
5260
+ };
5261
+ const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
5262
+ // The QA's reviewed signal was consumed before this dispatch; pre-transition
5263
+ // failures must re-arm it (unrotated token) so a later re-emit is consumed.
5264
+ const rearmReviewedSignal = async () => {
5265
+ if (!qaAgentId)
5266
+ return;
5267
+ const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
5268
+ await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
5269
+ };
5270
+ // Dev BEFORE QA: releasing the QA first is irreversible (binding cleared,
5271
+ // worktree removed, schedulable elsewhere) — a dev acquire failure after it
5272
+ // would leave the review-parked task with no stably-bound agent to retry
5273
+ // from. With the dev secured first, both failure exits keep the QA bound.
5274
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
5275
+ if (!acquired) {
5276
+ await rearmReviewedSignal();
5277
+ await this.safeEmit({
5278
+ id: '',
5279
+ type: 'human.intervention',
5280
+ timestamp: new Date().toISOString(),
5281
+ projectId,
5282
+ agentId: devAgentId,
5283
+ taskId,
5284
+ data: { phase: 'server-fix-dev-acquire-failed', devAgentId },
5285
+ });
5286
+ return null;
5287
+ }
5288
+ if (qaAgentId) {
5289
+ const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
5290
+ .catch(() => false);
5291
+ if (!released) {
5292
+ await rearmReviewedSignal();
5293
+ await this.safeEmit({
5294
+ id: '',
5295
+ type: 'human.intervention',
5296
+ timestamp: new Date().toISOString(),
5297
+ projectId,
5298
+ agentId: qaAgentId,
5299
+ taskId,
5300
+ data: { phase: 'server-fix-qa-release-failed', qaAgentId },
5301
+ });
5302
+ return null;
5303
+ }
5304
+ }
5305
+ // max_rounds entry = human "continue one round" via continueDevRound.
5306
+ const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
5307
+ if (!transition) {
5308
+ // Refusal = the task left review/max_rounds concurrently (cancel / fail /
5309
+ // mark-complete publish). Ownership moved with it — releasing dev here
5310
+ // would strip a binding the winning chain may be actively using (e.g. a
5311
+ // publish prompt running in the pane); its own cleanup releases the dev.
5312
+ await this.safeEmit({
5313
+ id: '',
5314
+ type: 'human.intervention',
5315
+ timestamp: new Date().toISOString(),
5316
+ projectId,
5317
+ agentId: devAgentId,
5318
+ taskId,
5319
+ data: { phase: 'server-fix-transition-failed', devAgentId },
5320
+ });
5321
+ return null;
5322
+ }
5323
+ let resumed = false;
5324
+ try {
5325
+ resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
5326
+ bypassTaskStatusGate: true,
5327
+ signalToken: newToken,
5328
+ serverPriorFindings: findingsJson,
5329
+ ...(taskPhase === 'spec' && currentSpecRound !== undefined
5330
+ ? { currentSpecRound }
5331
+ : {}),
5332
+ });
5333
+ }
5334
+ catch (err) {
5335
+ if (err instanceof DispatchTerminalError) {
5336
+ await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
5337
+ }
5338
+ else if (err instanceof EnsureSessionError && err.partial.handled) {
5339
+ // handled upstream
5340
+ }
5341
+ else {
5342
+ await rollbackToEntry();
5343
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5344
+ // Rollback restored review/old-token, but the QA's reviewed signal was
5345
+ // consumed — without a subscriber its re-emit can never retry the fix
5346
+ // dispatch (PR #288).
5347
+ await rearmReviewedSignal();
5348
+ }
5349
+ throw err;
5350
+ }
5351
+ if (!resumed) {
5352
+ await rollbackToEntry();
5353
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5354
+ await rearmReviewedSignal();
5355
+ await this.safeEmit({
5356
+ id: '',
5357
+ type: 'human.intervention',
5358
+ timestamp: new Date().toISOString(),
5359
+ projectId,
5360
+ agentId: devAgentId,
5361
+ taskId,
5362
+ data: { phase: 'server-fix-resume-failed', devAgentId },
5363
+ });
5364
+ return null;
5365
+ }
5366
+ await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
5367
+ return await this.taskStore.get(taskId);
5368
+ }
5369
+ async dispatchServerAfterDone(taskId, kind) {
5370
+ const task = await this.taskStore.get(taskId);
5371
+ if (!task)
5372
+ throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
5373
+ const devAgentId = task.agentId;
5374
+ if (!devAgentId)
5375
+ throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
5376
+ const branch = task.branch ?? BRANCH_PREFIX + taskId;
5377
+ const originalToken = task.signalToken;
5378
+ const newToken = createSignalToken();
5379
+ await this.updateTask(taskId, { signalToken: newToken });
5380
+ // The publish prompt never reached the pane — restore the pre-rotation token
5381
+ // (so recovery still matches the pre-dispatch arm) and clear the delivery
5382
+ // marker so retry knows this approved state is preemptible (PR #288).
5383
+ const rollbackToken = async () => {
5384
+ await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
5385
+ .catch(() => undefined);
5386
+ };
5387
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
5388
+ if (!acquired) {
5389
+ await rollbackToken();
5390
+ await this.safeEmit({
5391
+ id: '',
5392
+ type: 'human.intervention',
5393
+ timestamp: new Date().toISOString(),
5394
+ projectId: task.projectId,
5395
+ agentId: devAgentId,
5396
+ taskId,
5397
+ data: { phase: 'server-after-done-dev-acquire-failed', devAgentId },
5398
+ });
5399
+ return null;
5400
+ }
5401
+ // Pessimistic delivery marker BEFORE the irreversible paste: every failure
5402
+ // path below clears it. The remaining crash window (marker written, paste
5403
+ // never ran) fails CLOSED — retry 409s on a publish that never started and
5404
+ // the operator escapes via Cancel — instead of the old window's fail-open
5405
+ // double publish (paste ran, marker missing, retry re-pastes) (PR #288).
5406
+ await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
5407
+ let resumed = false;
5408
+ try {
5409
+ resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
5410
+ bypassTaskStatusGate: true,
5411
+ signalToken: newToken,
5412
+ serverAfterDone: { kind, branch },
5413
+ });
5414
+ }
5415
+ catch (err) {
5416
+ if (err instanceof DispatchTerminalError) {
5417
+ await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
5418
+ }
5419
+ else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5420
+ // Keep dev BOUND — its worktree holds the reviewed (unpushed) commits.
5421
+ // mark-complete retries the publish via server-after-done same-task reentry.
5422
+ await rollbackToken();
5423
+ }
5424
+ throw err;
5425
+ }
5426
+ if (!resumed) {
5427
+ await rollbackToken();
5428
+ await this.safeEmit({
5429
+ id: '',
5430
+ type: 'human.intervention',
5431
+ timestamp: new Date().toISOString(),
5432
+ projectId: task.projectId,
5433
+ agentId: devAgentId,
5434
+ taskId,
5435
+ data: {
5436
+ phase: 'server-after-done-resume-failed',
5437
+ devAgentId,
5438
+ note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
5439
+ },
5440
+ });
5441
+ return null;
5442
+ }
5443
+ await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
5444
+ return await this.taskStore.get(taskId);
5445
+ }
5446
+ // QA asked for file context during a server-mode review. Read from the DEV
5447
+ // worktree (the QA worktree sits on the base branch) and paste into QA's pane.
5448
+ async handleReadFileRequest(taskId, qaAgentId, req) {
5449
+ const task = await this.taskStore.get(taskId);
5450
+ if (!task)
5451
+ return;
5452
+ const dev = this.getAgentConfig(task.agentId);
5453
+ if (!dev)
5454
+ return;
5455
+ await this.refreshWorktreeCacheFor(task.agentId);
5456
+ let body;
5457
+ try {
5458
+ const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
5459
+ body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
5460
+ }
5461
+ catch (err) {
5462
+ const reason = err instanceof Error ? err.message : String(err);
5463
+ body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
5464
+ }
5465
+ // The read ran async — QA may have submitted its verdict and been released
5466
+ // or rebound meanwhile. Never paste old-task content into a new task's pane.
5467
+ const qaState = await this.agentStore.get(qaAgentId);
5468
+ if (qaState?.taskId !== taskId) {
5469
+ console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
5470
+ return;
5471
+ }
5472
+ try {
5473
+ await this.injectTextToAgent(qaAgentId, body);
5474
+ }
5475
+ catch (err) {
5476
+ console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
5477
+ }
5478
+ }
5479
+ // Plain text paste + submit into a live agent pane (no skills, no ack protocol).
5480
+ async injectTextToAgent(agentId, text) {
5481
+ const cfg = this.getAgentConfig(agentId);
5482
+ if (!cfg)
5483
+ throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
5484
+ const state = await this.agentStore.get(agentId);
5485
+ const paneId = state?.paneId;
5486
+ if (!paneId)
5487
+ throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5488
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5489
+ await tmux.injectPrompt(paneId, text, agentId);
5490
+ await tmux.sendEnter(paneId);
5491
+ }
5492
+ // Human gate confirm (spec §10): executes the configured completion for
5493
+ // ready (server mode) / merge-ready (github mode) tasks.
5494
+ async confirmHumanGate(taskId) {
5495
+ // Claim under the task lock: a Cancel racing this read can no longer flip the
5496
+ // task to cancelled (and retire its artifacts) while we proceed on a stale
5497
+ // gate snapshot — cancelTask checks the in-flight flag inside the same lock.
5498
+ const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
5499
+ try {
5500
+ const project = this.getProjectConfig(task.projectId);
5501
+ const mergeAuto = project?.merge === 'auto';
5502
+ // Snapshot from verdict time — a hot config flip between publish and
5503
+ // confirm must not reroute an already-published artifact (PR #288).
5504
+ const afterDone = this.resolveAfterDone(task);
5505
+ if (task.status === 'merge-ready') {
5506
+ if (mergeAuto && task.prNumber) {
5507
+ // Guard on the post-approve head persisted at the merge-ready transition.
5508
+ if (!task.latestHeadSha) {
5509
+ throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
5510
+ }
5511
+ await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
5512
+ matchHeadSha: task.latestHeadSha,
5513
+ }));
5514
+ await this.eventBus.emit({
5515
+ id: '',
5516
+ type: 'pr.merged',
5517
+ timestamp: new Date().toISOString(),
5518
+ projectId: task.projectId,
5519
+ agentId: task.agentId,
5520
+ taskId: task.id,
5521
+ data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
5522
+ });
5523
+ return (await this.taskStore.get(taskId));
5524
+ }
5525
+ return this.finishTaskAsDone(taskId);
5526
+ }
5527
+ // status === 'ready' (server mode)
5528
+ if (afterDone === 'pr' && mergeAuto && task.prNumber) {
5529
+ // Reviewed-head guard is mandatory here — publish fail-closes on capture,
5530
+ // so a missing sha means tampered/legacy state, not a soft fallback.
5531
+ if (!task.latestHeadSha) {
5532
+ throw new ApiError(409, `Task ${taskId} has no reviewed head recorded; cannot safely merge`);
5533
+ }
5534
+ await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
5535
+ matchHeadSha: task.latestHeadSha,
5536
+ }));
5537
+ // pr.merged's fromStatus now includes 'ready' — let the handler own the
5538
+ // merged transition + full cleanup chain (branch delete, /compact, release).
5539
+ await this.eventBus.emit({
5540
+ id: '',
5541
+ type: 'pr.merged',
5542
+ timestamp: new Date().toISOString(),
5543
+ projectId: task.projectId,
5544
+ agentId: task.agentId,
5545
+ taskId: task.id,
5546
+ data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
5547
+ });
5548
+ return (await this.taskStore.get(taskId));
5549
+ }
5550
+ if (afterDone === 'branch' && mergeAuto && task.branch) {
5551
+ await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
5552
+ const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
5553
+ if (merged)
5554
+ await this.releaseTaskAgents(taskId);
5555
+ return (await this.taskStore.get(taskId));
5556
+ }
5557
+ return this.finishTaskAsDone(taskId);
5558
+ }
5559
+ finally {
5560
+ this.markCompleteInFlight.delete(taskId);
5561
+ }
5562
+ }
5563
+ // Atomic gate claim: re-read + status check + markCompleteInFlight.add under
5564
+ // the task lock, so confirm and cancel serialize on the same snapshot.
5565
+ async claimCompleteGate(taskId, statuses) {
5566
+ return this.withTaskLock(async () => {
5567
+ const fresh = await this.taskStore.get(taskId);
5568
+ if (!fresh)
5569
+ throw new ApiError(404, `Task ${taskId} not found`);
5570
+ if (!statuses.includes(fresh.status)) {
5571
+ throw new ApiError(409, `Task ${taskId} is not awaiting confirmation (status=${fresh.status})`);
5572
+ }
5573
+ if (this.markCompleteInFlight.has(taskId)) {
5574
+ throw new ApiError(409, `Task ${taskId} is already being completed`);
5575
+ }
5576
+ this.markCompleteInFlight.add(taskId);
5577
+ return fresh;
5578
+ });
5579
+ }
5580
+ // Merge failures keep the gate: transient gh/network errors retry via another
5581
+ // Confirm, a stale head resolves via Cancel or an external decision — terminal
5582
+ // 'failed' would orphan the published PR/branch outside the task flow (PR #288).
5583
+ async executeConfirmMerge(task, merge) {
5584
+ try {
5585
+ await merge();
5586
+ }
5587
+ catch (err) {
5588
+ const message = err instanceof Error ? err.message : String(err);
5589
+ await this.safeEmit({
5590
+ id: '',
5591
+ type: 'human.intervention',
5592
+ timestamp: new Date().toISOString(),
5593
+ projectId: task.projectId,
5594
+ taskId: task.id,
5595
+ data: {
5596
+ phase: 'confirm-merge-failed',
5597
+ gate: task.status,
5598
+ error: message,
5599
+ note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
5600
+ },
5601
+ });
5602
+ throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
5603
+ }
5604
+ }
5605
+ async finishTaskAsDone(taskId) {
5606
+ const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
5607
+ if (!done)
5608
+ throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
5609
+ await this.releaseTaskAgents(taskId);
5610
+ return (await this.taskStore.get(taskId));
5611
+ }
5612
+ // Terminal-state resource release shared by done/merged(branch)/failed confirm
5613
+ // paths: stop the watcher, release dev+qa (worktree removal rides releaseAgentForTask).
5614
+ async releaseTaskAgents(taskId) {
5615
+ this.phaseSignalWatcher?.stop(taskId);
5616
+ const task = await this.taskStore.get(taskId);
5617
+ if (!task)
5618
+ return;
5619
+ for (const id of [task.agentId, task.qaAgentId]) {
5620
+ if (!id)
5621
+ continue;
5622
+ const state = await this.agentStore.get(id);
5623
+ if (!state || state.taskId !== taskId)
5624
+ continue;
5625
+ await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
5626
+ .catch(err => {
5627
+ console.warn(`[AgentManager] confirm release ${id} failed:`, err);
5628
+ });
5629
+ }
5630
+ }
5631
+ // afterDone:'branch' + merge:'auto' — fast-forward the remote default branch
5632
+ // to the reviewed branch ref-to-ref (`git push origin origin/bx/X:main`).
5633
+ // Never touches the repo working tree, and a plain push is ff-only by default:
5634
+ // a non-ff base is rejected by the remote and a human must rebase/decide (spec §6).
5635
+ repoMergeQueue = new Map();
5636
+ async ffMergeBranch(task) {
5637
+ const dev = this.getAgentConfig(task.agentId);
5638
+ if (!dev)
5639
+ throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
5640
+ const state = await this.agentStore.get(task.agentId);
5641
+ const repoPath = state?.repoPath;
5642
+ if (!repoPath)
5643
+ throw new Error(`ffMergeBranch: no repoPath for agent ${task.agentId}`);
5644
+ const branch = task.branch ?? BRANCH_PREFIX + task.id;
5645
+ const runner = this.createRunnerFor(dev);
5646
+ const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
5647
+ const run = prev.then(async () => {
5648
+ const cd = `cd ${shellQuote(repoPath)} && `;
5649
+ const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
5650
+ const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5651
+ if (db.exitCode !== 0 || defaultBranch === '') {
5652
+ // A silent 'main' fallback would push the reviewed branch onto the wrong
5653
+ // ref for repos whose default branch differs (PR #288).
5654
+ throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
5655
+ }
5656
+ const fetch = await runner.exec(`${cd}git fetch origin`);
5657
+ if (fetch.exitCode !== 0) {
5658
+ throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
5659
+ }
5660
+ // Reviewed-head guard (branch path): refuse if origin/<branch> moved after
5661
+ // the gate — symmetric with the pr path's --match-head-commit (PR #288).
5662
+ if (task.latestHeadSha) {
5663
+ const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
5664
+ if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
5665
+ throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
5666
+ `!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
5667
+ }
5668
+ }
5669
+ else {
5670
+ throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
5671
+ }
5672
+ const push = await runner.exec(`${cd}git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
5673
+ if (push.exitCode !== 0) {
5674
+ throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5675
+ }
5676
+ // The merge has landed; branch deletion is cleanup — a transient failure
5677
+ // here must not flip an already-merged task to failed (PR #288).
5678
+ const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
5679
+ if (del.exitCode !== 0) {
5680
+ console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
5681
+ }
5682
+ });
5683
+ this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
5684
+ await run;
5685
+ }
4653
5686
  }
4654
5687
  function buildAgentIndex(config) {
4655
5688
  const index = new Map();