baxian 1.2.52 → 1.2.54

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 (143) hide show
  1. package/dist/agent/detect/manifests/opencode.json +44 -0
  2. package/dist/agent/detect/manifests/qodercli.json +49 -0
  3. package/dist/agent/manager.d.ts +34 -6
  4. package/dist/agent/manager.d.ts.map +1 -1
  5. package/dist/agent/manager.js +365 -381
  6. package/dist/agent/manager.js.map +1 -1
  7. package/dist/agent/net-exec.d.ts +0 -1
  8. package/dist/agent/net-exec.d.ts.map +1 -1
  9. package/dist/agent/net-exec.js +1 -1
  10. package/dist/agent/net-exec.js.map +1 -1
  11. package/dist/agent/pane-streamer-manager.js +1 -1
  12. package/dist/agent/pane-streamer-manager.js.map +1 -1
  13. package/dist/agent/pane-streamer.d.ts +2 -3
  14. package/dist/agent/pane-streamer.d.ts.map +1 -1
  15. package/dist/agent/pane-streamer.js +1 -1
  16. package/dist/agent/pane-streamer.js.map +1 -1
  17. package/dist/agent/phase-signal.d.ts +0 -1
  18. package/dist/agent/phase-signal.d.ts.map +1 -1
  19. package/dist/agent/phase-signal.js +0 -5
  20. package/dist/agent/phase-signal.js.map +1 -1
  21. package/dist/agent/preflight.d.ts.map +1 -1
  22. package/dist/agent/preflight.js +2 -0
  23. package/dist/agent/preflight.js.map +1 -1
  24. package/dist/agent/prompt.d.ts +8 -0
  25. package/dist/agent/prompt.d.ts.map +1 -1
  26. package/dist/agent/prompt.js +24 -3
  27. package/dist/agent/prompt.js.map +1 -1
  28. package/dist/agent/review-transport.d.ts +11 -2
  29. package/dist/agent/review-transport.d.ts.map +1 -1
  30. package/dist/agent/review-transport.js +34 -10
  31. package/dist/agent/review-transport.js.map +1 -1
  32. package/dist/agent/runner.d.ts +0 -3
  33. package/dist/agent/runner.d.ts.map +1 -1
  34. package/dist/agent/runner.js +1 -18
  35. package/dist/agent/runner.js.map +1 -1
  36. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  37. package/dist/agent/tmux-probe-poller.js +21 -23
  38. package/dist/agent/tmux-probe-poller.js.map +1 -1
  39. package/dist/agent/tmux.d.ts +1 -6
  40. package/dist/agent/tmux.d.ts.map +1 -1
  41. package/dist/agent/tmux.js +65 -11
  42. package/dist/agent/tmux.js.map +1 -1
  43. package/dist/agent/worktree.d.ts +16 -0
  44. package/dist/agent/worktree.d.ts.map +1 -1
  45. package/dist/agent/worktree.js +119 -1
  46. package/dist/agent/worktree.js.map +1 -1
  47. package/dist/api/probe.d.ts +0 -10
  48. package/dist/api/probe.d.ts.map +1 -1
  49. package/dist/api/probe.js +9 -1
  50. package/dist/api/probe.js.map +1 -1
  51. package/dist/api/tasks.d.ts.map +1 -1
  52. package/dist/api/tasks.js +27 -0
  53. package/dist/api/tasks.js.map +1 -1
  54. package/dist/config/validator.js +7 -1
  55. package/dist/config/validator.js.map +1 -1
  56. package/dist/event/handlers.d.ts +0 -2
  57. package/dist/event/handlers.d.ts.map +1 -1
  58. package/dist/event/handlers.js +18 -24
  59. package/dist/event/handlers.js.map +1 -1
  60. package/dist/event/log.d.ts.map +1 -1
  61. package/dist/event/log.js +3 -5
  62. package/dist/event/log.js.map +1 -1
  63. package/dist/event/server-handlers.d.ts.map +1 -1
  64. package/dist/event/server-handlers.js +121 -38
  65. package/dist/event/server-handlers.js.map +1 -1
  66. package/dist/github/mapper.d.ts +0 -40
  67. package/dist/github/mapper.d.ts.map +1 -1
  68. package/dist/github/mapper.js +0 -140
  69. package/dist/github/mapper.js.map +1 -1
  70. package/dist/shared/concurrency.d.ts +3 -0
  71. package/dist/shared/concurrency.d.ts.map +1 -0
  72. package/dist/shared/concurrency.js +16 -0
  73. package/dist/shared/concurrency.js.map +1 -0
  74. package/dist/shared/constants.d.ts +0 -10
  75. package/dist/shared/constants.d.ts.map +1 -1
  76. package/dist/shared/constants.js +3 -10
  77. package/dist/shared/constants.js.map +1 -1
  78. package/dist/shared/index.d.ts +1 -0
  79. package/dist/shared/index.d.ts.map +1 -1
  80. package/dist/shared/index.js +1 -0
  81. package/dist/shared/index.js.map +1 -1
  82. package/dist/shared/types.d.ts +29 -33
  83. package/dist/shared/types.d.ts.map +1 -1
  84. package/dist/shared/types.js.map +1 -1
  85. package/dist/skill/registry.d.ts +2 -5
  86. package/dist/skill/registry.d.ts.map +1 -1
  87. package/dist/skill/registry.js +2 -2
  88. package/dist/skill/registry.js.map +1 -1
  89. package/dist/skills/baxian-server-review/SKILL.md +6 -4
  90. package/dist/skills/baxian-signals/SKILL.md +3 -1
  91. package/dist/state/error-record-store.d.ts +2 -0
  92. package/dist/state/error-record-store.d.ts.map +1 -1
  93. package/dist/state/error-record-store.js +17 -1
  94. package/dist/state/error-record-store.js.map +1 -1
  95. package/dist/state/pet-store.js +3 -1
  96. package/dist/state/pet-store.js.map +1 -1
  97. package/dist/state/snapshot.d.ts.map +1 -1
  98. package/dist/state/snapshot.js +5 -3
  99. package/dist/state/snapshot.js.map +1 -1
  100. package/dist/state/task-store.d.ts.map +1 -1
  101. package/dist/state/task-store.js +23 -16
  102. package/dist/state/task-store.js.map +1 -1
  103. package/dist/web/assets/index-Cx-wa7zd.js +13 -0
  104. package/dist/web/assets/{index-B1JSXKdx.css → index-DfcbGriX.css} +1 -1
  105. package/dist/web/index.html +2 -2
  106. package/package.json +1 -1
  107. package/dist/agent/detect/index.d.ts +0 -5
  108. package/dist/agent/detect/index.d.ts.map +0 -1
  109. package/dist/agent/detect/index.js +0 -5
  110. package/dist/agent/detect/index.js.map +0 -1
  111. package/dist/agent/diff-split.d.ts +0 -10
  112. package/dist/agent/diff-split.d.ts.map +0 -1
  113. package/dist/agent/diff-split.js +0 -79
  114. package/dist/agent/diff-split.js.map +0 -1
  115. package/dist/agent/index.d.ts +0 -8
  116. package/dist/agent/index.d.ts.map +0 -1
  117. package/dist/agent/index.js +0 -8
  118. package/dist/agent/index.js.map +0 -1
  119. package/dist/config/index.d.ts +0 -5
  120. package/dist/config/index.d.ts.map +0 -1
  121. package/dist/config/index.js +0 -5
  122. package/dist/config/index.js.map +0 -1
  123. package/dist/event/index.d.ts +0 -4
  124. package/dist/event/index.d.ts.map +0 -1
  125. package/dist/event/index.js +0 -4
  126. package/dist/event/index.js.map +0 -1
  127. package/dist/github/index.d.ts +0 -4
  128. package/dist/github/index.d.ts.map +0 -1
  129. package/dist/github/index.js +0 -3
  130. package/dist/github/index.js.map +0 -1
  131. package/dist/skill/index.d.ts +0 -2
  132. package/dist/skill/index.d.ts.map +0 -1
  133. package/dist/skill/index.js +0 -2
  134. package/dist/skill/index.js.map +0 -1
  135. package/dist/state/index.d.ts +0 -6
  136. package/dist/state/index.d.ts.map +0 -1
  137. package/dist/state/index.js +0 -6
  138. package/dist/state/index.js.map +0 -1
  139. package/dist/terminal/index.d.ts +0 -3
  140. package/dist/terminal/index.d.ts.map +0 -1
  141. package/dist/terminal/index.js +0 -3
  142. package/dist/terminal/index.js.map +0 -1
  143. 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;
