baxian 1.1.0 → 1.2.1

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 (88) hide show
  1. package/dist/agent/bootstrap.d.ts +1 -1
  2. package/dist/agent/bootstrap.d.ts.map +1 -1
  3. package/dist/agent/bootstrap.js +33 -0
  4. package/dist/agent/bootstrap.js.map +1 -1
  5. package/dist/agent/manager.d.ts +16 -13
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +272 -465
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +1 -4
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +3 -22
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +1 -10
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +5 -8
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/preflight.d.ts.map +1 -1
  18. package/dist/agent/preflight.js +49 -22
  19. package/dist/agent/preflight.js.map +1 -1
  20. package/dist/agent/prompt.d.ts +2 -3
  21. package/dist/agent/prompt.d.ts.map +1 -1
  22. package/dist/agent/prompt.js +35 -67
  23. package/dist/agent/prompt.js.map +1 -1
  24. package/dist/agent/repo-store.d.ts +5 -4
  25. package/dist/agent/repo-store.d.ts.map +1 -1
  26. package/dist/agent/repo-store.js +53 -49
  27. package/dist/agent/repo-store.js.map +1 -1
  28. package/dist/agent/review-transport.js +2 -2
  29. package/dist/agent/review-transport.js.map +1 -1
  30. package/dist/agent/tmux-probe-poller.js +4 -4
  31. package/dist/agent/tmux-probe-poller.js.map +1 -1
  32. package/dist/agent/tmux.js +9 -9
  33. package/dist/agent/tmux.js.map +1 -1
  34. package/dist/agent/worktree.d.ts +1 -0
  35. package/dist/agent/worktree.d.ts.map +1 -1
  36. package/dist/agent/worktree.js +14 -0
  37. package/dist/agent/worktree.js.map +1 -1
  38. package/dist/api/agents.d.ts.map +1 -1
  39. package/dist/api/agents.js +5 -1
  40. package/dist/api/agents.js.map +1 -1
  41. package/dist/api/config.js +1 -1
  42. package/dist/api/config.js.map +1 -1
  43. package/dist/api/hosts.js +2 -2
  44. package/dist/api/hosts.js.map +1 -1
  45. package/dist/api/tasks.js +2 -2
  46. package/dist/api/tasks.js.map +1 -1
  47. package/dist/cli.d.ts.map +1 -1
  48. package/dist/cli.js +8 -1
  49. package/dist/cli.js.map +1 -1
  50. package/dist/config/loader.js +5 -1
  51. package/dist/config/loader.js.map +1 -1
  52. package/dist/config/validator.d.ts.map +1 -1
  53. package/dist/config/validator.js +37 -3
  54. package/dist/config/validator.js.map +1 -1
  55. package/dist/event/handlers.d.ts.map +1 -1
  56. package/dist/event/handlers.js +18 -439
  57. package/dist/event/handlers.js.map +1 -1
  58. package/dist/event/server-handlers.d.ts.map +1 -1
  59. package/dist/event/server-handlers.js +31 -24
  60. package/dist/event/server-handlers.js.map +1 -1
  61. package/dist/github/poller.d.ts.map +1 -1
  62. package/dist/github/poller.js +13 -3
  63. package/dist/github/poller.js.map +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +7 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/shared/constants.d.ts +1 -1
  68. package/dist/shared/constants.d.ts.map +1 -1
  69. package/dist/shared/constants.js +1 -7
  70. package/dist/shared/constants.js.map +1 -1
  71. package/dist/shared/git-url.d.ts +14 -0
  72. package/dist/shared/git-url.d.ts.map +1 -0
  73. package/dist/shared/git-url.js +76 -0
  74. package/dist/shared/git-url.js.map +1 -0
  75. package/dist/shared/index.d.ts +1 -0
  76. package/dist/shared/index.d.ts.map +1 -1
  77. package/dist/shared/index.js +1 -0
  78. package/dist/shared/index.js.map +1 -1
  79. package/dist/shared/types.d.ts +2 -2
  80. package/dist/shared/types.d.ts.map +1 -1
  81. package/dist/skills/server-feedback/SKILL.md +4 -2
  82. package/dist/terminal/attach.d.ts.map +1 -1
  83. package/dist/terminal/attach.js +19 -3
  84. package/dist/terminal/attach.js.map +1 -1
  85. package/dist/web/assets/index-CC3XRKh1.js +4 -0
  86. package/dist/web/index.html +1 -1
  87. package/package.json +1 -1
  88. package/dist/web/assets/index-DE_xpPQe.js +0 -4
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { createSignalToken } from './phase-signal.js';
5
- import { BRANCH_PREFIX, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, } from '../shared/index.js';
5
+ import { BRANCH_PREFIX, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, isGitHubRepo, repoSlug, } from '../shared/index.js';
6
6
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
7
7
  import { PostApproveStore } from '../state/post-approve-store.js';
8
8
  import { SkillRegistry } from '../skill/registry.js';
@@ -73,8 +73,8 @@ function agentRuntimeKindFor(agent) {
73
73
  }
74
74
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
75
75
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
76
- // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
77
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'spec-fix']);
76
+ // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt.
77
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
78
78
  export function canDispatchWithBinding(binding) {
79
79
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
80
80
  }
