baxian 1.2.52 → 1.2.53

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 (134) hide show
  1. package/dist/agent/manager.d.ts +33 -6
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +320 -376
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/net-exec.d.ts +0 -1
  6. package/dist/agent/net-exec.d.ts.map +1 -1
  7. package/dist/agent/net-exec.js +1 -1
  8. package/dist/agent/net-exec.js.map +1 -1
  9. package/dist/agent/pane-streamer-manager.js +1 -1
  10. package/dist/agent/pane-streamer-manager.js.map +1 -1
  11. package/dist/agent/pane-streamer.d.ts +2 -3
  12. package/dist/agent/pane-streamer.d.ts.map +1 -1
  13. package/dist/agent/pane-streamer.js +1 -1
  14. package/dist/agent/pane-streamer.js.map +1 -1
  15. package/dist/agent/phase-signal.d.ts +0 -1
  16. package/dist/agent/phase-signal.d.ts.map +1 -1
  17. package/dist/agent/phase-signal.js +0 -5
  18. package/dist/agent/phase-signal.js.map +1 -1
  19. package/dist/agent/prompt.d.ts +8 -0
  20. package/dist/agent/prompt.d.ts.map +1 -1
  21. package/dist/agent/prompt.js +22 -3
  22. package/dist/agent/prompt.js.map +1 -1
  23. package/dist/agent/review-transport.d.ts +11 -2
  24. package/dist/agent/review-transport.d.ts.map +1 -1
  25. package/dist/agent/review-transport.js +34 -10
  26. package/dist/agent/review-transport.js.map +1 -1
  27. package/dist/agent/runner.d.ts +0 -3
  28. package/dist/agent/runner.d.ts.map +1 -1
  29. package/dist/agent/runner.js +1 -18
  30. package/dist/agent/runner.js.map +1 -1
  31. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  32. package/dist/agent/tmux-probe-poller.js +19 -23
  33. package/dist/agent/tmux-probe-poller.js.map +1 -1
  34. package/dist/agent/tmux.d.ts +0 -5
  35. package/dist/agent/tmux.d.ts.map +1 -1
  36. package/dist/agent/tmux.js +6 -6
  37. package/dist/agent/tmux.js.map +1 -1
  38. package/dist/agent/worktree.d.ts +16 -0
  39. package/dist/agent/worktree.d.ts.map +1 -1
  40. package/dist/agent/worktree.js +119 -1
  41. package/dist/agent/worktree.js.map +1 -1
  42. package/dist/api/probe.d.ts +0 -10
  43. package/dist/api/probe.d.ts.map +1 -1
  44. package/dist/api/tasks.d.ts.map +1 -1
  45. package/dist/api/tasks.js +27 -0
  46. package/dist/api/tasks.js.map +1 -1
  47. package/dist/event/handlers.d.ts +0 -2
  48. package/dist/event/handlers.d.ts.map +1 -1
  49. package/dist/event/handlers.js +18 -24
  50. package/dist/event/handlers.js.map +1 -1
  51. package/dist/event/log.d.ts.map +1 -1
  52. package/dist/event/log.js +3 -5
  53. package/dist/event/log.js.map +1 -1
  54. package/dist/event/server-handlers.d.ts.map +1 -1
  55. package/dist/event/server-handlers.js +121 -38
  56. package/dist/event/server-handlers.js.map +1 -1
  57. package/dist/github/mapper.d.ts +0 -40
  58. package/dist/github/mapper.d.ts.map +1 -1
  59. package/dist/github/mapper.js +0 -140
  60. package/dist/github/mapper.js.map +1 -1
  61. package/dist/shared/concurrency.d.ts +3 -0
  62. package/dist/shared/concurrency.d.ts.map +1 -0
  63. package/dist/shared/concurrency.js +16 -0
  64. package/dist/shared/concurrency.js.map +1 -0
  65. package/dist/shared/constants.d.ts +0 -10
  66. package/dist/shared/constants.d.ts.map +1 -1
  67. package/dist/shared/constants.js +3 -10
  68. package/dist/shared/constants.js.map +1 -1
  69. package/dist/shared/index.d.ts +1 -0
  70. package/dist/shared/index.d.ts.map +1 -1
  71. package/dist/shared/index.js +1 -0
  72. package/dist/shared/index.js.map +1 -1
  73. package/dist/shared/types.d.ts +28 -32
  74. package/dist/shared/types.d.ts.map +1 -1
  75. package/dist/shared/types.js.map +1 -1
  76. package/dist/skill/registry.d.ts +2 -5
  77. package/dist/skill/registry.d.ts.map +1 -1
  78. package/dist/skill/registry.js +2 -2
  79. package/dist/skill/registry.js.map +1 -1
  80. package/dist/skills/baxian-server-review/SKILL.md +6 -4
  81. package/dist/skills/baxian-signals/SKILL.md +1 -1
  82. package/dist/state/error-record-store.d.ts +2 -0
  83. package/dist/state/error-record-store.d.ts.map +1 -1
  84. package/dist/state/error-record-store.js +17 -1
  85. package/dist/state/error-record-store.js.map +1 -1
  86. package/dist/state/pet-store.js +3 -1
  87. package/dist/state/pet-store.js.map +1 -1
  88. package/dist/state/snapshot.d.ts.map +1 -1
  89. package/dist/state/snapshot.js +5 -3
  90. package/dist/state/snapshot.js.map +1 -1
  91. package/dist/state/task-store.d.ts.map +1 -1
  92. package/dist/state/task-store.js +23 -16
  93. package/dist/state/task-store.js.map +1 -1
  94. package/dist/web/assets/index-DAPd4Nv_.js +13 -0
  95. package/dist/web/assets/{index-B1JSXKdx.css → index-DfcbGriX.css} +1 -1
  96. package/dist/web/index.html +2 -2
  97. package/package.json +1 -1
  98. package/dist/agent/detect/index.d.ts +0 -5
  99. package/dist/agent/detect/index.d.ts.map +0 -1
  100. package/dist/agent/detect/index.js +0 -5
  101. package/dist/agent/detect/index.js.map +0 -1
  102. package/dist/agent/diff-split.d.ts +0 -10
  103. package/dist/agent/diff-split.d.ts.map +0 -1
  104. package/dist/agent/diff-split.js +0 -79
  105. package/dist/agent/diff-split.js.map +0 -1
  106. package/dist/agent/index.d.ts +0 -8
  107. package/dist/agent/index.d.ts.map +0 -1
  108. package/dist/agent/index.js +0 -8
  109. package/dist/agent/index.js.map +0 -1
  110. package/dist/config/index.d.ts +0 -5
  111. package/dist/config/index.d.ts.map +0 -1
  112. package/dist/config/index.js +0 -5
  113. package/dist/config/index.js.map +0 -1
  114. package/dist/event/index.d.ts +0 -4
  115. package/dist/event/index.d.ts.map +0 -1
  116. package/dist/event/index.js +0 -4
  117. package/dist/event/index.js.map +0 -1
  118. package/dist/github/index.d.ts +0 -4
  119. package/dist/github/index.d.ts.map +0 -1
  120. package/dist/github/index.js +0 -3
  121. package/dist/github/index.js.map +0 -1
  122. package/dist/skill/index.d.ts +0 -2
  123. package/dist/skill/index.d.ts.map +0 -1
  124. package/dist/skill/index.js +0 -2
  125. package/dist/skill/index.js.map +0 -1
  126. package/dist/state/index.d.ts +0 -6
  127. package/dist/state/index.d.ts.map +0 -1
  128. package/dist/state/index.js +0 -6
  129. package/dist/state/index.js.map +0 -1
  130. package/dist/terminal/index.d.ts +0 -3
  131. package/dist/terminal/index.d.ts.map +0 -1
  132. package/dist/terminal/index.js +0 -3
  133. package/dist/terminal/index.js.map +0 -1
  134. package/dist/web/assets/index-CJhHx7jR.js +0 -11