@@ -60,11 +54,22 @@ export function buildLaunchCommand(agent) {
60
54
  case 'codex':
61
55
  segments.push('codex --dangerously-bypass-approvals-and-sandbox');
62
56
  break;
57
+ case 'opencode':
58
+ // opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
59
+ // Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
60
+ // the .claude/skills scan — that closes the last collision source: a claude-code agent's
61
+ // .claude/skills/baxian-* in a shared base repo.
62
+ segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode --auto');
63
+ break;
64
+ case 'qodercli':
65
+ segments.push('qodercli --dangerously-skip-permissions');
66
+ break;
63
67
  }
64
68
  if (agent.model) {
65
69
  segments.push(`--model ${shellQuote(agent.model)}`);
66
70
  }
67
- if (agent.addDirs && agent.addDirs.length > 0) {
71
+ // opencode has no --add-dir; extra roots go through its permission model instead (validator rejects the pairing).
72
+ if (agent.addDirs && agent.addDirs.length > 0 && agent.runtime !== 'opencode') {
68
73
  for (const dir of agent.addDirs) {
69
74
  segments.push(`--add-dir ${shellQuote(dir)}`);
70
75
  }
@@ -74,6 +79,30 @@ export function buildLaunchCommand(agent) {
74
79
  function agentRuntimeKindFor(agent) {
75
80
  return agent.runtime;
76
81
  }
82
+ // restart-repl drives the REPL back to a shell before relaunch. Each runtime exits
83
+ // via its own slash command (a bare "exit" would be sent to the model as chat text).
84
+ const REPL_EXIT_COMMAND = {
85
+ 'claude-code': '/exit',
86
+ codex: '/quit',
87
+ opencode: '/exit',
88
+ qodercli: '/quit',
89
+ };
90
+ export function skillSubdirFor(runtime) {
91
+ switch (runtime) {
92
+ case 'codex':
93
+ case 'opencode':
94
+ // opencode has no way to disable its .agents/skills compatibility scan (only .claude via
95
+ // env), and when it shares a base repo with a codex agent it loads the codex copy of a
96
+ // same-named baxian-* skill (verified: the .agents copy wins over .opencode). So opencode
97
+ // materializes into .agents/skills too — both runtimes share one runtime-agnostic copy
98
+ // (serialized by the same skill-dir lock) instead of colliding on duplicate names.
99
+ return '.agents/skills';
100
+ case 'qodercli':
101
+ return '.qoder/skills';
102
+ default:
103
+ return '.claude/skills';
104
+ }
105
+ }
77
106
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
78
107
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
79
108
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
@@ -101,7 +130,7 @@ function cancelPhaseDowngrades(prev, next) {
101
130
  return cancelPhaseRank(next) < cancelPhaseRank(prev);
102
131
  }
103
132
  const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
104
- export function shouldReleaseHeldBinding(state, boundTask) {
133
+ function shouldReleaseHeldBinding(state, boundTask) {
105
134
  if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
106
135
  return false;
107
136
  const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
@@ -147,6 +176,7 @@ export class AgentManager {
147
176
  manualReviewInFlight = new Set();
148
177
  markCompleteInFlight = new Set();
149
178
  specVerdictInFlight = new Set();
179
+ codeVerdictInFlight = new Set();
150
180
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
151
181
  cancelCleanupInFlight = new Map();
152
182
  deletionInFlight = new Set();
@@ -240,6 +270,17 @@ export class AgentManager {
240
270
  console.warn(`[AgentManager] safeEmit ${event.type} failed (audit log loss; state machine unaffected):`, err);
241
271
  }
242
272
  }
273
+ async emitIntervention(projectId, agentId, taskId, data) {
274
+ await this.safeEmit({
275
+ id: '',
276
+ type: 'human.intervention',
277
+ timestamp: new Date().toISOString(),
278
+ projectId,
279
+ ...(agentId ? { agentId } : {}),
280
+ ...(taskId ? { taskId } : {}),
281
+ data,
282
+ });
283
+ }
243
284
  async recordError(input) {
244
285
  if (!this.errorRecordStore)
245
286
  return;
@@ -333,9 +374,14 @@ export class AgentManager {
333
374
  async provisionRepoSkills(runner, agent, workdir) {
334
375
  if (this.skillRegistry.names().length === 0)
335
376
  return;
336
- const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
377
+ const subdir = skillSubdirFor(agent.runtime);
337
378
  const destRoot = `${workdir}/${subdir}`;
338
379
  await this.excludeInjectedSkills(runner, workdir, subdir);
380
+ // opencode's skills are model-invoked, not slash commands; the dispatched /<skill> sigil
381
+ // reaches the model as message text and it loads the skill from .opencode/skills. We do NOT
382
+ // wrap skills as .opencode/commands: opencode expands $ARGUMENTS in a command template and
383
+ // would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
384
+ // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
339
385
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
340
386
  await this.ensureSkillDirSafe(runner, workdir, subdir);
341
387
  await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
@@ -344,7 +390,7 @@ export class AgentManager {
344
390
  skillDirChain = new Map();
345
391
  skillDirLockKey(agent, workdir) {
346
392
  const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
347
- const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
393
+ const subdir = skillSubdirFor(agent.runtime);
348
394
  const dir = workdir.replace(/\/+$/, '');
349
395
  return `${host}:${dir}:${subdir}`;
350
396
  }
@@ -591,14 +637,7 @@ export class AgentManager {
591
637
  });
592
638
  if (!wrote)
593
639
  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
- });
640
+ await this.emitIntervention(existing.projectId, agentId, undefined, { phase: 'greeting_failed', reason });
602
641
  }
603
642
  async regreetHeldAgent(agentId) {
604
643
  const agent = this.getAgentConfig(agentId);
@@ -696,17 +735,9 @@ export class AgentManager {
696
735
  });
697
736
  if (!wrote)
698
737
  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
- },
738
+ await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
739
+ phase: 'agent_dialog_pending',
740
+ 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
741
  });
711
742
  }
712
743
  async markBootstrapFailed(agentId, creationToken, errorMessage) {
@@ -917,16 +948,9 @@ export class AgentManager {
917
948
  });
918
949
  }
