baxian 1.2.0 → 1.2.2

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 (73) 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 +8 -4
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +120 -73
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal.d.ts.map +1 -1
  10. package/dist/agent/phase-signal.js.map +1 -1
  11. package/dist/agent/preflight.d.ts.map +1 -1
  12. package/dist/agent/preflight.js +49 -22
  13. package/dist/agent/preflight.js.map +1 -1
  14. package/dist/agent/prompt.d.ts +1 -1
  15. package/dist/agent/prompt.d.ts.map +1 -1
  16. package/dist/agent/prompt.js +8 -7
  17. package/dist/agent/prompt.js.map +1 -1
  18. package/dist/agent/repo-store.d.ts +5 -3
  19. package/dist/agent/repo-store.d.ts.map +1 -1
  20. package/dist/agent/repo-store.js +53 -24
  21. package/dist/agent/repo-store.js.map +1 -1
  22. package/dist/agent/review-transport.js +2 -2
  23. package/dist/agent/review-transport.js.map +1 -1
  24. package/dist/agent/tmux-probe-poller.js +4 -4
  25. package/dist/agent/tmux-probe-poller.js.map +1 -1
  26. package/dist/agent/tmux.js +9 -9
  27. package/dist/agent/tmux.js.map +1 -1
  28. package/dist/api/agents.d.ts.map +1 -1
  29. package/dist/api/agents.js +5 -1
  30. package/dist/api/agents.js.map +1 -1
  31. package/dist/api/config.js +1 -1
  32. package/dist/api/config.js.map +1 -1
  33. package/dist/api/hosts.js +2 -2
  34. package/dist/api/hosts.js.map +1 -1
  35. package/dist/api/tasks.js +1 -1
  36. package/dist/api/tasks.js.map +1 -1
  37. package/dist/config/loader.js +5 -1
  38. package/dist/config/loader.js.map +1 -1
  39. package/dist/config/validator.d.ts.map +1 -1
  40. package/dist/config/validator.js +37 -3
  41. package/dist/config/validator.js.map +1 -1
  42. package/dist/event/handlers.d.ts.map +1 -1
  43. package/dist/event/handlers.js +10 -11
  44. package/dist/event/handlers.js.map +1 -1
  45. package/dist/event/server-handlers.js +10 -10
  46. package/dist/event/server-handlers.js.map +1 -1
  47. package/dist/github/poller.d.ts.map +1 -1
  48. package/dist/github/poller.js +13 -3
  49. package/dist/github/poller.js.map +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +7 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/shared/constants.js +1 -1
  54. package/dist/shared/constants.js.map +1 -1
  55. package/dist/shared/git-url.d.ts +14 -0
  56. package/dist/shared/git-url.d.ts.map +1 -0
  57. package/dist/shared/git-url.js +76 -0
  58. package/dist/shared/git-url.js.map +1 -0
  59. package/dist/shared/index.d.ts +1 -0
  60. package/dist/shared/index.d.ts.map +1 -1
  61. package/dist/shared/index.js +1 -0
  62. package/dist/shared/index.js.map +1 -1
  63. package/dist/shared/types.d.ts +1 -1
  64. package/dist/shared/types.d.ts.map +1 -1
  65. package/dist/state/snapshot.js +1 -1
  66. package/dist/state/snapshot.js.map +1 -1
  67. package/dist/terminal/attach.d.ts.map +1 -1
  68. package/dist/terminal/attach.js +15 -5
  69. package/dist/terminal/attach.js.map +1 -1
  70. package/dist/web/assets/index-COIOmeco.js +4 -0
  71. package/dist/web/index.html +1 -1
  72. package/package.json +1 -1
  73. package/dist/web/assets/index-OtgjyQI1.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,7 +73,7 @@ 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).