@@ -35,12 +35,6 @@ export class CleanupFailedError extends Error {
35
35
  this.name = 'CleanupFailedError';
36
36
  }
37
37
  }
38
- export class DispatchTransientError extends Error {
39
- constructor(message) {
40
- super(message);
41
- this.name = 'DispatchTransientError';
42
- }
43
- }
44
38
  export class DispatchTerminalError extends Error {
45
39
  reason;
46
40
  replDrained;
@@ -101,7 +95,7 @@ function cancelPhaseDowngrades(prev, next) {
101
95
  return cancelPhaseRank(next) < cancelPhaseRank(prev);
102
96
  }
103
97
  const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
104
- export function shouldReleaseHeldBinding(state, boundTask) {
98
+ function shouldReleaseHeldBinding(state, boundTask) {
105
99
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
106
100
  return false;
107
101
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
@@ -147,6 +141,7 @@ export class AgentManager {
147
141
  manualReviewInFlight = new Set();
148
142
  markCompleteInFlight = new Set();
149
143
  specVerdictInFlight = new Set();
144
+ codeVerdictInFlight = new Set();
150
145
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
151
146
  cancelCleanupInFlight = new Map();
152
147
  deletionInFlight = new Set();
@@ -240,6 +235,17 @@ export class AgentManager {
240
235
  console.warn(`[AgentManager] safeEmit ${event.type} failed (audit log loss; state machine unaffected):`, err);
241
236
  }
242
237
  }
238
+ async emitIntervention(projectId, agentId, taskId, data) {
239
+ await this.safeEmit({
240
+ id: '',
241
+ type: 'human.intervention',
242
+ timestamp: new Date().toISOString(),
243
+ projectId,
244
+ ...(agentId ? { agentId } : {}),
245
+ ...(taskId ? { taskId } : {}),
246
+ data,
247
+ });
248
+ }
243
249
  async recordError(input) {
244
250
  if (!this.errorRecordStore)
245
251
  return;
@@ -591,14 +597,7 @@ export class AgentManager {
591
597
  });
592
598
  if (!wrote)
593
599
  return;
594
- await this.safeEmit({
595
- id: '',
596
- type: 'human.intervention',
597
- timestamp: now,
598
- projectId: existing.projectId,
599
- agentId,
600
- data: { phase: 'greeting_failed', reason },
601
- });
600
+ await this.emitIntervention(existing.projectId, agentId, undefined, { phase: 'greeting_failed', reason });
602
601
  }
603
602
  async regreetHeldAgent(agentId) {
604
603
  const agent = this.getAgentConfig(agentId);
@@ -696,17 +695,9 @@ export class AgentManager {
696
695
  });
697
696
  if (!wrote)
698
697
  return;
699
- await this.safeEmit({
700
- id: '',
701
- type: 'human.intervention',
702
- timestamp: now,
703
- projectId: projectIdForEmit,
704
- agentId,
705
- ...(taskIdForEmit ? { taskId: taskIdForEmit } : {}),
706
- data: {
707
- phase: 'agent_dialog_pending',
708
- reason: 'Agent REPL launched but blocked on a startup dialog (e.g. CLI update notice). Operator should attach via web terminal and dismiss it; baxian will auto-detect ready and resume.',
709
- },
698
+ await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
699
+ phase: 'agent_dialog_pending',
700
+ reason: 'Agent REPL launched but blocked on a startup dialog (e.g. CLI update notice). Operator should attach via web terminal and dismiss it; baxian will auto-detect ready and resume.',
710
701
  });
711
702
  }
712
703
  async markBootstrapFailed(agentId, creationToken, errorMessage) {
@@ -917,16 +908,9 @@ export class AgentManager {
917
908
  });
918
909
  }