919
950
  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
- },
951
+ await this.emitIntervention(projectIdForEmit, agentId, undefined, {
952
+ phase: 'agent_dialog_resolved_runtime',
953
+ note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
930
954
  });
931
955
  }
932
956
  return;
@@ -990,18 +1014,9 @@ export class AgentManager {
990
1014
  const onMenu = detectRuntimeMenu(stripped);
991
1015
  if (onMenu && !pendingMenu) {
992
1016
  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
- },
1017
+ await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
1018
+ phase: 'agent_runtime_menu_pending',
1019
+ note: 'Agent paused on an interactive menu mid-task. Attach via web terminal and respond; baxian will auto-clear once the menu closes.',
1005
1020
  });
1006
1021
  }
1007
1022
  else if (!onMenu && pendingMenu) {
@@ -1349,17 +1364,9 @@ export class AgentManager {
1349
1364
  });
1350
1365
  if (!wrote)
1351
1366
  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
- },
1367
+ await this.emitIntervention(projectId, agentId, taskId, {
1368
+ phase,
1369
+ reason,
1363
1370
  });
1364
1371
  }
1365
1372
  async markAwaitingIfAckUnknown(agentId, err, expectedTaskId) {
@@ -1480,18 +1487,10 @@ export class AgentManager {
1480
1487
  if (shouldReleaseBinding) {
1481
1488
  await this.lockManager.release(agentId);
1482
1489
  }
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
- },
1490
+ await this.emitIntervention(state.projectId, agentId, state.taskId, {
1491
+ phase: 'resumed',
1492
+ previousPhase: state.awaitingPhase,
1493
+ releasedBinding: shouldReleaseBinding,
1495
1494
  });
1496
1495
  return { resumed: true, releasedBinding: shouldReleaseBinding };
1497
1496
  });
@@ -1686,19 +1685,11 @@ export class AgentManager {
1686
1685
  catch (releaseErr) {
1687
1686
  console.warn(`[AgentManager] failTaskForDispatchError: releaseAgentForTask(${agentId}) failed:`, releaseErr);
1688
1687
  }
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
- },
1688
+ await this.emitIntervention(transitioned?.task.projectId ?? '', agentId, taskId, {
1689
+ phase: `dispatch-failed:${err.reason}`,
1690
+ reason: err.reason,
1691
+ message: err.message,
1692
+ replDrained: err.replDrained,
1702
1693
  });
