baxian 1.2.53 → 1.2.55

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 (98) hide show
  1. package/dist/agent/bootstrap-poller.d.ts +2 -1
  2. package/dist/agent/bootstrap-poller.d.ts.map +1 -1
  3. package/dist/agent/bootstrap-poller.js +3 -1
  4. package/dist/agent/bootstrap-poller.js.map +1 -1
  5. package/dist/agent/bootstrap.d.ts +1 -1
  6. package/dist/agent/bootstrap.d.ts.map +1 -1
  7. package/dist/agent/bootstrap.js +12 -19
  8. package/dist/agent/bootstrap.js.map +1 -1
  9. package/dist/agent/branch.d.ts +64 -0
  10. package/dist/agent/branch.d.ts.map +1 -0
  11. package/dist/agent/branch.js +386 -0
  12. package/dist/agent/branch.js.map +1 -0
  13. package/dist/agent/detect/manifests/claude-code.json +2 -2
  14. package/dist/agent/detect/manifests/opencode.json +44 -0
  15. package/dist/agent/detect/manifests/qodercli.json +49 -0
  16. package/dist/agent/detect/region.d.ts +1 -0
  17. package/dist/agent/detect/region.d.ts.map +1 -1
  18. package/dist/agent/detect/region.js +27 -19
  19. package/dist/agent/detect/region.js.map +1 -1
  20. package/dist/agent/lineage.d.ts +1 -1
  21. package/dist/agent/lineage.d.ts.map +1 -1
  22. package/dist/agent/lineage.js +6 -7
  23. package/dist/agent/lineage.js.map +1 -1
  24. package/dist/agent/manager.d.ts +13 -9
  25. package/dist/agent/manager.d.ts.map +1 -1
  26. package/dist/agent/manager.js +1071 -426
  27. package/dist/agent/manager.js.map +1 -1
  28. package/dist/agent/preflight.d.ts.map +1 -1
  29. package/dist/agent/preflight.js +21 -15
  30. package/dist/agent/preflight.js.map +1 -1
  31. package/dist/agent/prompt.d.ts +2 -2
  32. package/dist/agent/prompt.d.ts.map +1 -1
  33. package/dist/agent/prompt.js +9 -7
  34. package/dist/agent/prompt.js.map +1 -1
  35. package/dist/agent/repo-store.d.ts +8 -4
  36. package/dist/agent/repo-store.d.ts.map +1 -1
  37. package/dist/agent/repo-store.js +142 -54
  38. package/dist/agent/repo-store.js.map +1 -1
  39. package/dist/agent/review-transport.d.ts +6 -4
  40. package/dist/agent/review-transport.d.ts.map +1 -1
  41. package/dist/agent/review-transport.js +39 -13
  42. package/dist/agent/review-transport.js.map +1 -1
  43. package/dist/agent/runner.d.ts +1 -0
  44. package/dist/agent/runner.d.ts.map +1 -1
  45. package/dist/agent/runner.js +5 -0
  46. package/dist/agent/runner.js.map +1 -1
  47. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  48. package/dist/agent/tmux-probe-poller.js +2 -0
  49. package/dist/agent/tmux-probe-poller.js.map +1 -1
  50. package/dist/agent/tmux.d.ts +2 -1
  51. package/dist/agent/tmux.d.ts.map +1 -1
  52. package/dist/agent/tmux.js +73 -7
  53. package/dist/agent/tmux.js.map +1 -1
  54. package/dist/api/probe.d.ts.map +1 -1
  55. package/dist/api/probe.js +9 -1
  56. package/dist/api/probe.js.map +1 -1
  57. package/dist/api/projects.d.ts.map +1 -1
  58. package/dist/api/projects.js +228 -56
  59. package/dist/api/projects.js.map +1 -1
  60. package/dist/config/validator.d.ts.map +1 -1
  61. package/dist/config/validator.js +58 -8
  62. package/dist/config/validator.js.map +1 -1
  63. package/dist/event/server-handlers.js +8 -8
  64. package/dist/event/server-handlers.js.map +1 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/shared/constants.d.ts +0 -1
  69. package/dist/shared/constants.d.ts.map +1 -1
  70. package/dist/shared/constants.js +0 -1
  71. package/dist/shared/constants.js.map +1 -1
  72. package/dist/shared/types.d.ts +15 -4
  73. package/dist/shared/types.d.ts.map +1 -1
  74. package/dist/shared/types.js.map +1 -1
  75. package/dist/skills/baxian-pr-feedback/SKILL.md +1 -1
  76. package/dist/skills/baxian-pr-recheck/SKILL.md +1 -1
  77. package/dist/skills/baxian-pr-review/SKILL.md +1 -1
  78. package/dist/skills/baxian-server-feedback/SKILL.md +4 -4
  79. package/dist/skills/baxian-server-review/SKILL.md +7 -7
  80. package/dist/skills/baxian-signals/SKILL.md +3 -1
  81. package/dist/skills/baxian-task-check/SKILL.md +4 -4
  82. package/dist/state/agent-store.js +4 -4
  83. package/dist/state/agent-store.js.map +1 -1
  84. package/dist/state/lock.d.ts +15 -2
  85. package/dist/state/lock.d.ts.map +1 -1
  86. package/dist/state/lock.js +104 -21
  87. package/dist/state/lock.js.map +1 -1
  88. package/dist/state/task-store.d.ts.map +1 -1
  89. package/dist/state/task-store.js +5 -2
  90. package/dist/state/task-store.js.map +1 -1
  91. package/dist/web/assets/index-BmnSiyut.js +13 -0
  92. package/dist/web/index.html +1 -1
  93. package/package.json +1 -1
  94. package/dist/agent/worktree.d.ts +0 -32
  95. package/dist/agent/worktree.d.ts.map +0 -1
  96. package/dist/agent/worktree.js +0 -291
  97. package/dist/agent/worktree.js.map +0 -1
  98. package/dist/web/assets/index-DAPd4Nv_.js +0 -13
@@ -12,7 +12,7 @@ import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
12
12
  import { findForeignTaskTip } from './lineage.js';
13
13
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
14
14
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
15
- import { WorktreeManager } from './worktree.js';
15
+ import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
16
16
  import { RepoStore, createRepoStoreCache } from './repo-store.js';
17
17
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
18
18
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
@@ -46,19 +46,39 @@ export class DispatchTerminalError extends Error {
46
46
  }
47
47
  }