919
910
  else {
920
- await this.safeEmit({
921
- id: '',
922
- type: 'human.intervention',
923
- timestamp: now,
924
- projectId: projectIdForEmit,
925
- agentId,
926
- data: {
927
- phase: 'agent_dialog_resolved_runtime',
928
- note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
929
- },
911
+ await this.emitIntervention(projectIdForEmit, agentId, undefined, {
912
+ phase: 'agent_dialog_resolved_runtime',
913
+ note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
930
914
  });
931
915
  }
932
916
  return;
@@ -990,18 +974,9 @@ export class AgentManager {
990
974
  const onMenu = detectRuntimeMenu(stripped);
991
975
  if (onMenu && !pendingMenu) {
992
976
  pendingMenu = true;
993
- const now = new Date().toISOString();
994
- await this.safeEmit({
995
- id: '',
996
- type: 'human.intervention',
997
- timestamp: now,
998
- projectId: fresh.projectId,
999
- agentId,
1000
- taskId: fresh.taskId,
1001
- data: {
1002
- phase: 'agent_runtime_menu_pending',
1003
- note: 'Agent paused on an interactive menu mid-task. Attach via web terminal and respond; baxian will auto-clear once the menu closes.',
1004
- },
977
+ await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
978
+ phase: 'agent_runtime_menu_pending',
979
+ note: 'Agent paused on an interactive menu mid-task. Attach via web terminal and respond; baxian will auto-clear once the menu closes.',
1005
980
  });
1006
981
  }
1007
982
  else if (!onMenu && pendingMenu) {
@@ -1349,17 +1324,9 @@ export class AgentManager {
1349
1324
  });
1350
1325
  if (!wrote)
1351
1326
  return;
1352
- await this.safeEmit({
1353
- id: '',
1354
- type: 'human.intervention',
1355
- timestamp: now,
1356
- projectId,
1357
- agentId,
1358
- ...(taskId ? { taskId } : {}),
1359
- data: {
1360
- phase,
1361
- reason,
1362
- },
1327
+ await this.emitIntervention(projectId, agentId, taskId, {
1328
+ phase,
1329
+ reason,
1363
1330
  });
1364
1331
  }
1365
1332
  async markAwaitingIfAckUnknown(agentId, err, expectedTaskId) {
@@ -1480,18 +1447,10 @@ export class AgentManager {
1480
1447
  if (shouldReleaseBinding) {
1481
1448
  await this.lockManager.release(agentId);
1482
1449
  }
1483
- await this.safeEmit({
1484
- id: '',
1485
- type: 'human.intervention',
1486
- timestamp: now,
1487
- projectId: state.projectId,
1488
- agentId,
1489
- ...(state.taskId ? { taskId: state.taskId } : {}),
1490
- data: {
1491
- phase: 'resumed',
1492
- previousPhase: state.awaitingPhase,
1493
- releasedBinding: shouldReleaseBinding,
1494
- },
1450
+ await this.emitIntervention(state.projectId, agentId, state.taskId, {
1451
+ phase: 'resumed',
1452
+ previousPhase: state.awaitingPhase,
1453
+ releasedBinding: shouldReleaseBinding,
1495
1454
  });
1496
1455
  return { resumed: true, releasedBinding: shouldReleaseBinding };
1497
1456
  });
@@ -1686,19 +1645,11 @@ export class AgentManager {
1686
1645
  catch (releaseErr) {
1687
1646
  console.warn(`[AgentManager] failTaskForDispatchError: releaseAgentForTask(${agentId}) failed:`, releaseErr);
1688
1647
  }
1689
- await this.safeEmit({
1690
- id: '',
1691
- type: 'human.intervention',
1692
- timestamp: new Date().toISOString(),
1693
- projectId: transitioned?.task.projectId ?? '',
1694
- agentId,
1695
- taskId,
1696
- data: {
1697
- phase: `dispatch-failed:${err.reason}`,
1698
- reason: err.reason,
1699
- message: err.message,
1700
- replDrained: err.replDrained,
1701
- },
1648
+ await this.emitIntervention(transitioned?.task.projectId ?? '', agentId, taskId, {
1649
+ phase: `dispatch-failed:${err.reason}`,
1650
+ reason: err.reason,
1651
+ message: err.message,
1652
+ replDrained: err.replDrained,
1702
1653
  });
1703
1654
  }
1704
1655
  async failTasksForAgent(agentId, reason, opts = {}) {
@@ -2081,6 +2032,10 @@ export class AgentManager {
2081
2032
  const scanSnapshotOnRecover = isServerProtocol
2082
2033
  || (task.phase === undefined && task.status === 'in_progress')
2083
2034
  || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
2035
+ const allowRecoveredReadFile = task.status === 'review'
2036
+ && (task.phase === 'spec'
2037
+ || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2038
+ || (task.reviewMode === 'server' && task.reviewWorktreeMode === 'base'));
2084
2039
  try {
2085
2040
  await this.startPhaseSignalWatch({
2086
2041
  taskId: task.id,
@@ -2090,23 +2045,15 @@ export class AgentManager {
2090
2045
  token: task.signalToken,
2091
2046
  skipSnapshot: !scanSnapshotOnRecover,
2092
2047
  recovered: true,
2093
- ...(isServerProtocol && task.status === 'review'
2048
+ ...(allowRecoveredReadFile
2094
2049
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
2095
2050
  : {}),
2096
2051
  });
2097
2052
  if (interventionKindLabel) {
2098
- await this.safeEmit({
2099
- id: '',
2100
- type: 'human.intervention',
2101
- timestamp: new Date().toISOString(),
2102
- projectId: task.projectId,
2103
- agentId,
2104
- taskId: task.id,
2105
- data: {
2106
- phase: 'spec-signal-setup-during-recovery',
2107
- kind: interventionKindLabel,
2108
- note: 'Task is waiting for a spec signal after server recovery; if no signal arrives, the prompt may not have been fully delivered before the previous crash. Inspect the agent pane and consider manual retry or transition.',
2109
- },
2053
+ await this.emitIntervention(task.projectId, agentId, task.id, {
2054
+ phase: 'spec-signal-setup-during-recovery',
2055
+ kind: interventionKindLabel,
2056
+ note: 'Task is waiting for a spec signal after server recovery; if no signal arrives, the prompt may not have been fully delivered before the previous crash. Inspect the agent pane and consider manual retry or transition.',
2110
2057
  });
2111
2058
  }
2112
2059
  }
@@ -2318,18 +2265,10 @@ export class AgentManager {
2318
2265
  rolledBack = { projectId: task.projectId };
2319
2266
  });
2320
2267
  if (rolledBack && reason) {
2321
- await this.safeEmit({
2322
- id: '',
2323
- type: 'human.intervention',
2324
- timestamp: new Date().toISOString(),
2325
- projectId: rolledBack.projectId,
2326
- agentId,
2327
- taskId,
2328
- data: {
2329
- phase: reason.phase,
2330
- message: reason.message,
2331
- note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2332
- },
2268
+ await this.emitIntervention(rolledBack.projectId, agentId, taskId, {
2269
+ phase: reason.phase,
2270
+ message: reason.message,
2271
+ note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2333
2272
  });
2334
2273
  }
2335
2274
  const existing = await this.agentStore.get(agentId);
@@ -2944,14 +2883,23 @@ export class AgentManager {
2944
2883
  const worktree = new WorktreeManager(runner);
2945
2884
  const tmux = new TmuxManager(runner);
2946
2885
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2886
+ const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
2947
2887
  const customBranch = task.branch && !task.branch.startsWith(BRANCH_PREFIX) ? task.branch : undefined;
2948
- // review/recheck check out the PR branch detached and never touch origin/HEAD,
2949
- // so the fail-fast base resolution only runs where the default base is used.
2950
- const worktreePath = isServerQaPhase
2888
+ // Server code reviews materialize the captured head; other QA modes stay off origin/HEAD.
2889
+ // The fail-fast base resolution only runs where the default base is used.
2890
+ const reviewWorktree = isServerCodeReviewPhase && opts.serverContent !== undefined
2891
+ ? await worktree.createDetachedForReviewHead(workdir, taskId, {
2892
+ patch: opts.serverContent,
2893
+ baseSha: opts.serverBaseSha,
2894
+ headSha: opts.serverHeadSha,
2895
+ headTree: opts.serverHeadTree,
2896
+ })
2897
+ : undefined;
2898
+ const worktreePath = reviewWorktree?.path ?? (isServerQaPhase
2951
2899
  ? await worktree.createDetachedAtBase(workdir, taskId)
2952
2900
  : phase === 'review' || phase === 'recheck'
2953
2901
  ? await worktree.createDetached(workdir, taskId, task.branch)
2954
- : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch);
2902
+ : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch));
2955
2903
  await this.agentStore.update(agentId, (stateNow) => {
2956
2904
  if (!stateNow || stateNow.taskId !== taskId)
2957
2905
  return AGENT_STORE_NOOP;
@@ -2972,7 +2920,7 @@ export class AgentManager {
2972
2920
  try {
2973
2921
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2974
2922
  let payloadOpts = {};
2975
- if (opts.serverContent !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2923
+ if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2976
2924
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
2977
2925
  phase,
2978
2926
  ...(task.phase ? { taskPhase: task.phase } : {}),
@@ -2980,6 +2928,9 @@ export class AgentManager {
2980
2928
  reviewRound: task.reviewRound,
2981
2929
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
2982
2930
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2931
+ ...(reviewWorktree?.mode === 'head' ? { forceContentFile: true } : {}),
2932
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2933
+ ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
2983
2934
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
2984
2935
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
2985
2936
  });
@@ -2994,7 +2945,11 @@ export class AgentManager {
2994
2945
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2995
2946
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2996
2947
  ...(imagePaths.length ? { imagePaths } : {}),
2997
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2948
+ ...(reviewWorktree ? { serverReviewWorktree: reviewWorktree.mode } : {}),
2949
+ ...(reviewWorktree?.fallbackReason ? { serverReviewFallbackReason: reviewWorktree.fallbackReason } : {}),
2950
+ ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
2951
+ ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
2952
+ ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
2998
2953
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
2999
2954
  ...payloadOpts,
3000
2955
  });
@@ -3060,7 +3015,7 @@ export class AgentManager {
3060
3015
  catch { }
3061
3016
  return false;
3062
3017
  }
3063
- if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3018
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewWorktree: reviewWorktree?.mode }))) {
3064
3019
  try {
3065
3020
  await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3066
3021
  }
@@ -3244,21 +3199,13 @@ export class AgentManager {
3244
3199
  }
3245
3200
  console.warn(`[AgentManager] dispatch ack timeout for pane ${paneId} agent ${agentId}: ${message}`);
3246
3201
  const state = await this.agentStore.get(agentId).catch(() => null);
3247
- await this.safeEmit({
3248
- id: '',
3249
- type: 'human.intervention',
3250
- timestamp: new Date().toISOString(),
3251
- projectId: state?.projectId ?? '',
3252
- agentId,
3253
- ...(state?.taskId ? { taskId: state.taskId } : {}),
3254
- data: {
3255
- phase: 'dispatch-ack-timeout',
3256
- paneId,
3257
- message,
3258
- note: 'REPL did not acknowledge the pasted prompt within timeout. ' +
3259
- 'baxian intentionally did NOT send C-c — the input may still be queued. ' +
3260
- 'Attach via web terminal to verify and resolve.',
3261
- },
3202
+ await this.emitIntervention(state?.projectId ?? '', agentId, state?.taskId, {
3203
+ phase: 'dispatch-ack-timeout',
3204
+ paneId,
3205
+ message,
3206
+ note: 'REPL did not acknowledge the pasted prompt within timeout. ' +
3207
+ 'baxian intentionally did NOT send C-c — the input may still be queued. ' +
3208
+ 'Attach via web terminal to verify and resolve.',
3262
3209
  });
3263
3210
  const composerDelivered = !/pane already busy at baseline/.test(message);
3264
3211
  return { acked: false, composerDelivered };
@@ -3345,7 +3292,7 @@ export class AgentManager {
3345
3292
  && !ensure.freshRuntime;
3346
3293
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3347
3294
  let payloadOpts = {};
3348
- if (opts.serverContent !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3295
+ if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3349
3296
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3350
3297
  phase,
3351
3298
  ...(task.phase ? { taskPhase: task.phase } : {}),
@@ -3353,6 +3300,8 @@ export class AgentManager {
3353
3300
  reviewRound: task.reviewRound,
3354
3301
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
3355
3302
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
3303
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
3304
+ ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
3356
3305
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
3357
3306
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
3358
3307
  });
@@ -3369,7 +3318,6 @@ export class AgentManager {
3369
3318
  : {}),
3370
3319
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
3371
3320
  ...(imagePaths.length ? { imagePaths } : {}),
3372
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
3373
3321
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
3374
3322
  ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
3375
3323
  ...payloadOpts,
@@ -3415,7 +3363,7 @@ export class AgentManager {
3415
3363
  return false;
3416
3364
  }
3417
3365
  }
3418
- if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3366
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
3419
3367
  return false;
3420
3368
  }
3421
3369
  const now = new Date().toISOString();
@@ -3524,9 +3472,7 @@ export class AgentManager {
3524
3472
  const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
3525
3473
  if (state.taskId
3526
3474
  && boundTask?.id === state.taskId
3527
- && boundTask.status === 'merged'
3528
- && boundTask.prNumber != null
3529
- && boundTask.branch
3475
+ && (boundTask.status === 'merged' || boundTask.status === 'done')
3530
3476
  && !state.creationToken
3531
3477
  && state.status !== 'awaiting_human') {
3532
3478
  try {
@@ -3544,11 +3490,15 @@ export class AgentManager {
3544
3490
  || recovered.status === 'awaiting_human') {
3545
3491
  continue;
3546
3492
  }
3547
- await this.dispatchPostMergeCleanup(state.id, {
3548
- taskId: boundTask.id,
3549
- branch: boundTask.branch,
3550
- });
3551
- continue;
3493
+ if (boundTask.status === 'merged' && boundTask.prNumber != null && boundTask.branch) {
3494
+ await this.dispatchPostMergeCleanup(state.id, {
3495
+ taskId: boundTask.id,
3496
+ branch: boundTask.branch,
3497
+ });
3498
+ continue;
3499
+ }
3500
+ if (this.startCompactionThenRelease(recovered.id, recovered, boundTask.id))
3501
+ continue;
3552
3502
  }
3553
3503
  catch (cleanupErr) {
3554
3504
  console.warn(`[recover] dispatchPostMergeCleanup(${state.id}, ${boundTask.id}) failed:`, cleanupErr);
@@ -3659,15 +3609,7 @@ export class AgentManager {
3659
3609
  observation: { phase: 'recovery-failed' },
3660
3610
  recommendation: 'Inspect or recreate the tmux session, then retry the affected task.',
3661
3611
  });
3662
- await this.safeEmit({
3663
- id: '',
3664
- type: 'human.intervention',
3665
- timestamp: new Date().toISOString(),
3666
- projectId: state.projectId,
3667
- agentId: state.id,
3668
- taskId: state.taskId ?? '',
3669
- data: { phase: 'recovery-failed', error: message },
3670
- });
3612
+ await this.emitIntervention(state.projectId, state.id, state.taskId, { phase: 'recovery-failed', error: message });
3671
3613
  }
3672
3614
  }
3673
3615
  for (const c of deferredCleanups) {
@@ -3847,19 +3789,12 @@ export class AgentManager {
3847
3789
  }
3848
3790
  if (publishedCleanup) {
3849
3791
  if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
3850
- await this.safeEmit({
3851
- id: '',
3852
- type: 'human.intervention',
3853
- timestamp: new Date().toISOString(),
3854
- projectId: result.projectId,
3855
- taskId,
3856
- data: {
3857
- phase: 'cancel-published-artifact-cleanup-skipped',
3858
- afterDone: publishedCleanup.afterDone,
3859
- branch: publishedCleanup.branch,
3860
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3861
- reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3862
- },
3792
+ await this.emitIntervention(result.projectId, undefined, taskId, {
3793
+ phase: 'cancel-published-artifact-cleanup-skipped',
3794
+ afterDone: publishedCleanup.afterDone,
3795
+ branch: publishedCleanup.branch,
3796
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3797
+ reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3863
3798
  });
3864
3799
  return result;
3865
3800
  }
@@ -3896,19 +3831,12 @@ export class AgentManager {
3896
3831
  }
3897
3832
  catch (err) {
3898
3833
  console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
3899
- await this.safeEmit({
3900
- id: '',
3901
- type: 'human.intervention',
3902
- timestamp: new Date().toISOString(),
3903
- projectId: result.projectId,
3904
- taskId,
3905
- data: {
3906
- phase: 'cancel-published-artifact-cleanup-failed',
3907
- afterDone: publishedCleanup.afterDone,
3908
- branch: publishedCleanup.branch,
3909
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3910
- error: err instanceof Error ? err.message : String(err),
3911
- },
3834
+ await this.emitIntervention(result.projectId, undefined, taskId, {
3835
+ phase: 'cancel-published-artifact-cleanup-failed',
3836
+ afterDone: publishedCleanup.afterDone,
3837
+ branch: publishedCleanup.branch,
3838
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3839
+ error: err instanceof Error ? err.message : String(err),
3912
3840
  });
3913
3841
  }
3914
3842
  }
@@ -4420,18 +4348,10 @@ export class AgentManager {
4420
4348
  const cur = await this.agentStore.get(agentId);
4421
4349
  if (!cur || cur.taskId !== expectedTaskId)
4422
4350
  return;
4423
- await this.safeEmit({
4424
- id: '',
4425
- type: 'human.intervention',
4426
- timestamp: new Date().toISOString(),
4427
- projectId: cur.projectId,
4428
- agentId,
4429
- taskId: expectedTaskId,
4430
- data: {
4431
- phase: 'manual-review-dev-parked-qa-failed',
4432
- note: 'Manual QA dispatch parked the dev agent into waiting; QA then failed to start. ' +
4433
- 'Dev binding is kept but no new prompt is running — re-dispatch the manual review or cancel the task.',
4434
- },
4351
+ await this.emitIntervention(cur.projectId, agentId, expectedTaskId, {
4352
+ phase: 'manual-review-dev-parked-qa-failed',
4353
+ note: 'Manual QA dispatch parked the dev agent into waiting; QA then failed to start. ' +
4354
+ 'Dev binding is kept but no new prompt is running — re-dispatch the manual review or cancel the task.',
4435
4355
  });
4436
4356
  }
4437
4357
  async retryTask(taskId) {
@@ -5008,30 +4928,14 @@ export class AgentManager {
5008
4928
  if (task.agentId) {
5009
4929
  const devOk = await this.markAgentWaiting(task.agentId, taskId).catch(() => false);
5010
4930
  if (!devOk) {
5011
- await this.safeEmit({
5012
- id: '',
5013
- type: 'human.intervention',
5014
- timestamp: new Date().toISOString(),
5015
- projectId: task.projectId,
5016
- agentId: task.agentId,
5017
- taskId,
5018
- data: { phase: 'spec-ready-dev-park-failed', devAgentId: task.agentId },
5019
- });
4931
+ await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'spec-ready-dev-park-failed', devAgentId: task.agentId });
5020
4932
  }
5021
4933
  }
5022
4934
  if (task.qaAgentId) {
5023
4935
  const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
5024
4936
  .catch(() => false);
5025
4937
  if (!released) {
5026
- await this.safeEmit({
5027
- id: '',
5028
- type: 'human.intervention',
5029
- timestamp: new Date().toISOString(),
5030
- projectId: task.projectId,
5031
- agentId: task.qaAgentId,
5032
- taskId,
5033
- data: { phase: 'spec-ready-qa-release-failed', qaAgentId: task.qaAgentId },
5034
- });
4938
+ await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'spec-ready-qa-release-failed', qaAgentId: task.qaAgentId });
5035
4939
  }
5036
4940
  }
5037
4941
  return transition.task;
@@ -5103,6 +5007,111 @@ export class AgentManager {
5103
5007
  throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
5104
5008
  return dispatched;
5105
5009
  }
5010
+ async submitCodeVerdict(taskId, comments) {
5011
+ const trimmed = comments?.trim();
5012
+ if (!trimmed) {
5013
+ throw new ApiError(400, 'comments is required for request-changes');
5014
+ }
5015
+ const task = await this.withTaskLock(async () => {
5016
+ const fresh = await this.taskStore.get(taskId);
5017
+ if (!fresh)
5018
+ throw new ApiError(404, `Task ${taskId} not found`);
5019
+ if (fresh.status !== 'ready') {
5020
+ throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
5021
+ }
5022
+ if (fresh.phase === 'spec') {
5023
+ throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
5024
+ }
5025
+ if (fresh.reviewMode !== 'server') {
5026
+ throw new ApiError(409, `Task ${taskId} is not in server review mode; request changes on the PR instead`);
5027
+ }
5028
+ if (!fresh.agentId) {
5029
+ throw new ApiError(400, `Task ${taskId} has no dev agent; cannot request changes`);
5030
+ }
5031
+ if (this.markCompleteInFlight.has(taskId)) {
5032
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
5033
+ }
5034
+ if (this.codeVerdictInFlight.has(taskId)) {
5035
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
5036
+ }
5037
+ this.codeVerdictInFlight.add(taskId);
5038
+ return fresh;
5039
+ });
5040
+ try {
5041
+ return await this.executeCodeVerdict(task, trimmed);
5042
+ }
5043
+ finally {
5044
+ this.codeVerdictInFlight.delete(taskId);
5045
+ }
5046
+ }
5047
+ async executeCodeVerdict(task, trimmed) {
5048
+ const taskId = task.id;
5049
+ const store = this.getReviewStore();
5050
+ if (!store)
5051
+ throw new ApiError(500, 'Review store unavailable');
5052
+ const round = Math.max(task.reviewRound, 1);
5053
+ const at = new Date().toISOString();
5054
+ // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
5055
+ const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
5056
+ if (round >= cap) {
5057
+ throw new ApiError(409, `Task ${taskId} has reached the code review round cap (${cap}); `
5058
+ + 'confirm completion, or cancel the task');
5059
+ }
5060
+ const roundData = await store.getRound(taskId, 'code', round);
5061
+ // 无 QA 项目经 autoApproveCode 到达 ready 时从未存轮;补底轮次保证 userDecision 留痕与 fix 闭环
5062
+ const base = roundData ?? await this.synthesizeEmptyCodeRound(task, round, at);
5063
+ // runCodeFixSubmission 按 Math.max(reviewRound,1) 读轮、recheck 按 reviewRound+1 存新轮;
5064
+ // reviewRound 停在 0 会让 recheck 覆写本轮,故落库为 1 对齐两侧
5065
+ if (task.reviewRound === 0) {
5066
+ await this.withTaskLock(async () => {
5067
+ const fresh = await this.taskStore.get(taskId);
5068
+ if (!fresh)
5069
+ return;
5070
+ fresh.reviewRound = 1;
5071
+ fresh.updatedAt = new Date().toISOString();
5072
+ await this.taskStore.set(fresh);
5073
+ });
5074
+ }
5075
+ const priorFindings = base.findings?.findings ?? [];
5076
+ const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
5077
+ const mergedFindings = {
5078
+ round,
5079
+ verdict: 'request-changes',
5080
+ findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
5081
+ };
5082
+ await store.putRound(taskId, 'code', {
5083
+ ...base,
5084
+ findings: mergedFindings,
5085
+ userDecision: { verdict: 'request-changes', comments: trimmed, at },
5086
+ });
5087
+ const dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
5088
+ if (!dispatched)
5089
+ throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
5090
+ return dispatched;
5091
+ }
5092
+ async synthesizeEmptyCodeRound(task, round, at) {
5093
+ const empty = { round, phase: 'code', content: '', startedAt: at };
5094
+ const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
5095
+ if (!dev || !task.agentId)
5096
+ return empty;
5097
+ // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
5098
+ try {
5099
+ await this.refreshWorktreeCacheFor(task.agentId);
5100
+ const content = await this.getReviewTransport().readContent(task, dev, 'code');
5101
+ return {
5102
+ round,
5103
+ phase: 'code',
5104
+ content: content.content,
5105
+ ...(content.diffstat ? { diffstat: content.diffstat } : {}),
5106
+ ...(content.baseSha ? { baseSha: content.baseSha } : {}),
5107
+ startedAt: at,
5108
+ };
5109
+ }
5110
+ catch (err) {
5111
+ console.warn(`[AgentManager] submitCodeVerdict: code content read failed for ${task.id}; using empty round:`, err);
5112
+ return empty;
5113
+ }
5114
+ }
5106
5115
  async transitionToCodePhase(taskId) {
5107
5116
  const task = await this.taskStore.get(taskId);
5108
5117
  if (!task)
@@ -5125,29 +5134,13 @@ export class AgentManager {
5125
5134
  const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
5126
5135
  .catch(() => false);
5127
5136
  if (!released) {
5128
- await this.safeEmit({
5129
- id: '',
5130
- type: 'human.intervention',
5131
- timestamp: new Date().toISOString(),
5132
- projectId: task.projectId,
5133
- agentId: task.qaAgentId,
5134
- taskId,
5135
- data: { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId },
5136
- });
5137
+ await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId });
5137
5138
  }
5138
5139
  }
5139
5140
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5140
5141
  if (!acquired) {
5141
5142
  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);
5142
- await this.safeEmit({
5143
- id: '',
5144
- type: 'human.intervention',
5145
- timestamp: new Date().toISOString(),
5146
- projectId: task.projectId,
5147
- agentId: devAgentId,
5148
- taskId,
5149
- data: { phase: 'code-dev-acquire-failed', devAgentId },
5150
- });
5143
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-dev-acquire-failed', devAgentId });
5151
5144
  return null;
5152
5145
  }
5153
5146
  let resumed = false;
@@ -5166,15 +5159,7 @@ export class AgentManager {
5166
5159
  }
5167
5160
  if (!resumed) {
5168
5161
  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);
5169
- await this.safeEmit({
5170
- id: '',
5171
- type: 'human.intervention',
5172
- timestamp: new Date().toISOString(),
5173
- projectId: task.projectId,
5174
- agentId: devAgentId,
5175
- taskId,
5176
- data: { phase: 'code-resume-failed', devAgentId },
5177
- });
5162
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-resume-failed', devAgentId });
5178
5163
  return null;
5179
5164
  }
5180
5165
  return await this.taskStore.get(taskId);
@@ -5203,15 +5188,7 @@ export class AgentManager {
5203
5188
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
5204
5189
  : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
5205
5190
  await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
5206
- await this.safeEmit({
5207
- id: '',
5208
- type: 'human.intervention',
5209
- timestamp: new Date().toISOString(),
5210
- projectId: task.projectId,
5211
- agentId: task.agentId,
5212
- taskId,
5213
- data: { phase: 'server-review-no-qa-partner', devAgentId: task.agentId },
5214
- });
5191
+ await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
5215
5192
  return null;
5216
5193
  }
5217
5194
  const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
@@ -5261,15 +5238,7 @@ export class AgentManager {
5261
5238
  const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
5262
5239
  if (!acquired) {
5263
5240
  await rearmEntrySignal();
5264
- await this.safeEmit({
5265
- id: '',
5266
- type: 'human.intervention',
5267
- timestamp: new Date().toISOString(),
5268
- projectId,
5269
- agentId: qaId,
5270
- taskId,
5271
- data: { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId },
5272
- });
5241
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId });
5273
5242
  return null;
5274
5243
  }
5275
5244
  if (devAgentId) {
@@ -5277,19 +5246,19 @@ export class AgentManager {
5277
5246
  if (!devOk) {
5278
5247
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5279
5248
  await rearmEntrySignal();
5280
- await this.safeEmit({
5281
- id: '',
5282
- type: 'human.intervention',
5283
- timestamp: new Date().toISOString(),
5284
- projectId,
5285
- agentId: devAgentId,
5286
- taskId,
5287
- data: { phase: 'server-review-dev-park-failed', devAgentId },
5288
- });
5249
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-review-dev-park-failed', devAgentId });
5289
5250
  return null;
5290
5251
  }
5291
5252
  }
5292
5253
  }
5254
+ else {
5255
+ const qaState = await this.agentStore.get(qaId);
5256
+ if (qaState?.taskId !== taskId) {
5257
+ await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
5258
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-continuation-qa-not-bound', qaAgentId: qaId });
5259
+ return null;
5260
+ }
5261
+ }
5293
5262
  const roundPatch = opts.phase === 'spec'
5294
5263
  ? { specReviewRound: newRound, phase: 'spec' }
5295
5264
  : { reviewRound: newRound };
@@ -5301,33 +5270,38 @@ export class AgentManager {
5301
5270
  ...(opts.batch
5302
5271
  ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5303
5272
  : { batchIndex: undefined, batchTotal: undefined }),
5273
+ // Fresh dispatch: clear any stale mode until startSession materializes the worktree,
5274
+ // so a crash in this window recovers conservatively (no read-file) rather than
5275
+ // re-arming it for what might be a head-mode review.
5276
+ ...(opts.continuation ? {} : { reviewWorktreeMode: undefined }),
5304
5277
  ...roundPatch,
5305
5278
  });
5306
5279
  if (!transition) {
5307
5280
  if (!opts.continuation) {
5308
5281
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5309
5282
  }
5310
- await this.safeEmit({
5311
- id: '',
5312
- type: 'human.intervention',
5313
- timestamp: new Date().toISOString(),
5314
- projectId,
5315
- agentId: qaId,
5316
- taskId,
5317
- data: { phase: 'server-review-transition-failed', qaAgentId: qaId },
5318
- });
5283
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
5319
5284
  return null;
5320
5285
  }
5286
+ let dispatchedWorktreeMode;
5321
5287
  const sessionOpts = {
5322
5288
  bypassTaskStatusGate: true,
5323
5289
  signalToken: newToken,
5324
5290
  serverContent: opts.content,
5291
+ ...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
5325
5292
  ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
5293
+ ...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
5294
+ ...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
5295
+ ...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
5326
5296
  ...(opts.batch ? { serverBatch: opts.batch } : {}),
5327
5297
  ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
5328
5298
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5329
5299
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5330
- armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); }),
5300
+ armBeforeInject: (ctx) => {
5301
+ dispatchedWorktreeMode = ctx.serverReviewWorktree;
5302
+ const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewWorktree === 'base';
5303
+ return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
5304
+ },
5331
5305
  };
5332
5306
  const rearmConsumedSignal = async () => {
5333
5307
  if (opts.continuation) {
@@ -5366,17 +5340,14 @@ export class AgentManager {
5366
5340
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5367
5341
  }
5368
5342
  await rearmConsumedSignal();
5369
- await this.safeEmit({
5370
- id: '',
5371
- type: 'human.intervention',
5372
- timestamp: new Date().toISOString(),
5373
- projectId,
5374
- agentId: qaId,
5375
- taskId,
5376
- data: { phase: 'server-review-start-failed', qaAgentId: qaId },
5377
- });
5343
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
5378
5344
  return null;
5379
5345
  }
5346
+ // Persist the materialized worktree mode so a restart can re-arm read-file for a base
5347
+ // fallback review (whose prompt depends on it) but not for a head-mode one.
5348
+ if (opts.phase !== 'spec' && !opts.continuation) {
5349
+ await this.updateTask(taskId, { reviewWorktreeMode: dispatchedWorktreeMode }).catch(() => { });
5350
+ }
5380
5351
  return await this.taskStore.get(taskId);
5381
5352
  }
5382
5353
  async dispatchServerFixToDev(taskId, findingsJson) {
@@ -5416,15 +5387,7 @@ export class AgentManager {
5416
5387
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
5417
5388
  if (!acquired) {
5418
5389
  await rearmReviewedSignal();
5419
- await this.safeEmit({
5420
- id: '',
5421
- type: 'human.intervention',
5422
- timestamp: new Date().toISOString(),
5423
- projectId,
5424
- agentId: devAgentId,
5425
- taskId,
5426
- data: { phase: 'server-fix-dev-acquire-failed', devAgentId },
5427
- });
5390
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-dev-acquire-failed', devAgentId });
5428
5391
  return null;
5429
5392
  }
5430
5393
  if (qaAgentId) {
@@ -5432,29 +5395,13 @@ export class AgentManager {
5432
5395
  .catch(() => false);
5433
5396
  if (!released) {
5434
5397
  await rearmReviewedSignal();
5435
- await this.safeEmit({
5436
- id: '',
5437
- type: 'human.intervention',
5438
- timestamp: new Date().toISOString(),
5439
- projectId,
5440
- agentId: qaAgentId,
5441
- taskId,
5442
- data: { phase: 'server-fix-qa-release-failed', qaAgentId },
5443
- });
5398
+ await this.emitIntervention(projectId, qaAgentId, taskId, { phase: 'server-fix-qa-release-failed', qaAgentId });
5444
5399
  return null;
5445
5400
  }
5446
5401
  }
5447
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
5402
+ const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
5448
5403
  if (!transition) {
5449
- await this.safeEmit({
5450
- id: '',
5451
- type: 'human.intervention',
5452
- timestamp: new Date().toISOString(),
5453
- projectId,
5454
- agentId: devAgentId,
5455
- taskId,
5456
- data: { phase: 'server-fix-transition-failed', devAgentId },
5457
- });
5404
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-transition-failed', devAgentId });
5458
5405
  return null;
5459
5406
  }
5460
5407
  let resumed = false;
@@ -5485,15 +5432,7 @@ export class AgentManager {
5485
5432
  await rollbackToEntry();
5486
5433
  await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5487
5434
  await rearmReviewedSignal();
5488
- await this.safeEmit({
5489
- id: '',
5490
- type: 'human.intervention',
5491
- timestamp: new Date().toISOString(),
5492
- projectId,
5493
- agentId: devAgentId,
5494
- taskId,
5495
- data: { phase: 'server-fix-resume-failed', devAgentId },
5496
- });
5435
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-resume-failed', devAgentId });
5497
5436
  return null;
5498
5437
  }
5499
5438
  await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
@@ -5510,6 +5449,33 @@ export class AgentManager {
5510
5449
  return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
5511
5450
  return `raw:${repo.trim()}`;
5512
5451
  }
5452
+ async computeCodeInterdiff(taskId, round) {
5453
+ const store = this.getReviewStore();
5454
+ if (!store || !Number.isInteger(round) || round < 2)
5455
+ return { ok: false, reason: 'no-anchor' };
5456
+ const [cur, prev] = await Promise.all([
5457
+ store.getRound(taskId, 'code', round),
5458
+ store.getRound(taskId, 'code', round - 1),
5459
+ ]);
5460
+ const curSha = cur?.headSha;
5461
+ const prevSha = prev?.headSha;
5462
+ if (!curSha || !prevSha)
5463
+ return { ok: false, reason: 'no-anchor' };
5464
+ const task = await this.taskStore.get(taskId);
5465
+ if (!task?.agentId)
5466
+ return { ok: false, reason: 'released' };
5467
+ const agent = this.getAgentConfig(task.agentId);
5468
+ const agentState = await this.agentStore.get(task.agentId);
5469
+ // A rebound agent's worktree belongs to its new task — its git state describes
5470
+ // another branch, so the interdiff would be meaningless. Same skip rule as
5471
+ // findLineageViolation: worktree must still be bound to THIS task.
5472
+ if (!agent || agentState?.taskId !== taskId || !agentState.worktreePath) {
5473
+ return { ok: false, reason: 'released' };
5474
+ }
5475
+ await this.refreshWorktreeCacheFor(task.agentId);
5476
+ const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
5477
+ return { ok: true, diff };
5478
+ }
5513
5479
  async findLineageViolation(taskId, baseSha) {
5514
5480
  const task = await this.taskStore.get(taskId);
5515
5481
  if (!task?.agentId)
@@ -5567,36 +5533,20 @@ export class AgentManager {
5567
5533
  violation = await this.findLineageViolation(taskId);
5568
5534
  }
5569
5535
  catch (err) {
5570
- await this.safeEmit({
5571
- id: '',
5572
- type: 'human.intervention',
5573
- timestamp: new Date().toISOString(),
5574
- projectId: task.projectId,
5575
- agentId: devAgentId,
5576
- taskId,
5577
- data: {
5578
- phase: 'server-after-done-lineage-check-failed',
5579
- error: err instanceof Error ? err.message : String(err),
5580
- note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
5581
- },
5536
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5537
+ phase: 'server-after-done-lineage-check-failed',
5538
+ error: err instanceof Error ? err.message : String(err),
5539
+ note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
5582
5540
  });
5583
5541
  return null;
5584
5542
  }
5585
5543
  if (violation) {
5586
- await this.safeEmit({
5587
- id: '',
5588
- type: 'human.intervention',
5589
- timestamp: new Date().toISOString(),
5590
- projectId: task.projectId,
5591
- agentId: devAgentId,
5592
- taskId,
5593
- data: {
5594
- phase: 'server-after-done-lineage-violation',
5595
- offendingTaskId: violation.taskId,
5596
- offendingBranch: violation.branch,
5597
- offendingSha: violation.sha,
5598
- note: 'The task branch embeds another active task\'s commits; publishing would leak them into this PR. Rebase the branch onto origin/HEAD, then mark-complete to retry the publish.',
5599
- },
5544
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5545
+ phase: 'server-after-done-lineage-violation',
5546
+ offendingTaskId: violation.taskId,
5547
+ offendingBranch: violation.branch,
5548
+ offendingSha: violation.sha,
5549
+ note: 'The task branch embeds another active task\'s commits; publishing would leak them into this PR. Rebase the branch onto origin/HEAD, then mark-complete to retry the publish.',
5600
5550
  });
5601
5551
  return null;
5602
5552
  }
@@ -5611,15 +5561,7 @@ export class AgentManager {
5611
5561
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
5612
5562
  if (!acquired) {
5613
5563
  await rollbackToken();
5614
- await this.safeEmit({
5615
- id: '',
5616
- type: 'human.intervention',
5617
- timestamp: new Date().toISOString(),
5618
- projectId: task.projectId,
5619
- agentId: devAgentId,
5620
- taskId,
5621
- data: { phase: 'server-after-done-dev-acquire-failed', devAgentId },
5622
- });
5564
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
5623
5565
  return null;
5624
5566
  }
5625
5567
  await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
@@ -5642,18 +5584,10 @@ export class AgentManager {
5642
5584
  }
5643
5585
  if (!resumed) {
5644
5586
  await rollbackToken();
5645
- await this.safeEmit({
5646
- id: '',
5647
- type: 'human.intervention',
5648
- timestamp: new Date().toISOString(),
5649
- projectId: task.projectId,
5650
- agentId: devAgentId,
5651
- taskId,
5652
- data: {
5653
- phase: 'server-after-done-resume-failed',
5654
- devAgentId,
5655
- note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
5656
- },
5587
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5588
+ phase: 'server-after-done-resume-failed',
5589
+ devAgentId,
5590
+ note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
5657
5591
  });
5658
5592
  return null;
5659
5593
  }
@@ -5792,6 +5726,9 @@ export class AgentManager {
5792
5726
  if (this.markCompleteInFlight.has(taskId)) {
5793
5727
  throw new ApiError(409, `Task ${taskId} is already being completed`);
5794
5728
  }
5729
+ if (this.codeVerdictInFlight.has(taskId)) {
5730
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
5731
+ }
5795
5732
  this.markCompleteInFlight.add(taskId);
5796
5733
  return fresh;
5797
5734
  });
@@ -5802,18 +5739,11 @@ export class AgentManager {
5802
5739
  }
5803
5740
  catch (err) {
5804
5741
  const message = err instanceof Error ? err.message : String(err);
5805
- await this.safeEmit({
5806
- id: '',
5807
- type: 'human.intervention',
5808
- timestamp: new Date().toISOString(),
5809
- projectId: task.projectId,
5810
- taskId: task.id,
5811
- data: {
5812
- phase: 'confirm-merge-failed',
5813
- gate: task.status,
5814
- error: message,
5815
- note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
5816
- },
5742
+ await this.emitIntervention(task.projectId, undefined, task.id, {
5743
+ phase: 'confirm-merge-failed',
5744
+ gate: task.status,
5745
+ error: message,
5746
+ note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
5817
5747
  });
5818
5748
  throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
5819
5749
  }
@@ -5836,12 +5766,26 @@ export class AgentManager {
5836
5766
  const state = await this.agentStore.get(id);
5837
5767
  if (!state || state.taskId !== taskId)
5838
5768
  continue;
5769
+ if (this.startCompactionThenRelease(id, state, taskId))
5770
+ continue;
5839
5771
  await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
5840
5772
  .catch(err => {
5841
5773
  console.warn(`[AgentManager] confirm release ${id} failed:`, err);
5842
5774
  });
5843
5775
  }
5844
5776
  }
5777
+ startCompactionThenRelease(agentId, state, taskId) {
5778
+ // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
5779
+ if (!state.paneId || state.status === 'awaiting_human' || state.creationToken)
5780
+ return false;
5781
+ const cfg = this.getAgentConfig(agentId);
5782
+ if (!cfg)
5783
+ return false;
5784
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5785
+ void this.runPostMergeCompaction(tmux, state.paneId, agentId, taskId, agentRuntimeKindFor(cfg))
5786
+ .catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
5787
+ return true;
5788
+ }
5845
5789
  repoMergeQueue = new Map();
5846
5790
  async ffMergeBranch(task) {
5847
5791
  const dev = this.getAgentConfig(task.agentId);