1703
1694
  }
1704
1695
  async failTasksForAgent(agentId, reason, opts = {}) {
@@ -1787,10 +1778,10 @@ export class AgentManager {
1787
1778
  const paneId = await tmux.getSinglePaneId(agentId);
1788
1779
  await tmux.sendKeysToPane(paneId, 'C-c');
1789
1780
  const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
1790
- const RUNTIME = /^(?:claude|codex|node|\d+\.\d+\.\d+)$/;
1781
+ const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
1791
1782
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
1792
1783
  if (RUNTIME.test(cmd)) {
1793
- await tmux.sendKeysToPane(paneId, 'exit', 'Enter');
1784
+ await tmux.sendKeysToPane(paneId, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
1794
1785
  await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000, expectShell: true });
1795
1786
  }
1796
1787
  else if (!SHELL.test(cmd)) {
@@ -2081,6 +2072,10 @@ export class AgentManager {
2081
2072
  const scanSnapshotOnRecover = isServerProtocol
2082
2073
  || (task.phase === undefined && task.status === 'in_progress')
2083
2074
  || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
2075
+ const allowRecoveredReadFile = task.status === 'review'
2076
+ && (task.phase === 'spec'
2077
+ || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2078
+ || (task.reviewMode === 'server' && task.reviewWorktreeMode === 'base'));
2084
2079
  try {
2085
2080
  await this.startPhaseSignalWatch({
2086
2081
  taskId: task.id,
@@ -2090,23 +2085,15 @@ export class AgentManager {
2090
2085
  token: task.signalToken,
2091
2086
  skipSnapshot: !scanSnapshotOnRecover,
2092
2087
  recovered: true,
2093
- ...(isServerProtocol && task.status === 'review'
2088
+ ...(allowRecoveredReadFile
2094
2089
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
2095
2090
  : {}),
2096
2091
  });
2097
2092
  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
- },
2093
+ await this.emitIntervention(task.projectId, agentId, task.id, {
2094
+ phase: 'spec-signal-setup-during-recovery',
2095
+ kind: interventionKindLabel,
2096
+ 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
2097
  });
2111
2098
  }
2112
2099
  }
@@ -2318,18 +2305,10 @@ export class AgentManager {
2318
2305
  rolledBack = { projectId: task.projectId };
2319
2306
  });
2320
2307
  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
- },
2308
+ await this.emitIntervention(rolledBack.projectId, agentId, taskId, {
2309
+ phase: reason.phase,
2310
+ message: reason.message,
2311
+ note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2333
2312
  });
2334
2313
  }
2335
2314
  const existing = await this.agentStore.get(agentId);
@@ -2944,14 +2923,23 @@ export class AgentManager {
2944
2923
  const worktree = new WorktreeManager(runner);
2945
2924
  const tmux = new TmuxManager(runner);
2946
2925
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2926
+ const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
2947
2927
  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
2928
+ // Server code reviews materialize the captured head; other QA modes stay off origin/HEAD.
2929
+ // The fail-fast base resolution only runs where the default base is used.
2930
+ const reviewWorktree = isServerCodeReviewPhase && opts.serverContent !== undefined
2931
+ ? await worktree.createDetachedForReviewHead(workdir, taskId, {
2932
+ patch: opts.serverContent,
2933
+ baseSha: opts.serverBaseSha,
2934
+ headSha: opts.serverHeadSha,
2935
+ headTree: opts.serverHeadTree,
2936
+ })
2937
+ : undefined;
2938
+ const worktreePath = reviewWorktree?.path ?? (isServerQaPhase
2951
2939
  ? await worktree.createDetachedAtBase(workdir, taskId)
2952
2940
  : phase === 'review' || phase === 'recheck'
2953
2941
  ? await worktree.createDetached(workdir, taskId, task.branch)
2954
- : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch);
2942
+ : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch));
2955
2943
  await this.agentStore.update(agentId, (stateNow) => {
2956
2944
  if (!stateNow || stateNow.taskId !== taskId)
2957
2945
  return AGENT_STORE_NOOP;
@@ -2972,7 +2960,7 @@ export class AgentManager {
2972
2960
  try {
2973
2961
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2974
2962
  let payloadOpts = {};
2975
- if (opts.serverContent !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2963
+ if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2976
2964
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
2977
2965
  phase,
2978
2966
  ...(task.phase ? { taskPhase: task.phase } : {}),
@@ -2980,6 +2968,9 @@ export class AgentManager {
2980
2968
  reviewRound: task.reviewRound,
2981
2969
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
2982
2970
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2971
+ ...(reviewWorktree?.mode === 'head' ? { forceContentFile: true } : {}),
2972
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2973
+ ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
2983
2974
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
2984
2975
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
2985
2976
  });
@@ -2994,7 +2985,11 @@ export class AgentManager {
2994
2985
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2995
2986
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2996
2987
  ...(imagePaths.length ? { imagePaths } : {}),
2997
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2988
+ ...(reviewWorktree ? { serverReviewWorktree: reviewWorktree.mode } : {}),
2989
+ ...(reviewWorktree?.fallbackReason ? { serverReviewFallbackReason: reviewWorktree.fallbackReason } : {}),
2990
+ ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
2991
+ ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
2992
+ ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
2998
2993
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
2999
2994
  ...payloadOpts,
3000
2995
  });
@@ -3060,7 +3055,7 @@ export class AgentManager {
3060
3055
  catch { }
3061
3056
  return false;
3062
3057
  }
3063
- if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3058
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewWorktree: reviewWorktree?.mode }))) {
3064
3059
  try {
3065
3060
  await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3066
3061
  }
@@ -3244,21 +3239,13 @@ export class AgentManager {
3244
3239
  }
3245
3240
  console.warn(`[AgentManager] dispatch ack timeout for pane ${paneId} agent ${agentId}: ${message}`);
3246
3241
  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
- },
3242
+ await this.emitIntervention(state?.projectId ?? '', agentId, state?.taskId, {
3243
+ phase: 'dispatch-ack-timeout',
3244
+ paneId,
3245
+ message,
3246
+ note: 'REPL did not acknowledge the pasted prompt within timeout. ' +
3247
+ 'baxian intentionally did NOT send C-c — the input may still be queued. ' +
3248
+ 'Attach via web terminal to verify and resolve.',
3262
3249
  });