48
48
  export function buildLaunchCommand(agent) {
49
+ const yolo = agent.yolo !== false;
49
50
  const segments = [];
50
51
  switch (agent.runtime) {
51
52
  case 'claude-code':
52
- segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude --permission-mode bypassPermissions');
53
+ segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude');
54
+ if (yolo)
55
+ segments.push('--permission-mode bypassPermissions');
53
56
  break;
54
57
  case 'codex':
55
- segments.push('codex --dangerously-bypass-approvals-and-sandbox');
58
+ segments.push('codex');
59
+ if (yolo)
60
+ segments.push('--dangerously-bypass-approvals-and-sandbox');
61
+ break;
62
+ case 'opencode':
63
+ // opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
64
+ // Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
65
+ // the .claude/skills scan — that closes the last collision source: a claude-code agent's
66
+ // .claude/skills/baxian-* in a shared base repo.
67
+ segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
68
+ if (yolo)
69
+ segments.push('--auto');
70
+ break;
71
+ case 'qodercli':
72
+ segments.push('qodercli');
73
+ if (yolo)
74
+ segments.push('--dangerously-skip-permissions');
56
75
  break;
57
76
  }
58
77
  if (agent.model) {
59
78
  segments.push(`--model ${shellQuote(agent.model)}`);
60
79
  }
61
- if (agent.addDirs && agent.addDirs.length > 0) {
80
+ // opencode has no --add-dir; extra roots go through its permission model instead (validator rejects the pairing).
81
+ if (agent.addDirs && agent.addDirs.length > 0 && agent.runtime !== 'opencode') {
62
82
  for (const dir of agent.addDirs) {
63
83
  segments.push(`--add-dir ${shellQuote(dir)}`);
64
84
  }
@@ -68,6 +88,51 @@ export function buildLaunchCommand(agent) {
68
88
  function agentRuntimeKindFor(agent) {
69
89
  return agent.runtime;
70
90
  }
91
+ // restart-repl drives the REPL back to a shell before relaunch. Each runtime exits
92
+ // via its own slash command (a bare "exit" would be sent to the model as chat text).
93
+ const REPL_EXIT_COMMAND = {
94
+ 'claude-code': '/exit',
95
+ codex: '/quit',
96
+ opencode: '/exit',
97
+ qodercli: '/quit',
98
+ };
99
+ const WORKDIR_SESSION_OPTION = '@baxian-workdir';
100
+ function normalizedDir(left) {
101
+ if (!left)
102
+ return null;
103
+ return left.replace(/\/+$/, '') || '/';
104
+ }
105
+ async function sameDirOnHost(runner, left, right) {
106
+ if (!left || !right)
107
+ return false;
108
+ if (normalizedDir(left) === normalizedDir(right))
109
+ return true;
110
+ const physical = async (path) => {
111
+ const result = await runner.exec(`cd -P ${shellQuote(path)} && pwd -P`);
112
+ const resolved = normalizedDir(result.stdout.trim());
113
+ if (result.exitCode !== 0 || !resolved) {
114
+ throw new Error(`Cannot resolve physical path for ${path}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
115
+ }
116
+ return resolved;
117
+ };
118
+ return await physical(left) === await physical(right);
119
+ }
120
+ export function skillSubdirFor(runtime) {
121
+ switch (runtime) {
122
+ case 'codex':
123
+ case 'opencode':
124
+ // opencode has no way to disable its .agents/skills compatibility scan (only .claude via
125
+ // env), and when it shares a base repo with a codex agent it loads the codex copy of a
126
+ // same-named baxian-* skill (verified: the .agents copy wins over .opencode). So opencode
127
+ // materializes into .agents/skills too — both runtimes share one runtime-agnostic copy
128
+ // (serialized by the same skill-dir lock) instead of colliding on duplicate names.
129
+ return '.agents/skills';
130
+ case 'qodercli':
131
+ return '.qoder/skills';
132
+ default:
133
+ return '.claude/skills';
134
+ }
135
+ }
71
136
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
72
137
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
73
138
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
@@ -78,15 +143,13 @@ export function canDispatchWithBinding(binding) {
78
143
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
79
144
  }
80
145
  const TURN_COMPLETED_AWAITING_PHASES = new Set();
81
- // 'cancel-clear-failed' is no longer produced (cancel stopped clearing); kept so legacy persisted holds stay recognized.
82
- const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-clear-failed', 'cancel-interrupt-failed']);
146
+ const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
83
147
  function isCancelCleanupHold(binding) {
84
148
  return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
85
149
  }
86
150
  const CANCEL_CLEANUP_PHASE_RANK = {
87
151
  'cancel-clearing': 1,
88
152
  'cancel-interrupt-failed': 2,
89
- 'cancel-clear-failed': 3,
90
153
  };
91
154
  function cancelPhaseRank(phase) {
92
155
  return phase ? (CANCEL_CLEANUP_PHASE_RANK[phase] ?? 0) : 0;
@@ -136,8 +199,6 @@ export class AgentManager {
136
199
  runtimeLivenessProbeMs = 700;
137
200
  cleanComposerWaitMs = 5_000;
138
201
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
139
- postMergeFetchTimeoutMs = 60_000;
140
- postMergeBranchTimeoutMs = 10_000;
141
202
  manualReviewInFlight = new Set();
142
203
  markCompleteInFlight = new Set();
143
204
  specVerdictInFlight = new Set();
@@ -186,6 +247,28 @@ export class AgentManager {
186
247
  this.taskMutationQueue = next.catch(() => undefined);
187
248
  return next;
188
249
  }
250
+ async resolveTaskLockToken(state, taskId) {
251
+ if (state.lockToken && await this.lockManager.isOwner(state.id, taskId, state.lockToken)) {
252
+ return state.lockToken;
253
+ }
254
+ if (state.lockToken)
255
+ return null;
256
+ const claim = await this.lockManager.claimOf(state.id);
257
+ const token = claim?.taskId === taskId ? claim.token : null;
258
+ if (!token)
259
+ return null;
260
+ await this.agentStore.update(state.id, (latest) => {
261
+ if (!latest || latest.taskId !== taskId || latest.lockToken)
262
+ return AGENT_STORE_NOOP;
263
+ return { ...latest, lockToken: token, updatedAt: new Date().toISOString() };
264
+ });
265
+ return token;
266
+ }
267
+ async assertTaskLockOwner(agentId, taskId, token) {
268
+ if (!(await this.lockManager.isOwner(agentId, taskId, token))) {
269
+ throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
270
+ }
271
+ }
189
272
  getReviewStore() {
190
273
  return this.reviewStore;
191
274
  }
@@ -213,18 +296,19 @@ export class AgentManager {
213
296
  getReviewTransport() {
214
297
  this.reviewTransportInstance ??= new ReviewTransport({
215
298
  createRunnerFor: (agent) => this.createRunnerFor(agent),
216
- resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
299
+ resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
217
300
  });
218
301
  return this.reviewTransportInstance;
219
302
  }
220
- bindingWorktreeCache = new Map();
221
- async refreshWorktreeCacheFor(agentId) {
303
+ bindingWorkdirCache = new Map();
304
+ async refreshWorkdirCacheFor(agentId) {
222
305
  const state = await this.agentStore.get(agentId);
223
- if (state?.worktreePath) {
224
- this.bindingWorktreeCache.set(agentId, state.worktreePath);
225
- return state.worktreePath;
306
+ const workdir = state?.workdir;
307
+ if (workdir) {
308
+ this.bindingWorkdirCache.set(agentId, workdir);
309
+ return workdir;
226
310
  }
227
- this.bindingWorktreeCache.delete(agentId);
311
+ this.bindingWorkdirCache.delete(agentId);
228
312
  return undefined;
229
313
  }
230
314
  async safeEmit(event) {
@@ -260,6 +344,7 @@ export class AgentManager {
260
344
  const config = prepareConfig(validated);
261
345
  this.config = config;
262
346
  this.agentIndex = buildAgentIndex(config);
347
+ this.repoCache.owners.clear();
263
348
  }
264
349
  getConfig() {
265
350
  return this.config;
@@ -298,17 +383,15 @@ export class AgentManager {
298
383
  try {
299
384
  const resolved = await this.ensureWorkdir(agent, project, runner);
300
385
  workdir = resolved.workdir;
301
- if (resolved.repoStore) {
302
- await this.agentStore.update(agentId, (existing) => {
303
- if (!existing)
304
- return AGENT_STORE_NOOP;
305
- return {
306
- ...existing,
307
- repoPath: workdir,
308
- updatedAt: new Date().toISOString(),
309
- };
310
- });
311
- }
386
+ await this.agentStore.update(agentId, (existing) => {
387
+ if (!existing)
388
+ return AGENT_STORE_NOOP;
389
+ return {
390
+ ...existing,
391
+ workdir,
392
+ updatedAt: new Date().toISOString(),
393
+ };
394
+ });
312
395
  }
313
396
  catch (err) {
314
397
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -332,16 +415,21 @@ export class AgentManager {
332
415
  `it creates itself — kill it manually or pick a different agent id`);
333
416
  }
334
417
  if (alive && (mode === 'runtime' || mode === 'recover')) {
335
- return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
418
+ return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
336
419
  }
337
- return this.buildFreshSession(tmux, agent, agentId, workdir);
420
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
338
421
  }
339
422
  async provisionRepoSkills(runner, agent, workdir) {
340
423
  if (this.skillRegistry.names().length === 0)
341
424
  return;
342
- const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
425
+ const subdir = skillSubdirFor(agent.runtime);
343
426
  const destRoot = `${workdir}/${subdir}`;
344
427
  await this.excludeInjectedSkills(runner, workdir, subdir);
428
+ // opencode's skills are model-invoked, not slash commands; the dispatched /<skill> sigil
429
+ // reaches the model as message text and it loads the skill from .opencode/skills. We do NOT
430
+ // wrap skills as .opencode/commands: opencode expands $ARGUMENTS in a command template and
431
+ // would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
432
+ // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
345
433
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
346
434
  await this.ensureSkillDirSafe(runner, workdir, subdir);
347
435
  await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
@@ -350,14 +438,19 @@ export class AgentManager {
350
438
  skillDirChain = new Map();
351
439
  skillDirLockKey(agent, workdir) {
352
440
  const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
353
- const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
441
+ const subdir = skillSubdirFor(agent.runtime);
354
442
  const dir = workdir.replace(/\/+$/, '');
355
443
  return `${host}:${dir}:${subdir}`;
356
444
  }
357
445
  runUnderSkillDirLock(key, fn) {
358
446
  const prev = this.skillDirChain.get(key) ?? Promise.resolve();
359
447
  const run = prev.then(fn, fn);
360
- this.skillDirChain.set(key, run.then(() => undefined, () => undefined));
448
+ const settled = run.then(() => undefined, () => undefined);
449
+ this.skillDirChain.set(key, settled);
450
+ void settled.finally(() => {
451
+ if (this.skillDirChain.get(key) === settled)
452
+ this.skillDirChain.delete(key);
453
+ });
361
454
  return run;
362
455
  }
363
456
  async atomicWriteFile(runner, finalPath, content) {
@@ -397,14 +490,28 @@ export class AgentManager {
397
490
  return tagged !== this.skillRegistry.contentHash();
398
491
  }
399
492
  async excludeInjectedSkills(runner, workdir, subdir) {
400
- const inner = `cd ${shellQuote(workdir)} && if p="$(git rev-parse --git-path info/exclude 2>/dev/null)"; then ` +
493
+ const inner = `cd ${shellQuote(workdir)} && ` +
494
+ `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
495
+ `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
496
+ `if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
497
+ `p="$(git rev-parse --git-path info/exclude)" || { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
401
498
  `pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
402
- `mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; fi`;
499
+ `{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; } ` +
500
+ `|| { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
501
+ `if git check-ignore -q -- ${shellQuote(`${subdir}/baxian-__probe__/SKILL.md`)}; then echo BX_SKILLS_OK; ` +
502
+ `else echo BX_SKILLS_EXCLUDE_INEFFECTIVE; fi`;
403
503
  const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
404
504
  if (res.exitCode !== 0) {
405
- console.warn(`[AgentManager] skill info/exclude best-effort failed in ${workdir} ` +
406
- `(skills still materialized): ${res.stderr || 'unknown error'}`);
505
+ throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
407
506
  }
507
+ const verdict = res.stdout.trim().split('\n').pop() ?? '';
508
+ if (verdict === 'BX_SKILLS_NON_GIT' || verdict === 'BX_SKILLS_OK')
509
+ return;
510
+ if (verdict.startsWith('BX_SKILLS_TRACKED')) {
511
+ throw new Error(`refusing to inject baxian skills into ${workdir}: project tracks ` +
512
+ `${verdict.replace('BX_SKILLS_TRACKED ', '')}`);
513
+ }
514
+ throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
408
515
  }
409
516
  async pinRuntimeSessionOptions(tmux, agentId) {
410
517
  await tmux.setOption(agentId, 'prefix', 'C-b');
@@ -415,17 +522,18 @@ export class AgentManager {
415
522
  await tmux.setOption(agentId, 'window-size', 'latest');
416
523
  await this.pinRuntimeSessionOptions(tmux, agentId);
417
524
  }
418
- async buildFreshSession(tmux, agent, agentId, workdir) {
419
- return this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir));
525
+ async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
526
+ return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
420
527
  }
421
- async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
528
+ async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
422
529
  let createdSession = false;
423
530
  const runtime = agentRuntimeKindFor(agent);
424
531
  try {
425
- await tmux.createSession(agentId, workdir);
532
+ await tmux.createSession(agentId, sessionDir);
426
533
  createdSession = true;
427
534
  await tmux.setOption(agentId, '@baxian-agent-id', agentId);
428
535
  await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
536
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
429
537
  await tmux.setOption(agentId, 'allow-passthrough', 'on');
430
538
  await tmux.setOption(agentId, 'set-titles', 'on');
431
539
  await this.pinFreshSessionOptions(tmux, agentId);
@@ -997,7 +1105,7 @@ export class AgentManager {
997
1105
  signal.addEventListener('abort', onAbort, { once: true });
998
1106
  });
999
1107
  }
1000
- async adoptOrRestartSession(tmux, agent, agentId, workdir) {
1108
+ async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1001
1109
  let claim;
1002
1110
  try {
1003
1111
  claim = await tmux.getOption(agentId, '@baxian-agent-id');
@@ -1030,6 +1138,36 @@ export class AgentManager {
1030
1138
  catch (err) {
1031
1139
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1032
1140
  }
1141
+ let currentPath;
1142
+ try {
1143
+ currentPath = await tmux.getPaneCurrentPath(paneId);
1144
+ }
1145
+ catch (err) {
1146
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
1147
+ }
1148
+ let pathMatches;
1149
+ try {
1150
+ pathMatches = await sameDirOnHost(runner, currentPath, sessionDir);
1151
+ }
1152
+ catch (err) {
1153
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path comparison failed: ${err instanceof Error ? err.message : String(err)}`);
1154
+ }
1155
+ if (!pathMatches) {
1156
+ if (state.kind === 'live-runtime') {
1157
+ try {
1158
+ await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
1159
+ }
1160
+ catch (err) {
1161
+ throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
1162
+ }
1163
+ }
1164
+ else if (state.kind !== 'shell') {
1165
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
1166
+ }
1167
+ await tmux.killSession(agentId);
1168
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1169
+ }
1170
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
1033
1171
  switch (state.kind) {
1034
1172
  case 'live-runtime': {
1035
1173
  let stale;
@@ -1040,8 +1178,14 @@ export class AgentManager {
1040
1178
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
1041
1179
  }
1042
1180
  if (stale) {
1043
- await tmux.killSession(agentId).catch(() => { });
1044
- return this.buildFreshSession(tmux, agent, agentId, workdir);
1181
+ return {
1182
+ ok: true,
1183
+ createdSession: false,
1184
+ freshRuntime: false,
1185
+ skillsStale: true,
1186
+ paneId,
1187
+ workdir,
1188
+ };
1045
1189
  }
1046
1190
  return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1047
1191
  }
@@ -1112,7 +1256,10 @@ export class AgentManager {
1112
1256
  if (!cfg)
1113
1257
  throw new Error(`Unknown agent: ${agentId}`);
1114
1258
  const state = await this.agentStore.get(agentId);
1115
- const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
1259
+ const existingToken = state?.taskId === taskId && state
1260
+ ? await this.resolveTaskLockToken(state, taskId)
1261
+ : null;
1262
+ const sameTaskLocked = existingToken !== null;
1116
1263
  const reentryPhases = new Set([
1117
1264
  'fix', 'post-approve', 'code',
1118
1265
  'server-feedback', 'server-after-done',
@@ -1125,17 +1272,16 @@ export class AgentManager {
1125
1272
  if (!sameTaskReentry && !canDispatchWithBinding(state)) {
1126
1273
  return false;
1127
1274
  }
1128
- if (!reuseLock) {
1129
- const ok = await this.lockManager.acquire(agentId, taskId);
1130
- if (!ok)
1131
- return false;
1132
- }
1275
+ const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1276
+ if (!token)
1277
+ return false;
1133
1278
  const now = new Date().toISOString();
1134
1279
  await this.agentStore.update(agentId, (existing) => ({
1135
1280
  ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1136
1281
  id: agentId,
1137
1282
  projectId: cfg.projectId,
1138
1283
  taskId,
1284
+ lockToken: token,
1139
1285
  updatedAt: now,
1140
1286
  }));
1141
1287
  return true;
@@ -1168,6 +1314,11 @@ export class AgentManager {
1168
1314
  const cfg = this.getAgentConfig(agentId);
1169
1315
  if (!cfg)
1170
1316
  return false;
1317
+ const lockToken = await this.resolveTaskLockToken(state, expectedTaskId);
1318
+ if (!lockToken) {
1319
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} no longer owns task ${expectedTaskId}; skipping`);
1320
+ return false;
1321
+ }
1171
1322
  const now = new Date().toISOString();
1172
1323
  if (mode === 'waiting') {
1173
1324
  await this.agentStore.update(agentId, (latest) => {
@@ -1179,9 +1330,9 @@ export class AgentManager {
1179
1330
  projectId: latest.projectId,
1180
1331
  updatedAt: now,
1181
1332
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
1333
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
1182
1334
  ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
1183
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
1184
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
1335
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1185
1336
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1186
1337
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1187
1338
  };
@@ -1194,19 +1345,118 @@ export class AgentManager {
1194
1345
  });
1195
1346
  return true;
1196
1347
  }
1197
- if (state.worktreePath) {
1198
- const cleanupDir = this.resolveWorkdir(cfg, state);
1199
- if (cleanupDir) {
1200
- const runner = this.createRunnerFor(cfg);
1201
- const worktree = new WorktreeManager(runner);
1202
- try {
1203
- await worktree.remove(cleanupDir, state.worktreePath);
1348
+ if (state.workdir) {
1349
+ const runner = this.createRunnerFor(cfg);
1350
+ const tmux = new TmuxManager(runner);
1351
+ const hold = async (reason) => {
1352
+ const pendingAt = new Date().toISOString();
1353
+ if (boundTask && cfg.role === 'dev') {
1354
+ boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
1355
+ boundTask.updatedAt = pendingAt;
1356
+ await this.taskStore.set(boundTask);
1357
+ }
1358
+ await this.agentStore.update(agentId, (latest) => {
1359
+ if (!latest || latest.taskId !== expectedTaskId || latest.lockToken !== lockToken) {
1360
+ return AGENT_STORE_NOOP;
1361
+ }
1362
+ return {
1363
+ ...latest,
1364
+ status: 'awaiting_human',
1365
+ awaitingPhase: 'branch-cleanup-pending',
1366
+ awaitingReason: reason,
1367
+ awaitingSince: pendingAt,
1368
+ updatedAt: pendingAt,
1369
+ };
1370
+ });
1371
+ await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
1372
+ phase: 'branch-cleanup-pending',
1373
+ reason,
1374
+ });
1375
+ return false;
1376
+ };
1377
+ try {
1378
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1379
+ const paneId = await this.resolvePaneId(state, cfg);
1380
+ if (paneId) {
1381
+ await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1382
+ }
1383
+ else {
1384
+ let sessionAlive;
1385
+ try {
1386
+ sessionAlive = await tmux.hasSession(agentId);
1387
+ }
1388
+ catch (err) {
1389
+ return hold(`Runtime availability probe failed; refusing checkout cleanup: ` +
1390
+ `${err instanceof Error ? err.message : String(err)}`);
1391
+ }
1392
+ if (sessionAlive) {
1393
+ return hold('Runtime session still exists but its pane is unavailable; refusing checkout cleanup');
1394
+ }
1395
+ }
1396
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1397
+ if (cfg.role === 'dev' && boundTask) {
1398
+ const branches = new BranchManager(runner);
1399
+ const cleanup = await branches.cleanupTaskBranch(state.workdir, {
1400
+ taskId: boundTask.id,
1401
+ taskBranch: boundTask.branch,
1402
+ branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
1403
+ }, () => this.assertTaskLockOwner(agentId, expectedTaskId, lockToken));
1404
+ let cleanupStateChanged = false;
1405
+ if (cleanup.status === 'pending') {
1406
+ if (boundTask.branchCleanupPending?.agentId !== agentId
1407
+ || boundTask.branchCleanupPending.reason !== cleanup.reason
1408
+ || boundTask.branchCleanupSkipped !== undefined) {
1409
+ boundTask.branchCleanupPending = {
1410
+ agentId,
1411
+ reason: cleanup.reason,
1412
+ updatedAt: new Date().toISOString(),
1413
+ };
1414
+ delete boundTask.branchCleanupSkipped;
1415
+ cleanupStateChanged = true;
1416
+ }
1417
+ }
1418
+ else if (cleanup.status === 'skipped') {
1419
+ if (boundTask.branchCleanupSkipped?.agentId !== agentId
1420
+ || boundTask.branchCleanupSkipped.reason !== cleanup.reason
1421
+ || boundTask.branchCleanupPending !== undefined) {
1422
+ boundTask.branchCleanupSkipped = {
1423
+ agentId,
1424
+ reason: cleanup.reason,
1425
+ updatedAt: new Date().toISOString(),
1426
+ };
1427
+ delete boundTask.branchCleanupPending;
1428
+ cleanupStateChanged = true;
1429
+ }
1430
+ }
1431
+ else {
1432
+ if (boundTask.branchCleanupPending?.agentId === agentId) {
1433
+ delete boundTask.branchCleanupPending;
1434
+ cleanupStateChanged = true;
1435
+ }
1436
+ if (boundTask.branchCleanupSkipped?.agentId === agentId) {
1437
+ delete boundTask.branchCleanupSkipped;
1438
+ cleanupStateChanged = true;
1439
+ }
1440
+ }
1441
+ if (cleanupStateChanged) {
1442
+ boundTask.updatedAt = new Date().toISOString();
1443
+ await this.taskStore.set(boundTask);
1444
+ }
1445
+ if (cleanup.status !== 'deleted')
1446
+ await branches.parkOnDefaultDetached(state.workdir);
1204
1447
  }
1205
- catch (err) {
1206
- console.warn(`[AgentManager] releaseAgentForTask worktree.remove failed for ${state.worktreePath}:`, err);
1448
+ else {
1449
+ await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
1207
1450
  }
1208
1451
  }
1452
+ catch (err) {
1453
+ const reason = err instanceof DirtyWorkdirError
1454
+ ? err.message
1455
+ : `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
1456
+ return hold(reason);
1457
+ }
1209
1458
  }
1459
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1210
1460
  await this.agentStore.update(agentId, (existing) => {
1211
1461
  if (!existing)
1212
1462
  return AGENT_STORE_NOOP;
@@ -1215,16 +1465,172 @@ export class AgentManager {
1215
1465
  return {
1216
1466
  id: existing.id,
1217
1467
  projectId: existing.projectId,
1218
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1468
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1219
1469
  ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1220
1470
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1221
1471
  updatedAt: now,
1222
1472
  };
1223
1473
  });
1224
- await this.lockManager.release(agentId);
1225
- return true;
1474
+ return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1226
1475
  });
1227
1476
  }
1477
+ async reconcileTaskBranches() {
1478
+ const tasks = await this.taskStore.list();
1479
+ const terminalByBranch = new Map(tasks
1480
+ .filter(task => task.branchCreatedByBaxian === true
1481
+ && TERMINAL_STATUSES.includes(task.status)
1482
+ && task.branch === `${BRANCH_PREFIX}${task.id}`
1483
+ && task.branchCleanupSkipped === undefined)
1484
+ .map(task => [task.branch, task]));
1485
+ const owner = 'maintenance:branch-reconcile';
1486
+ const persistCleanup = async (task, agentId, cleanup) => {
1487
+ await this.withTaskLock(async () => {
1488
+ const latest = await this.taskStore.get(task.id);
1489
+ if (!latest
1490
+ || latest.branch !== task.branch
1491
+ || latest.branchCreatedByBaxian !== true
1492
+ || !TERMINAL_STATUSES.includes(latest.status))
1493
+ return;
1494
+ const now = new Date().toISOString();
1495
+ let changed = false;
1496
+ if (cleanup.status === 'pending') {
1497
+ if (latest.branchCleanupPending?.agentId !== agentId
1498
+ || latest.branchCleanupPending.reason !== cleanup.reason
1499
+ || latest.branchCleanupSkipped !== undefined) {
1500
+ latest.branchCleanupPending = { agentId, reason: cleanup.reason, updatedAt: now };
1501
+ delete latest.branchCleanupSkipped;
1502
+ changed = true;
1503
+ }
1504
+ }
1505
+ else if (cleanup.status === 'skipped') {
1506
+ if (latest.branchCleanupSkipped?.agentId !== agentId
1507
+ || latest.branchCleanupSkipped.reason !== cleanup.reason
1508
+ || latest.branchCleanupPending !== undefined) {
1509
+ latest.branchCleanupSkipped = { agentId, reason: cleanup.reason, updatedAt: now };
1510
+ delete latest.branchCleanupPending;
1511
+ changed = true;
1512
+ }
1513
+ }
1514
+ else {
1515
+ if (latest.branchCleanupPending !== undefined) {
1516
+ delete latest.branchCleanupPending;
1517
+ changed = true;
1518
+ }
1519
+ if (latest.branchCleanupSkipped !== undefined) {
1520
+ delete latest.branchCleanupSkipped;
1521
+ changed = true;
1522
+ }
1523
+ }
1524
+ if (!changed)
1525
+ return;
1526
+ latest.updatedAt = now;
1527
+ await this.taskStore.set(latest);
1528
+ });
1529
+ };
1530
+ for (const cfg of this.agentIndex.values()) {
1531
+ if (cfg.role !== 'dev')
1532
+ continue;
1533
+ const candidateTasks = [...terminalByBranch.values()].filter(task => task.agentId === cfg.id || task.branchCleanupPending?.agentId === cfg.id);
1534
+ if (candidateTasks.length === 0)
1535
+ continue;
1536
+ const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
1537
+ const state = await this.agentStore.get(cfg.id);
1538
+ if (!state || !canDispatchWithBinding(state))
1539
+ continue;
1540
+ const workdir = state.workdir ?? cfg.workdir;
1541
+ if (!workdir)
1542
+ continue;
1543
+ const runner = this.createRunnerFor(cfg);
1544
+ const branches = new BranchManager(runner);
1545
+ let refs;
1546
+ try {
1547
+ refs = await branches.listLocalTaskRefs(workdir);
1548
+ }
1549
+ catch (err) {
1550
+ console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) local ref preflight failed: ` +
1551
+ `${err instanceof Error ? err.message : String(err)}`);
1552
+ continue;
1553
+ }
1554
+ if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
1555
+ continue;
1556
+ const token = await this.lockManager.acquire(cfg.id, owner);
1557
+ if (!token)
1558
+ continue;
1559
+ const cleanupResults = [];
1560
+ try {
1561
+ const lockedState = await this.agentStore.get(cfg.id);
1562
+ if (!lockedState || !canDispatchWithBinding(lockedState))
1563
+ continue;
1564
+ if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1565
+ continue;
1566
+ refs = await branches.listLocalTaskRefs(workdir);
1567
+ refs = refs.filter(ref => candidateByBranch.has(ref.slice('refs/heads/'.length)));
1568
+ if (refs.length === 0)
1569
+ continue;
1570
+ const tmux = new TmuxManager(runner);
1571
+ if (await tmux.hasSession(cfg.id)) {
1572
+ const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
1573
+ if (claim !== cfg.id) {
1574
+ throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
1575
+ }
1576
+ const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
1577
+ await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1578
+ }
1579
+ if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1580
+ continue;
1581
+ for (const actualRef of refs) {
1582
+ const branch = actualRef.slice('refs/heads/'.length);
1583
+ const task = candidateByBranch.get(branch);
1584
+ if (!task)
1585
+ continue;
1586
+ const cleanup = await branches.cleanupTaskBranch(workdir, {
1587
+ taskId: task.id,
1588
+ taskBranch: task.branch,
1589
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
1590
+ }, () => this.assertTaskLockOwner(cfg.id, owner, token));
1591
+ cleanupResults.push({ task, cleanup });
1592
+ }
1593
+ }
1594
+ catch (err) {
1595
+ const message = err instanceof Error ? err.message : String(err);
1596
+ if (err instanceof DirtyWorkdirError) {
1597
+ await this.markAwaitingHuman(cfg.id, 'dirty-workdir', message, { expectedTaskId: null });
1598
+ }
1599
+ else if (err instanceof ReplNotReadyError || message.includes('stayed busy past')) {
1600
+ for (const actualRef of refs) {
1601
+ const task = candidateByBranch.get(actualRef.slice('refs/heads/'.length));
1602
+ if (!task)
1603
+ continue;
1604
+ cleanupResults.push({
1605
+ task,
1606
+ cleanup: {
1607
+ status: 'pending',
1608
+ reason: `runtime is not idle; local branch cleanup deferred: ${message}`,
1609
+ },
1610
+ });
1611
+ }
1612
+ }
1613
+ else {
1614
+ console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) failed: ${message}`);
1615
+ await this.recordError({
1616
+ agentId: cfg.id,
1617
+ projectId: cfg.projectId,
1618
+ operation: 'branch-reconcile',
1619
+ reason: 'BRANCH_RECONCILE_FAILED',
1620
+ message,
1621
+ observation: { phase: 'branch-reconcile' },
1622
+ recommendation: 'Verify the Workdir and remote, then let the next reconciliation retry.',
1623
+ });
1624
+ }
1625
+ }
1626
+ finally {
1627
+ await this.lockManager.releaseIfOwner(cfg.id, owner, token);
1628
+ }
1629
+ for (const result of cleanupResults) {
1630
+ await persistCleanup(result.task, cfg.id, result.cleanup);
1631
+ }
1632
+ }
1633
+ }
1228
1634
  async setAgentNeedInput(agentId, pending, opts = {}) {
1229
1635
  const now = new Date().toISOString();
1230
1636
  try {
@@ -1377,6 +1783,13 @@ export class AgentManager {
1377
1783
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
1378
1784
  return { resumed: false, releasedBinding: false, reason };
1379
1785
  }
1786
+ if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
1787
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
1788
+ const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
1789
+ 'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
1790
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
1791
+ return { resumed: false, releasedBinding: false, reason };
1792
+ }
1380
1793
  if (state.awaitingPhase === 'code-dispatch-failed'
1381
1794
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1382
1795
  && state.taskId) {
@@ -1408,19 +1821,14 @@ export class AgentManager {
1408
1821
  }
1409
1822
  const now = new Date().toISOString();
1410
1823
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
1411
- const cfg = this.getAgentConfig(agentId);
1412
- if (shouldReleaseBinding && state.worktreePath && cfg) {
1413
- const cleanupDir = this.resolveWorkdir(cfg, state);
1414
- if (cleanupDir) {
1415
- const runner = this.createRunnerFor(cfg);
1416
- const worktree = new WorktreeManager(runner);
1417
- try {
1418
- await worktree.remove(cleanupDir, state.worktreePath);
1419
- }
1420
- catch (err) {
1421
- console.warn(`[AgentManager] resumeAgent worktree.remove failed for ${state.worktreePath}:`, err);
1422
- }
1423
- }
1824
+ if (shouldReleaseBinding && state.taskId) {
1825
+ return {
1826
+ resumed: true,
1827
+ releasedBinding: false,
1828
+ releaseTaskId: state.taskId,
1829
+ projectId: state.projectId,
1830
+ previousPhase: state.awaitingPhase,
1831
+ };
1424
1832
  }
1425
1833
  await this.agentStore.update(agentId, (existing) => {
1426
1834
  if (!existing)
@@ -1429,7 +1837,7 @@ export class AgentManager {
1429
1837
  id: existing.id,
1430
1838
  projectId: existing.projectId,
1431
1839
  updatedAt: now,
1432
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1840
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1433
1841
  ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1434
1842
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1435
1843
  status: 'ok',
@@ -1437,23 +1845,41 @@ export class AgentManager {
1437
1845
  if (!shouldReleaseBinding) {
1438
1846
  if (existing.taskId !== undefined)
1439
1847
  next.taskId = existing.taskId;
1440
- if (existing.worktreePath !== undefined)
1441
- next.worktreePath = existing.worktreePath;
1848
+ if (existing.lockToken !== undefined)
1849
+ next.lockToken = existing.lockToken;
1442
1850
  if (existing.startedAt !== undefined)
1443
1851
  next.startedAt = existing.startedAt;
1444
1852
  }
1445
1853
  return next;
1446
1854
  });
1447
- if (shouldReleaseBinding) {
1448
- await this.lockManager.release(agentId);
1449
- }
1450
1855
  await this.emitIntervention(state.projectId, agentId, state.taskId, {
1451
1856
  phase: 'resumed',
1452
1857
  previousPhase: state.awaitingPhase,
1453
- releasedBinding: shouldReleaseBinding,
1858
+ releasedBinding: false,
1454
1859
  });
1455
- return { resumed: true, releasedBinding: shouldReleaseBinding };
1860
+ return { resumed: true, releasedBinding: false };
1456
1861
  });
1862
+ if (result.releaseTaskId) {
1863
+ const released = await this.releaseAgentForTask(agentId, result.releaseTaskId, 'idle', {
1864
+ allowAwaitingHuman: true,
1865
+ fromCancelCleanup: true,
1866
+ });
1867
+ if (!released) {
1868
+ const refreshed = await this.agentStore.get(agentId);
1869
+ return {
1870
+ resumed: false,
1871
+ releasedBinding: false,
1872
+ reason: refreshed?.awaitingReason
1873
+ ?? `Agent ${agentId} could not safely release task ${result.releaseTaskId}.`,
1874
+ };
1875
+ }
1876
+ await this.emitIntervention(result.projectId, agentId, result.releaseTaskId, {
1877
+ phase: 'resumed',
1878
+ previousPhase: result.previousPhase,
1879
+ releasedBinding: true,
1880
+ });
1881
+ return { resumed: true, releasedBinding: true };
1882
+ }
1457
1883
  if (result.redispatchCodeTaskId) {
1458
1884
  try {
1459
1885
  const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
@@ -1658,7 +2084,7 @@ export class AgentManager {
1658
2084
  const out = [];
1659
2085
  for (const t of tasks) {
1660
2086
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1661
- // spec-ready 不豁免:approve/打回都依赖 dev worktree,dev 丢失后停驻只是假活
2087
+ // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
1662
2088
  if (t.status === 'ready' || t.status === 'merge-ready')
1663
2089
  continue;
1664
2090
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -1738,28 +2164,26 @@ export class AgentManager {
1738
2164
  const paneId = await tmux.getSinglePaneId(agentId);
1739
2165
  await tmux.sendKeysToPane(paneId, 'C-c');
1740
2166
  const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
1741
- const RUNTIME = /^(?:claude|codex|node|\d+\.\d+\.\d+)$/;
2167
+ const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
1742
2168
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
1743
2169
  if (RUNTIME.test(cmd)) {
1744
- await tmux.sendKeysToPane(paneId, 'exit', 'Enter');
2170
+ await tmux.sendKeysToPane(paneId, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
1745
2171
  await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000, expectShell: true });
1746
2172
  }
1747
2173
  else if (!SHELL.test(cmd)) {
1748
2174
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
1749
2175
  }
1750
2176
  const project = this.getProjectConfig(cfg.projectId);
1751
- let workdir;
1752
- let provisioned = false;
1753
- if (project) {
1754
- try {
1755
- workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
1756
- await this.provisionRepoSkills(runner, cfg, workdir);
1757
- provisioned = true;
1758
- }
1759
- catch (err) {
1760
- console.warn(`[restart-repl] skill re-provision failed for ${agentId} (continuing):`, err);
1761
- }
1762
- }
2177
+ if (!project)
2178
+ throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
2179
+ const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
2180
+ const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
2181
+ if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
2182
+ throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
2183
+ 'use retry to rebuild the session safely');
2184
+ }
2185
+ await this.provisionRepoSkills(runner, cfg, workdir);
2186
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
1763
2187
  const runtime = agentRuntimeKindFor(cfg);
1764
2188
  const relaunch = async () => {
1765
2189
  await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
@@ -1770,16 +2194,9 @@ export class AgentManager {
1770
2194
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1771
2195
  scrollback: 0,
1772
2196
  });
1773
- if (provisioned) {
1774
- await this.tagSessionSkillsVersion(tmux, agentId);
1775
- }
2197
+ await this.tagSessionSkillsVersion(tmux, agentId);
1776
2198
  };
1777
- if (workdir !== undefined) {
1778
- await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1779
- }
1780
- else {
1781
- await relaunch();
1782
- }
2199
+ await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1783
2200
  await this.agentStore.update(agentId, (state) => {
1784
2201
  if (!state)
1785
2202
  return AGENT_STORE_NOOP;
@@ -1815,7 +2232,21 @@ export class AgentManager {
1815
2232
  continue;
1816
2233
  const runner = this.createRunnerFor(cfg);
1817
2234
  const tmux = new TmuxManager(runner);
1818
- const worktree = new WorktreeManager(runner);
2235
+ const boundState = await this.agentStore.get(id);
2236
+ if (boundState?.taskId) {
2237
+ try {
2238
+ const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
2239
+ allowAwaitingHuman: true,
2240
+ });
2241
+ if (!released) {
2242
+ throw new Error(`could not safely release task ${boundState.taskId}`);
2243
+ }
2244
+ }
2245
+ catch (err) {
2246
+ failures.push({ agentId: id, step: 'binding.release', error: err });
2247
+ continue;
2248
+ }
2249
+ }
1819
2250
  this.stopRuntimeMenuWatch(id);
1820
2251
  if (this.paneStreamerManager) {
1821
2252
  try {
@@ -1841,18 +2272,6 @@ export class AgentManager {
1841
2272
  catch (err) {
1842
2273
  failures.push({ agentId: id, step: 'tmux', error: err });
1843
2274
  }
1844
- const state = await this.agentStore.get(id);
1845
- if (state?.worktreePath) {
1846
- const cleanupDir = this.resolveWorkdir(cfg, state);
1847
- if (cleanupDir) {
1848
- try {
1849
- await worktree.remove(cleanupDir, state.worktreePath);
1850
- }
1851
- catch (err) {
1852
- failures.push({ agentId: id, step: 'worktree.remove', error: err });
1853
- }
1854
- }
1855
- }
1856
2275
  }
1857
2276
  if (failures.length > 0) {
1858
2277
  const summary = failures
@@ -1885,7 +2304,6 @@ export class AgentManager {
1885
2304
  workdirForEstimate = cfg.workdir;
1886
2305
  }
1887
2306
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
1888
- const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
1889
2307
  const now = new Date().toISOString();
1890
2308
  const fakeTask = {
1891
2309
  id: 'task-9999999999',
@@ -1904,7 +2322,7 @@ export class AgentManager {
1904
2322
  task: fakeTask,
1905
2323
  phase: 'develop',
1906
2324
  agent: cfg,
1907
- worktreePath: worktreePathBound,
2325
+ workdir: workdirGuess,
1908
2326
  skillRegistry: this.skillRegistry,
1909
2327
  signalToken: 'preview-signal-token',
1910
2328
  });
@@ -2035,7 +2453,7 @@ export class AgentManager {
2035
2453
  const allowRecoveredReadFile = task.status === 'review'
2036
2454
  && (task.phase === 'spec'
2037
2455
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2038
- || (task.reviewMode === 'server' && task.reviewWorktreeMode === 'base'));
2456
+ || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
2039
2457
  try {
2040
2458
  await this.startPhaseSignalWatch({
2041
2459
  taskId: task.id,
@@ -2153,8 +2571,11 @@ export class AgentManager {
2153
2571
  return this.taskStore.list({ projectId });
2154
2572
  }
2155
2573
  async findTaskByBranch(branch, projectId) {
2156
- const all = await this.taskStore.list({ projectId });
2157
- return all.find(t => t.branch === branch);
2574
+ const repoKey = this.projectRepoKey(projectId);
2575
+ if (!repoKey)
2576
+ return undefined;
2577
+ const all = await this.taskStore.list();
2578
+ return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
2158
2579
  }
2159
2580
  async listTasksByPrNumber(prNumber, projectId) {
2160
2581
  const all = await this.taskStore.list({ projectId });
@@ -2221,13 +2642,11 @@ export class AgentManager {
2221
2642
  createRepoStore(agent, project, runner) {
2222
2643
  const host = resolveAgentHost(this.config.host, agent.host);
2223
2644
  if (this.repoStoreFactory) {
2224
- return this.repoStoreFactory(runner, repoSlug(project.repo), agent.mode, host, this.repoCache);
2645
+ return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
2225
2646
  }
2226
- return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
2647
+ return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
2227
2648
  }
2228
2649
  async ensureWorkdir(agent, project, runner) {
2229
- if (agent.workdir)
2230
- return { workdir: agent.workdir, repoStore: null };
2231
2650
  const repoStore = this.createRepoStore(agent, project, runner);
2232
2651
  const workdir = await repoStore.ensure();
2233
2652
  return { workdir, repoStore };
@@ -2235,22 +2654,29 @@ export class AgentManager {
2235
2654
  resolveWorkdir(agent, agentState) {
2236
2655
  if (agent.workdir)
2237
2656
  return agent.workdir;
2238
- return agentState?.repoPath ?? null;
2239
- }
2240
- async resolveAutoBaseRef(runner, workdir) {
2241
- const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
2242
- if (result.exitCode !== 0) {
2243
- // Falling back to the shared clone's HEAD would seed the worktree with whatever
2244
- // another agent left checked out — refuse instead of silently cross-contaminating.
2245
- throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
2246
- `run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
2247
- }
2248
- return 'origin/HEAD';
2657
+ return agentState?.workdir ?? null;
2249
2658
  }
2250
2659
  getRepoCache() {
2251
2660
  return this.repoCache;
2252
2661
  }
2253
- async rollbackFailedDispatch(taskId, agentId, reason) {
2662
+ async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
2663
+ const existing = await this.agentStore.get(agentId);
2664
+ if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
2665
+ console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
2666
+ `skipping rollback — agent already reassigned`);
2667
+ return;
2668
+ }
2669
+ if (isCancelCleanupHold(existing)) {
2670
+ console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
2671
+ `leaving binding to the owner`);
2672
+ return;
2673
+ }
2674
+ if (!expectedLockToken
2675
+ || !(await this.lockManager.isOwner(agentId, taskId, expectedLockToken))
2676
+ || (existing?.taskId === taskId && existing.lockToken !== expectedLockToken)) {
2677
+ console.warn(`[AgentManager] rollback: generation mismatch for ${agentId}/${taskId}; skipping stale rollback`);
2678
+ return;
2679
+ }
2254
2680
  let rolledBack = null;
2255
2681
  await this.withTaskLock(async () => {
2256
2682
  const task = await this.taskStore.get(taskId);
@@ -2271,17 +2697,7 @@ export class AgentManager {
2271
2697
  note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2272
2698
  });
2273
2699
  }
2274
- const existing = await this.agentStore.get(agentId);
2275
- if (existing && existing.taskId !== taskId) {
2276
- console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
2277
- `skipping agent cleanup — agent already reassigned`);
2278
- return;
2279
- }
2280
- if (isCancelCleanupHold(existing)) {
2281
- console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
2282
- `leaving binding to the owner`);
2283
- return;
2284
- }
2700
+ const lockToken = expectedLockToken;
2285
2701
  const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
2286
2702
  if (!projectId) {
2287
2703
  console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
@@ -2289,16 +2705,23 @@ export class AgentManager {
2289
2705
  }
2290
2706
  else {
2291
2707
  const now = new Date().toISOString();
2292
- await this.agentStore.update(agentId, (latest) => ({
2293
- ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
2294
- id: agentId,
2295
- projectId,
2296
- taskId: undefined,
2297
- worktreePath: undefined,
2298
- updatedAt: now,
2299
- }));
2708
+ await this.agentStore.update(agentId, (latest) => {
2709
+ if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
2710
+ return AGENT_STORE_NOOP;
2711
+ if (latest?.taskId !== undefined && latest.taskId !== taskId)
2712
+ return AGENT_STORE_NOOP;
2713
+ return {
2714
+ ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
2715
+ id: agentId,
2716
+ projectId,
2717
+ taskId: undefined,
2718
+ lockToken: undefined,
2719
+ updatedAt: now,
2720
+ };
2721
+ });
2300
2722
  }
2301
- await this.lockManager.release(agentId);
2723
+ if (lockToken)
2724
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
2302
2725
  }
2303
2726
  async pickAgent(projectId, preferredAgentId) {
2304
2727
  const cfg = this.getAgentConfig(preferredAgentId);
@@ -2329,6 +2752,7 @@ export class AgentManager {
2329
2752
  }
2330
2753
  }
2331
2754
  const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
2755
+ const branchCreatedByBaxian = input.branch === undefined;
2332
2756
  if (input.branch) {
2333
2757
  const existing = await this.findTaskByBranch(input.branch, projectId);
2334
2758
  if (existing) {
@@ -2349,6 +2773,7 @@ export class AgentManager {
2349
2773
  reviewRound: 0,
2350
2774
  status: 'pending',
2351
2775
  branch: taskBranch,
2776
+ branchCreatedByBaxian,
2352
2777
  reviewMode: this.effectiveReviewMode(projectId),
2353
2778
  createdAt: now,
2354
2779
  updatedAt: now,
@@ -2378,6 +2803,7 @@ export class AgentManager {
2378
2803
  reviewRound: 0,
2379
2804
  status: 'pending',
2380
2805
  branch: taskBranch,
2806
+ branchCreatedByBaxian,
2381
2807
  reviewMode: this.effectiveReviewMode(projectId),
2382
2808
  createdAt: now,
2383
2809
  updatedAt: now,
@@ -2399,8 +2825,8 @@ export class AgentManager {
2399
2825
  });
2400
2826
  return queued;
2401
2827
  }
2402
- const acquired = await this.lockManager.acquire(dev.id, taskId);
2403
- if (!acquired) {
2828
+ const lockToken = await this.lockManager.acquire(dev.id, taskId);
2829
+ if (!lockToken) {
2404
2830
  const queued = {
2405
2831
  id: taskId,
2406
2832
  projectId,
@@ -2411,6 +2837,7 @@ export class AgentManager {
2411
2837
  reviewRound: 0,
2412
2838
  status: 'pending',
2413
2839
  branch: taskBranch,
2840
+ branchCreatedByBaxian,
2414
2841
  reviewMode: this.effectiveReviewMode(projectId),
2415
2842
  createdAt: now,
2416
2843
  updatedAt: now,
@@ -2443,6 +2870,7 @@ export class AgentManager {
2443
2870
  reviewRound: 0,
2444
2871
  status: 'in_progress',
2445
2872
  branch: taskBranch,
2873
+ branchCreatedByBaxian,
2446
2874
  reviewMode: this.effectiveReviewMode(projectId),
2447
2875
  createdAt: now,
2448
2876
  updatedAt: now,
@@ -2453,10 +2881,11 @@ export class AgentManager {
2453
2881
  id: dev.id,
2454
2882
  projectId,
2455
2883
  taskId,
2884
+ lockToken,
2456
2885
  bootstrappingTaskId: taskId,
2457
2886
  updatedAt: now,
2458
2887
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2459
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
2888
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2460
2889
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2461
2890
  }));
2462
2891
  await this.safeEmit({
@@ -2476,7 +2905,8 @@ export class AgentManager {
2476
2905
  if (task.status === 'in_progress' && task.agentId) {
2477
2906
  const signalToken = createSignalToken();
2478
2907
  await this.updateTask(task.id, { signalToken });
2479
- const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken);
2908
+ const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
2909
+ const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
2480
2910
  if (opts.background) {
2481
2911
  void start.catch((err) => {
2482
2912
  console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
@@ -2490,7 +2920,7 @@ export class AgentManager {
2490
2920
  }
2491
2921
  return task;
2492
2922
  }
2493
- async startCreatedTaskSession(taskId, agentId, signalToken) {
2923
+ async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
2494
2924
  let started = false;
2495
2925
  let dispatchErr = null;
2496
2926
  try {
@@ -2551,7 +2981,7 @@ export class AgentManager {
2551
2981
  await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
2552
2982
  phase: 'dispatch-rollback',
2553
2983
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2554
- } : undefined);
2984
+ } : undefined, dispatchLockToken);
2555
2985
  }
2556
2986
  }
2557
2987
  return (await this.taskStore.get(taskId)) ?? null;
@@ -2656,6 +3086,33 @@ export class AgentManager {
2656
3086
  async clearAgent(agentId) {
2657
3087
  return this.sendSlashCommand(agentId, '/clear');
2658
3088
  }
3089
+ async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
3090
+ await this.acquireCompactGuard(agentId);
3091
+ try {
3092
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
3093
+ await revalidate();
3094
+ await tmux.clearComposerDraft(paneId);
3095
+ const baseline = await tmux.captureSettledSnapshot(paneId, {
3096
+ timeoutMs: this.dispatchSettleTimeoutMs,
3097
+ });
3098
+ const baselineTitle = await tmux.readPaneTitle(paneId);
3099
+ await tmux.sendKeysLiteral(paneId, '/clear');
3100
+ await tmux.sendEnter(paneId);
3101
+ await tmux.waitSubmitAck(paneId, baseline, runtime, {
3102
+ timeoutMs: this.dispatchAckTimeoutMs,
3103
+ baselineTitle,
3104
+ acceptComposerChange: true,
3105
+ });
3106
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
3107
+ if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
3108
+ throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
3109
+ }
3110
+ await revalidate();
3111
+ }
3112
+ finally {
3113
+ this.compactInFlight.delete(agentId);
3114
+ }
3115
+ }
2659
3116
  async persistTaskImages(taskId, images) {
2660
3117
  const dir = join(this.imageStagingRoot, taskId);
2661
3118
  await mkdir(dir, { recursive: true });
@@ -2744,8 +3201,8 @@ export class AgentManager {
2744
3201
  if (!canDispatchWithBinding(state)) {
2745
3202
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
2746
3203
  }
2747
- const acquired = await this.lockManager.acquire(agentId, taskId);
2748
- if (!acquired) {
3204
+ const lockToken = await this.lockManager.acquire(agentId, taskId);
3205
+ if (!lockToken) {
2749
3206
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
2750
3207
  }
2751
3208
  const now = new Date().toISOString();
@@ -2763,10 +3220,11 @@ export class AgentManager {
2763
3220
  id: agentId,
2764
3221
  projectId: cfg.projectId,
2765
3222
  taskId,
3223
+ lockToken,
2766
3224
  bootstrappingTaskId: taskId,
2767
3225
  updatedAt: now,
2768
3226
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2769
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
3227
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2770
3228
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2771
3229
  }));
2772
3230
  await this.safeEmit({
@@ -2778,7 +3236,7 @@ export class AgentManager {
2778
3236
  taskId,
2779
3237
  data: { agentId, manuallyDispatched: true },
2780
3238
  });
2781
- return { task: claimedTask };
3239
+ return { task: claimedTask, lockToken };
2782
3240
  });
2783
3241
  if (claim.errorCode !== undefined)
2784
3242
  return claim;
@@ -2810,7 +3268,7 @@ export class AgentManager {
2810
3268
  await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
2811
3269
  phase: 'dispatch-rollback',
2812
3270
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2813
- } : undefined);
3271
+ } : undefined, claim.lockToken);
2814
3272
  }
2815
3273
  const refreshed = await this.taskStore.get(claimed.id);
2816
3274
  if (dispatchErr === null) {
@@ -2819,6 +3277,50 @@ export class AgentManager {
2819
3277
  const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
2820
3278
  return { task: refreshed, errorCode: 500, error: err };
2821
3279
  }
3280
+ async switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner) {
3281
+ if (!task.branch)
3282
+ throw new Error(`Task ${task.id} has no review branch`);
3283
+ const current = await this.taskStore.get(task.id);
3284
+ let expectedHeadSha = current?.latestHeadSha ?? task.latestHeadSha;
3285
+ if (!expectedHeadSha) {
3286
+ const fetched = await this.fetchPrHeadSha(task.id);
3287
+ expectedHeadSha = await this.persistReviewHeadIfCurrent(task.id, undefined, fetched);
3288
+ }
3289
+ await assertOwner();
3290
+ try {
3291
+ await branchManager.switchToRemoteBranchDetached(workdir, task.branch, expectedHeadSha);
3292
+ return;
3293
+ }
3294
+ catch (err) {
3295
+ if (!(err instanceof ReviewHeadMismatchError))
3296
+ throw err;
3297
+ const latest = await this.taskStore.get(task.id);
3298
+ let replacement = latest?.latestHeadSha;
3299
+ if (!replacement || replacement === expectedHeadSha) {
3300
+ const fetched = await this.fetchPrHeadSha(task.id);
3301
+ replacement = await this.persistReviewHeadIfCurrent(task.id, expectedHeadSha, fetched);
3302
+ }
3303
+ if (replacement === expectedHeadSha)
3304
+ throw err;
3305
+ await assertOwner();
3306
+ await branchManager.switchToRemoteBranchDetached(workdir, task.branch, replacement);
3307
+ }
3308
+ }
3309
+ persistReviewHeadIfCurrent(taskId, expected, replacement) {
3310
+ return this.withTaskLock(async () => {
3311
+ const latest = await this.taskStore.get(taskId);
3312
+ if (!latest || TERMINAL_STATUSES.includes(latest.status)) {
3313
+ throw new Error(`Task ${taskId} became terminal while refreshing its review head`);
3314
+ }
3315
+ if (latest.latestHeadSha && latest.latestHeadSha !== expected) {
3316
+ return latest.latestHeadSha;
3317
+ }
3318
+ latest.latestHeadSha = replacement;
3319
+ latest.updatedAt = new Date().toISOString();
3320
+ await this.taskStore.set(latest);
3321
+ return replacement;
3322
+ });
3323
+ }
2822
3324
  async startSession(taskId, agentId, phase, opts = {}) {
2823
3325
  const agent = this.getAgentConfig(agentId);
2824
3326
  if (!agent)
@@ -2858,6 +3360,23 @@ export class AgentManager {
2858
3360
  `${preAgent.taskId} (request ${taskId}); aborting`);
2859
3361
  return false;
2860
3362
  }
3363
+ if (!preAgent || preAgent.taskId !== taskId) {
3364
+ console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} has no exact task binding for lock validation`);
3365
+ return false;
3366
+ }
3367
+ const lockToken = await this.resolveTaskLockToken(preAgent, taskId);
3368
+ if (!lockToken) {
3369
+ console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} does not own the exclusive lock for ${taskId}`);
3370
+ return false;
3371
+ }
3372
+ const assertOwner = async () => {
3373
+ const stateNow = await this.agentStore.get(agentId);
3374
+ if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
3375
+ throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
3376
+ }
3377
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3378
+ };
3379
+ await assertOwner();
2861
3380
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
2862
3381
  let ensure;
2863
3382
  try {
@@ -2868,9 +3387,19 @@ export class AgentManager {
2868
3387
  throw err;
2869
3388
  }
2870
3389
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
3390
+ const stateNow = await this.agentStore.get(agentId);
3391
+ const stillOwner = stateNow?.taskId === taskId
3392
+ && stateNow.lockToken === lockToken
3393
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
2871
3394
  try {
2872
- const runner = this.createRunnerFor(agent);
2873
- await new TmuxManager(runner).killSession(agentId);
3395
+ if (stillOwner) {
3396
+ const runner = this.createRunnerFor(agent);
3397
+ await new TmuxManager(runner).killSession(agentId);
3398
+ }
3399
+ else {
3400
+ console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
3401
+ `${agentId}/${taskId}`);
3402
+ }
2874
3403
  }
2875
3404
  catch (cleanupErr) {
2876
3405
  console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
@@ -2880,36 +3409,91 @@ export class AgentManager {
2880
3409
  }
2881
3410
  const { paneId, workdir } = ensure;
2882
3411
  const runner = this.createRunnerFor(agent);
2883
- const worktree = new WorktreeManager(runner);
2884
3412
  const tmux = new TmuxManager(runner);
3413
+ const branchManager = new BranchManager(runner);
3414
+ const lockState = await this.agentStore.get(agentId);
3415
+ if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
3416
+ return false;
3417
+ await assertOwner();
3418
+ await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
2885
3419
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2886
3420
  const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
2887
- const customBranch = task.branch && !task.branch.startsWith(BRANCH_PREFIX) ? task.branch : undefined;
2888
- // Server code reviews materialize the captured head; other QA modes stay off origin/HEAD.
2889
- // The fail-fast base resolution only runs where the default base is used.
2890
- const reviewWorktree = isServerCodeReviewPhase && opts.serverContent !== undefined
2891
- ? await worktree.createDetachedForReviewHead(workdir, taskId, {
2892
- patch: opts.serverContent,
2893
- baseSha: opts.serverBaseSha,
2894
- headSha: opts.serverHeadSha,
2895
- headTree: opts.serverHeadTree,
2896
- })
2897
- : undefined;
2898
- const worktreePath = reviewWorktree?.path ?? (isServerQaPhase
2899
- ? await worktree.createDetachedAtBase(workdir, taskId)
2900
- : phase === 'review' || phase === 'recheck'
2901
- ? await worktree.createDetached(workdir, taskId, task.branch)
2902
- : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch));
3421
+ let reviewCheckout;
3422
+ const dispatchWorkdir = workdir;
3423
+ try {
3424
+ if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
3425
+ reviewCheckout = await branchManager.materializeReviewHead(workdir, {
3426
+ patch: opts.serverContent,
3427
+ baseSha: opts.serverBaseSha,
3428
+ headSha: opts.serverHeadSha,
3429
+ headTree: opts.serverHeadTree,
3430
+ });
3431
+ }
3432
+ else if (isServerQaPhase) {
3433
+ await branchManager.switchToDefaultDetached(workdir);
3434
+ }
3435
+ else if (phase === 'review' || phase === 'recheck') {
3436
+ if (!task.branch)
3437
+ throw new Error(`Task ${taskId} has no review branch`);
3438
+ await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
3439
+ }
3440
+ else {
3441
+ if (!task.branch)
3442
+ throw new Error(`Task ${taskId} has no task branch`);
3443
+ await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true);
3444
+ }
3445
+ await assertOwner();
3446
+ if (!ensure.freshRuntime) {
3447
+ await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
3448
+ if (ensure.skillsStale)
3449
+ await this.tagSessionSkillsVersion(tmux, agentId);
3450
+ }
3451
+ }
3452
+ catch (err) {
3453
+ const message = err instanceof Error ? err.message : String(err);
3454
+ const holdPhase = err instanceof DirtyWorkdirError
3455
+ ? 'dirty-workdir'
3456
+ : 'checkout-preparation-failed';
3457
+ await this.markAwaitingHuman(agentId, holdPhase, message, { expectedTaskId: taskId })
3458
+ .catch((holdErr) => {
3459
+ console.error(`[AgentManager] startSession could not persist ${holdPhase} hold for ${agentId}:`, holdErr);
3460
+ });
3461
+ throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout preparation failed for task ${taskId}: ${message}`);
3462
+ }
3463
+ const cleanupCheckout = async () => {
3464
+ try {
3465
+ await assertOwner();
3466
+ }
3467
+ catch {
3468
+ return;
3469
+ }
3470
+ await branchManager.parkOnDefaultDetached(workdir);
3471
+ await assertOwner();
3472
+ };
3473
+ const cleanupCheckoutOrHold = async () => {
3474
+ try {
3475
+ await cleanupCheckout();
3476
+ }
3477
+ catch (err) {
3478
+ const message = err instanceof Error ? err.message : String(err);
3479
+ let holdFailure = '';
3480
+ try {
3481
+ await this.markAwaitingHuman(agentId, 'checkout-cleanup-failed', `Task checkout could not be parked safely: ${message}`, { expectedTaskId: taskId });
3482
+ }
3483
+ catch (holdErr) {
3484
+ holdFailure = `; persisting the hold also failed: ${holdErr instanceof Error ? holdErr.message : String(holdErr)}`;
3485
+ }
3486
+ throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout cleanup failed for task ${taskId}: ${message}${holdFailure}`);
3487
+ }
3488
+ };
2903
3489
  await this.agentStore.update(agentId, (stateNow) => {
2904
3490
  if (!stateNow || stateNow.taskId !== taskId)
2905
3491
  return AGENT_STORE_NOOP;
2906
- // A fresh dispatch supersedes any question asked under the previous prompt.
2907
3492
  const { needInputAt: _needInputAt, ...rest } = stateNow;
2908
3493
  return {
2909
3494
  ...rest,
2910
3495
  paneId,
2911
- worktreePath,
2912
- repoPath: workdir,
3496
+ workdir,
2913
3497
  updatedAt: new Date().toISOString(),
2914
3498
  };
2915
3499
  });
@@ -2918,17 +3502,18 @@ export class AgentManager {
2918
3502
  const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2919
3503
  let prompt;
2920
3504
  try {
3505
+ await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
2921
3506
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2922
3507
  let payloadOpts = {};
2923
3508
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2924
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3509
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
2925
3510
  phase,
2926
3511
  ...(task.phase ? { taskPhase: task.phase } : {}),
2927
3512
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
2928
3513
  reviewRound: task.reviewRound,
2929
3514
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
2930
3515
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2931
- ...(reviewWorktree?.mode === 'head' ? { forceContentFile: true } : {}),
3516
+ ...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
2932
3517
  ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2933
3518
  ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
2934
3519
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
@@ -2939,14 +3524,14 @@ export class AgentManager {
2939
3524
  task,
2940
3525
  phase,
2941
3526
  agent,
2942
- worktreePath,
3527
+ workdir: dispatchWorkdir,
2943
3528
  skillRegistry: this.skillRegistry,
2944
3529
  hasQaPartner,
2945
3530
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2946
3531
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2947
3532
  ...(imagePaths.length ? { imagePaths } : {}),
2948
- ...(reviewWorktree ? { serverReviewWorktree: reviewWorktree.mode } : {}),
2949
- ...(reviewWorktree?.fallbackReason ? { serverReviewFallbackReason: reviewWorktree.fallbackReason } : {}),
3533
+ ...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
3534
+ ...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
2950
3535
  ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
2951
3536
  ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
2952
3537
  ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
@@ -2955,10 +3540,7 @@ export class AgentManager {
2955
3540
  });
2956
3541
  }
2957
3542
  catch (err) {
2958
- try {
2959
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
2960
- }
2961
- catch { }
3543
+ await cleanupCheckoutOrHold();
2962
3544
  if (err instanceof PromptSizeError) {
2963
3545
  throw new DispatchTerminalError('prompt_too_large', err.message);
2964
3546
  }
@@ -2969,57 +3551,39 @@ export class AgentManager {
2969
3551
  }
2970
3552
  const taskFresh = await this.taskStore.get(taskId);
2971
3553
  if (!taskFresh) {
2972
- console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; cleaning up worktree before paste`);
2973
- try {
2974
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
2975
- }
2976
- catch { }
3554
+ console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
3555
+ await cleanupCheckoutOrHold();
2977
3556
  return false;
2978
3557
  }
2979
3558
  if (TERMINAL_STATUSES.includes(taskFresh.status)) {
2980
3559
  console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} is terminal ` +
2981
- `for phase=${phase}; cleaning up worktree before paste`);
2982
- try {
2983
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
2984
- }
2985
- catch { }
3560
+ `for phase=${phase}; parking checkout before paste`);
3561
+ await cleanupCheckoutOrHold();
2986
3562
  return false;
2987
3563
  }
2988
3564
  if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
2989
3565
  console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} not in ` +
2990
- `expected ${expectedStatuses.join('/')} for phase=${phase}; cleaning up worktree before paste`);
2991
- try {
2992
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
2993
- }
2994
- catch { }
3566
+ `expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
3567
+ await cleanupCheckoutOrHold();
2995
3568
  return false;
2996
3569
  }
2997
3570
  const agentFresh = await this.agentStore.get(agentId);
2998
3571
  if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
2999
3572
  if (!agentFresh || agentFresh.taskId !== taskId) {
3000
3573
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} not bound to ${taskId} ` +
3001
- `(got taskId=${agentFresh?.taskId}); cleaning up worktree before paste`);
3002
- try {
3003
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3004
- }
3005
- catch { }
3574
+ `(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
3575
+ await cleanupCheckoutOrHold();
3006
3576
  return false;
3007
3577
  }
3008
3578
  }
3009
3579
  else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
3010
3580
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} reassigned to ${agentFresh.taskId} ` +
3011
- `(was ${taskId}); cleaning up worktree before paste`);
3012
- try {
3013
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3014
- }
3015
- catch { }
3581
+ `(was ${taskId}); parking checkout before paste`);
3582
+ await cleanupCheckoutOrHold();
3016
3583
  return false;
3017
3584
  }
3018
- if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewWorktree: reviewWorktree?.mode }))) {
3019
- try {
3020
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3021
- }
3022
- catch { }
3585
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
3586
+ await cleanupCheckoutOrHold();
3023
3587
  return false;
3024
3588
  }
3025
3589
  const now = new Date().toISOString();
@@ -3036,8 +3600,8 @@ export class AgentManager {
3036
3600
  projectId: agent.projectId,
3037
3601
  paneId,
3038
3602
  taskId,
3039
- worktreePath,
3040
- repoPath: workdir,
3603
+ workdir,
3604
+ lockToken,
3041
3605
  startedAt: now,
3042
3606
  bootstrappingTaskId: taskId,
3043
3607
  updatedAt: now,
@@ -3047,10 +3611,7 @@ export class AgentManager {
3047
3611
  if (cancelHoldWon) {
3048
3612
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
3049
3613
  `aborting so cancel can finish interrupt + /clear`);
3050
- try {
3051
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3052
- }
3053
- catch { }
3614
+ await cleanupCheckoutOrHold();
3054
3615
  return false;
3055
3616
  }
3056
3617
  agentMarkedRunning = true;
@@ -3072,7 +3633,7 @@ export class AgentManager {
3072
3633
  projectId: agent.projectId,
3073
3634
  agentId,
3074
3635
  taskId,
3075
- data: { phase, worktreePath },
3636
+ data: { phase, workdir: dispatchWorkdir },
3076
3637
  });
3077
3638
  this.startRuntimeMenuWatch(agentId);
3078
3639
  return true;
@@ -3080,13 +3641,11 @@ export class AgentManager {
3080
3641
  catch (err) {
3081
3642
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
3082
3643
  if (!isAckUnknown) {
3083
- try {
3084
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3085
- }
3086
- catch { }
3644
+ await cleanupCheckoutOrHold();
3087
3645
  if (agentMarkedRunning) {
3088
3646
  try {
3089
3647
  let released = false;
3648
+ let releaseToken;
3090
3649
  await this.agentStore.update(agentId, (agentNow) => {
3091
3650
  if (!agentNow || agentNow.taskId !== taskId) {
3092
3651
  console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
@@ -3099,18 +3658,19 @@ export class AgentManager {
3099
3658
  return AGENT_STORE_NOOP;
3100
3659
  }
3101
3660
  released = true;
3661
+ releaseToken = agentNow.lockToken;
3102
3662
  void err;
3103
3663
  return {
3104
3664
  id: agentId,
3105
3665
  projectId: agent.projectId,
3106
3666
  paneId,
3107
- repoPath: workdir,
3667
+ workdir,
3108
3668
  updatedAt: new Date().toISOString(),
3109
3669
  ...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
3110
3670
  };
3111
3671
  });
3112
- if (released) {
3113
- await this.lockManager.release(agentId);
3672
+ if (released && releaseToken) {
3673
+ await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
3114
3674
  }
3115
3675
  }
3116
3676
  catch (cleanupErr) {
@@ -3123,13 +3683,25 @@ export class AgentManager {
3123
3683
  }
3124
3684
  async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
3125
3685
  const before = await this.agentStore.get(agentId);
3686
+ const beforeLockToken = before?.taskId
3687
+ ? await this.resolveTaskLockToken(before, before.taskId)
3688
+ : null;
3689
+ if (before?.taskId && !beforeLockToken) {
3690
+ throw new Error(`dispatch aborted: agent ${agentId} no longer owns task ${before.taskId}`);
3691
+ }
3126
3692
  await this.acquireCompactGuard(agentId);
3127
3693
  try {
3128
3694
  if (before) {
3129
3695
  const now = await this.agentStore.get(agentId);
3130
- if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
3696
+ if (!now
3697
+ || now.paneId !== before.paneId
3698
+ || now.taskId !== before.taskId
3699
+ || (beforeLockToken !== null && now.lockToken !== beforeLockToken)) {
3131
3700
  throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
3132
3701
  }
3702
+ if (before.taskId && beforeLockToken) {
3703
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3704
+ }
3133
3705
  if (isCancelCleanupHold(now)) {
3134
3706
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
3135
3707
  }
@@ -3141,9 +3713,16 @@ export class AgentManager {
3141
3713
  const revalidate = before
3142
3714
  ? async () => {
3143
3715
  const fresh = await this.agentStore.get(agentId);
3144
- if (!fresh || fresh.paneId !== before.paneId || fresh.taskId !== before.taskId || isCancelCleanupHold(fresh)) {
3716
+ if (!fresh
3717
+ || fresh.paneId !== before.paneId
3718
+ || fresh.taskId !== before.taskId
3719
+ || (beforeLockToken !== null && fresh.lockToken !== beforeLockToken)
3720
+ || isCancelCleanupHold(fresh)) {
3145
3721
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
3146
3722
  }
3723
+ if (before.taskId && beforeLockToken) {
3724
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3725
+ }
3147
3726
  const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
3148
3727
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
3149
3728
  throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
@@ -3260,7 +3839,12 @@ export class AgentManager {
3260
3839
  `(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
3261
3840
  return false;
3262
3841
  }
3263
- const worktreePath = agentState.worktreePath;
3842
+ const taskWorkdir = agentState.workdir;
3843
+ if (!taskWorkdir)
3844
+ throw new Error(`Agent ${agentId} has no Workdir`);
3845
+ const lockToken = await this.resolveTaskLockToken(agentState, taskId);
3846
+ if (!lockToken)
3847
+ throw new Error(`Agent ${agentId} no longer owns task ${taskId}`);
3264
3848
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
3265
3849
  let ensure;
3266
3850
  try {
@@ -3271,9 +3855,19 @@ export class AgentManager {
3271
3855
  throw err;
3272
3856
  }
3273
3857
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
3858
+ const stateNow = await this.agentStore.get(agentId);
3859
+ const stillOwner = stateNow?.taskId === taskId
3860
+ && stateNow.lockToken === lockToken
3861
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
3274
3862
  try {
3275
- const runner = this.createRunnerFor(agent);
3276
- await new TmuxManager(runner).killSession(agentId);
3863
+ if (stillOwner) {
3864
+ const runner = this.createRunnerFor(agent);
3865
+ await new TmuxManager(runner).killSession(agentId);
3866
+ }
3867
+ else {
3868
+ console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
3869
+ `${agentId}/${taskId}`);
3870
+ }
3277
3871
  }
3278
3872
  catch (cleanupErr) {
3279
3873
  console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
@@ -3284,16 +3878,43 @@ export class AgentManager {
3284
3878
  const { paneId } = ensure;
3285
3879
  const runner = this.createRunnerFor(agent);
3286
3880
  const tmux = new TmuxManager(runner);
3881
+ const stateAfterEnsure = await this.agentStore.get(agentId);
3882
+ if (stateAfterEnsure?.taskId !== taskId || stateAfterEnsure.lockToken !== lockToken) {
3883
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership changed during ensureSession; skipping`);
3884
+ return false;
3885
+ }
3886
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3887
+ const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3888
+ const taskAfterEnsure = await this.taskStore.get(taskId);
3889
+ if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
3890
+ console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure?.status} terminal/missing after ensure; skipping`);
3891
+ return false;
3892
+ }
3893
+ if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskAfterEnsure.status)) {
3894
+ console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure.status} not in ` +
3895
+ `expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
3896
+ return false;
3897
+ }
3898
+ if (agentState.workdir && agent.role === 'dev' && task.branch) {
3899
+ const branches = new BranchManager(runner);
3900
+ await branches.assertClean(taskWorkdir);
3901
+ const actualRef = await branches.currentRef(taskWorkdir);
3902
+ if (actualRef !== `refs/heads/${task.branch}`) {
3903
+ throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
3904
+ `expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
3905
+ }
3906
+ }
3287
3907
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3288
3908
  let prompt;
3289
3909
  try {
3910
+ await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
3290
3911
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3291
3912
  && opts.postApproveRedispatchCount > 0
3292
3913
  && !ensure.freshRuntime;
3293
3914
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3294
3915
  let payloadOpts = {};
3295
3916
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3296
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3917
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
3297
3918
  phase,
3298
3919
  ...(task.phase ? { taskPhase: task.phase } : {}),
3299
3920
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
@@ -3310,7 +3931,7 @@ export class AgentManager {
3310
3931
  task,
3311
3932
  phase,
3312
3933
  agent,
3313
- worktreePath,
3934
+ workdir: taskWorkdir,
3314
3935
  skillRegistry: this.skillRegistry,
3315
3936
  ...(signalToken ? { signalToken } : {}),
3316
3937
  ...(useIncrementalNudge
@@ -3332,7 +3953,6 @@ export class AgentManager {
3332
3953
  }
3333
3954
  throw err;
3334
3955
  }
3335
- const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3336
3956
  const taskFresh = await this.taskStore.get(taskId);
3337
3957
  if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
3338
3958
  console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
@@ -3356,6 +3976,11 @@ export class AgentManager {
3356
3976
  `(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
3357
3977
  return false;
3358
3978
  }
3979
+ if (!agentFresh || agentFresh.lockToken !== lockToken) {
3980
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
3981
+ return false;
3982
+ }
3983
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3359
3984
  if (phase === 'post-approve') {
3360
3985
  const completionFresh = await this.getPostApproveCompletion(taskId);
3361
3986
  if (!completionFresh || completionFresh.token !== signalToken) {
@@ -3375,14 +4000,15 @@ export class AgentManager {
3375
4000
  return {
3376
4001
  ...rest,
3377
4002
  paneId,
3378
- worktreePath,
4003
+ workdir: taskWorkdir,
4004
+ lockToken,
3379
4005
  updatedAt: now,
3380
4006
  };
3381
4007
  });
3382
4008
  await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3383
4009
  return true;
3384
4010
  }
3385
- async rollbackUndeliveredBootstrap(state, agentConfig) {
4011
+ async rollbackUndeliveredBootstrap(state) {
3386
4012
  if (!state.taskId
3387
4013
  || state.bootstrappingTaskId !== state.taskId
3388
4014
  || state.awaitingPhase === 'bootstrap-marker-clear-failed') {
@@ -3398,18 +4024,7 @@ export class AgentManager {
3398
4024
  }
3399
4025
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
3400
4026
  `(prompt never ack'd); rolling the task back to pending`);
3401
- if (state.worktreePath) {
3402
- const cleanupDir = this.resolveWorkdir(agentConfig, state);
3403
- if (cleanupDir) {
3404
- try {
3405
- await new WorktreeManager(this.createRunnerFor(agentConfig)).remove(cleanupDir, state.worktreePath);
3406
- }
3407
- catch (worktreeErr) {
3408
- console.warn(`[recover] mid-bootstrap rollback: worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
3409
- }
3410
- }
3411
- }
3412
- await this.rollbackFailedDispatch(state.taskId, state.id);
4027
+ await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
3413
4028
  await this.agentStore.update(state.id, (latest) => {
3414
4029
  if (!latest || latest.status !== 'awaiting_human')
3415
4030
  return AGENT_STORE_NOOP;
@@ -3438,16 +4053,56 @@ export class AgentManager {
3438
4053
  return { ...rest, updatedAt: new Date().toISOString() };
3439
4054
  });
3440
4055
  }
4056
+ async releaseOrphanedLocks(states) {
4057
+ const bindings = new Map(states.map(state => [state.id, state]));
4058
+ const claims = await this.lockManager.listClaims();
4059
+ for (const claim of claims) {
4060
+ const binding = bindings.get(claim.agentId);
4061
+ const exactBinding = binding?.taskId === claim.taskId && binding.lockToken === claim.token;
4062
+ if (!claim.taskId.startsWith('maintenance:') && exactBinding)
4063
+ continue;
4064
+ const released = await this.lockManager.releaseIfOwner(claim.agentId, claim.taskId, claim.token);
4065
+ if (!released) {
4066
+ console.warn(`[recover] lock changed while reclaiming ${claim.agentId}/${claim.taskId}; preserving the newer claim`);
4067
+ }
4068
+ }
4069
+ }
3441
4070
  async recover() {
3442
4071
  const states = await this.agentStore.list();
4072
+ await this.releaseOrphanedLocks(states);
3443
4073
  const deferredCleanups = [];
3444
4074
  for (const state of states) {
3445
4075
  const agentConfig = this.getAgentConfig(state.id);
3446
4076
  if (!agentConfig)
3447
4077
  continue;
4078
+ if (state.taskId) {
4079
+ let token = null;
4080
+ try {
4081
+ token = await this.resolveTaskLockToken(state, state.taskId);
4082
+ }
4083
+ catch (err) {
4084
+ console.warn(`[recover] lock validation failed for ${state.id}/${state.taskId}:`, err);
4085
+ }
4086
+ if (!token) {
4087
+ const reason = `Exclusive lock ownership cannot be proven for task ${state.taskId}; runtime recovery was not attempted.`;
4088
+ await this.markAwaitingHuman(state.id, 'recovery-lock-invalid', reason, { expectedTaskId: state.taskId });
4089
+ await this.recordError({
4090
+ agentId: state.id,
4091
+ projectId: state.projectId,
4092
+ taskId: state.taskId,
4093
+ operation: 'recovery',
4094
+ reason: 'LOCK_OWNERSHIP_INVALID',
4095
+ message: reason,
4096
+ observation: { phase: 'pre-runtime-recovery' },
4097
+ recommendation: 'Inspect the persisted lock and task binding, then cancel or delete the Agent if ownership cannot be restored.',
4098
+ });
4099
+ continue;
4100
+ }
4101
+ state.lockToken = token;
4102
+ }
3448
4103
  try {
3449
4104
  const result = await this.ensureSession(state.id, 'recover');
3450
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4105
+ if (await this.rollbackUndeliveredBootstrap(state))
3451
4106
  continue;
3452
4107
  if (state.creationToken && !state.taskId) {
3453
4108
  const ct = state.creationToken;
@@ -3506,18 +4161,16 @@ export class AgentManager {
3506
4161
  }
3507
4162
  const cancelHold = isCancelCleanupHold(state);
3508
4163
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
3509
- if (shouldReleaseBinding && state.worktreePath) {
3510
- const cleanupDir = this.resolveWorkdir(agentConfig, state);
3511
- if (cleanupDir) {
3512
- const runner = this.createRunnerFor(agentConfig);
3513
- const worktree = new WorktreeManager(runner);
3514
- try {
3515
- await worktree.remove(cleanupDir, state.worktreePath);
3516
- }
3517
- catch (worktreeErr) {
3518
- console.warn(`[recover] worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
3519
- }
3520
- }
4164
+ if (shouldReleaseBinding && state.taskId) {
4165
+ await this.agentStore.update(state.id, (latest) => {
4166
+ if (!latest || latest.taskId !== state.taskId)
4167
+ return AGENT_STORE_NOOP;
4168
+ return { ...latest, paneId: result.paneId, updatedAt: new Date().toISOString() };
4169
+ });
4170
+ await this.releaseAgentForTask(state.id, state.taskId, 'idle', {
4171
+ allowAwaitingHuman: true,
4172
+ });
4173
+ continue;
3521
4174
  }
3522
4175
  const preserveHeld = !shouldReleaseBinding
3523
4176
  && state.status === 'awaiting_human';
@@ -3529,7 +4182,7 @@ export class AgentManager {
3529
4182
  projectId: latest.projectId,
3530
4183
  paneId: result.paneId,
3531
4184
  updatedAt: new Date().toISOString(),
3532
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
4185
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
3533
4186
  status: 'ok',
3534
4187
  };
3535
4188
  if (shouldReleaseBinding) {
@@ -3538,7 +4191,7 @@ export class AgentManager {
3538
4191
  const withBinding = {
3539
4192
  ...base,
3540
4193
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
3541
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
4194
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
3542
4195
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
3543
4196
  };
3544
4197
  if (!preserveHeld)
@@ -3551,16 +4204,13 @@ export class AgentManager {
3551
4204
  ...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
3552
4205
  };
3553
4206
  });
3554
- if (shouldReleaseBinding) {
3555
- await this.lockManager.release(state.id);
3556
- }
3557
4207
  if (state.taskId && !shouldReleaseBinding && !cancelHold) {
3558
4208
  this.startRuntimeMenuWatch(state.id);
3559
4209
  }
3560
4210
  }
3561
4211
  catch (err) {
3562
4212
  if (err instanceof EnsureSessionError && err.partial.dialogPending) {
3563
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4213
+ if (await this.rollbackUndeliveredBootstrap(state))
3564
4214
  continue;
3565
4215
  await this.markDialogPending(state.id, state.creationToken);
3566
4216
  void this.slowPollDialogPending(state.id, state.creationToken, {
@@ -3589,10 +4239,15 @@ export class AgentManager {
3589
4239
  ...latest,
3590
4240
  paneId: undefined,
3591
4241
  creationToken: undefined,
4242
+ ...(latest.taskId ? {
4243
+ status: 'awaiting_human',
4244
+ awaitingPhase: 'recovery-failed',
4245
+ awaitingReason: message,
4246
+ awaitingSince: new Date().toISOString(),
4247
+ } : {}),
3592
4248
  updatedAt: new Date().toISOString(),
3593
4249
  };
3594
4250
  });
3595
- await this.lockManager.release(state.id);
3596
4251
  const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
3597
4252
  deferredCleanups.push({
3598
4253
  failingAgentId: state.id,
@@ -3636,23 +4291,32 @@ export class AgentManager {
3636
4291
  hadBinding = !!existing.taskId;
3637
4292
  taskId = existing.taskId;
3638
4293
  if (!existing.taskId
3639
- && !existing.worktreePath
3640
4294
  && !existing.startedAt
3641
4295
  && !existing.paneId
3642
4296
  && !existing.creationToken) {
3643
4297
  return AGENT_STORE_NOOP;
3644
4298
  }
3645
4299
  changed = true;
4300
+ if (existing.taskId) {
4301
+ return {
4302
+ ...existing,
4303
+ paneId: undefined,
4304
+ status: 'awaiting_human',
4305
+ awaitingPhase: 'runtime-missing',
4306
+ awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
4307
+ awaitingSince: timestamp,
4308
+ updatedAt: timestamp,
4309
+ };
4310
+ }
3646
4311
  return {
3647
4312
  id: existing.id,
3648
4313
  projectId: existing.projectId,
3649
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
4314
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
3650
4315
  updatedAt: timestamp,
3651
4316
  };
3652
4317
  });
3653
4318
  if (!projectId || !changed)
3654
4319
  return null;
3655
- await this.lockManager.release(agentId);
3656
4320
  return { projectId, timestamp, hadBinding, taskId };
3657
4321
  });
3658
4322
  if (!reconciled)
@@ -3707,18 +4371,18 @@ export class AgentManager {
3707
4371
  || (task.status === 'approved' && !!task.publishDispatchedAt);
3708
4372
  if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
3709
4373
  const afterDone = this.resolveAfterDone(task);
3710
- if (afterDone !== null && task.branch) {
4374
+ if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
3711
4375
  publishedCleanup = {
3712
4376
  afterDone,
3713
4377
  branch: task.branch,
3714
- ...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
4378
+ prNumber: task.prNumber,
3715
4379
  devAgentId: task.agentId,
3716
4380
  mayBeInFlight: task.status === 'approved',
3717
4381
  };
3718
4382
  }
3719
4383
  }
3720
4384
  else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
3721
- // github 模式持有开放 PR 的任务(review/fixing/approved/merge-ready/max_rounds 等)取消时关 PR 删分支;
4385
+ // GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
3722
4386
  // server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
3723
4387
  publishedCleanup = {
3724
4388
  afterDone: 'pr',
@@ -3793,40 +4457,18 @@ export class AgentManager {
3793
4457
  phase: 'cancel-published-artifact-cleanup-skipped',
3794
4458
  afterDone: publishedCleanup.afterDone,
3795
4459
  branch: publishedCleanup.branch,
3796
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4460
+ prNumber: publishedCleanup.prNumber,
3797
4461
  reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3798
4462
  });
3799
4463
  return result;
3800
4464
  }
3801
4465
  const project = this.getProjectConfig(result.projectId);
3802
4466
  try {
3803
- if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
4467
+ if (project) {
3804
4468
  const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3805
4469
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
3806
4470
  if (close.exitCode !== 0)
3807
4471
  throw new Error(close.stderr.trim() || close.stdout.trim());
3808
- // Branch deletion is a separate idempotent step, not `--delete-branch`:
3809
- // a retried close that finds the PR already closed exits 0 before gh's
3810
- // branch-deletion block, which would report success while leaking the
3811
- // published branch.
3812
- const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
3813
- encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
3814
- const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
3815
- if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
3816
- throw new Error(del.stderr.trim() || del.stdout.trim());
3817
- }
3818
- }
3819
- else if (publishedCleanup.devAgentId) {
3820
- const dev = this.getAgentConfig(publishedCleanup.devAgentId);
3821
- const state = await this.agentStore.get(publishedCleanup.devAgentId);
3822
- if (dev && state?.repoPath) {
3823
- const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3824
- // The goal is "branch absent": a retried delete whose first round
3825
- // landed reports a missing remote ref, which is success, not failure.
3826
- if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
3827
- throw new Error(del.stderr.trim() || del.stdout.trim());
3828
- }
3829
- }
3830
4472
  }
3831
4473
  }
3832
4474
  catch (err) {
@@ -3835,7 +4477,7 @@ export class AgentManager {
3835
4477
  phase: 'cancel-published-artifact-cleanup-failed',
3836
4478
  afterDone: publishedCleanup.afterDone,
3837
4479
  branch: publishedCleanup.branch,
3838
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4480
+ prNumber: publishedCleanup.prNumber,
3839
4481
  error: err instanceof Error ? err.message : String(err),
3840
4482
  });
3841
4483
  }
@@ -4151,8 +4793,8 @@ export class AgentManager {
4151
4793
  }
4152
4794
  const devAgentId = task.agentId;
4153
4795
  const devState = await this.agentStore.get(devAgentId);
4154
- if (devState?.taskId !== taskId || !devState.worktreePath) {
4155
- throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s reserved worktree (cannot continue); ` +
4796
+ if (devState?.taskId !== taskId || !devState.workdir) {
4797
+ throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
4156
4798
  `use mark-complete to merge the PR as-is, or cancel the task`);
4157
4799
  }
4158
4800
  const prevReviewRound = task.reviewRound;
@@ -4435,7 +5077,7 @@ export class AgentManager {
4435
5077
  : '';
4436
5078
  // A merge is not idempotent from the caller's view (user-triggered, retryable
4437
5079
  // in the UI), so it gets a timeout but no automatic retry.
4438
- const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash --delete-branch`, { retries: 0 });
5080
+ const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash`, { retries: 0 });
4439
5081
  if (result.exitCode !== 0) {
4440
5082
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4441
5083
  }
@@ -4568,6 +5210,9 @@ export class AgentManager {
4568
5210
  return;
4569
5211
  }
4570
5212
  if (!state.taskId) {
5213
+ const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
5214
+ if (!lockToken)
5215
+ return;
4571
5216
  await this.agentStore.update(agentId, (existing) => {
4572
5217
  if (!existing)
4573
5218
  return AGENT_STORE_NOOP;
@@ -4579,21 +5224,20 @@ export class AgentManager {
4579
5224
  return AGENT_STORE_NOOP;
4580
5225
  if (existing.paneId !== state.paneId)
4581
5226
  return AGENT_STORE_NOOP;
4582
- return { ...existing, taskId: ctx.taskId, updatedAt: new Date().toISOString() };
5227
+ return {
5228
+ ...existing,
5229
+ taskId: ctx.taskId,
5230
+ lockToken,
5231
+ updatedAt: new Date().toISOString(),
5232
+ };
4583
5233
  });
4584
5234
  const fresh = await this.agentStore.get(agentId);
4585
- if (fresh?.taskId !== ctx.taskId)
5235
+ if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
5236
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
4586
5237
  return;
5238
+ }
4587
5239
  }
4588
- await this.removeMergedWorktree(agent, agentId, ctx.taskId);
4589
5240
  const runner = this.createRunnerFor(agent);
4590
- if (state.repoPath) {
4591
- await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
4592
- }
4593
- else {
4594
- console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
4595
- `branch delete for ${ctx.branch} (it may linger locally)`);
4596
- }
4597
5241
  const tmux = new TmuxManager(runner);
4598
5242
  const runtime = agentRuntimeKindFor(agent);
4599
5243
  void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
@@ -4609,56 +5253,6 @@ export class AgentManager {
4609
5253
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
4610
5254
  }
4611
5255
  }
4612
- async removeMergedWorktree(cfg, agentId, expectedTaskId) {
4613
- await this.withTaskLock(async () => {
4614
- const state = await this.agentStore.get(agentId);
4615
- if (!state || state.taskId !== expectedTaskId || !state.worktreePath)
4616
- return;
4617
- const cleanupDir = this.resolveWorkdir(cfg, state);
4618
- if (cleanupDir) {
4619
- const runner = this.createRunnerFor(cfg);
4620
- const worktree = new WorktreeManager(runner);
4621
- try {
4622
- await worktree.remove(cleanupDir, state.worktreePath);
4623
- }
4624
- catch (err) {
4625
- console.warn(`[AgentManager] removeMergedWorktree worktree.remove failed for ${state.worktreePath}:`, err);
4626
- }
4627
- }
4628
- const now = new Date().toISOString();
4629
- await this.agentStore.update(agentId, (existing) => {
4630
- if (!existing || existing.taskId !== expectedTaskId)
4631
- return AGENT_STORE_NOOP;
4632
- const next = { ...existing, updatedAt: now };
4633
- delete next.worktreePath;
4634
- return next;
4635
- });
4636
- });
4637
- }
4638
- async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
4639
- const fetchCmd = `cd ${shellQuote(repoPath)} && git fetch --prune origin && git worktree prune --expire=now`;
4640
- try {
4641
- const fetchResult = await runner.exec(fetchCmd, { timeout: this.postMergeFetchTimeoutMs });
4642
- if (fetchResult.exitCode !== 0) {
4643
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): fetch/prune exit=${fetchResult.exitCode} ` +
4644
- `stderr=${fetchResult.stderr.trim()}`);
4645
- }
4646
- }
4647
- catch (err) {
4648
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) fetch/prune threw:`, err);
4649
- }
4650
- const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
4651
- try {
4652
- const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
4653
- if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
4654
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
4655
- `stderr=${delResult.stderr.trim()}`);
4656
- }
4657
- }
4658
- catch (err) {
4659
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
4660
- }
4661
- }
4662
5256
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
4663
5257
  await this.acquireCompactGuard(agentId);
4664
5258
  try {
@@ -4689,33 +5283,59 @@ export class AgentManager {
4689
5283
  return true;
4690
5284
  }
4691
5285
  async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
5286
+ const initial = await this.agentStore.get(agentId);
5287
+ if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
5288
+ return;
5289
+ const lockToken = await this.resolveTaskLockToken(initial, originalTaskId);
5290
+ if (!lockToken)
5291
+ return;
4692
5292
  const bindingStillOurs = async () => {
4693
5293
  const s = await this.agentStore.get(agentId);
4694
- return !!s && s.taskId === originalTaskId && s.paneId === paneId;
5294
+ return !!s
5295
+ && s.taskId === originalTaskId
5296
+ && s.lockToken === lockToken
5297
+ && s.paneId === paneId
5298
+ && await this.lockManager.isOwner(agentId, originalTaskId, lockToken);
4695
5299
  };
4696
5300
  if (!await bindingStillOurs())
4697
5301
  return;
4698
5302
  let cleared = false;
5303
+ let clearError;
4699
5304
  try {
4700
5305
  cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
4701
5306
  }
4702
5307
  catch (err) {
4703
- console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear failed:`, err);
5308
+ clearError = err;
4704
5309
  }
4705
5310
  if (!cleared) {
4706
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
5311
+ if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
4707
5312
  cleared = true;
4708
5313
  }
4709
5314
  else if (!await bindingStillOurs()) {
4710
5315
  return;
4711
5316
  }
4712
- else if (!await this.clearComposerForReuse(tmux, paneId, agentId)) {
5317
+ else if (clearError instanceof Error
5318
+ && clearError.message.includes('runtime rejected /clear')) {
5319
+ try {
5320
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5321
+ }
5322
+ catch (err) {
5323
+ clearError = err;
5324
+ }
5325
+ if (clearError instanceof Error && !clearError.message.includes('runtime rejected /clear')) {
5326
+ await this.markAwaitingHuman(agentId, 'post-merge-cleanup-not-idle', `Task finished, but the runtime could not be proven idle after /clear: ${clearError.message}`, { expectedTaskId: originalTaskId });
5327
+ return;
5328
+ }
5329
+ }
5330
+ else {
5331
+ const reason = clearError instanceof Error ? clearError.message : String(clearError ?? 'unknown error');
5332
+ await this.markAwaitingHuman(agentId, 'post-merge-cleanup-not-idle', `Task finished, but /clear and the runtime idle check did not complete safely: ${reason}`, { expectedTaskId: originalTaskId });
4713
5333
  return;
4714
5334
  }
4715
5335
  }
4716
5336
  await this.releasePostMergeAgent(agentId, originalTaskId);
4717
5337
  }
4718
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
5338
+ async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
4719
5339
  let paneExists = true;
4720
5340
  try {
4721
5341
  if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
@@ -4729,7 +5349,11 @@ export class AgentManager {
4729
5349
  paneExists = false;
4730
5350
  }
4731
5351
  const state = await this.agentStore.get(agentId);
4732
- if (!state || state.taskId !== taskId || state.paneId !== paneId)
5352
+ if (!state
5353
+ || state.taskId !== taskId
5354
+ || state.lockToken !== lockToken
5355
+ || state.paneId !== paneId
5356
+ || !(await this.lockManager.isOwner(agentId, taskId, lockToken)))
4733
5357
  return false;
4734
5358
  try {
4735
5359
  if (paneExists)
@@ -4738,20 +5362,24 @@ export class AgentManager {
4738
5362
  if (!ensure.freshRuntime)
4739
5363
  return false;
4740
5364
  await this.agentStore.update(agentId, (existing) => {
4741
- if (!existing || existing.taskId !== taskId)
5365
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
4742
5366
  return AGENT_STORE_NOOP;
4743
- if (existing.paneId === ensure.paneId && existing.repoPath === ensure.workdir) {
5367
+ }
5368
+ if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
4744
5369
  return AGENT_STORE_NOOP;
4745
5370
  }
4746
5371
  return {
4747
5372
  ...existing,
4748
5373
  paneId: ensure.paneId,
4749
- repoPath: ensure.workdir,
5374
+ workdir: ensure.workdir,
4750
5375
  updatedAt: new Date().toISOString(),
4751
5376
  };
4752
5377
  });
4753
5378
  const current = await this.agentStore.get(agentId);
4754
- return current?.taskId === taskId && current.paneId === ensure.paneId;
5379
+ return current?.taskId === taskId
5380
+ && current.lockToken === lockToken
5381
+ && current.paneId === ensure.paneId
5382
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
4755
5383
  }
4756
5384
  catch (err) {
4757
5385
  console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
@@ -5096,7 +5724,7 @@ export class AgentManager {
5096
5724
  return empty;
5097
5725
  // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
5098
5726
  try {
5099
- await this.refreshWorktreeCacheFor(task.agentId);
5727
+ await this.refreshWorkdirCacheFor(task.agentId);
5100
5728
  const content = await this.getReviewTransport().readContent(task, dev, 'code');
5101
5729
  return {
5102
5730
  round,
@@ -5270,10 +5898,10 @@ export class AgentManager {
5270
5898
  ...(opts.batch
5271
5899
  ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5272
5900
  : { batchIndex: undefined, batchTotal: undefined }),
5273
- // Fresh dispatch: clear any stale mode until startSession materializes the worktree,
5901
+ // Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
5274
5902
  // so a crash in this window recovers conservatively (no read-file) rather than
5275
5903
  // re-arming it for what might be a head-mode review.
5276
- ...(opts.continuation ? {} : { reviewWorktreeMode: undefined }),
5904
+ ...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
5277
5905
  ...roundPatch,
5278
5906
  });
5279
5907
  if (!transition) {
@@ -5283,7 +5911,7 @@ export class AgentManager {
5283
5911
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
5284
5912
  return null;
5285
5913
  }
5286
- let dispatchedWorktreeMode;
5914
+ let dispatchedCheckoutMode;
5287
5915
  const sessionOpts = {
5288
5916
  bypassTaskStatusGate: true,
5289
5917
  signalToken: newToken,
@@ -5298,8 +5926,8 @@ export class AgentManager {
5298
5926
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5299
5927
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5300
5928
  armBeforeInject: (ctx) => {
5301
- dispatchedWorktreeMode = ctx.serverReviewWorktree;
5302
- const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewWorktree === 'base';
5929
+ dispatchedCheckoutMode = ctx.serverReviewCheckout;
5930
+ const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
5303
5931
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
5304
5932
  },
5305
5933
  };
@@ -5343,10 +5971,20 @@ export class AgentManager {
5343
5971
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
5344
5972
  return null;
5345
5973
  }
5346
- // Persist the materialized worktree mode so a restart can re-arm read-file for a base
5347
- // fallback review (whose prompt depends on it) but not for a head-mode one.
5974
+ // Persist the checkout mode so base-mode recovery can re-arm read-file.
5348
5975
  if (opts.phase !== 'spec' && !opts.continuation) {
5349
- await this.updateTask(taskId, { reviewWorktreeMode: dispatchedWorktreeMode }).catch(() => { });
5976
+ try {
5977
+ await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
5978
+ }
5979
+ catch (err) {
5980
+ const message = err instanceof Error ? err.message : String(err);
5981
+ console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
5982
+ await this.emitIntervention(projectId, qaId, taskId, {
5983
+ phase: 'review-checkout-mode-persist-failed',
5984
+ error: message,
5985
+ note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
5986
+ });
5987
+ }
5350
5988
  }
5351
5989
  return await this.taskStore.get(taskId);
5352
5990
  }
@@ -5466,13 +6104,13 @@ export class AgentManager {
5466
6104
  return { ok: false, reason: 'released' };
5467
6105
  const agent = this.getAgentConfig(task.agentId);
5468
6106
  const agentState = await this.agentStore.get(task.agentId);
5469
- // A rebound agent's worktree belongs to its new task — its git state describes
6107
+ // A rebound agent's Workdir checkout belongs to its new task — its git state describes
5470
6108
  // another branch, so the interdiff would be meaningless. Same skip rule as
5471
- // findLineageViolation: worktree must still be bound to THIS task.
5472
- if (!agent || agentState?.taskId !== taskId || !agentState.worktreePath) {
6109
+ // findLineageViolation: the Workdir must still be bound to THIS task.
6110
+ if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
5473
6111
  return { ok: false, reason: 'released' };
5474
6112
  }
5475
- await this.refreshWorktreeCacheFor(task.agentId);
6113
+ await this.refreshWorkdirCacheFor(task.agentId);
5476
6114
  const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
5477
6115
  return { ok: true, diff };
5478
6116
  }
@@ -5482,13 +6120,13 @@ export class AgentManager {
5482
6120
  return null;
5483
6121
  const agent = this.getAgentConfig(task.agentId);
5484
6122
  const agentState = await this.agentStore.get(task.agentId);
5485
- // A rebound agent's worktree belongs to its new task — checking it would
6123
+ // A rebound agent's Workdir checkout belongs to its new task — checking it would
5486
6124
  // produce verdicts about the wrong branch. Skip; the dispatch path's own
5487
6125
  // binding guards (acquire) surface the real failure.
5488
6126
  if (agentState?.taskId !== taskId)
5489
6127
  return null;
5490
- const worktree = agentState.worktreePath;
5491
- if (!agent || !worktree)
6128
+ const workdir = agentState.workdir;
6129
+ if (!agent || !workdir)
5492
6130
  return null;
5493
6131
  const runner = this.createRunnerFor(agent);
5494
6132
  let base = baseSha;
@@ -5496,13 +6134,13 @@ export class AgentManager {
5496
6134
  // Callers without a baseSha (publish) run long after the review diff was
5497
6135
  // read; refresh origin/HEAD first or upstream commits merged since then
5498
6136
  // would linger in base..HEAD and flag tasks that leak nothing.
5499
- const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(worktree)} fetch origin --quiet`);
6137
+ const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
5500
6138
  if (fetch.exitCode !== 0) {
5501
- throw new Error(`lineage fetch failed in ${worktree}: ${fetch.stderr.trim()}`);
6139
+ throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
5502
6140
  }
5503
- const mb = await runner.exec(`git -C ${shellQuote(worktree)} merge-base origin/HEAD HEAD`);
6141
+ const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
5504
6142
  if (mb.exitCode !== 0) {
5505
- throw new Error(`lineage merge-base failed in ${worktree}: ${mb.stderr.trim()}`);
6143
+ throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
5506
6144
  }
5507
6145
  base = mb.stdout.trim();
5508
6146
  }
@@ -5519,7 +6157,7 @@ export class AgentManager {
5519
6157
  .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
5520
6158
  if (candidates.length === 0)
5521
6159
  return null;
5522
- return findForeignTaskTip((cmd) => runner.exec(cmd), worktree, base, candidates);
6160
+ return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
5523
6161
  }
5524
6162
  async dispatchServerAfterDone(taskId, kind) {
5525
6163
  const task = await this.taskStore.get(taskId);
@@ -5601,7 +6239,7 @@ export class AgentManager {
5601
6239
  const dev = this.getAgentConfig(task.agentId);
5602
6240
  if (!dev)
5603
6241
  return;
5604
- await this.refreshWorktreeCacheFor(task.agentId);
6242
+ await this.refreshWorkdirCacheFor(task.agentId);
5605
6243
  let body;
5606
6244
  try {
5607
6245
  const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
@@ -5636,6 +6274,11 @@ export class AgentManager {
5636
6274
  if (isCancelCleanupHold(state)) {
5637
6275
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
5638
6276
  }
6277
+ const taskId = state?.taskId;
6278
+ const lockToken = taskId ? await this.resolveTaskLockToken(state, taskId) : null;
6279
+ if (taskId && !lockToken) {
6280
+ throw new Error(`injectTextToAgent: agent ${agentId} no longer owns the exclusive lock for task ${taskId}`);
6281
+ }
5639
6282
  const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
5640
6283
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
5641
6284
  throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
@@ -5645,10 +6288,16 @@ export class AgentManager {
5645
6288
  throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5646
6289
  const fresh = await this.agentStore.get(agentId);
5647
6290
  if (!fresh || fresh.paneId !== paneId
5648
- || (opts.expectedTaskId !== undefined && fresh.taskId !== opts.expectedTaskId)
5649
- || isCancelCleanupHold(fresh)) {
6291
+ || fresh.taskId !== taskId
6292
+ || (taskId !== undefined && fresh.lockToken !== lockToken)) {
6293
+ throw new Error(`injectTextToAgent: agent ${agentId} binding or exclusive lock changed before paste`);
6294
+ }
6295
+ if (isCancelCleanupHold(fresh)) {
5650
6296
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
5651
6297
  }
6298
+ if (taskId && lockToken) {
6299
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
6300
+ }
5652
6301
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5653
6302
  await tmux.injectPrompt(paneId, text, agentId);
5654
6303
  await tmux.sendEnter(paneId);
@@ -5792,14 +6441,14 @@ export class AgentManager {
5792
6441
  if (!dev)
5793
6442
  throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
5794
6443
  const state = await this.agentStore.get(task.agentId);
5795
- const repoPath = state?.repoPath;
5796
- if (!repoPath)
5797
- throw new Error(`ffMergeBranch: no repoPath for agent ${task.agentId}`);
6444
+ const workdir = state?.workdir;
6445
+ if (!workdir)
6446
+ throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
5798
6447
  const branch = task.branch ?? BRANCH_PREFIX + task.id;
5799
6448
  const runner = this.createRunnerFor(dev);
5800
6449
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
5801
6450
  const run = prev.then(async () => {
5802
- const cd = `cd ${shellQuote(repoPath)} && `;
6451
+ const cd = `cd ${shellQuote(workdir)} && `;
5803
6452
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
5804
6453
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5805
6454
  if (db.exitCode !== 0 || defaultBranch === '') {
@@ -5823,10 +6472,6 @@ export class AgentManager {
5823
6472
  if (push.exitCode !== 0) {
5824
6473
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5825
6474
  }
5826
- const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
5827
- if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
5828
- console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
5829
- }
5830
6475
  });
5831
6476
  this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
5832
6477
  await run;