76
+ // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt.
77
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';
@@ -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;
@@ -185,12 +185,32 @@ export class AgentManager {
185
185
  getReviewStore() {
186
186
  return this.reviewStore;
187
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
+ }
188
196
  // Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
189
- // config — `??` would swallow it and reroute an already-decided task (PR #288).
197
+ // config — `??` would swallow it and reroute an already-decided task.
190
198
  resolveAfterDone(task) {
191
199
  if (task.afterDone !== undefined)
192
200
  return task.afterDone;
193
- 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;
194
214
  }
195
215
  getReviewTransport() {
196
216
  this.reviewTransportInstance ??= new ReviewTransport({
@@ -564,7 +584,7 @@ export class AgentManager {
564
584
  if (!discoveredPaneId)
565
585
  return false;
566
586
  const probeNow = new Date().toISOString();
567
- // 撤销 updatedAt guard(round-13 codex review):updatedAt 太宽,正常 background updates
587
+ // 不用 updatedAt guardupdatedAt 太宽,正常 background updates
568
588
  // (repoPath refresh / poller bump 等) 也会触发假阳性让合法 retry dialog 路径误拒。
569
589
  // race ("DELETE+recreate 后旧回调写新 agent") 在持锁路径下是 theoretical (retry endpoint 持锁
570
590
  // 全程到 handleDialogPendingFromRuntime 返回;startSession/continueSession 由 acquireAgentForTask
@@ -601,7 +621,7 @@ export class AgentManager {
601
621
  // fromStatus 来自 caller 显式计算:startSession/continueSession 用 opts.dialogFailFromStatuses ??
602
622
  // PHASE_EXPECTED_STATUS[phase],dispatchReviewToQa 走 bypassTaskStatusGate 时显式传 [taskStatusAtClaim]
603
623
  // (manual review 入口可能是 approved/fixing/in_progress,但 phase='review' 的 default 只接受 'review' →
604
- // 不传就 skip → task 卡 active 死锁,见 owner round-15 评审)。
624
+ // 不传就 skip → task 卡 active 死锁)。
605
625
  const expectedFromStatuses = opts.expectedFromStatuses ?? [...ACTIVE_TASK_STATUSES];
606
626
  const transitioned = await this.transitionTaskStatus(state.taskId, 'failed', { fromStatus: expectedFromStatuses });
607
627
  if (transitioned) {
@@ -1220,7 +1240,7 @@ export class AgentManager {
1220
1240
  // code-dispatch-failed: the code-phase prompt never reached the pane (spec
1221
1241
  // approval already transitioned the task). Resume = clear the hold AND
1222
1242
  // redispatch the code prompt (outside this lock) — without the redispatch
1223
- // the task would stay in_progress with nothing running (PR #288).
1243
+ // the task would stay in_progress with nothing running.
1224
1244
  if (state.awaitingPhase === 'code-dispatch-failed'
1225
1245
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1226
1246
  && state.taskId) {
@@ -1413,7 +1433,7 @@ export class AgentManager {
1413
1433
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1414
1434
  // Human gates are decision states, not running work: an absent agent
1415
1435
  // session must not terminally fail a task whose published PR/branch
1416
- // would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
1436
+ // would then be orphaned — Confirm/Cancel remain the only exits.
1417
1437
  if (t.status === 'ready' || t.status === 'merge-ready')
1418
1438
  continue;
1419
1439
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -1655,7 +1675,7 @@ export class AgentManager {
1655
1675
  return this.config.project.find(p => p.id === projectId);
1656
1676
  }
1657
1677
  getProjectByRepo(repo) {
1658
- return this.config.project.find(p => p.repo === repo);
1678
+ return this.config.project.find(p => repoSlug(p.repo) === repo);
1659
1679
  }
1660
1680
  findQaPartner(devAgentId) {
1661
1681
  for (const project of this.config.project) {
@@ -1845,10 +1865,11 @@ export class AgentManager {
1845
1865
  // Three independent endpoints — fetch concurrently, not back-to-back. Reviews
1846
1866
  // cover the same-identity `gh pr review --comment` reply path (a PR review with
1847
1867
  // a body, surfaced via submitted_at, not an inline/issue comment).
1868
+ const repo = repoSlug(project.repo);
1848
1869
  const [inlineReplies, issueComments, reviews] = await Promise.all([
1849
- this.ghCreatedAt(`repos/${project.repo}/pulls/${task.prNumber}/comments`, '.[] | select(.in_reply_to_id != null) | .created_at'),
1850
- this.ghCreatedAt(`repos/${project.repo}/issues/${task.prNumber}/comments`, '.[].created_at'),
1851
- 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'),
1852
1873
  ]);
1853
1874
  const stamps = [...inlineReplies, ...issueComments, ...reviews];
1854
1875
  return stamps.some(ts => {
@@ -1876,7 +1897,7 @@ export class AgentManager {
1876
1897
  if (!project) {
1877
1898
  throw new Error(`fetchPrHeadSha: unknown project ${task.projectId}`);
1878
1899
  }
1879
- 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`);
1880
1901
  if (result.exitCode !== 0) {
1881
1902
  throw new Error(`gh pr view failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
1882
1903
  }
@@ -1900,7 +1921,7 @@ export class AgentManager {
1900
1921
  const project = this.getProjectConfig(task.projectId);
1901
1922
  if (!project)
1902
1923
  return undefined;
1903
- 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'`);
1904
1925
  if (result.exitCode !== 0)
1905
1926
  return undefined;
1906
1927
  const [headRefName, headSha] = result.stdout.trim().split('\t');
@@ -1979,7 +2000,7 @@ export class AgentManager {
1979
2000
  createRepoStore(agent, project, runner) {
1980
2001
  const host = resolveAgentHost(this.config.host, agent.host);
1981
2002
  if (this.repoStoreFactory) {
1982
- return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache);
2003
+ return this.repoStoreFactory(runner, repoSlug(project.repo), agent.mode, host, this.repoCache);
1983
2004
  }
1984
2005
  return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
1985
2006
  }
@@ -2059,9 +2080,19 @@ export class AgentManager {
2059
2080
  return this.withTaskLock(async () => {
2060
2081
  const taskId = await this.taskStore.nextId();
2061
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
+ }
2062
2093
  // Stage images first so the task is written + emitted (task.created) WITH its images already
2063
2094
  // on disk — a pending task is never observable, or crash-recoverable, without them. A persist
2064
- // 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.
2065
2096
  const imageFilenames = input.images?.length
2066
2097
  ? await this.persistTaskImages(taskId, input.images)
2067
2098
  : undefined;
@@ -2077,7 +2108,7 @@ export class AgentManager {
2077
2108
  reviewRound: 0,
2078
2109
  status: 'pending',
2079
2110
  branch: BRANCH_PREFIX + taskId,
2080
- reviewMode: this.config.review.mode ?? 'github',
2111
+ reviewMode: this.effectiveReviewMode(projectId),
2081
2112
  createdAt: now,
2082
2113
  updatedAt: now,
2083
2114
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2106,7 +2137,7 @@ export class AgentManager {
2106
2137
  reviewRound: 0,
2107
2138
  status: 'pending',
2108
2139
  branch: BRANCH_PREFIX + taskId,
2109
- reviewMode: this.config.review.mode ?? 'github',
2140
+ reviewMode: this.effectiveReviewMode(projectId),
2110
2141
  createdAt: now,
2111
2142
  updatedAt: now,
2112
2143
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2139,7 +2170,7 @@ export class AgentManager {
2139
2170
  reviewRound: 0,
2140
2171
  status: 'pending',
2141
2172
  branch: BRANCH_PREFIX + taskId,
2142
- reviewMode: this.config.review.mode ?? 'github',
2173
+ reviewMode: this.effectiveReviewMode(projectId),
2143
2174
  createdAt: now,
2144
2175
  updatedAt: now,
2145
2176
  ...(qa ? { qaAgentId: qa.id } : {}),
@@ -2171,7 +2202,7 @@ export class AgentManager {
2171
2202
  reviewRound: 0,
2172
2203
  status: 'in_progress',
2173
2204
  branch: BRANCH_PREFIX + taskId,
2174
- reviewMode: this.config.review.mode ?? 'github',
2205
+ reviewMode: this.effectiveReviewMode(projectId),
2175
2206
  createdAt: now,
2176
2207
  updatedAt: now,
2177
2208
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -2203,7 +2234,7 @@ export class AgentManager {
2203
2234
  }
2204
2235
  async createAndStartTask(projectId, input, opts = {}) {
2205
2236
  // createTask stages images atomically before the task is visible (store + task.created),
2206
- // 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.
2207
2238
  const task = await this.createTask(projectId, input);
2208
2239
  if (task.status === 'in_progress' && task.agentId) {
2209
2240
  // Persist token first — prompt build 和 watcher 验证共用 task.signalToken。
@@ -2294,7 +2325,7 @@ export class AgentManager {
2294
2325
  }
2295
2326
  return (await this.taskStore.get(taskId)) ?? null;
2296
2327
  }
2297
- /** 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). */
2298
2329
  async attachImageToRunningAgent(agentId, bytes, ext) {
2299
2330
  const cfg = this.getAgentConfig(agentId);
2300
2331
  if (!cfg)
@@ -2320,8 +2351,7 @@ export class AgentManager {
2320
2351
  this.compactInFlight.delete(agentId);
2321
2352
  }
2322
2353
  }
2323
- // busy 时注入会把指令拼进正在运行的回合,宁可 409。
2324
- async compactAgent(agentId) {
2354
+ async sendSlashCommand(agentId, command) {
2325
2355
  const cfg = this.getAgentConfig(agentId);
2326
2356
  if (!cfg)
2327
2357
  throw new ApiError(404, `Unknown agent: ${agentId}`);
@@ -2344,7 +2374,7 @@ export class AgentManager {
2344
2374
  || now.paneId !== paneId
2345
2375
  || now.taskId !== taskIdAtStart
2346
2376
  || now.updatedAt !== updatedAtAtStart) {
2347
- throw new ApiError(409, `Agent ${agentId} session changed while waiting; compact aborted`);
2377
+ throw new ApiError(409, `Agent ${agentId} session changed while waiting; ${command} aborted`);
2348
2378
  }
2349
2379
  };
2350
2380
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
@@ -2359,18 +2389,22 @@ export class AgentManager {
2359
2389
  };
2360
2390
  await waitReady();
2361
2391
  await assertSessionUnchanged();
2362
- // 残留草稿会被「草稿/compact」连带提交;C-c 清线后再发。
2363
2392
  await tmux.sendKeysToPane(paneId, 'C-c');
2364
2393
  await waitReady();
2365
2394
  await assertSessionUnchanged();
2366
- await tmux.sendKeysLiteral(paneId, '/compact');
2395
+ await tmux.sendKeysLiteral(paneId, command);
2367
2396
  await tmux.sendEnter(paneId);
2368
- // 压缩仍在运行:guard 交给后台尾随等待,runtime 回到 idle 才释放,
2369
- // 否则紧随的上传/派发会粘进压缩中的 pane。
2397
+ if (command === '/clear') {
2398
+ await this.agentStore.update(agentId, (s) => {
2399
+ if (!s)
2400
+ return AGENT_STORE_NOOP;
2401
+ return { ...s, injectedSkills: undefined };
2402
+ });
2403
+ }
2370
2404
  guardHandedOff = true;
2371
2405
  void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
2372
2406
  .catch(err => {
2373
- console.warn(`[AgentManager] compactAgent(${agentId}) post-/compact idle wait failed:`, err);
2407
+ console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
2374
2408
  })
2375
2409
  .finally(() => {
2376
2410
  this.compactInFlight.delete(agentId);
@@ -2381,6 +2415,12 @@ export class AgentManager {
2381
2415
  this.compactInFlight.delete(agentId);
2382
2416
  }
2383
2417
  }
2418
+ async compactAgent(agentId) {
2419
+ return this.sendSlashCommand(agentId, '/compact');
2420
+ }
2421
+ async clearAgent(agentId) {
2422
+ return this.sendSlashCommand(agentId, '/clear');
2423
+ }
2384
2424
  async persistTaskImages(taskId, images) {
2385
2425
  const dir = join(this.imageStagingRoot, taskId);
2386
2426
  await mkdir(dir, { recursive: true });
@@ -2392,7 +2432,7 @@ export class AgentManager {
2392
2432
  }
2393
2433
  return filenames;
2394
2434
  }
2395
- // Retry reads staged bytes up-front; a missing source is a visible 409, never a silent drop (task-055).
2435
+ // Retry reads staged bytes up-front; a missing source is a visible 409, never a silent drop.
2396
2436
  async readStagedImages(taskId, filenames) {
2397
2437
  const dir = join(this.imageStagingRoot, taskId);
2398
2438
  const out = [];
@@ -2410,7 +2450,7 @@ export class AgentManager {
2410
2450
  return out;
2411
2451
  }
2412
2452
  // Materialize staged task images onto the agent host at dispatch; absolute host paths get
2413
- // woven into the prompt. Missing staging aborts the dispatch loudly (no silent skip, task-055).
2453
+ // woven into the prompt. Missing staging aborts the dispatch loudly (no silent skip).
2414
2454
  async materializeTaskImages(runner, task) {
2415
2455
  const filenames = task.images ?? [];
2416
2456
  if (filenames.length === 0)
@@ -2434,7 +2474,7 @@ export class AgentManager {
2434
2474
  // Dev-facing deliverable phases all carry the task's uploaded images, since the image is a
2435
2475
  // persistent task input the dev needs while producing or revising the spec/code — and a fresh
2436
2476
  // runtime (restart/recovery) loses the original context. IMAGE_DISPATCH_PHASES 中的后续阶段经
2437
- // continueSession 触发此方法;QA 阶段和 post-approve 不传图(task-055)。
2477
+ // continueSession 触发此方法;QA 阶段和 post-approve 不传图。
2438
2478
  async imagePathsForDispatch(runner, task, phase) {
2439
2479
  if (!IMAGE_DISPATCH_PHASES.has(phase))
2440
2480
  return [];
@@ -2653,6 +2693,14 @@ export class AgentManager {
2653
2693
  const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2654
2694
  let prompt;
2655
2695
  try {
2696
+ // Server review has no platform fallback: a dev with no QA partner would emit a
2697
+ // completion signal nobody consumes and the task strands (server mode is forced
2698
+ // for non-GitHub repos via effectiveReviewMode). Enforce at this single dispatch
2699
+ // chokepoint — the catch below removes the worktree, then the task fails cleanly.
2700
+ if (task.reviewMode === 'server' && !hasQaPartner) {
2701
+ throw new DispatchTerminalError('server_review_needs_qa', `Task ${task.id} runs in server review mode but dev "${agentId}" has no QA partner; ` +
2702
+ `pair a QA agent with this dev (server review has no platform fallback to absorb the verdict).`);
2703
+ }
2656
2704
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2657
2705
  prompt = buildPromptInline({
2658
2706
  task,
@@ -2775,7 +2823,7 @@ export class AgentManager {
2775
2823
  }
2776
2824
  // dedup baseline 记录的是「已 paste 进 idle composer 的 skill 文本」:paste 落入 composer 即进
2777
2825
  // REPL 上下文,与 submit-ack 无关。ack 超时(首个 Enter 被吞)下 skill 仍在 composer,跳过落盘会让
2778
- // 下一轮整组重注入——正是 task-071 的 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
2826
+ // 下一轮整组重注入——即 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
2779
2827
  // 已 busy,文本进了运行中输入流而非 composer)则不能落盘,否则恢复提示会缺必需 skill。freshRuntime
2780
2828
  // 负责 REPL 真正重启时作废 baseline。
2781
2829
  if (ack.composerDelivered) {
@@ -2905,7 +2953,7 @@ export class AgentManager {
2905
2953
  });
2906
2954
  // "pane already busy at baseline" means the paste landed on an already-running input stream,
2907
2955
  // NOT an idle composer — the skills did NOT become context, so callers must not record them
2908
- // as injected (codex 3345468647). Any other timeout (idle composer / swallowed Enter) did
2956
+ // as injected. Any other timeout (idle composer / swallowed Enter) did
2909
2957
  // deliver the prompt text into the composer.
2910
2958
  const composerDelivered = !/pane already busy at baseline/.test(message);
2911
2959
  return { acked: false, composerDelivered };
@@ -3028,7 +3076,7 @@ export class AgentManager {
3028
3076
  && opts.postApproveRedispatchCount > 0
3029
3077
  && !ensure.freshRuntime;
3030
3078
  // code phase (post spec-approval) flows through here, not startSession — materialize the
3031
- // task's uploaded images so a fresh code-phase context still sees their paths (task-055).
3079
+ // task's uploaded images so a fresh code-phase context still sees their paths.
3032
3080
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3033
3081
  prompt = buildPromptInline({
3034
3082
  task,
@@ -3440,7 +3488,7 @@ export class AgentManager {
3440
3488
  let qaToRelease;
3441
3489
  // Server-mode ready gate may have already published remote artifacts
3442
3490
  // (pushed branch / open PR). Capture before flipping to cancelled so the
3443
- // post-lock cleanup can retire them instead of orphaning (PR #288).
3491
+ // post-lock cleanup can retire them instead of orphaning.
3444
3492
  // mayBeInFlight: approved+marker means the publish prompt may STILL be
3445
3493
  // running — retirement must wait for the dev interrupt or the in-flight
3446
3494
  // push/pr-create would recreate the artifacts right after cleanup.
@@ -3484,7 +3532,7 @@ export class AgentManager {
3484
3532
  }
3485
3533
  }
3486
3534
  else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
3487
- // GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
3535
+ // GitHub-mode gate cancel leaves the same orphaned PR/branch.
3488
3536
  publishedCleanup = {
3489
3537
  afterDone: 'pr',
3490
3538
  branch: task.branch,
@@ -3570,7 +3618,7 @@ export class AgentManager {
3570
3618
  const project = this.getProjectConfig(result.projectId);
3571
3619
  try {
3572
3620
  if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
3573
- const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
3621
+ const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3574
3622
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
3575
3623
  if (close.exitCode !== 0)
3576
3624
  throw new Error(close.stderr.trim() || close.stdout.trim());
@@ -3605,7 +3653,7 @@ export class AgentManager {
3605
3653
  }
3606
3654
  return result;
3607
3655
  }
3608
- // task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
3656
+ // create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
3609
3657
  // 允许入队(落 pending);可执行性判断下沉到 dispatchPendingTask(或 createTask 已空闲分支)。
3610
3658
  // 仍保留:agent 存在/同 project/role=dev(非空时)+ prompt size 上界。
3611
3659
  async validateTaskDispatch(projectId, input) {
@@ -3868,7 +3916,7 @@ export class AgentManager {
3868
3916
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
3869
3917
  }
3870
3918
  // Server-mode continue: grant one round past the cap, then re-run the server
3871
- // fix protocol from the stored findings — no PR exists at this point (PR #288).
3919
+ // fix protocol from the stored findings — no PR exists at this point.
3872
3920
  if (task.reviewMode === 'server') {
3873
3921
  if (!task.agentId) {
3874
3922
  throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
@@ -4070,7 +4118,7 @@ export class AgentManager {
4070
4118
  return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
4071
4119
  }
4072
4120
  if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
4073
- // Code-track fixing: dev emits pr-fixed when the round is done (task-060).
4121
+ // Code-track fixing: dev emits pr-fixed when the round is done.
4074
4122
  return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
4075
4123
  }
4076
4124
  if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
@@ -4156,7 +4204,7 @@ export class AgentManager {
4156
4204
  preferredAgentId: old.preferredAgentId,
4157
4205
  };
4158
4206
  // Retry preserves uploaded images: read the old task's staged bytes up-front
4159
- // (missing → visible 409 before any new task/binding is created). task-055.
4207
+ // (missing → visible 409 before any new task/binding is created).
4160
4208
  if (old.images?.length) {
4161
4209
  input.images = await this.readStagedImages(old.id, old.images);
4162
4210
  }
@@ -4222,14 +4270,14 @@ export class AgentManager {
4222
4270
  const matchHead = opts.matchHeadSha
4223
4271
  ? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
4224
4272
  : '';
4225
- const result = await this.platformRunner.exec(`gh pr merge ${task.prNumber} --repo ${shellQuote(project.repo)}${matchHead} --squash --delete-branch`);
4273
+ const result = await this.platformRunner.exec(`gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash --delete-branch`);
4226
4274
  if (result.exitCode !== 0) {
4227
4275
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4228
4276
  }
4229
4277
  }
4230
4278
  // Manually finish a max_rounds task: merge its PR, then reuse the normal merged
4231
4279
  // cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
4232
- // cleanup + /compact + release). Same path the poller drives when it detects the merge.
4280
+ // cleanup + /clear + release). Same path the poller drives when it detects the merge.
4233
4281
  async markTaskComplete(taskId) {
4234
4282
  const peek = await this.taskStore.get(taskId);
4235
4283
  if (!peek)
@@ -4247,7 +4295,7 @@ export class AgentManager {
4247
4295
  const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
4248
4296
  try {
4249
4297
  // Server-mode publish retry: a failed afterDone dispatch leaves the task
4250
- // 'approved' with dev released — mark-complete re-runs the publish (PR #288).
4298
+ // 'approved' with dev released — mark-complete re-runs the publish.
4251
4299
  const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
4252
4300
  if (!serverApprovedRetry && task.status !== 'max_rounds') {
4253
4301
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
@@ -4267,7 +4315,7 @@ export class AgentManager {
4267
4315
  // publishDispatchedAt persists delivery across restarts: set = the
4268
4316
  // prompt reached the pane before the restart (still in flight, 409);
4269
4317
  // cleared = the dispatch failed and this approved state is retryable —
4270
- // stop the recovered watch and let the retry own the dispatch (PR #288).
4318
+ // stop the recovered watch and let the retry own the dispatch.
4271
4319
  if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
4272
4320
  if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
4273
4321
  throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
@@ -4287,13 +4335,13 @@ export class AgentManager {
4287
4335
  return (await this.taskStore.get(taskId));
4288
4336
  }
4289
4337
  // Server-mode capped task, human accepts as-is: no PR exists yet — run the
4290
- // afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
4338
+ // afterDone flow (or finish directly) instead of the legacy PR merge.
4291
4339
  // Inside the in-flight claim so a concurrent Continue can't act on the same
4292
4340
  // max_rounds snapshot and release dev mid-publish.
4293
4341
  if (task.reviewMode === 'server') {
4294
4342
  // Max_rounds never routed an approve verdict — snapshot afterDone NOW so
4295
4343
  // the eventual ready-confirm uses this decision, not future hot config.
4296
- const afterDone = this.config.review.afterDone ?? null;
4344
+ const afterDone = this.coerceAfterDone(task.projectId, this.config.review.afterDone);
4297
4345
  await this.updateTask(taskId, { afterDone });
4298
4346
  if (afterDone === null) {
4299
4347
  const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
@@ -4364,11 +4412,9 @@ export class AgentManager {
4364
4412
  if (!dev)
4365
4413
  return;
4366
4414
  this.phaseSignalWatcher?.stop(taskId);
4367
- // Keep the agent BOUND (non-dispatchable) until branch cleanup + /compact finish, then
4415
+ // Keep the agent BOUND (non-dispatchable) until branch cleanup + context reset finish, then
4368
4416
  // release. dispatchPostMergeCleanup owns the whole lifecycle: worktree removal → branch
4369
- // delete → compact release. Holding the binding is what stops the Start button (and
4370
- // dispatchPendingTask) from giving this agent a new task before it has cleaned up and
4371
- // compacted its context.
4417
+ // delete → /clear (or /compact if cleanup failed) release.
4372
4418
  if (task.prNumber && task.branch) {
4373
4419
  const ctx = {
4374
4420
  prNumber: task.prNumber,
@@ -4425,7 +4471,8 @@ export class AgentManager {
4425
4471
  const tmux = new TmuxManager(runner);
4426
4472
  const prompt = buildPostMergeCleanupPrompt(ctx, cleanupResult);
4427
4473
  const runtime = agentRuntimeKindFor(agent);
4428
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime, prompt).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
4474
+ const cleanSlate = cleanupResult.outcome === 'deleted' || cleanupResult.outcome === 'absent';
4475
+ void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime, prompt, cleanSlate).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
4429
4476
  }
4430
4477
  // Release the post-merge binding (clears taskId + frees the lock we held → agent dispatchable
4431
4478
  // again). Shared success tail. Skips when the binding has already moved to another task, so it
@@ -4442,7 +4489,7 @@ export class AgentManager {
4442
4489
  }
4443
4490
  }
4444
4491
  // Removes the merged worktree but KEEPS taskId on the binding, so the agent remains
4445
- // non-dispatchable while branch delete + /compact run. Only worktreePath is dropped.
4492
+ // non-dispatchable while branch delete + /clear run. Only worktreePath is dropped.
4446
4493
  async removeMergedWorktree(cfg, agentId, expectedTaskId) {
4447
4494
  await this.withTaskLock(async () => {
4448
4495
  const state = await this.agentStore.get(agentId);
@@ -4507,12 +4554,12 @@ export class AgentManager {
4507
4554
  return { outcome: 'failed', detail };
4508
4555
  }
4509
4556
  }
4510
- async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4557
+ async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate) {
4511
4558
  // 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
4512
4559
  // 且 finally 会误删对方的 guard 放穿后续请求。
4513
4560
  await this.acquireCompactGuard(agentId);
4514
4561
  try {
4515
- await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt);
4562
+ await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate);
4516
4563
  }
4517
4564
  finally {
4518
4565
  this.compactInFlight.delete(agentId);
@@ -4529,7 +4576,7 @@ export class AgentManager {
4529
4576
  this.compactInFlight.add(agentId);
4530
4577
  return true;
4531
4578
  }
4532
- async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
4579
+ async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt, cleanSlate) {
4533
4580
  const bindingStillOurs = async () => {
4534
4581
  const s = await this.agentStore.get(agentId);
4535
4582
  return !!s && s.taskId === originalTaskId && s.paneId === paneId;
@@ -4553,7 +4600,7 @@ export class AgentManager {
4553
4600
  await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
4554
4601
  if (!await bindingStillOurs())
4555
4602
  return;
4556
- await tmux.sendKeysLiteral(paneId, '/compact');
4603
+ await tmux.sendKeysLiteral(paneId, cleanSlate ? '/clear' : '/compact');
4557
4604
  await tmux.sendEnter(paneId);
4558
4605
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
4559
4606
  await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
@@ -4834,7 +4881,7 @@ export class AgentManager {
4834
4881
  return null;
4835
4882
  const { qaId, devAgentId, projectId, newToken, newRound } = claim;
4836
4883
  // continueSession failure after the transition would otherwise strand the
4837
- // task in 'review' with a fresh token nobody will ever signal (PR #288).
4884
+ // task in 'review' with a fresh token nobody will ever signal.
4838
4885
  const rollback = async () => {
4839
4886
  await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
4840
4887
  signalToken: claim.originalToken,
@@ -4851,7 +4898,7 @@ export class AgentManager {
4851
4898
  // The entry signal (code/spec-done|fixed) was already consumed by the
4852
4899
  // watcher; a pre-transition failure must re-arm it with the unrotated token
4853
4900
  // or the agent's re-emit after the operator fixes availability has no
4854
- // consumer (PR #288).
4901
+ // consumer.
4855
4902
  const rearmEntrySignal = async () => {
4856
4903
  const entryKind = claim.originalStatus === 'fixing'
4857
4904
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -4935,7 +4982,7 @@ export class AgentManager {
4935
4982
  // A continuation consumed the QA's reviewed signal (not the dev's entry
4936
4983
  // signal): rollback restores the prior slice's review/token, so re-arm the
4937
4984
  // reviewed watcher — the QA's re-emit replays the stored batch findings and
4938
- // resumes the next-slice dispatch (PR #288).
4985
+ // resumes the next-slice dispatch.
4939
4986
  const rearmConsumedSignal = async () => {
4940
4987
  if (opts.continuation) {
4941
4988
  await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
@@ -5005,7 +5052,7 @@ export class AgentManager {
5005
5052
  taskPhase: (task.phase ?? 'code'),
5006
5053
  currentSpecRound: task.specReviewRound,
5007
5054
  // Continue-one-round enters from max_rounds — failure must restore THAT,
5008
- // not silently demote the human's pause decision to 'review' (PR #288).
5055
+ // not silently demote the human's pause decision to 'review'.
5009
5056
  originalStatus: task.status,
5010
5057
  originalToken: task.signalToken,
5011
5058
  };
@@ -5101,7 +5148,7 @@ export class AgentManager {
5101
5148
  await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
5102
5149
  // Rollback restored review/old-token, but the QA's reviewed signal was
5103
5150
  // consumed — without a subscriber its re-emit can never retry the fix
5104
- // dispatch (PR #288).
5151
+ // dispatch.
5105
5152
  await rearmReviewedSignal();
5106
5153
  }
5107
5154
  throw err;
@@ -5137,7 +5184,7 @@ export class AgentManager {
5137
5184
  await this.updateTask(taskId, { signalToken: newToken });
5138
5185
  // The publish prompt never reached the pane — restore the pre-rotation token
5139
5186
  // (so recovery still matches the pre-dispatch arm) and clear the delivery
5140
- // marker so retry knows this approved state is preemptible (PR #288).
5187
+ // marker so retry knows this approved state is preemptible.
5141
5188
  const rollbackToken = async () => {
5142
5189
  await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
5143
5190
  .catch(() => undefined);
@@ -5160,7 +5207,7 @@ export class AgentManager {
5160
5207
  // path below clears it. The remaining crash window (marker written, paste
5161
5208
  // never ran) fails CLOSED — retry 409s on a publish that never started and
5162
5209
  // the operator escapes via Cancel — instead of the old window's fail-open
5163
- // double publish (paste ran, marker missing, retry re-pastes) (PR #288).
5210
+ // double publish (paste ran, marker missing, retry re-pastes).
5164
5211
  await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
5165
5212
  let resumed = false;
5166
5213
  try {
@@ -5268,7 +5315,7 @@ export class AgentManager {
5268
5315
  const project = this.getProjectConfig(task.projectId);
5269
5316
  const mergeAuto = project?.merge === 'auto';
5270
5317
  // Snapshot from verdict time — a hot config flip between publish and
5271
- // confirm must not reroute an already-published artifact (PR #288).
5318
+ // confirm must not reroute an already-published artifact.
5272
5319
  const afterDone = this.resolveAfterDone(task);
5273
5320
  if (task.status === 'merge-ready') {
5274
5321
  if (mergeAuto && task.prNumber) {
@@ -5303,7 +5350,7 @@ export class AgentManager {
5303
5350
  matchHeadSha: task.latestHeadSha,
5304
5351
  }));
5305
5352
  // pr.merged's fromStatus now includes 'ready' — let the handler own the
5306
- // merged transition + full cleanup chain (branch delete, /compact, release).
5353
+ // merged transition + full cleanup chain (branch delete, /clear, release).
5307
5354
  await this.eventBus.emit({
5308
5355
  id: '',
5309
5356
  type: 'pr.merged',
@@ -5347,7 +5394,7 @@ export class AgentManager {
5347
5394
  }
5348
5395
  // Merge failures keep the gate: transient gh/network errors retry via another
5349
5396
  // Confirm, a stale head resolves via Cancel or an external decision — terminal
5350
- // 'failed' would orphan the published PR/branch outside the task flow (PR #288).
5397
+ // 'failed' would orphan the published PR/branch outside the task flow.
5351
5398
  async executeConfirmMerge(task, merge) {
5352
5399
  try {
5353
5400
  await merge();
@@ -5418,7 +5465,7 @@ export class AgentManager {
5418
5465
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5419
5466
  if (db.exitCode !== 0 || defaultBranch === '') {
5420
5467
  // A silent 'main' fallback would push the reviewed branch onto the wrong
5421
- // ref for repos whose default branch differs (PR #288).
5468
+ // ref for repos whose default branch differs.
5422
5469
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
5423
5470
  }
5424
5471
  const fetch = await runner.exec(`${cd}git fetch origin`);
@@ -5426,7 +5473,7 @@ export class AgentManager {
5426
5473
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
5427
5474
  }
5428
5475
  // Reviewed-head guard (branch path): refuse if origin/<branch> moved after
5429
- // the gate — symmetric with the pr path's --match-head-commit (PR #288).
5476
+ // the gate — symmetric with the pr path's --match-head-commit.
5430
5477
  if (task.latestHeadSha) {
5431
5478
  const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
5432
5479
  if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
@@ -5442,7 +5489,7 @@ export class AgentManager {
5442
5489
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5443
5490
  }
5444
5491
  // The merge has landed; branch deletion is cleanup — a transient failure
5445
- // here must not flip an already-merged task to failed (PR #288).
5492
+ // here must not flip an already-merged task to failed.
5446
5493
  const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
5447
5494
  if (del.exitCode !== 0) {
5448
5495
  console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);