3263
3250
  const composerDelivered = !/pane already busy at baseline/.test(message);
3264
3251
  return { acked: false, composerDelivered };
@@ -3345,7 +3332,7 @@ export class AgentManager {
3345
3332
  && !ensure.freshRuntime;
3346
3333
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3347
3334
  let payloadOpts = {};
3348
- if (opts.serverContent !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3335
+ if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3349
3336
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3350
3337
  phase,
3351
3338
  ...(task.phase ? { taskPhase: task.phase } : {}),
@@ -3353,6 +3340,8 @@ export class AgentManager {
3353
3340
  reviewRound: task.reviewRound,
3354
3341
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
3355
3342
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
3343
+ ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
3344
+ ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
3356
3345
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
3357
3346
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
3358
3347
  });
@@ -3369,7 +3358,6 @@ export class AgentManager {
3369
3358
  : {}),
3370
3359
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
3371
3360
  ...(imagePaths.length ? { imagePaths } : {}),
3372
- ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
3373
3361
  ...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
3374
3362
  ...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
3375
3363
  ...payloadOpts,
@@ -3415,7 +3403,7 @@ export class AgentManager {
3415
3403
  return false;
3416
3404
  }
3417
3405
  }
3418
- if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
3406
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
3419
3407
  return false;
3420
3408
  }
3421
3409
  const now = new Date().toISOString();
@@ -3524,9 +3512,7 @@ export class AgentManager {
3524
3512
  const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
3525
3513
  if (state.taskId
3526
3514
  && boundTask?.id === state.taskId
3527
- && boundTask.status === 'merged'
3528
- && boundTask.prNumber != null
3529
- && boundTask.branch
3515
+ && (boundTask.status === 'merged' || boundTask.status === 'done')
3530
3516
  && !state.creationToken
3531
3517
  && state.status !== 'awaiting_human') {
3532
3518
  try {
@@ -3544,11 +3530,15 @@ export class AgentManager {
3544
3530
  || recovered.status === 'awaiting_human') {
3545
3531
  continue;
3546
3532
  }
3547
- await this.dispatchPostMergeCleanup(state.id, {
3548
- taskId: boundTask.id,
3549
- branch: boundTask.branch,
3550
- });
3551
- continue;
3533
+ if (boundTask.status === 'merged' && boundTask.prNumber != null && boundTask.branch) {
3534
+ await this.dispatchPostMergeCleanup(state.id, {
3535
+ taskId: boundTask.id,
3536
+ branch: boundTask.branch,
3537
+ });
3538
+ continue;
3539
+ }
3540
+ if (this.startCompactionThenRelease(recovered.id, recovered, boundTask.id))
3541
+ continue;
3552
3542
  }
3553
3543
  catch (cleanupErr) {
3554
3544
  console.warn(`[recover] dispatchPostMergeCleanup(${state.id}, ${boundTask.id}) failed:`, cleanupErr);
@@ -3659,15 +3649,7 @@ export class AgentManager {
3659
3649
  observation: { phase: 'recovery-failed' },
3660
3650
  recommendation: 'Inspect or recreate the tmux session, then retry the affected task.',
3661
3651
  });
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
- });
3652
+ await this.emitIntervention(state.projectId, state.id, state.taskId, { phase: 'recovery-failed', error: message });
3671
3653
  }
3672
3654
  }
3673
3655
  for (const c of deferredCleanups) {
@@ -3847,19 +3829,12 @@ export class AgentManager {
3847
3829
  }
3848
3830
  if (publishedCleanup) {
3849
3831
  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
- },
3832
+ await this.emitIntervention(result.projectId, undefined, taskId, {
3833
+ phase: 'cancel-published-artifact-cleanup-skipped',
3834
+ afterDone: publishedCleanup.afterDone,
3835
+ branch: publishedCleanup.branch,
3836
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3837
+ reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3863
3838
  });
3864
3839
  return result;
3865
3840
  }
@@ -3896,19 +3871,12 @@ export class AgentManager {
3896
3871
  }
3897
3872
  catch (err) {
3898
3873
  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
- },
3874
+ await this.emitIntervention(result.projectId, undefined, taskId, {
3875
+ phase: 'cancel-published-artifact-cleanup-failed',
3876
+ afterDone: publishedCleanup.afterDone,
3877
+ branch: publishedCleanup.branch,
3878
+ ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
3879
+ error: err instanceof Error ? err.message : String(err),
3912
3880
  });
3913
3881
  }
3914
3882
  }
@@ -4420,18 +4388,10 @@ export class AgentManager {
4420
4388
  const cur = await this.agentStore.get(agentId);
4421
4389
  if (!cur || cur.taskId !== expectedTaskId)
4422
4390
  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
- },
4391
+ await this.emitIntervention(cur.projectId, agentId, expectedTaskId, {
4392
+ phase: 'manual-review-dev-parked-qa-failed',
4393
+ note: 'Manual QA dispatch parked the dev agent into waiting; QA then failed to start. ' +
4394
+ 'Dev binding is kept but no new prompt is running — re-dispatch the manual review or cancel the task.',
4435
4395
  });