@@ -121,7 +121,7 @@ export class AgentManager {
121
121
  dispatchAckTimeoutMs;
122
122
  dispatchSettleTimeoutMs;
123
123
  // Re-send Enter after this long of continuous post-paste idle — recovers a swallowed first Enter
124
- // (task-071) without risking a double-submit (a real submit goes busy well within this window).
124
+ // without risking a double-submit (a real submit goes busy well within this window).
125
125
  dispatchAckResendIntervalMs = 3_000;
126
126
  taskMutationQueue = Promise.resolve();
127
127
  agentIndex;
@@ -132,6 +132,7 @@ export class AgentManager {
132
132
  runtimeMenuPollIntervalMs = 10_000;
133
133
  compactIdleWaitMs = 5 * 60_000;
134
134
  compactIdlePollMs = 2_000;
135
+ manualCompactWaitMs = 5_000;
135
136
  postMergeFetchTimeoutMs = 60_000;
136
137
  postMergeBranchTimeoutMs = 10_000;
137
138
  // taskIds with in-flight manual review — second concurrent POST gets 409.
@@ -143,6 +144,7 @@ export class AgentManager {
143
144
  // agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
144
145
  // 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
145
146
  deletionInFlight = new Set();
147
+ compactInFlight = new Set();
146
148
  constructor(deps) {
147
149
  this.config = deps.config;
148
150
  this.agentStore = deps.agentStore;
@@ -183,12 +185,32 @@ export class AgentManager {
183
185
  getReviewStore() {
184
186
  return this.reviewStore;
185
187
  }
188
+ // Non-GitHub repos are forced to server review mode — there is no platform PR/review
189
+ // to map onto. GitHub honors the global config. Snapshotted per-task at creation.
190
+ effectiveReviewMode(projectId) {
191
+ const project = this.getProjectConfig(projectId);
192
+ if (project && !isGitHubRepo(project.repo))
193
+ return 'server';
194
+ return this.config.review.mode ?? 'github';
195
+ }
186
196
  // Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
187
- // config — `??` would swallow it and reroute an already-decided task (PR #288).
197
+ // config — `??` would swallow it and reroute an already-decided task.
188
198
  resolveAfterDone(task) {
189
199
  if (task.afterDone !== undefined)
190
200
  return task.afterDone;
191
- return this.config.review.afterDone ?? null;
201
+ return this.coerceAfterDone(task.projectId, this.config.review.afterDone);
202
+ }
203
+ // Non-GitHub repos have no PR platform: 'pr' degrades to 'branch' (push + optional
204
+ // ff-merge). An unset afterDone defaults to 'branch' so reviewed work actually reaches
205
+ // the remote; an explicit null still means "don't publish". GitHub is unchanged.
206
+ coerceAfterDone(projectId, configured) {
207
+ const project = this.getProjectConfig(projectId);
208
+ if (project && !isGitHubRepo(project.repo)) {
209
+ if (configured === 'pr' || configured === undefined)
210
+ return 'branch';
211
+ return configured;
212
+ }
213
+ return configured ?? null;
192
214
  }
193
215
  getReviewTransport() {
194
216
  this.reviewTransportInstance ??= new ReviewTransport({
@@ -562,7 +584,7 @@ export class AgentManager {
562
584
  if (!discoveredPaneId)
563
585
  return false;
564
586
  const probeNow = new Date().toISOString();
565
- // 撤销 updatedAt guard(round-13 codex review):updatedAt 太宽,正常 background updates
587
+ // 不用 updatedAt guardupdatedAt 太宽,正常 background updates
566
588
  // (repoPath refresh / poller bump 等) 也会触发假阳性让合法 retry dialog 路径误拒。
567
589
  // race ("DELETE+recreate 后旧回调写新 agent") 在持锁路径下是 theoretical (retry endpoint 持锁
568
590
  // 全程到 handleDialogPendingFromRuntime 返回;startSession/continueSession 由 acquireAgentForTask
@@ -599,7 +621,7 @@ export class AgentManager {
599
621
  // fromStatus 来自 caller 显式计算:startSession/continueSession 用 opts.dialogFailFromStatuses ??
600
622
  // PHASE_EXPECTED_STATUS[phase],dispatchReviewToQa 走 bypassTaskStatusGate 时显式传 [taskStatusAtClaim]
601
623
  // (manual review 入口可能是 approved/fixing/in_progress,但 phase='review' 的 default 只接受 'review' →
602
- // 不传就 skip → task 卡 active 死锁,见 owner round-15 评审)。
624
+ // 不传就 skip → task 卡 active 死锁)。
603
625
  const expectedFromStatuses = opts.expectedFromStatuses ?? [...ACTIVE_TASK_STATUSES];
604
626
  const transitioned = await this.transitionTaskStatus(state.taskId, 'failed', { fromStatus: expectedFromStatuses });
605
627
  if (transitioned) {
@@ -975,7 +997,7 @@ export class AgentManager {
975
997
  const state = await this.agentStore.get(agentId);
976
998
  const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
977
999
  const reentryPhases = new Set([
978
- 'fix', 'post-approve', 'spec-fix', 'code',
1000
+ 'fix', 'post-approve', 'code',
979
1001
  'server-feedback', 'server-after-done',
980
1002
  ]);
981
1003
  const sameTaskReentry = state?.taskId === taskId &&
@@ -1218,7 +1240,7 @@ export class AgentManager {
1218
1240
  // code-dispatch-failed: the code-phase prompt never reached the pane (spec
1219
1241
  // approval already transitioned the task). Resume = clear the hold AND
1220
1242
  // redispatch the code prompt (outside this lock) — without the redispatch
1221
- // the task would stay in_progress with nothing running (PR #288).
1243
+ // the task would stay in_progress with nothing running.
1222
1244
  if (state.awaitingPhase === 'code-dispatch-failed'
1223
1245
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1224
1246
  && state.taskId) {
@@ -1411,7 +1433,7 @@ export class AgentManager {
1411
1433
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1412
1434
  // Human gates are decision states, not running work: an absent agent
1413
1435
  // session must not terminally fail a task whose published PR/branch
1414
- // would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
1436
+ // would then be orphaned — Confirm/Cancel remain the only exits.
1415
1437
  if (t.status === 'ready' || t.status === 'merge-ready')
1416
1438
  continue;
1417
1439
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -1653,7 +1675,7 @@ export class AgentManager {
1653
1675
  return this.config.project.find(p => p.id === projectId);
1654
1676
  }
1655
1677
  getProjectByRepo(repo) {
1656
- return this.config.project.find(p => p.repo === repo);
1678
+ return this.config.project.find(p => repoSlug(p.repo) === repo);
1657
1679
  }
1658
1680
  findQaPartner(devAgentId) {
1659
1681
  for (const project of this.config.project) {
@@ -1744,7 +1766,6 @@ export class AgentManager {
1744
1766
  expectedKinds: 'pr-merge-ready',
1745
1767
  token: completion.token,
1746
1768
  skipSnapshot,
1747
- reviewMode: task.reviewMode ?? 'github',
1748
1769
  recovered: true,
1749
1770
  });
1750
1771
  }
@@ -1753,8 +1774,8 @@ export class AgentManager {
1753
1774
  }
1754
1775
  }
1755
1776
  }
1756
- // skipSnapshot=true: scrollback 里的 signal 重启后不应再触发。
1757
- // 只对 spec verdict / spec-fixed emit intervention — spec-created 在 develop
1777
+ // snapshot 扫描按协议族决定:server 协议(含全模式 spec 阶段)恢复时必扫,github code 阶段仅 review/fixing 扫。
1778
+ // 只对 spec verdict / spec-fixed emit intervention — spec-done 在 develop
1758
1779
  // prompt 里是 optional, 报警会让所有 in_progress task 噪音化。
1759
1780
  // expectedKinds 必须覆盖 dispatch 时实际 set up 的 kind 集,否则真信号无法匹配。
1760
1781
  async setupRecoveredSpecSignals() {
@@ -1769,23 +1790,25 @@ export class AgentManager {
1769
1790
  continue;
1770
1791
  const { expectedKinds, agentId } = mapped;
1771
1792
  // Only spec verdict / spec-fixed / PR verdict warrant an intervention —
1772
- // optional kinds (spec-created, pr-created in develop) would spam every
1793
+ // optional kinds (spec-done, pr-created in develop) would spam every
1773
1794
  // in_progress task on restart.
1774
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-approved|spec-changes-requested'
1795
+ const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
1775
1796
  : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
1776
1797
  : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
1777
1798
  : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
1778
1799
  : undefined;
1779
- // Scan the pane snapshot for one-shot completion signals the agent may have
1780
- // echoed before the server persisted/consumed it (lost on restart otherwise,
1781
- // since the agent does not re-emit): PR verdict (review) and pr-fixed (code
1782
- // fixing). Both handlers are idempotent under replay (token + status gates),
1783
- // and token rotation still rejects stale ones. Other phases keep
1784
- // skipSnapshot=true their handlers aren't as cleanly replay-safe.
1785
- // Server mode scans on EVERY recovered state: the pane signal is the only
1786
- // verdict channel (no poller backstop) and all server handlers are
1787
- // replay-safe (token/status gates + stored-data resumption).
1788
- const scanSnapshotOnRecover = task.reviewMode === 'server'
1800
+ // spec 阶段恒为 server 协议(无 poller 兜底);code 阶段才按 reviewMode 区分。
1801
+ // Scan pane snapshot on recover for signals the agent emitted before the
1802
+ // server consumed them (lost on restart; agent won't re-emit).
1803
+ // github code states (review/fixing): replay-safe handlers token + status
1804
+ // gates reject duplicates; PR verdict & pr-fixed covered.
1805
+ // github pre-spec (phase undefined, in_progress): spec-done has only the pane
1806
+ // channel (pr-created has a poller backstop, scanning it is idempotent).
1807
+ // server protocol incl. all-mode spec phase: no poller backstop, pane is the
1808
+ // only signal channel; handlers equally replay-safe via same gates.
1809
+ const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
1810
+ const scanSnapshotOnRecover = isServerProtocol
1811
+ || (task.phase === undefined && task.status === 'in_progress')
1789
1812
  || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
1790
1813
  try {
1791
1814
  await this.phaseSignalWatcher.start({
@@ -1795,9 +1818,8 @@ export class AgentManager {
1795
1818
  expectedKinds,
1796
1819
  token: task.signalToken,
1797
1820
  skipSnapshot: !scanSnapshotOnRecover,
1798
- reviewMode: task.reviewMode ?? 'github',
1799
1821
  recovered: true,
1800
- ...(task.reviewMode === 'server' && task.status === 'review'
1822
+ ...(isServerProtocol && task.status === 'review'
1801
1823
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
1802
1824
  : {}),
1803
1825
  });
@@ -1843,10 +1865,11 @@ export class AgentManager {
1843
1865
  // Three independent endpoints — fetch concurrently, not back-to-back. Reviews
1844
1866
  // cover the same-identity `gh pr review --comment` reply path (a PR review with
1845
1867
  // a body, surfaced via submitted_at, not an inline/issue comment).
1868
+ const repo = repoSlug(project.repo);
1846
1869
  const [inlineReplies, issueComments, reviews] = await Promise.all([
1847
- this.ghCreatedAt(`repos/${project.repo}/pulls/${task.prNumber}/comments`, '.[] | select(.in_reply_to_id != null) | .created_at'),
1848
- this.ghCreatedAt(`repos/${project.repo}/issues/${task.prNumber}/comments`, '.[].created_at'),
1849
- this.ghCreatedAt(`repos/${project.repo}/pulls/${task.prNumber}/reviews`, '.[] | select(.submitted_at != null) | .submitted_at'),
1870
+ this.ghCreatedAt(`repos/${repo}/pulls/${task.prNumber}/comments`, '.[] | select(.in_reply_to_id != null) | .created_at'),
1871
+ this.ghCreatedAt(`repos/${repo}/issues/${task.prNumber}/comments`, '.[].created_at'),
1872
+ this.ghCreatedAt(`repos/${repo}/pulls/${task.prNumber}/reviews`, '.[] | select(.submitted_at != null) | .submitted_at'),
1850
1873
  ]);
1851
1874
  const stamps = [...inlineReplies, ...issueComments, ...reviews];
1852
1875
  return stamps.some(ts => {
@@ -1874,7 +1897,7 @@ export class AgentManager {
1874
1897
  if (!project) {
1875
1898
  throw new Error(`fetchPrHeadSha: unknown project ${task.projectId}`);
1876
1899
  }
1877
- const result = await this.platformRunner.exec(`gh pr view ${task.prNumber} --repo ${shellQuote(project.repo)} --json headRefOid --jq .headRefOid`);
1900
+ const result = await this.platformRunner.exec(`gh pr view ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefOid --jq .headRefOid`);
1878
1901
  if (result.exitCode !== 0) {
1879
1902
  throw new Error(`gh pr view failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
1880
1903
  }
@@ -1898,7 +1921,7 @@ export class AgentManager {
1898
1921
  const project = this.getProjectConfig(task.projectId);
1899
1922
  if (!project)
1900
1923
  return undefined;
1901
- const result = await this.platformRunner.exec(`gh pr view ${prNumber} --repo ${shellQuote(project.repo)} --json headRefName,headRefOid --jq '.headRefName + "\\t" + .headRefOid'`);
1924
+ const result = await this.platformRunner.exec(`gh pr view ${prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefName,headRefOid --jq '.headRefName + "\\t" + .headRefOid'`);
1902
1925
  if (result.exitCode !== 0)
1903
1926
  return undefined;
1904
1927
  const [headRefName, headSha] = result.stdout.trim().split('\t');
@@ -1977,7 +2000,7 @@ export class AgentManager {
1977
2000
  createRepoStore(agent, project, runner) {
1978
2001
  const host = resolveAgentHost(this.config.host, agent.host);
1979
2002
  if (this.repoStoreFactory) {
1980
- return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache);
2003
+ return this.repoStoreFactory(runner, repoSlug(project.repo), agent.mode, host, this.repoCache);
1981
2004
  }
1982
2005
  return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
1983
2006
  }
@@ -2057,9 +2080,19 @@ export class AgentManager {
2057
2080
  return this.withTaskLock(async () => {
2058
2081
  const taskId = await this.taskStore.nextId();
2059
2082
  const now = new Date().toISOString();
2083
+ // Server review has no platform fallback: a dev with no QA partner would strand (server mode
2084
+ // is forced for non-GitHub repos). Fail fast BEFORE staging images — a rejected create must not
2085
+ // orphan state/task-images/<taskId> with no TaskState to reclaim it. The dispatch path also
2086
+ // guards (unassigned-claim entry). Unassigned skips this: empty preferredAgentId → no dev config.
2087
+ if (this.getAgentConfig(input.preferredAgentId)?.role === 'dev'
2088
+ && this.effectiveReviewMode(projectId) === 'server'
2089
+ && !this.findQaPartner(input.preferredAgentId)) {
2090
+ throw new ApiError(400, `Project "${projectId}" uses server review mode (forced for non-GitHub repos); ` +
2091
+ `dev "${input.preferredAgentId}" needs a QA partner — add a QA agent paired with this dev before creating tasks.`);
2092
+ }
2060
2093
  // Stage images first so the task is written + emitted (task.created) WITH its images already
2061
2094
  // on disk — a pending task is never observable, or crash-recoverable, without them. A persist
2062
- // failure here throws before any store write / lock, so nothing half-created survives (task-055).
2095
+ // failure here throws before any store write / lock, so nothing half-created survives.
2063
2096
  const imageFilenames = input.images?.length
2064
2097
  ? await this.persistTaskImages(taskId, input.images)
2065
2098
  : undefined;
@@ -2075,7 +2108,7 @@ export class AgentManager {
2075
2108
  reviewRound: 0,
2076
2109
  status: 'pending',
2077
2110
  branch: BRANCH_PREFIX + taskId,
2078
- reviewMode: this.config.review.mode ?? 'github',
2111
+ reviewMode: this.effectiveReviewMode(projectId),
2079
2112
  createdAt: now,
2080
2113
  updatedAt: now,
2081
2114
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2104,7 +2137,7 @@ export class AgentManager {
2104
2137
  reviewRound: 0,
2105
2138
  status: 'pending',
2106
2139
  branch: BRANCH_PREFIX + taskId,
2107
- reviewMode: this.config.review.mode ?? 'github',
2140
+ reviewMode: this.effectiveReviewMode(projectId),
2108
2141
  createdAt: now,
2109
2142
  updatedAt: now,
2110
2143
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2137,7 +2170,7 @@ export class AgentManager {
2137
2170
  reviewRound: 0,
2138
2171
  status: 'pending',
2139
2172
  branch: BRANCH_PREFIX + taskId,
2140
- reviewMode: this.config.review.mode ?? 'github',
2173
+ reviewMode: this.effectiveReviewMode(projectId),
2141
2174
  createdAt: now,
2142
2175
  updatedAt: now,
2143
2176
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2169,7 +2202,7 @@ export class AgentManager {
2169
2202
  reviewRound: 0,
2170
2203
  status: 'in_progress',
2171
2204
  branch: BRANCH_PREFIX + taskId,
2172
- reviewMode: this.config.review.mode ?? 'github',
2205
+ reviewMode: this.effectiveReviewMode(projectId),
2173
2206
  createdAt: now,
2174
2207
  updatedAt: now,
2175
2208
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2201,7 +2234,7 @@ export class AgentManager {
2201
2234
  }
2202
2235
  async createAndStartTask(projectId, input, opts = {}) {
2203
2236
  // createTask stages images atomically before the task is visible (store + task.created),
2204
- // so a pending task can never be observed — or crash-recovered — without its images (task-055).
2237
+ // so a pending task can never be observed — or crash-recovered — without its images.
2205
2238
  const task = await this.createTask(projectId, input);
2206
2239
  if (task.status === 'in_progress' && task.agentId) {
2207
2240
  // Persist token first — prompt build 和 watcher 验证共用 task.signalToken。
@@ -2253,7 +2286,7 @@ export class AgentManager {
2253
2286
  return null;
2254
2287
  }
2255
2288
  // 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
2256
- // spec-created/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2289
+ // spec-done/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
2257
2290
  // Kinds derive from the task's frozen reviewMode — a hot mode flip during the
2258
2291
  // startSession window must not desync the armed kinds from the sent prompt.
2259
2292
  const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
@@ -2292,7 +2325,7 @@ export class AgentManager {
2292
2325
  }
2293
2326
  return (await this.taskStore.get(taskId)) ?? null;
2294
2327
  }
2295
- /** task-055 entry A: write an uploaded image to the running agent's host, paste its path (no Enter). */
2328
+ /** Write an uploaded image to the running agent's host, paste its path (no Enter). */
2296
2329
  async attachImageToRunningAgent(agentId, bytes, ext) {
2297
2330
  const cfg = this.getAgentConfig(agentId);
2298
2331
  if (!cfg)
@@ -2301,12 +2334,83 @@ export class AgentManager {
2301
2334
  const paneId = state?.paneId;
2302
2335
  if (!paneId)
2303
2336
  throw new ApiError(409, `Agent ${agentId} has no live session`);
2304
- const runner = this.createRunnerFor(cfg);
2305
- const path = agentHostPath(agentId, imageFilename(ext));
2306
- await writeImageToHost(runner, path, bytes);
2307
- const tmux = new TmuxManager(runner);
2308
- await tmux.injectPrompt(paneId, `${path} `, agentId);
2309
- return { path };
2337
+ // 写文件→粘贴全程持有 pane 互斥:写文件可能卡住,恢复后的粘贴若落进
2338
+ // compact C-c→/compact 窗口会把路径拼进指令提交。
2339
+ if (!this.tryAcquireCompactGuard(agentId)) {
2340
+ throw new ApiError(409, `Agent ${agentId} compact or upload in progress; retry shortly`);
2341
+ }
2342
+ try {
2343
+ const runner = this.createRunnerFor(cfg);
2344
+ const path = agentHostPath(agentId, imageFilename(ext));
2345
+ await writeImageToHost(runner, path, bytes);
2346
+ const tmux = new TmuxManager(runner);
2347
+ await tmux.injectPrompt(paneId, `${path} `, agentId);
2348
+ return { path };
2349
+ }
2350
+ finally {
2351
+ this.compactInFlight.delete(agentId);
2352
+ }
2353
+ }
2354
+ // busy 时注入会把指令拼进正在运行的回合,宁可 409。
2355
+ async compactAgent(agentId) {
2356
+ const cfg = this.getAgentConfig(agentId);
2357
+ if (!cfg)
2358
+ throw new ApiError(404, `Unknown agent: ${agentId}`);
2359
+ if (!this.tryAcquireCompactGuard(agentId)) {
2360
+ throw new ApiError(409, `Agent ${agentId} compact or upload already in progress`);
2361
+ }
2362
+ let guardHandedOff = false;
2363
+ try {
2364
+ const state = await this.agentStore.get(agentId);
2365
+ const paneId = state?.paneId;
2366
+ if (!paneId)
2367
+ throw new ApiError(409, `Agent ${agentId} has no live session`);
2368
+ const taskIdAtStart = state.taskId;
2369
+ const updatedAtAtStart = state.updatedAt;
2370
+ // updatedAt 拦同任务 phase 派发(paneId/taskId 均不变,派发 paste 前必写 state);
2371
+ // 快照变了决不注入——C-c 会打断刚注入的 prompt。
2372
+ const assertSessionUnchanged = async () => {
2373
+ const now = await this.agentStore.get(agentId);
2374
+ if (!now
2375
+ || now.paneId !== paneId
2376
+ || now.taskId !== taskIdAtStart
2377
+ || now.updatedAt !== updatedAtAtStart) {
2378
+ throw new ApiError(409, `Agent ${agentId} session changed while waiting; compact aborted`);
2379
+ }
2380
+ };
2381
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
2382
+ const waitReady = async () => {
2383
+ try {
2384
+ await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
2385
+ }
2386
+ catch (err) {
2387
+ const detail = err instanceof Error ? err.message : String(err);
2388
+ throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
2389
+ }
2390
+ };
2391
+ await waitReady();
2392
+ await assertSessionUnchanged();
2393
+ // 残留草稿会被「草稿/compact」连带提交;C-c 清线后再发。
2394
+ await tmux.sendKeysToPane(paneId, 'C-c');
2395
+ await waitReady();
2396
+ await assertSessionUnchanged();
2397
+ await tmux.sendKeysLiteral(paneId, '/compact');
2398
+ await tmux.sendEnter(paneId);
2399
+ // 压缩仍在运行:guard 交给后台尾随等待,runtime 回到 idle 才释放,
2400
+ // 否则紧随的上传/派发会粘进压缩中的 pane。
2401
+ guardHandedOff = true;
2402
+ void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
2403
+ .catch(err => {
2404
+ console.warn(`[AgentManager] compactAgent(${agentId}) post-/compact idle wait failed:`, err);
2405
+ })
2406
+ .finally(() => {
2407
+ this.compactInFlight.delete(agentId);
2408
+ });
2409
+ }
2410
+ finally {
2411
+ if (!guardHandedOff)
2412
+ this.compactInFlight.delete(agentId);
2413
+ }
2310
2414
  }
2311
2415
  async persistTaskImages(taskId, images) {
2312
2416
  const dir = join(this.imageStagingRoot, taskId);
@@ -2319,7 +2423,7 @@ export class AgentManager {
2319
2423
  }
2320
2424
  return filenames;
2321
2425
  }
2322
- // Retry reads staged bytes up-front; a missing source is a visible 409, never a silent drop (task-055).
2426
+ // Retry reads staged bytes up-front; a missing source is a visible 409, never a silent drop.
2323
2427
  async readStagedImages(taskId, filenames) {
2324
2428
  const dir = join(this.imageStagingRoot, taskId);
2325
2429
  const out = [];
@@ -2337,7 +2441,7 @@ export class AgentManager {
2337
2441
  return out;
2338
2442
  }
2339
2443
  // Materialize staged task images onto the agent host at dispatch; absolute host paths get
2340
- // woven into the prompt. Missing staging aborts the dispatch loudly (no silent skip, task-055).
2444
+ // woven into the prompt. Missing staging aborts the dispatch loudly (no silent skip).
2341
2445
  async materializeTaskImages(runner, task) {
2342
2446
  const filenames = task.images ?? [];
2343
2447
  if (filenames.length === 0)
@@ -2358,12 +2462,10 @@ export class AgentManager {
2358
2462
  }
2359
2463
  return hostPaths;
2360
2464
  }
2361
- // Dev-facing deliverable phases (initial + rework) all carry the task's uploaded images, since
2362
- // the image is a persistent task input the dev needs while producing or revising the spec/code —
2363
- // and a fresh runtime (restart/recovery) loses the original context. `develop` flows through
2364
- // startSession; `code`/`fix`/`spec-fix` through continueSession both call this. Excluded:
2365
- // QA phases (review/recheck/spec-review) and post-approve (feedback-processing; if it needs
2366
- // changes baxian routes to `fix`, which carries the image) (task-055).
2465
+ // Dev-facing deliverable phases all carry the task's uploaded images, since the image is a
2466
+ // persistent task input the dev needs while producing or revising the spec/code — and a fresh
2467
+ // runtime (restart/recovery) loses the original context. IMAGE_DISPATCH_PHASES 中的后续阶段经
2468
+ // continueSession 触发此方法;QA 阶段和 post-approve 不传图。
2367
2469
  async imagePathsForDispatch(runner, task, phase) {
2368
2470
  if (!IMAGE_DISPATCH_PHASES.has(phase))
2369
2471
  return [];
@@ -2552,7 +2654,7 @@ export class AgentManager {
2552
2654
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2553
2655
  const worktreePath = isServerQaPhase
2554
2656
  ? await worktree.createDetachedAtBase(workdir, taskId)
2555
- : phase === 'review' || phase === 'recheck' || phase === 'spec-review'
2657
+ : phase === 'review' || phase === 'recheck'
2556
2658
  ? await worktree.createDetached(workdir, taskId, task.branch)
2557
2659
  : await worktree.create(workdir, taskId, baseRef);
2558
2660
  // Persist worktreePath now so a crash before set-running leaves a recoverable trail.
@@ -2578,8 +2680,18 @@ export class AgentManager {
2578
2680
  const reuseInjectedSkills = ensure.freshRuntime
2579
2681
  ? null
2580
2682
  : reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
2683
+ // develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
2684
+ const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2581
2685
  let prompt;
2582
2686
  try {
2687
+ // Server review has no platform fallback: a dev with no QA partner would emit a
2688
+ // completion signal nobody consumes and the task strands (server mode is forced
2689
+ // for non-GitHub repos via effectiveReviewMode). Enforce at this single dispatch
2690
+ // chokepoint — the catch below removes the worktree, then the task fails cleanly.
2691
+ if (task.reviewMode === 'server' && !hasQaPartner) {
2692
+ throw new DispatchTerminalError('server_review_needs_qa', `Task ${task.id} runs in server review mode but dev "${agentId}" has no QA partner; ` +
2693
+ `pair a QA agent with this dev (server review has no platform fallback to absorb the verdict).`);
2694
+ }
2583
2695
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2584
2696
  prompt = buildPromptInline({
2585
2697
  task,
@@ -2587,9 +2699,9 @@ export class AgentManager {
2587
2699
  agent,
2588
2700
  worktreePath,
2589
2701
  skillRegistry: this.skillRegistry,
2702
+ hasQaPartner,
2590
2703
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2591
2704
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2592
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2593
2705
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2594
2706
  ...(imagePaths.length ? { imagePaths } : {}),
2595
2707
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
@@ -2702,7 +2814,7 @@ export class AgentManager {
2702
2814
  }
2703
2815
  // dedup baseline 记录的是「已 paste 进 idle composer 的 skill 文本」:paste 落入 composer 即进
2704
2816
  // REPL 上下文,与 submit-ack 无关。ack 超时(首个 Enter 被吞)下 skill 仍在 composer,跳过落盘会让
2705
- // 下一轮整组重注入——正是 task-071 的 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
2817
+ // 下一轮整组重注入——即 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
2706
2818
  // 已 busy,文本进了运行中输入流而非 composer)则不能落盘,否则恢复提示会缺必需 skill。freshRuntime
2707
2819
  // 负责 REPL 真正重启时作废 baseline。
2708
2820
  if (ack.composerDelivered) {
@@ -2762,7 +2874,27 @@ export class AgentManager {
2762
2874
  throw err;
2763
2875
  }
2764
2876
  }
2765
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, _runtime) {
2877
+ // 注入方必须持有 pane 互斥:compact 侧的快照校验关不死「校验→按键」
2878
+ // 之间的 async 边界,竞态只能在这里关死。
2879
+ async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
2880
+ const before = await this.agentStore.get(agentId);
2881
+ await this.acquireCompactGuard(agentId);
2882
+ try {
2883
+ // guard 等待期间任务可能被 Cancel(释放绑定)或会话重建;过期派发
2884
+ // 决不落 pane。无快照(direct 调用)时跳过——真实派发必有绑定。
2885
+ if (before) {
2886
+ const now = await this.agentStore.get(agentId);
2887
+ if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
2888
+ throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
2889
+ }
2890
+ }
2891
+ return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime);
2892
+ }
2893
+ finally {
2894
+ this.compactInFlight.delete(agentId);
2895
+ }
2896
+ }
2897
+ async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, _runtime) {
2766
2898
  await tmux.injectPrompt(paneId, prompt, agentId);
2767
2899
  let baseline;
2768
2900
  try {
@@ -2812,7 +2944,7 @@ export class AgentManager {
2812
2944
  });
2813
2945
  // "pane already busy at baseline" means the paste landed on an already-running input stream,
2814
2946
  // NOT an idle composer — the skills did NOT become context, so callers must not record them
2815
- // as injected (codex 3345468647). Any other timeout (idle composer / swallowed Enter) did
2947
+ // as injected. Any other timeout (idle composer / swallowed Enter) did
2816
2948
  // deliver the prompt text into the composer.
2817
2949
  const composerDelivered = !/pane already busy at baseline/.test(message);
2818
2950
  return { acked: false, composerDelivered };
@@ -2935,7 +3067,7 @@ export class AgentManager {
2935
3067
  && opts.postApproveRedispatchCount > 0
2936
3068
  && !ensure.freshRuntime;
2937
3069
  // code phase (post spec-approval) flows through here, not startSession — materialize the
2938
- // task's uploaded images so a fresh code-phase context still sees their paths (task-055).
3070
+ // task's uploaded images so a fresh code-phase context still sees their paths.
2939
3071
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2940
3072
  prompt = buildPromptInline({
2941
3073
  task,
@@ -2948,7 +3080,6 @@ export class AgentManager {
2948
3080
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
2949
3081
  : {}),
2950
3082
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2951
- ...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
2952
3083
  ...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
2953
3084
  ...(imagePaths.length ? { imagePaths } : {}),
2954
3085
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
@@ -3348,7 +3479,7 @@ export class AgentManager {
3348
3479
  let qaToRelease;
3349
3480
  // Server-mode ready gate may have already published remote artifacts
3350
3481
  // (pushed branch / open PR). Capture before flipping to cancelled so the
3351
- // post-lock cleanup can retire them instead of orphaning (PR #288).
3482
+ // post-lock cleanup can retire them instead of orphaning.
3352
3483
  // mayBeInFlight: approved+marker means the publish prompt may STILL be
3353
3484
  // running — retirement must wait for the dev interrupt or the in-flight
3354
3485
  // push/pr-create would recreate the artifacts right after cleanup.
@@ -3392,7 +3523,7 @@ export class AgentManager {
3392
3523
  }
3393
3524
  }
3394
3525
  else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
3395
- // GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
3526
+ // GitHub-mode gate cancel leaves the same orphaned PR/branch.
3396
3527
  publishedCleanup = {
3397
3528
  afterDone: 'pr',
3398
3529
  branch: task.branch,
@@ -3478,7 +3609,7 @@ export class AgentManager {
3478
3609
  const project = this.getProjectConfig(result.projectId);
3479
3610
  try {
3480
3611
  if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
3481
- const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
3612
+ const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3482
3613
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
3483
3614
  if (close.exitCode !== 0)
3484
3615
  throw new Error(close.stderr.trim() || close.stdout.trim());
@@ -3513,7 +3644,7 @@ export class AgentManager {
3513
3644
  }
3514
3645
  return result;
3515
3646
  }
3516
- // task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
3647
+ // create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
3517
3648
  // 允许入队(落 pending);可执行性判断下沉到 dispatchPendingTask(或 createTask 已空闲分支)。
3518
3649
  // 仍保留:agent 存在/同 project/role=dev(非空时)+ prompt size 上界。
3519
3650
  async validateTaskDispatch(projectId, input) {
@@ -3776,7 +3907,7 @@ export class AgentManager {
3776
3907
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
3777
3908
  }
3778
3909
  // Server-mode continue: grant one round past the cap, then re-run the server
3779
- // fix protocol from the stored findings — no PR exists at this point (PR #288).
3910
+ // fix protocol from the stored findings — no PR exists at this point.
3780
3911
  if (task.reviewMode === 'server') {
3781
3912
  if (!task.agentId) {
3782
3913
  throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
@@ -3937,7 +4068,7 @@ export class AgentManager {
3937
4068
  }
3938
4069
  // General case: rolled-back task may still want a watcher matching its
3939
4070
  // current (restored) state — develop dispatch still waiting on
3940
- // spec-created/pr-created, recheck still waiting on verdict, etc.
4071
+ // spec-done/pr-created, recheck still waiting on verdict, etc.
3941
4072
  const restored = await this.taskStore.get(taskId);
3942
4073
  if (!restored || !restored.signalToken)
3943
4074
  return;
@@ -3966,23 +4097,23 @@ export class AgentManager {
3966
4097
  const mode = reviewMode ?? this.config.review.mode ?? 'github';
3967
4098
  return mode === 'server'
3968
4099
  ? ['spec-done', 'code-done']
3969
- : ['spec-created', 'pr-created'];
4100
+ : ['spec-done', 'pr-created'];
3970
4101
  }
3971
4102
  mapTaskStateToExpectedWatcher(task) {
3972
4103
  if (task.reviewMode === 'server')
3973
4104
  return this.mapServerTaskToExpectedWatcher(task);
3974
4105
  if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
3975
- return { expectedKinds: ['spec-approved', 'spec-changes-requested'], agentId: task.qaAgentId };
4106
+ return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
3976
4107
  }
3977
4108
  if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
3978
4109
  return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
3979
4110
  }
3980
4111
  if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
3981
- // Code-track fixing: dev emits pr-fixed when the round is done (task-060).
4112
+ // Code-track fixing: dev emits pr-fixed when the round is done.
3982
4113
  return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
3983
4114
  }
3984
4115
  if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
3985
- return { expectedKinds: ['spec-created', 'pr-created'], agentId: task.agentId };
4116
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
3986
4117
  }
3987
4118
  if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
3988
4119
  return { expectedKinds: ['pr-created'], agentId: task.agentId };
@@ -4064,7 +4195,7 @@ export class AgentManager {
4064
4195
  preferredAgentId: old.preferredAgentId,
4065
4196
  };
4066
4197
  // Retry preserves uploaded images: read the old task's staged bytes up-front
4067
- // (missing → visible 409 before any new task/binding is created). task-055.
4198
+ // (missing → visible 409 before any new task/binding is created).
4068
4199
  if (old.images?.length) {
4069
4200
  input.images = await this.readStagedImages(old.id, old.images);
4070
4201
  }
@@ -4130,7 +4261,7 @@ export class AgentManager {
4130
4261
  const matchHead = opts.matchHeadSha
4131
4262
  ? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
4132
4263
  : '';
4133
- const result = await this.platformRunner.exec(`gh pr merge ${task.prNumber} --repo ${shellQuote(project.repo)}${matchHead} --squash --delete-branch`);
4264
+ const result = await this.platformRunner.exec(`gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash --delete-branch`);
4134
4265
  if (result.exitCode !== 0) {
4135
4266
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4136
4267
  }
@@ -4155,7 +4286,7 @@ export class AgentManager {
4155
4286
  const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
4156
4287
  try {
4157
4288
  // Server-mode publish retry: a failed afterDone dispatch leaves the task
4158
- // 'approved' with dev released — mark-complete re-runs the publish (PR #288).
4289
+ // 'approved' with dev released — mark-complete re-runs the publish.
4159
4290
  const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
4160
4291
  if (!serverApprovedRetry && task.status !== 'max_rounds') {
4161
4292
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
@@ -4175,7 +4306,7 @@ export class AgentManager {
4175
4306
  // publishDispatchedAt persists delivery across restarts: set = the
4176
4307
  // prompt reached the pane before the restart (still in flight, 409);
4177
4308
  // cleared = the dispatch failed and this approved state is retryable —
4178
- // stop the recovered watch and let the retry own the dispatch (PR #288).
4309
+ // stop the recovered watch and let the retry own the dispatch.
4179
4310
  if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
4180
4311
  if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
4181
4312
  throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
@@ -4195,13 +4326,13 @@ export class AgentManager {
4195
4326
  return (await this.taskStore.get(taskId));
4196
4327
  }
4197
4328
  // Server-mode capped task, human accepts as-is: no PR exists yet — run the
4198
- // afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
4329
+ // afterDone flow (or finish directly) instead of the legacy PR merge.
4199
4330
  // Inside the in-flight claim so a concurrent Continue can't act on the same
4200
4331
  // max_rounds snapshot and release dev mid-publish.
4201
4332
  if (task.reviewMode === 'server') {
4202
4333
  // Max_rounds never routed an approve verdict — snapshot afterDone NOW so
4203
4334
  // the eventual ready-confirm uses this decision, not future hot config.
4204
- const afterDone = this.config.review.afterDone ?? null;
4335
+ const afterDone = this.coerceAfterDone(task.projectId, this.config.review.afterDone);
4205
4336
  await this.updateTask(taskId, { afterDone });
4206
4337
  if (afterDone === null) {
4207
4338
  const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
@@ -4416,6 +4547,28 @@ export class AgentManager {
4416
4547
  }
4417
4548
  }
4418
4549
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4550
+ // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
4551
+ // 且 finally 会误删对方的 guard 放穿后续请求。
4552
+ await this.acquireCompactGuard(agentId);
4553
+ try {
4554
+ await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt);
4555
+ }
4556
+ finally {
4557
+ this.compactInFlight.delete(agentId);
4558
+ }
4559
+ }
4560
+ async acquireCompactGuard(agentId) {
4561
+ while (!this.tryAcquireCompactGuard(agentId)) {
4562
+ await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4563
+ }
4564
+ }
4565
+ tryAcquireCompactGuard(agentId) {
4566
+ if (this.compactInFlight.has(agentId))
4567
+ return false;
4568
+ this.compactInFlight.add(agentId);
4569
+ return true;
4570
+ }
4571
+ async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4419
4572
  const bindingStillOurs = async () => {
4420
4573
  const s = await this.agentStore.get(agentId);
4421
4574
  return !!s && s.taskId === originalTaskId && s.paneId === paneId;
@@ -4488,12 +4641,6 @@ export class AgentManager {
4488
4641
  stopPhaseSignalWatcher(taskId) {
4489
4642
  this.phaseSignalWatcher?.stop(taskId);
4490
4643
  }
4491
- // Backwards-compat alias for spec-only call sites (recovery, transitions
4492
- // that already named the kind). New callers should use setupPhaseSignalWatcher
4493
- // directly with the right expectedKinds.
4494
- stopSpecSignalWatcher(taskId) {
4495
- this.stopPhaseSignalWatcher(taskId);
4496
- }
4497
4644
  // Prompt build (via task.signalToken) and watcher must share the same token.
4498
4645
  // Returns whether dispatch may safely proceed. False ONLY when a configured watcher failed
4499
4646
  // to arm — the dangerous case where a same-identity verdict would have no consumer. When no
@@ -4513,9 +4660,6 @@ export class AgentManager {
4513
4660
  expectedKinds,
4514
4661
  token,
4515
4662
  skipSnapshot,
4516
- // Mode routing rides every arm automatically: the snapshot lives on the
4517
- // task, so github-mode tasks keep their legacy event types untouched.
4518
- reviewMode: task.reviewMode ?? 'github',
4519
4663
  ...(onReadFile ? { onReadFile } : {}),
4520
4664
  });
4521
4665
  }
@@ -4580,358 +4724,6 @@ export class AgentManager {
4580
4724
  if (mapped)
4581
4725
  await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
4582
4726
  }
4583
- async readSpecReviewFile(taskId, fileName) {
4584
- const task = await this.taskStore.get(taskId);
4585
- if (!task)
4586
- return null;
4587
- if (!task.branch) {
4588
- throw new Error(`readSpecReviewFile: task ${taskId} has no branch`);
4589
- }
4590
- const project = this.getProjectConfig(task.projectId);
4591
- if (!project) {
4592
- throw new Error(`readSpecReviewFile: unknown project ${task.projectId}`);
4593
- }
4594
- const dev = this.getAgentConfig(task.agentId);
4595
- if (!dev) {
4596
- throw new Error(`readSpecReviewFile: task ${taskId} has no dev agent bound`);
4597
- }
4598
- const runner = this.createRunnerFor(dev);
4599
- const store = this.createRepoStore(dev, project, runner);
4600
- const workdir = await this.resolveWorkdir(dev, await this.agentStore.get(dev.id))
4601
- ?? await store.ensure();
4602
- const filePath = `.baxian/spec-review/${fileName}`;
4603
- return store.readFileFromBranch(workdir, task.branch, filePath);
4604
- }
4605
- async dispatchSpecReviewToQa(taskId) {
4606
- // Phase 1 (lock): validate + decide qa + compute newToken/newRound (无 mutation, 无 park)。
4607
- // 关键约束:task 不能在 startSession 之前被改 — startSession 内部调用
4608
- // buildPromptInline,prompt 必须看到的是新 token 和新 round;这里只 *计算*,
4609
- // 真正写回 task 放到 Phase 3。
4610
- const claim = await this.withTaskLock(async () => {
4611
- const task = await this.taskStore.get(taskId);
4612
- if (!task)
4613
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} not found`);
4614
- if (!task.branch)
4615
- throw new Error(`dispatchSpecReviewToQa: task ${taskId} has no branch`);
4616
- // Stale spec-created guard: 一旦 task 离开 pre-spec 阶段 (phase='code' 或其他
4617
- // 非 'spec'/undefined 值),迟到的 spec-created signal 不应再 dispatch review。
4618
- // 允许 phase==='spec' 是预留 dev 在 fix-complete 后再 emit spec-created 的扩展点。
4619
- if (task.phase !== undefined && task.phase !== 'spec') {
4620
- await this.safeEmit({
4621
- id: '',
4622
- type: 'human.intervention',
4623
- timestamp: new Date().toISOString(),
4624
- projectId: task.projectId,
4625
- agentId: task.agentId,
4626
- taskId,
4627
- data: { phase: 'spec-created-stale-after-code', taskPhase: task.phase },
4628
- });
4629
- return null;
4630
- }
4631
- const qa = this.findQaPartner(task.agentId);
4632
- if (!qa) {
4633
- await this.safeEmit({
4634
- id: '',
4635
- type: 'human.intervention',
4636
- timestamp: new Date().toISOString(),
4637
- projectId: task.projectId,
4638
- agentId: task.agentId,
4639
- taskId,
4640
- data: { phase: 'spec-review-no-qa-partner', devAgentId: task.agentId },
4641
- });
4642
- return null;
4643
- }
4644
- // 记录入口 status — fix-then-review 重派 (fromStatus 含 'fixing') 时,
4645
- // spawn 失败 rollback 不能无差别回 in_progress;必须回到原 status 以保留 spec phase。
4646
- // transitionTaskStatus 的 fromStatus 守门已限定为这三种之一; 其他 status 不会走到这里。
4647
- const isReviewEntry = task.status === 'in_progress'
4648
- || task.status === 'fixing'
4649
- || task.status === 'pending';
4650
- if (!isReviewEntry)
4651
- return null;
4652
- return {
4653
- qaId: qa.id,
4654
- devAgentId: task.agentId,
4655
- projectId: task.projectId,
4656
- newToken: createSignalToken(),
4657
- newRound: (task.specReviewRound ?? 0) + 1,
4658
- originalStatus: task.status,
4659
- // 记录原 spec-created token — pre-spec entry rollback 时 restore,
4660
- // 让 dev 后续 spec-created signal (with 原 token) 经 handler freshness gate 通过 → auto retry。
4661
- originalToken: task.signalToken,
4662
- // 回滚时 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4663
- originalRound: task.specReviewRound,
4664
- };
4665
- });
4666
- if (!claim)
4667
- return null;
4668
- const { qaId, devAgentId, projectId, newToken, newRound, originalStatus, originalToken, originalRound } = claim;
4669
- // Phase 2a: 先 acquire QA — 失败时 dev 还未 park,直接 return 即可。
4670
- const acquired = await this.acquireAgentForTask(qaId, taskId, 'spec-review');
4671
- if (!acquired) {
4672
- await this.safeEmit({
4673
- id: '',
4674
- type: 'human.intervention',
4675
- timestamp: new Date().toISOString(),
4676
- projectId,
4677
- agentId: qaId,
4678
- taskId,
4679
- data: { phase: 'spec-review-qa-acquire-failed', qaAgentId: qaId },
4680
- });
4681
- return null;
4682
- }
4683
- // Phase 2b: dev gate — park dev so it stops editing the spec while QA reviews。
4684
- // 顺序在 acquireQA 之后:避免 QA 失败时 dev 已 parked 但 task 仍 in_progress,
4685
- // 无任何后续 dispatch 把 dev 拉出 waiting (即 dev 永久挂起)。
4686
- if (devAgentId) {
4687
- const devOk = await this.markAgentWaiting(devAgentId, taskId);
4688
- if (!devOk) {
4689
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4690
- .catch(() => undefined);
4691
- await this.safeEmit({
4692
- id: '',
4693
- type: 'human.intervention',
4694
- timestamp: new Date().toISOString(),
4695
- projectId,
4696
- agentId: devAgentId,
4697
- taskId,
4698
- data: { phase: 'spec-review-dev-park-failed', devAgentId },
4699
- });
4700
- return null;
4701
- }
4702
- }
4703
- // Phase 2c (lock): atomic transition + persist newToken/newRound/phase/qaAgentId.
4704
- // 必须在 startSession 之前;若顺序反过来,startSession 之后崩溃但 transition 没做时,
4705
- // setupRecoveredSpecSignals 会读旧 status/token 推断错 kind/token,新 signal 无法匹配 → 链路死。
4706
- const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'pending'] }, {
4707
- specReviewRound: newRound,
4708
- signalToken: newToken,
4709
- phase: 'spec',
4710
- qaAgentId: qaId,
4711
- });
4712
- if (!transition) {
4713
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4714
- .catch(() => undefined);
4715
- // 不 re-acquire dev: markAgentWaiting (mode='waiting') 仅 bump updatedAt,
4716
- // dev 仍 bound 到 task; develop phase 不在 reentry 集合, 重 acquire 必返回 false (dead code)。
4717
- await this.safeEmit({
4718
- id: '',
4719
- type: 'human.intervention',
4720
- timestamp: new Date().toISOString(),
4721
- projectId,
4722
- agentId: qaId,
4723
- taskId,
4724
- data: { phase: 'spec-review-transition-failed', qaAgentId: qaId },
4725
- });
4726
- return null;
4727
- }
4728
- // Phase 2d: startSession 用显式 newToken/newRound 透传到 prompt。
4729
- // 失败时回滚 transition + 清新 persist 字段,避免 task 留在 review 但 qa 无 session 的 stuck。
4730
- // 不调 acquireAgentForTask(dev, 'develop'):markAgentWaiting 走 mode='waiting' 仅 bump updatedAt
4731
- // (不清 binding 也不真正 park REPL),dev 仍 bound 到 task;且 develop phase 不在
4732
- // canDispatchWithBinding 的 reentry 集合,重 acquire 必返回 false — 是 dead code。
4733
- let started = false;
4734
- try {
4735
- started = await this.startSession(taskId, qaId, 'spec-review', {
4736
- bypassTaskStatusGate: true,
4737
- signalToken: newToken,
4738
- currentSpecRound: newRound,
4739
- });
4740
- }
4741
- catch (err) {
4742
- // DispatchTerminalError 都委托给 failTaskForDispatchError:ack_unknown 会保留绑定走
4743
- // markAwaitingHuman,其他 reason(prompt_too_large 等非 transient)让 task 进 failed
4744
- // 而不是 rollback 让 cron 反复 retry。其他异常(瞬时 / 不明)才走 rollback + release。
4745
- if (err instanceof DispatchTerminalError) {
4746
- await this.failTaskForDispatchError(taskId, 'spec-review', qaId, err);
4747
- }
4748
- else if (err instanceof EnsureSessionError && err.partial.handled) {
4749
- // handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release,
4750
- // 否则 boundTask terminal 让 release gate 放行清掉仍卡 dialog 的 pane lock。
4751
- }
4752
- else {
4753
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4754
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4755
- .catch(() => undefined);
4756
- }
4757
- throw err;
4758
- }
4759
- if (!started) {
4760
- await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
4761
- await this.releaseAgentForTask(qaId, taskId, 'idle')
4762
- .catch(() => undefined);
4763
- await this.safeEmit({
4764
- id: '',
4765
- type: 'human.intervention',
4766
- timestamp: new Date().toISOString(),
4767
- projectId,
4768
- agentId: qaId,
4769
- taskId,
4770
- data: { phase: 'spec-review-start-failed', qaAgentId: qaId },
4771
- });
4772
- return null;
4773
- }
4774
- // Phase 3: set up watcher。spec-created 已被消费,先 tear down 防止 dev 之后无关 signal 误触发。
4775
- // QA echoes exactly one verdict signal — set up both kinds, the first match wins.
4776
- this.stopSpecSignalWatcher(taskId);
4777
- await this.armPostDispatchSignalOrHold(taskId, qaId, ['spec-approved', 'spec-changes-requested'], newToken);
4778
- return await this.taskStore.get(taskId);
4779
- }
4780
- // startSession 失败回滚:
4781
- // - pre-spec entry: restore originalToken 让 dev 后续 spec-created signal 经 freshness gate 通过 → auto retry。
4782
- // - fixing entry: 保留 phase='spec' + qaAgentId(否则 spec.* freshness gate 全 fail),清 token 防 stale。
4783
- // round 必须 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
4784
- async rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound) {
4785
- if (originalStatus === 'fixing') {
4786
- await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: undefined, specReviewRound: originalRound });
4787
- return;
4788
- }
4789
- await this.transitionTaskStatus(taskId, originalStatus, { fromStatus: ['review'] }, {
4790
- signalToken: originalToken,
4791
- phase: undefined,
4792
- qaAgentId: undefined,
4793
- specReviewRound: originalRound,
4794
- });
4795
- }
4796
- async dispatchSpecFixToDev(taskId, findings) {
4797
- // Phase 1 (lock): validate + phase guard + decide newToken。
4798
- // fix 是同 round 的 dev 处理 QA findings,round 不递增;只刷新 token 让 prompt + watcher 唯一识别本轮 fix。
4799
- const claim = await this.withTaskLock(async () => {
4800
- const task = await this.taskStore.get(taskId);
4801
- if (!task)
4802
- throw new Error(`dispatchSpecFixToDev: task ${taskId} not found`);
4803
- const devAgentId = task.agentId;
4804
- if (!devAgentId) {
4805
- throw new Error(`dispatchSpecFixToDev: task ${taskId} has no dev agent`);
4806
- }
4807
- // 离开 spec 阶段的 task 不应再被 spec-fix dispatch 击中 (defense in depth — handler 也 gate)。
4808
- if (task.phase !== 'spec') {
4809
- await this.safeEmit({
4810
- id: '',
4811
- type: 'human.intervention',
4812
- timestamp: new Date().toISOString(),
4813
- projectId: task.projectId,
4814
- agentId: devAgentId,
4815
- taskId,
4816
- data: { phase: 'spec-fix-stale-phase', taskPhase: task.phase },
4817
- });
4818
- return null;
4819
- }
4820
- return {
4821
- devAgentId,
4822
- qaAgentId: task.qaAgentId,
4823
- projectId: task.projectId,
4824
- newToken: createSignalToken(),
4825
- currentRound: task.specReviewRound ?? 1,
4826
- };
4827
- });
4828
- if (!claim)
4829
- return null;
4830
- const { devAgentId, qaAgentId, projectId, newToken, currentRound } = claim;
4831
- if (qaAgentId) {
4832
- // release 失败留 stale qa binding,下一轮 acquireAgentForTask(qa) 必拒;abort + emit intervention。
4833
- const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
4834
- .catch(err => {
4835
- console.warn(`[AgentManager] dispatchSpecFixToDev release qa=${qaAgentId} failed:`, err);
4836
- return false;
4837
- });
4838
- if (!released) {
4839
- await this.safeEmit({
4840
- id: '',
4841
- type: 'human.intervention',
4842
- timestamp: new Date().toISOString(),
4843
- projectId,
4844
- agentId: qaAgentId,
4845
- taskId,
4846
- data: { phase: 'spec-fix-qa-release-failed', qaAgentId },
4847
- });
4848
- return null;
4849
- }
4850
- }
4851
- // Phase 2a: acquire dev。
4852
- const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'spec-fix');
4853
- if (!acquired) {
4854
- await this.safeEmit({
4855
- id: '',
4856
- type: 'human.intervention',
4857
- timestamp: new Date().toISOString(),
4858
- projectId,
4859
- agentId: devAgentId,
4860
- taskId,
4861
- data: { phase: 'spec-fix-dev-acquire-failed', devAgentId },
4862
- });
4863
- return null;
4864
- }
4865
- // Phase 2b (lock): atomic transition + persist newToken/phase。
4866
- // 必须在 continueSession 之前;否则崩溃后 setupRecoveredSpecSignals 读旧 token,
4867
- // 与 dev 输出的 newToken signal 不匹配 → 链路死。
4868
- const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: newToken, phase: 'spec' });
4869
- if (!transition) {
4870
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4871
- .catch(() => undefined);
4872
- await this.safeEmit({
4873
- id: '',
4874
- type: 'human.intervention',
4875
- timestamp: new Date().toISOString(),
4876
- projectId,
4877
- agentId: devAgentId,
4878
- taskId,
4879
- data: { phase: 'spec-fix-transition-failed', devAgentId },
4880
- });
4881
- return null;
4882
- }
4883
- // Phase 2c: continueSession 透传 newToken + currentRound 给 prompt。
4884
- // 失败时回滚 transition + 清新 token,避免 task 留在 fixing 但 dev 无 spec-fix prompt 的 stuck。
4885
- let resumed = false;
4886
- try {
4887
- resumed = await this.continueSession(taskId, devAgentId, 'spec-fix', {
4888
- specFindings: findings,
4889
- signalToken: newToken,
4890
- currentSpecRound: currentRound,
4891
- bypassTaskStatusGate: true,
4892
- });
4893
- }
4894
- catch (err) {
4895
- // 同 spec-review:DispatchTerminalError 走 failTaskForDispatchError 统一处理
4896
- // (ack_unknown → markAwaitingHuman,其他 reason → release + task failed)。
4897
- if (err instanceof DispatchTerminalError) {
4898
- await this.failTaskForDispatchError(taskId, 'spec-fix', devAgentId, err);
4899
- }
4900
- else if (err instanceof EnsureSessionError && err.partial.handled) {
4901
- // handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release。
4902
- }
4903
- else {
4904
- await this.rollbackSpecFixTransition(taskId);
4905
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4906
- .catch(() => undefined);
4907
- }
4908
- console.error(`[AgentManager] dispatchSpecFixToDev continueSession(dev=${devAgentId}) failed:`, err);
4909
- throw err;
4910
- }
4911
- if (!resumed) {
4912
- await this.rollbackSpecFixTransition(taskId);
4913
- await this.releaseAgentForTask(devAgentId, taskId, 'idle')
4914
- .catch(() => undefined);
4915
- await this.safeEmit({
4916
- id: '',
4917
- type: 'human.intervention',
4918
- timestamp: new Date().toISOString(),
4919
- projectId,
4920
- agentId: devAgentId,
4921
- taskId,
4922
- data: { phase: 'spec-fix-resume-failed', devAgentId },
4923
- });
4924
- return null;
4925
- }
4926
- // Phase 3: set up watcher。
4927
- await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'spec-fixed', newToken);
4928
- return await this.taskStore.get(taskId);
4929
- }
4930
- // continueSession 失败回滚:fixing → review + 清新 token。
4931
- // 保留 phase='spec' 与 qaAgentId — 失败后 review 状态需要人工 retry 或重新 dispatch。
4932
- async rollbackSpecFixTransition(taskId) {
4933
- await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['fixing'] }, { signalToken: undefined });
4934
- }
4935
4727
  async transitionToCodePhase(taskId) {
4936
4728
  const task = await this.taskStore.get(taskId);
4937
4729
  if (!task)
@@ -5000,7 +4792,7 @@ export class AgentManager {
5000
4792
  resumed = await this.continueSession(taskId, devAgentId, 'code');
5001
4793
  }
5002
4794
  catch (err) {
5003
- // 同 spec-review/spec-fix:DispatchTerminalError 委托给 failTaskForDispatchError
4795
+ // 同 dispatchServerReviewToQa/dispatchServerFixToDev:DispatchTerminalError 委托给 failTaskForDispatchError
5004
4796
  // (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
5005
4797
  if (err instanceof DispatchTerminalError) {
5006
4798
  await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
@@ -5038,7 +4830,8 @@ export class AgentManager {
5038
4830
  const task = await this.taskStore.get(taskId);
5039
4831
  if (!task)
5040
4832
  throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
5041
- if (task.reviewMode !== 'server') {
4833
+ // spec 阶段恒为 server 中转;code 阶段仍 server-only。
4834
+ if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5042
4835
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5043
4836
  }
5044
4837
  const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
@@ -5073,18 +4866,22 @@ export class AgentManager {
5073
4866
  originalRound: roundField,
5074
4867
  originalBatchIndex: task.batchIndex,
5075
4868
  originalBatchTotal: task.batchTotal,
4869
+ originalPhase: task.phase,
5076
4870
  };
5077
4871
  });
5078
4872
  if (!claim)
5079
4873
  return null;
5080
4874
  const { qaId, devAgentId, projectId, newToken, newRound } = claim;
5081
4875
  // continueSession failure after the transition would otherwise strand the
5082
- // task in 'review' with a fresh token nobody will ever signal (PR #288).
4876
+ // task in 'review' with a fresh token nobody will ever signal.
5083
4877
  const rollback = async () => {
5084
4878
  await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
5085
4879
  signalToken: claim.originalToken,
5086
4880
  batchIndex: claim.originalBatchIndex,
5087
4881
  batchTotal: claim.originalBatchTotal,
4882
+ // spec transition 写入 phase:'spec';github 首轮失败若不还原,dev 直发
4883
+ // pr-created 会被 legacy freshness gate 拒(设计 §2)。
4884
+ phase: claim.originalPhase,
5088
4885
  ...(opts.phase === 'spec'
5089
4886
  ? { specReviewRound: claim.originalRound }
5090
4887
  : { reviewRound: claim.originalRound }),
@@ -5093,7 +4890,7 @@ export class AgentManager {
5093
4890
  // The entry signal (code/spec-done|fixed) was already consumed by the
5094
4891
  // watcher; a pre-transition failure must re-arm it with the unrotated token
5095
4892
  // or the agent's re-emit after the operator fixes availability has no
5096
- // consumer (PR #288).
4893
+ // consumer.
5097
4894
  const rearmEntrySignal = async () => {
5098
4895
  const entryKind = claim.originalStatus === 'fixing'
5099
4896
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -5177,7 +4974,7 @@ export class AgentManager {
5177
4974
  // A continuation consumed the QA's reviewed signal (not the dev's entry
5178
4975
  // signal): rollback restores the prior slice's review/token, so re-arm the
5179
4976
  // reviewed watcher — the QA's re-emit replays the stored batch findings and
5180
- // resumes the next-slice dispatch (PR #288).
4977
+ // resumes the next-slice dispatch.
5181
4978
  const rearmConsumedSignal = async () => {
5182
4979
  if (opts.continuation) {
5183
4980
  await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
@@ -5234,7 +5031,7 @@ export class AgentManager {
5234
5031
  const task = await this.taskStore.get(taskId);
5235
5032
  if (!task)
5236
5033
  throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5237
- if (task.reviewMode !== 'server') {
5034
+ if (task.reviewMode !== 'server' && task.phase !== 'spec') {
5238
5035
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5239
5036
  }
5240
5037
  if (!task.agentId)
@@ -5247,7 +5044,7 @@ export class AgentManager {
5247
5044
  taskPhase: (task.phase ?? 'code'),
5248
5045
  currentSpecRound: task.specReviewRound,
5249
5046
  // Continue-one-round enters from max_rounds — failure must restore THAT,
5250
- // not silently demote the human's pause decision to 'review' (PR #288).
5047
+ // not silently demote the human's pause decision to 'review'.
5251
5048
  originalStatus: task.status,
5252
5049
  originalToken: task.signalToken,
5253
5050
  };
@@ -5343,7 +5140,7 @@ export class AgentManager {
5343
5140
  await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5344
5141
  // Rollback restored review/old-token, but the QA's reviewed signal was
5345
5142
  // consumed — without a subscriber its re-emit can never retry the fix
5346
- // dispatch (PR #288).
5143
+ // dispatch.
5347
5144
  await rearmReviewedSignal();
5348
5145
  }
5349
5146
  throw err;
@@ -5379,7 +5176,7 @@ export class AgentManager {
5379
5176
  await this.updateTask(taskId, { signalToken: newToken });
5380
5177
  // The publish prompt never reached the pane — restore the pre-rotation token
5381
5178
  // (so recovery still matches the pre-dispatch arm) and clear the delivery
5382
- // marker so retry knows this approved state is preemptible (PR #288).
5179
+ // marker so retry knows this approved state is preemptible.
5383
5180
  const rollbackToken = async () => {
5384
5181
  await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
5385
5182
  .catch(() => undefined);
@@ -5402,7 +5199,7 @@ export class AgentManager {
5402
5199
  // path below clears it. The remaining crash window (marker written, paste
5403
5200
  // never ran) fails CLOSED — retry 409s on a publish that never started and
5404
5201
  // the operator escapes via Cancel — instead of the old window's fail-open
5405
- // double publish (paste ran, marker missing, retry re-pastes) (PR #288).
5202
+ // double publish (paste ran, marker missing, retry re-pastes).
5406
5203
  await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
5407
5204
  let resumed = false;
5408
5205
  try {
@@ -5470,24 +5267,34 @@ export class AgentManager {
5470
5267
  return;
5471
5268
  }
5472
5269
  try {
5473
- await this.injectTextToAgent(qaAgentId, body);
5270
+ await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
5474
5271
  }
5475
5272
  catch (err) {
5476
5273
  console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
5477
5274
  }
5478
5275
  }
5479
5276
  // Plain text paste + submit into a live agent pane (no skills, no ack protocol).
5480
- async injectTextToAgent(agentId, text) {
5277
+ async injectTextToAgent(agentId, text, opts = {}) {
5481
5278
  const cfg = this.getAgentConfig(agentId);
5482
5279
  if (!cfg)
5483
5280
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
5484
- const state = await this.agentStore.get(agentId);
5485
- const paneId = state?.paneId;
5486
- if (!paneId)
5487
- throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5488
- const tmux = new TmuxManager(this.createRunnerFor(cfg));
5489
- await tmux.injectPrompt(paneId, text, agentId);
5490
- await tmux.sendEnter(paneId);
5281
+ await this.acquireCompactGuard(agentId);
5282
+ try {
5283
+ // 锁内重读:guard 等待期间绑定可能已易主,过期文本决不落 pane。
5284
+ const state = await this.agentStore.get(agentId);
5285
+ if (opts.expectedTaskId !== undefined && state?.taskId !== opts.expectedTaskId) {
5286
+ throw new Error(`injectTextToAgent: agent ${agentId} no longer bound to ${opts.expectedTaskId}`);
5287
+ }
5288
+ const paneId = state?.paneId;
5289
+ if (!paneId)
5290
+ throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5291
+ const tmux = new TmuxManager(this.createRunnerFor(cfg));
5292
+ await tmux.injectPrompt(paneId, text, agentId);
5293
+ await tmux.sendEnter(paneId);
5294
+ }
5295
+ finally {
5296
+ this.compactInFlight.delete(agentId);
5297
+ }
5491
5298
  }
5492
5299
  // Human gate confirm (spec §10): executes the configured completion for
5493
5300
  // ready (server mode) / merge-ready (github mode) tasks.
@@ -5500,7 +5307,7 @@ export class AgentManager {
5500
5307
  const project = this.getProjectConfig(task.projectId);
5501
5308
  const mergeAuto = project?.merge === 'auto';
5502
5309
  // Snapshot from verdict time — a hot config flip between publish and
5503
- // confirm must not reroute an already-published artifact (PR #288).
5310
+ // confirm must not reroute an already-published artifact.
5504
5311
  const afterDone = this.resolveAfterDone(task);
5505
5312
  if (task.status === 'merge-ready') {
5506
5313
  if (mergeAuto && task.prNumber) {
@@ -5579,7 +5386,7 @@ export class AgentManager {
5579
5386
  }
5580
5387
  // Merge failures keep the gate: transient gh/network errors retry via another
5581
5388
  // Confirm, a stale head resolves via Cancel or an external decision — terminal
5582
- // 'failed' would orphan the published PR/branch outside the task flow (PR #288).
5389
+ // 'failed' would orphan the published PR/branch outside the task flow.
5583
5390
  async executeConfirmMerge(task, merge) {
5584
5391
  try {
5585
5392
  await merge();
@@ -5650,7 +5457,7 @@ export class AgentManager {
5650
5457
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5651
5458
  if (db.exitCode !== 0 || defaultBranch === '') {
5652
5459
  // A silent 'main' fallback would push the reviewed branch onto the wrong
5653
- // ref for repos whose default branch differs (PR #288).
5460
+ // ref for repos whose default branch differs.
5654
5461
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
5655
5462
  }
5656
5463
  const fetch = await runner.exec(`${cd}git fetch origin`);
@@ -5658,7 +5465,7 @@ export class AgentManager {
5658
5465
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
5659
5466
  }
5660
5467
  // Reviewed-head guard (branch path): refuse if origin/<branch> moved after
5661
- // the gate — symmetric with the pr path's --match-head-commit (PR #288).
5468
+ // the gate — symmetric with the pr path's --match-head-commit.
5662
5469
  if (task.latestHeadSha) {
5663
5470
  const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
5664
5471
  if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
@@ -5674,7 +5481,7 @@ export class AgentManager {
5674
5481
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5675
5482
  }
5676
5483
  // The merge has landed; branch deletion is cleanup — a transient failure
5677
- // here must not flip an already-merged task to failed (PR #288).
5484
+ // here must not flip an already-merged task to failed.
5678
5485
  const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
5679
5486
  if (del.exitCode !== 0) {
5680
5487
  console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);