4436
4396
  }
4437
4397
  async retryTask(taskId) {
@@ -5008,30 +4968,14 @@ export class AgentManager {
5008
4968
  if (task.agentId) {
5009
4969
  const devOk = await this.markAgentWaiting(task.agentId, taskId).catch(() => false);
5010
4970
  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
- });
4971
+ await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'spec-ready-dev-park-failed', devAgentId: task.agentId });
5020
4972
  }
5021
4973
  }
5022
4974
  if (task.qaAgentId) {
5023
4975
  const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
5024
4976
  .catch(() => false);
5025
4977
  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
- });
4978
+ await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'spec-ready-qa-release-failed', qaAgentId: task.qaAgentId });
5035
4979
  }
5036
4980
  }
5037
4981
  return transition.task;
@@ -5103,6 +5047,111 @@ export class AgentManager {
5103
5047
  throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
5104
5048
  return dispatched;
5105
5049
  }
5050
+ async submitCodeVerdict(taskId, comments) {
5051
+ const trimmed = comments?.trim();
5052
+ if (!trimmed) {
5053
+ throw new ApiError(400, 'comments is required for request-changes');
5054
+ }
5055
+ const task = await this.withTaskLock(async () => {
5056
+ const fresh = await this.taskStore.get(taskId);
5057
+ if (!fresh)
5058
+ throw new ApiError(404, `Task ${taskId} not found`);
5059
+ if (fresh.status !== 'ready') {
5060
+ throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
5061
+ }
5062
+ if (fresh.phase === 'spec') {
5063
+ throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
5064
+ }
5065
+ if (fresh.reviewMode !== 'server') {
5066
+ throw new ApiError(409, `Task ${taskId} is not in server review mode; request changes on the PR instead`);
5067
+ }
5068
+ if (!fresh.agentId) {
5069
+ throw new ApiError(400, `Task ${taskId} has no dev agent; cannot request changes`);
5070
+ }
5071
+ if (this.markCompleteInFlight.has(taskId)) {
5072
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
5073
+ }
5074
+ if (this.codeVerdictInFlight.has(taskId)) {
5075
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
5076
+ }
5077
+ this.codeVerdictInFlight.add(taskId);
5078
+ return fresh;
5079
+ });
5080
+ try {
5081
+ return await this.executeCodeVerdict(task, trimmed);
5082
+ }
5083
+ finally {
5084
+ this.codeVerdictInFlight.delete(taskId);
5085
+ }
5086
+ }
5087
+ async executeCodeVerdict(task, trimmed) {
5088
+ const taskId = task.id;
5089
+ const store = this.getReviewStore();
5090
+ if (!store)
5091
+ throw new ApiError(500, 'Review store unavailable');
5092
+ const round = Math.max(task.reviewRound, 1);
5093
+ const at = new Date().toISOString();
5094
+ // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
5095
+ const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
5096
+ if (round >= cap) {
5097
+ throw new ApiError(409, `Task ${taskId} has reached the code review round cap (${cap}); `
5098
+ + 'confirm completion, or cancel the task');
5099
+ }
5100
+ const roundData = await store.getRound(taskId, 'code', round);
5101
+ // 无 QA 项目经 autoApproveCode 到达 ready 时从未存轮;补底轮次保证 userDecision 留痕与 fix 闭环
5102
+ const base = roundData ?? await this.synthesizeEmptyCodeRound(task, round, at);
5103
+ // runCodeFixSubmission 按 Math.max(reviewRound,1) 读轮、recheck 按 reviewRound+1 存新轮;
5104
+ // reviewRound 停在 0 会让 recheck 覆写本轮,故落库为 1 对齐两侧
5105
+ if (task.reviewRound === 0) {
5106
+ await this.withTaskLock(async () => {
5107
+ const fresh = await this.taskStore.get(taskId);
5108
+ if (!fresh)
5109
+ return;
5110
+ fresh.reviewRound = 1;
5111
+ fresh.updatedAt = new Date().toISOString();
5112
+ await this.taskStore.set(fresh);
5113
+ });
5114
+ }
5115
+ const priorFindings = base.findings?.findings ?? [];
5116
+ const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
5117
+ const mergedFindings = {
5118
+ round,
5119
+ verdict: 'request-changes',
5120
+ findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
5121
+ };
5122
+ await store.putRound(taskId, 'code', {
5123
+ ...base,
5124
+ findings: mergedFindings,
5125
+ userDecision: { verdict: 'request-changes', comments: trimmed, at },
5126
+ });
5127
+ const dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
5128
+ if (!dispatched)
5129
+ throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
5130
+ return dispatched;
5131
+ }
5132
+ async synthesizeEmptyCodeRound(task, round, at) {
5133
+ const empty = { round, phase: 'code', content: '', startedAt: at };
5134
+ const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
5135
+ if (!dev || !task.agentId)
5136
+ return empty;
5137
+ // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
5138
+ try {
5139
+ await this.refreshWorktreeCacheFor(task.agentId);
5140
+ const content = await this.getReviewTransport().readContent(task, dev, 'code');
5141
+ return {
5142
+ round,
5143
+ phase: 'code',
5144
+ content: content.content,
5145
+ ...(content.diffstat ? { diffstat: content.diffstat } : {}),
5146
+ ...(content.baseSha ? { baseSha: content.baseSha } : {}),
5147
+ startedAt: at,
5148
+ };
5149
+ }
5150
+ catch (err) {
5151
+ console.warn(`[AgentManager] submitCodeVerdict: code content read failed for ${task.id}; using empty round:`, err);
5152
+ return empty;
5153
+ }
5154
+ }
5106
5155
  async transitionToCodePhase(taskId) {
5107
5156
  const task = await this.taskStore.get(taskId);
5108
5157
  if (!task)
@@ -5125,29 +5174,13 @@ export class AgentManager {
5125
5174
  const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
5126
5175
  .catch(() => false);
5127
5176
  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
- });
5177
+ await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId });
5137
5178
  }
5138
5179
  }
5139
5180
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5140
5181
  if (!acquired) {
5141
5182
  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
- });
5183
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-dev-acquire-failed', devAgentId });
5151
5184
  return null;
5152
5185
  }
5153
5186
  let resumed = false;
@@ -5166,15 +5199,7 @@ export class AgentManager {
5166
5199
  }
5167
5200
  if (!resumed) {
5168
5201
  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
- });
5202
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-resume-failed', devAgentId });
5178
5203
  return null;
5179
5204
  }
5180
5205
  return await this.taskStore.get(taskId);
@@ -5203,15 +5228,7 @@ export class AgentManager {
5203
5228
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
5204
5229
  : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
5205
5230
  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
- });
5231
+ await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
5215
5232
  return null;
5216
5233
  }
5217
5234
  const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
@@ -5261,15 +5278,7 @@ export class AgentManager {
5261
5278
  const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
5262
5279
  if (!acquired) {
5263
5280
  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
- });
5281
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId });
5273
5282
  return null;
5274
5283
  }
5275
5284
  if (devAgentId) {
@@ -5277,19 +5286,19 @@ export class AgentManager {
5277
5286
  if (!devOk) {
5278
5287
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5279
5288
  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
- });
5289
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-review-dev-park-failed', devAgentId });
5289
5290
  return null;
5290
5291
  }
5291
5292
  }
5292
5293
  }
5294
+ else {
5295
+ const qaState = await this.agentStore.get(qaId);
5296
+ if (qaState?.taskId !== taskId) {
5297
+ await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
5298
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-continuation-qa-not-bound', qaAgentId: qaId });
5299
+ return null;
5300
+ }
5301
+ }
5293
5302
  const roundPatch = opts.phase === 'spec'
5294
5303
  ? { specReviewRound: newRound, phase: 'spec' }
5295
5304
  : { reviewRound: newRound };
@@ -5301,33 +5310,38 @@ export class AgentManager {
5301
5310
  ...(opts.batch
5302
5311
  ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5303
5312
  : { batchIndex: undefined, batchTotal: undefined }),
5313
+ // Fresh dispatch: clear any stale mode until startSession materializes the worktree,
5314
+ // so a crash in this window recovers conservatively (no read-file) rather than
5315
+ // re-arming it for what might be a head-mode review.
5316
+ ...(opts.continuation ? {} : { reviewWorktreeMode: undefined }),
5304
5317
  ...roundPatch,
5305
5318
  });
5306
5319
  if (!transition) {
5307
5320
  if (!opts.continuation) {
5308
5321
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5309
5322
  }
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
- });
5323
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
5319
5324
  return null;
5320
5325
  }
5326
+ let dispatchedWorktreeMode;
5321
5327
  const sessionOpts = {
5322
5328
  bypassTaskStatusGate: true,
5323
5329
  signalToken: newToken,
5324
5330
  serverContent: opts.content,
5331
+ ...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
5325
5332
  ...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
5333
+ ...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
5334
+ ...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
5335
+ ...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
5326
5336
  ...(opts.batch ? { serverBatch: opts.batch } : {}),
5327
5337
  ...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
5328
5338
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5329
5339
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5330
- armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); }),
5340
+ armBeforeInject: (ctx) => {
5341
+ dispatchedWorktreeMode = ctx.serverReviewWorktree;
5342
+ const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewWorktree === 'base';
5343
+ return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
5344
+ },
5331
5345
  };
5332
5346
  const rearmConsumedSignal = async () => {
5333
5347
  if (opts.continuation) {
@@ -5366,17 +5380,14 @@ export class AgentManager {
5366
5380
  await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
5367
5381
  }
5368
5382
  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
- });
5383
+ await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
5378
5384
  return null;
5379
5385
  }
5386
+ // Persist the materialized worktree mode so a restart can re-arm read-file for a base
5387
+ // fallback review (whose prompt depends on it) but not for a head-mode one.
5388
+ if (opts.phase !== 'spec' && !opts.continuation) {
5389
+ await this.updateTask(taskId, { reviewWorktreeMode: dispatchedWorktreeMode }).catch(() => { });
5390
+ }
5380
5391
  return await this.taskStore.get(taskId);
5381
5392
  }
5382
5393
  async dispatchServerFixToDev(taskId, findingsJson) {
@@ -5416,15 +5427,7 @@ export class AgentManager {
5416
5427
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
5417
5428
  if (!acquired) {
5418
5429
  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
- });
5430
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-dev-acquire-failed', devAgentId });
5428
5431
  return null;
5429
5432
  }
5430
5433
  if (qaAgentId) {
@@ -5432,29 +5435,13 @@ export class AgentManager {
5432
5435
  .catch(() => false);
5433
5436
  if (!released) {
5434
5437
  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
- });
5438
+ await this.emitIntervention(projectId, qaAgentId, taskId, { phase: 'server-fix-qa-release-failed', qaAgentId });
5444
5439
  return null;
5445
5440
  }
5446
5441
  }
5447
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
5442
+ const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
5448
5443
  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
- });
5444
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-transition-failed', devAgentId });
5458
5445
  return null;
5459
5446
  }
5460
5447
  let resumed = false;
@@ -5485,15 +5472,7 @@ export class AgentManager {
5485
5472
  await rollbackToEntry();
5486
5473
  await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5487
5474
  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
- });
5475
+ await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-resume-failed', devAgentId });
5497
5476
  return null;
5498
5477
  }
5499
5478
  await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
@@ -5510,6 +5489,33 @@ export class AgentManager {
5510
5489
  return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
5511
5490
  return `raw:${repo.trim()}`;
5512
5491
  }
5492
+ async computeCodeInterdiff(taskId, round) {
5493
+ const store = this.getReviewStore();
5494
+ if (!store || !Number.isInteger(round) || round < 2)
5495
+ return { ok: false, reason: 'no-anchor' };
5496
+ const [cur, prev] = await Promise.all([
5497
+ store.getRound(taskId, 'code', round),
5498
+ store.getRound(taskId, 'code', round - 1),
5499
+ ]);
5500
+ const curSha = cur?.headSha;
5501
+ const prevSha = prev?.headSha;
5502
+ if (!curSha || !prevSha)
5503
+ return { ok: false, reason: 'no-anchor' };
5504
+ const task = await this.taskStore.get(taskId);
5505
+ if (!task?.agentId)
5506
+ return { ok: false, reason: 'released' };
5507
+ const agent = this.getAgentConfig(task.agentId);
5508
+ const agentState = await this.agentStore.get(task.agentId);
5509
+ // A rebound agent's worktree belongs to its new task — its git state describes
5510
+ // another branch, so the interdiff would be meaningless. Same skip rule as
5511
+ // findLineageViolation: worktree must still be bound to THIS task.
5512
+ if (!agent || agentState?.taskId !== taskId || !agentState.worktreePath) {
5513
+ return { ok: false, reason: 'released' };
5514
+ }
5515
+ await this.refreshWorktreeCacheFor(task.agentId);
5516
+ const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
5517
+ return { ok: true, diff };
5518
+ }
5513
5519
  async findLineageViolation(taskId, baseSha) {
5514
5520
  const task = await this.taskStore.get(taskId);
5515
5521
  if (!task?.agentId)
@@ -5567,36 +5573,20 @@ export class AgentManager {
5567
5573
  violation = await this.findLineageViolation(taskId);
5568
5574
  }
5569
5575
  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
- },
5576
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5577
+ phase: 'server-after-done-lineage-check-failed',
5578
+ error: err instanceof Error ? err.message : String(err),
5579
+ note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
5582
5580
  });
5583
5581
  return null;
5584
5582
  }
5585
5583
  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
- },
5584
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5585
+ phase: 'server-after-done-lineage-violation',
5586
+ offendingTaskId: violation.taskId,
5587
+ offendingBranch: violation.branch,
5588
+ offendingSha: violation.sha,
5589
+ 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
5590
  });
5601
5591
  return null;
5602
5592
  }
@@ -5611,15 +5601,7 @@ export class AgentManager {
5611
5601
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
5612
5602
  if (!acquired) {
5613
5603
  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
- });
5604
+ await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
5623
5605
  return null;
5624
5606
  }
5625
5607
  await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
@@ -5642,18 +5624,10 @@ export class AgentManager {
5642
5624
  }
5643
5625
  if (!resumed) {
5644
5626
  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
- },
5627
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
5628
+ phase: 'server-after-done-resume-failed',
5629
+ devAgentId,
5630
+ note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
5657
5631
  });
5658
5632
  return null;
5659
5633
  }
@@ -5792,6 +5766,9 @@ export class AgentManager {
5792
5766
  if (this.markCompleteInFlight.has(taskId)) {
5793
5767
  throw new ApiError(409, `Task ${taskId} is already being completed`);
5794
5768
  }
5769
+ if (this.codeVerdictInFlight.has(taskId)) {
5770
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
5771
+ }
5795
5772
  this.markCompleteInFlight.add(taskId);
5796
5773
  return fresh;
5797
5774
  });
@@ -5802,18 +5779,11 @@ export class AgentManager {
5802
5779
  }
5803
5780
  catch (err) {
5804
5781
  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
- },
5782
+ await this.emitIntervention(task.projectId, undefined, task.id, {
5783
+ phase: 'confirm-merge-failed',
5784
+ gate: task.status,
5785
+ error: message,
5786
+ note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
5817
5787
  });
5818
5788
  throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
5819
5789
  }
@@ -5836,12 +5806,26 @@ export class AgentManager {
5836
5806
  const state = await this.agentStore.get(id);
5837
5807
  if (!state || state.taskId !== taskId)
5838
5808
  continue;
5809
+ if (this.startCompactionThenRelease(id, state, taskId))
5810
+ continue;
5839
5811
  await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
5840
5812
  .catch(err => {
5841
5813
  console.warn(`[AgentManager] confirm release ${id} failed:`, err);
5842
5814
  });
5843
5815
  }
5844
5816
  }
5817
+ startCompactionThenRelease(agentId, state, taskId) {
5818
+ // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
5819
+ if (!state.paneId || state.status === 'awaiting_human' || state.creationToken)
5820
+ return false;
5821
+ const cfg = this.getAgentConfig(agentId);
5822
+ if (!cfg)
5823
+ return false;
5824
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5825
+ void this.runPostMergeCompaction(tmux, state.paneId, agentId, taskId, agentRuntimeKindFor(cfg))
5826
+ .catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
5827
+ return true;
5828
+ }
5845
5829
  repoMergeQueue = new Map();
5846
5830
  async ffMergeBranch(task) {
5847
5831
  const dev = this.getAgentConfig(task.agentId);