baxian 1.2.54 → 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 (90) 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/region.d.ts +1 -0
  15. package/dist/agent/detect/region.d.ts.map +1 -1
  16. package/dist/agent/detect/region.js +27 -19
  17. package/dist/agent/detect/region.js.map +1 -1
  18. package/dist/agent/lineage.d.ts +1 -1
  19. package/dist/agent/lineage.d.ts.map +1 -1
  20. package/dist/agent/lineage.js +6 -7
  21. package/dist/agent/lineage.js.map +1 -1
  22. package/dist/agent/manager.d.ts +12 -9
  23. package/dist/agent/manager.d.ts.map +1 -1
  24. package/dist/agent/manager.js +1028 -423
  25. package/dist/agent/manager.js.map +1 -1
  26. package/dist/agent/preflight.d.ts.map +1 -1
  27. package/dist/agent/preflight.js +19 -15
  28. package/dist/agent/preflight.js.map +1 -1
  29. package/dist/agent/prompt.d.ts +2 -2
  30. package/dist/agent/prompt.d.ts.map +1 -1
  31. package/dist/agent/prompt.js +7 -7
  32. package/dist/agent/prompt.js.map +1 -1
  33. package/dist/agent/repo-store.d.ts +8 -4
  34. package/dist/agent/repo-store.d.ts.map +1 -1
  35. package/dist/agent/repo-store.js +142 -54
  36. package/dist/agent/repo-store.js.map +1 -1
  37. package/dist/agent/review-transport.d.ts +6 -4
  38. package/dist/agent/review-transport.d.ts.map +1 -1
  39. package/dist/agent/review-transport.js +39 -13
  40. package/dist/agent/review-transport.js.map +1 -1
  41. package/dist/agent/runner.d.ts +1 -0
  42. package/dist/agent/runner.d.ts.map +1 -1
  43. package/dist/agent/runner.js +5 -0
  44. package/dist/agent/runner.js.map +1 -1
  45. package/dist/agent/tmux.d.ts +1 -0
  46. package/dist/agent/tmux.d.ts.map +1 -1
  47. package/dist/agent/tmux.js +21 -9
  48. package/dist/agent/tmux.js.map +1 -1
  49. package/dist/api/projects.d.ts.map +1 -1
  50. package/dist/api/projects.js +228 -56
  51. package/dist/api/projects.js.map +1 -1
  52. package/dist/config/validator.d.ts.map +1 -1
  53. package/dist/config/validator.js +52 -8
  54. package/dist/config/validator.js.map +1 -1
  55. package/dist/event/server-handlers.js +8 -8
  56. package/dist/event/server-handlers.js.map +1 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/shared/constants.d.ts +0 -1
  61. package/dist/shared/constants.d.ts.map +1 -1
  62. package/dist/shared/constants.js +0 -1
  63. package/dist/shared/constants.js.map +1 -1
  64. package/dist/shared/types.d.ts +14 -3
  65. package/dist/shared/types.d.ts.map +1 -1
  66. package/dist/shared/types.js.map +1 -1
  67. package/dist/skills/baxian-pr-feedback/SKILL.md +1 -1
  68. package/dist/skills/baxian-pr-recheck/SKILL.md +1 -1
  69. package/dist/skills/baxian-pr-review/SKILL.md +1 -1
  70. package/dist/skills/baxian-server-feedback/SKILL.md +4 -4
  71. package/dist/skills/baxian-server-review/SKILL.md +7 -7
  72. package/dist/skills/baxian-signals/SKILL.md +1 -1
  73. package/dist/skills/baxian-task-check/SKILL.md +4 -4
  74. package/dist/state/agent-store.js +4 -4
  75. package/dist/state/agent-store.js.map +1 -1
  76. package/dist/state/lock.d.ts +15 -2
  77. package/dist/state/lock.d.ts.map +1 -1
  78. package/dist/state/lock.js +104 -21
  79. package/dist/state/lock.js.map +1 -1
  80. package/dist/state/task-store.d.ts.map +1 -1
  81. package/dist/state/task-store.js +5 -2
  82. package/dist/state/task-store.js.map +1 -1
  83. package/dist/web/assets/index-BmnSiyut.js +13 -0
  84. package/dist/web/index.html +1 -1
  85. package/package.json +1 -1
  86. package/dist/agent/worktree.d.ts +0 -32
  87. package/dist/agent/worktree.d.ts.map +0 -1
  88. package/dist/agent/worktree.js +0 -291
  89. package/dist/agent/worktree.js.map +0 -1
  90. package/dist/web/assets/index-Cx-wa7zd.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,23 +46,32 @@ 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');
56
61
  break;
57
62
  case 'opencode':
58
63
  // opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
59
64
  // Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
60
65
  // the .claude/skills scan — that closes the last collision source: a claude-code agent's
61
66
  // .claude/skills/baxian-* in a shared base repo.
62
- segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode --auto');
67
+ segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
68
+ if (yolo)
69
+ segments.push('--auto');
63
70
  break;
64
71
  case 'qodercli':
65
- segments.push('qodercli --dangerously-skip-permissions');
72
+ segments.push('qodercli');
73
+ if (yolo)
74
+ segments.push('--dangerously-skip-permissions');
66
75
  break;
67
76
  }
68
77
  if (agent.model) {
@@ -87,6 +96,27 @@ const REPL_EXIT_COMMAND = {
87
96
  opencode: '/exit',
88
97
  qodercli: '/quit',
89
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
+ }
90
120
  export function skillSubdirFor(runtime) {
91
121
  switch (runtime) {
92
122
  case 'codex':
@@ -113,15 +143,13 @@ export function canDispatchWithBinding(binding) {
113
143
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
114
144
  }
115
145
  const TURN_COMPLETED_AWAITING_PHASES = new Set();
116
- // 'cancel-clear-failed' is no longer produced (cancel stopped clearing); kept so legacy persisted holds stay recognized.
117
- 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']);
118
147
  function isCancelCleanupHold(binding) {
119
148
  return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
120
149
  }
121
150
  const CANCEL_CLEANUP_PHASE_RANK = {
122
151
  'cancel-clearing': 1,
123
152
  'cancel-interrupt-failed': 2,
124
- 'cancel-clear-failed': 3,
125
153
  };
126
154
  function cancelPhaseRank(phase) {
127
155
  return phase ? (CANCEL_CLEANUP_PHASE_RANK[phase] ?? 0) : 0;
@@ -171,8 +199,6 @@ export class AgentManager {
171
199
  runtimeLivenessProbeMs = 700;
172
200
  cleanComposerWaitMs = 5_000;
173
201
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
174
- postMergeFetchTimeoutMs = 60_000;
175
- postMergeBranchTimeoutMs = 10_000;
176
202
  manualReviewInFlight = new Set();
177
203
  markCompleteInFlight = new Set();
178
204
  specVerdictInFlight = new Set();
@@ -221,6 +247,28 @@ export class AgentManager {
221
247
  this.taskMutationQueue = next.catch(() => undefined);
222
248
  return next;
223
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
+ }
224
272
  getReviewStore() {
225
273
  return this.reviewStore;
226
274
  }
@@ -248,18 +296,19 @@ export class AgentManager {
248
296
  getReviewTransport() {
249
297
  this.reviewTransportInstance ??= new ReviewTransport({
250
298
  createRunnerFor: (agent) => this.createRunnerFor(agent),
251
- resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
299
+ resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
252
300
  });
253
301
  return this.reviewTransportInstance;
254
302
  }
255
- bindingWorktreeCache = new Map();
256
- async refreshWorktreeCacheFor(agentId) {
303
+ bindingWorkdirCache = new Map();
304
+ async refreshWorkdirCacheFor(agentId) {
257
305
  const state = await this.agentStore.get(agentId);
258
- if (state?.worktreePath) {
259
- this.bindingWorktreeCache.set(agentId, state.worktreePath);
260
- return state.worktreePath;
306
+ const workdir = state?.workdir;
307
+ if (workdir) {
308
+ this.bindingWorkdirCache.set(agentId, workdir);
309
+ return workdir;
261
310
  }
262
- this.bindingWorktreeCache.delete(agentId);
311
+ this.bindingWorkdirCache.delete(agentId);
263
312
  return undefined;
264
313
  }
265
314
  async safeEmit(event) {
@@ -295,6 +344,7 @@ export class AgentManager {
295
344
  const config = prepareConfig(validated);
296
345
  this.config = config;
297
346
  this.agentIndex = buildAgentIndex(config);
347
+ this.repoCache.owners.clear();
298
348
  }
299
349
  getConfig() {
300
350
  return this.config;
@@ -333,17 +383,15 @@ export class AgentManager {
333
383
  try {
334
384
  const resolved = await this.ensureWorkdir(agent, project, runner);
335
385
  workdir = resolved.workdir;
336
- if (resolved.repoStore) {
337
- await this.agentStore.update(agentId, (existing) => {
338
- if (!existing)
339
- return AGENT_STORE_NOOP;
340
- return {
341
- ...existing,
342
- repoPath: workdir,
343
- updatedAt: new Date().toISOString(),
344
- };
345
- });
346
- }
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
+ });
347
395
  }
348
396
  catch (err) {
349
397
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -367,9 +415,9 @@ export class AgentManager {
367
415
  `it creates itself — kill it manually or pick a different agent id`);
368
416
  }
369
417
  if (alive && (mode === 'runtime' || mode === 'recover')) {
370
- return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
418
+ return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
371
419
  }
372
- return this.buildFreshSession(tmux, agent, agentId, workdir);
420
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
373
421
  }
374
422
  async provisionRepoSkills(runner, agent, workdir) {
375
423
  if (this.skillRegistry.names().length === 0)
@@ -397,7 +445,12 @@ export class AgentManager {
397
445
  runUnderSkillDirLock(key, fn) {
398
446
  const prev = this.skillDirChain.get(key) ?? Promise.resolve();
399
447
  const run = prev.then(fn, fn);
400
- 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
+ });
401
454
  return run;
402
455
  }
403
456
  async atomicWriteFile(runner, finalPath, content) {
@@ -437,14 +490,28 @@ export class AgentManager {
437
490
  return tagged !== this.skillRegistry.contentHash();
438
491
  }
439
492
  async excludeInjectedSkills(runner, workdir, subdir) {
440
- 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; }; ` +
441
498
  `pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
442
- `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`;
443
503
  const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
444
504
  if (res.exitCode !== 0) {
445
- console.warn(`[AgentManager] skill info/exclude best-effort failed in ${workdir} ` +
446
- `(skills still materialized): ${res.stderr || 'unknown error'}`);
505
+ throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
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 ', '')}`);
447
513
  }
514
+ throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
448
515
  }
449
516
  async pinRuntimeSessionOptions(tmux, agentId) {
450
517
  await tmux.setOption(agentId, 'prefix', 'C-b');
@@ -455,17 +522,18 @@ export class AgentManager {
455
522
  await tmux.setOption(agentId, 'window-size', 'latest');
456
523
  await this.pinRuntimeSessionOptions(tmux, agentId);
457
524
  }
458
- async buildFreshSession(tmux, agent, agentId, workdir) {
459
- 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));
460
527
  }
461
- async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
528
+ async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
462
529
  let createdSession = false;
463
530
  const runtime = agentRuntimeKindFor(agent);
464
531
  try {
465
- await tmux.createSession(agentId, workdir);
532
+ await tmux.createSession(agentId, sessionDir);
466
533
  createdSession = true;
467
534
  await tmux.setOption(agentId, '@baxian-agent-id', agentId);
468
535
  await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
536
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
469
537
  await tmux.setOption(agentId, 'allow-passthrough', 'on');
470
538
  await tmux.setOption(agentId, 'set-titles', 'on');
471
539
  await this.pinFreshSessionOptions(tmux, agentId);
@@ -1037,7 +1105,7 @@ export class AgentManager {
1037
1105
  signal.addEventListener('abort', onAbort, { once: true });
1038
1106
  });
1039
1107
  }
1040
- async adoptOrRestartSession(tmux, agent, agentId, workdir) {
1108
+ async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1041
1109
  let claim;
1042
1110
  try {
1043
1111
  claim = await tmux.getOption(agentId, '@baxian-agent-id');
@@ -1070,6 +1138,36 @@ export class AgentManager {
1070
1138
  catch (err) {
1071
1139
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1072
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);
1073
1171
  switch (state.kind) {
1074
1172
  case 'live-runtime': {
1075
1173
  let stale;
@@ -1080,8 +1178,14 @@ export class AgentManager {
1080
1178
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
1081
1179
  }
1082
1180
  if (stale) {
1083
- await tmux.killSession(agentId).catch(() => { });
1084
- 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
+ };
1085
1189
  }
1086
1190
  return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1087
1191
  }
@@ -1152,7 +1256,10 @@ export class AgentManager {
1152
1256
  if (!cfg)
1153
1257
  throw new Error(`Unknown agent: ${agentId}`);
1154
1258
  const state = await this.agentStore.get(agentId);
1155
- 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;
1156
1263
  const reentryPhases = new Set([
1157
1264
  'fix', 'post-approve', 'code',
1158
1265
  'server-feedback', 'server-after-done',
@@ -1165,17 +1272,16 @@ export class AgentManager {
1165
1272
  if (!sameTaskReentry && !canDispatchWithBinding(state)) {
1166
1273
  return false;
1167
1274
  }
1168
- if (!reuseLock) {
1169
- const ok = await this.lockManager.acquire(agentId, taskId);
1170
- if (!ok)
1171
- return false;
1172
- }
1275
+ const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1276
+ if (!token)
1277
+ return false;
1173
1278
  const now = new Date().toISOString();
1174
1279
  await this.agentStore.update(agentId, (existing) => ({
1175
1280
  ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1176
1281
  id: agentId,
1177
1282
  projectId: cfg.projectId,
1178
1283
  taskId,
1284
+ lockToken: token,
1179
1285
  updatedAt: now,
1180
1286
  }));
1181
1287
  return true;
@@ -1208,6 +1314,11 @@ export class AgentManager {
1208
1314
  const cfg = this.getAgentConfig(agentId);
1209
1315
  if (!cfg)
1210
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
+ }
1211
1322
  const now = new Date().toISOString();
1212
1323
  if (mode === 'waiting') {
1213
1324
  await this.agentStore.update(agentId, (latest) => {
@@ -1219,9 +1330,9 @@ export class AgentManager {
1219
1330
  projectId: latest.projectId,
1220
1331
  updatedAt: now,
1221
1332
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
1333
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
1222
1334
  ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
1223
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
1224
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
1335
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1225
1336
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1226
1337
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1227
1338
  };
@@ -1234,19 +1345,118 @@ export class AgentManager {
1234
1345
  });
1235
1346
  return true;
1236
1347
  }
1237
- if (state.worktreePath) {
1238
- const cleanupDir = this.resolveWorkdir(cfg, state);
1239
- if (cleanupDir) {
1240
- const runner = this.createRunnerFor(cfg);
1241
- const worktree = new WorktreeManager(runner);
1242
- try {
1243
- 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);
1244
1447
  }
1245
- catch (err) {
1246
- console.warn(`[AgentManager] releaseAgentForTask worktree.remove failed for ${state.worktreePath}:`, err);
1448
+ else {
1449
+ await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
1247
1450
  }
1248
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
+ }
1249
1458
  }
1459
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1250
1460
  await this.agentStore.update(agentId, (existing) => {
1251
1461
  if (!existing)
1252
1462
  return AGENT_STORE_NOOP;
@@ -1255,16 +1465,172 @@ export class AgentManager {
1255
1465
  return {
1256
1466
  id: existing.id,
1257
1467
  projectId: existing.projectId,
1258
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1468
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1259
1469
  ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1260
1470
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1261
1471
  updatedAt: now,
1262
1472
  };
1263
1473
  });
1264
- await this.lockManager.release(agentId);
1265
- return true;
1474
+ return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1266
1475
  });
1267
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
+ }
1268
1634
  async setAgentNeedInput(agentId, pending, opts = {}) {
1269
1635
  const now = new Date().toISOString();
1270
1636
  try {
@@ -1417,6 +1783,13 @@ export class AgentManager {
1417
1783
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
1418
1784
  return { resumed: false, releasedBinding: false, reason };
1419
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
+ }
1420
1793
  if (state.awaitingPhase === 'code-dispatch-failed'
1421
1794
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1422
1795
  && state.taskId) {
@@ -1448,19 +1821,14 @@ export class AgentManager {
1448
1821
  }
1449
1822
  const now = new Date().toISOString();
1450
1823
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
1451
- const cfg = this.getAgentConfig(agentId);
1452
- if (shouldReleaseBinding && state.worktreePath && cfg) {
1453
- const cleanupDir = this.resolveWorkdir(cfg, state);
1454
- if (cleanupDir) {
1455
- const runner = this.createRunnerFor(cfg);
1456
- const worktree = new WorktreeManager(runner);
1457
- try {
1458
- await worktree.remove(cleanupDir, state.worktreePath);
1459
- }
1460
- catch (err) {
1461
- console.warn(`[AgentManager] resumeAgent worktree.remove failed for ${state.worktreePath}:`, err);
1462
- }
1463
- }
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
+ };
1464
1832
  }
1465
1833
  await this.agentStore.update(agentId, (existing) => {
1466
1834
  if (!existing)
@@ -1469,7 +1837,7 @@ export class AgentManager {
1469
1837
  id: existing.id,
1470
1838
  projectId: existing.projectId,
1471
1839
  updatedAt: now,
1472
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1840
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1473
1841
  ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1474
1842
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1475
1843
  status: 'ok',
@@ -1477,23 +1845,41 @@ export class AgentManager {
1477
1845
  if (!shouldReleaseBinding) {
1478
1846
  if (existing.taskId !== undefined)
1479
1847
  next.taskId = existing.taskId;
1480
- if (existing.worktreePath !== undefined)
1481
- next.worktreePath = existing.worktreePath;
1848
+ if (existing.lockToken !== undefined)
1849
+ next.lockToken = existing.lockToken;
1482
1850
  if (existing.startedAt !== undefined)
1483
1851
  next.startedAt = existing.startedAt;
1484
1852
  }
1485
1853
  return next;
1486
1854
  });
1487
- if (shouldReleaseBinding) {
1488
- await this.lockManager.release(agentId);
1489
- }
1490
1855
  await this.emitIntervention(state.projectId, agentId, state.taskId, {
1491
1856
  phase: 'resumed',
1492
1857
  previousPhase: state.awaitingPhase,
1493
- releasedBinding: shouldReleaseBinding,
1858
+ releasedBinding: false,
1494
1859
  });
1495
- return { resumed: true, releasedBinding: shouldReleaseBinding };
1860
+ return { resumed: true, releasedBinding: false };
1496
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
+ }
1497
1883
  if (result.redispatchCodeTaskId) {
1498
1884
  try {
1499
1885
  const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
@@ -1698,7 +2084,7 @@ export class AgentManager {
1698
2084
  const out = [];
1699
2085
  for (const t of tasks) {
1700
2086
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1701
- // spec-ready 不豁免:approve/打回都依赖 dev worktree,dev 丢失后停驻只是假活
2087
+ // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
1702
2088
  if (t.status === 'ready' || t.status === 'merge-ready')
1703
2089
  continue;
1704
2090
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -1788,18 +2174,16 @@ export class AgentManager {
1788
2174
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
1789
2175
  }
1790
2176
  const project = this.getProjectConfig(cfg.projectId);
1791
- let workdir;
1792
- let provisioned = false;
1793
- if (project) {
1794
- try {
1795
- workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
1796
- await this.provisionRepoSkills(runner, cfg, workdir);
1797
- provisioned = true;
1798
- }
1799
- catch (err) {
1800
- console.warn(`[restart-repl] skill re-provision failed for ${agentId} (continuing):`, err);
1801
- }
1802
- }
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);
1803
2187
  const runtime = agentRuntimeKindFor(cfg);
1804
2188
  const relaunch = async () => {
1805
2189
  await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
@@ -1810,16 +2194,9 @@ export class AgentManager {
1810
2194
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1811
2195
  scrollback: 0,
1812
2196
  });
1813
- if (provisioned) {
1814
- await this.tagSessionSkillsVersion(tmux, agentId);
1815
- }
2197
+ await this.tagSessionSkillsVersion(tmux, agentId);
1816
2198
  };
1817
- if (workdir !== undefined) {
1818
- await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1819
- }
1820
- else {
1821
- await relaunch();
1822
- }
2199
+ await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1823
2200
  await this.agentStore.update(agentId, (state) => {
1824
2201
  if (!state)
1825
2202
  return AGENT_STORE_NOOP;
@@ -1855,7 +2232,21 @@ export class AgentManager {
1855
2232
  continue;
1856
2233
  const runner = this.createRunnerFor(cfg);
1857
2234
  const tmux = new TmuxManager(runner);
1858
- 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
+ }
1859
2250
  this.stopRuntimeMenuWatch(id);
1860
2251
  if (this.paneStreamerManager) {
1861
2252
  try {
@@ -1881,18 +2272,6 @@ export class AgentManager {
1881
2272
  catch (err) {
1882
2273
  failures.push({ agentId: id, step: 'tmux', error: err });
1883
2274
  }
1884
- const state = await this.agentStore.get(id);
1885
- if (state?.worktreePath) {
1886
- const cleanupDir = this.resolveWorkdir(cfg, state);
1887
- if (cleanupDir) {
1888
- try {
1889
- await worktree.remove(cleanupDir, state.worktreePath);
1890
- }
1891
- catch (err) {
1892
- failures.push({ agentId: id, step: 'worktree.remove', error: err });
1893
- }
1894
- }
1895
- }
1896
2275
  }
1897
2276
  if (failures.length > 0) {
1898
2277
  const summary = failures
@@ -1925,7 +2304,6 @@ export class AgentManager {
1925
2304
  workdirForEstimate = cfg.workdir;
1926
2305
  }
1927
2306
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
1928
- const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
1929
2307
  const now = new Date().toISOString();
1930
2308
  const fakeTask = {
1931
2309
  id: 'task-9999999999',
@@ -1944,7 +2322,7 @@ export class AgentManager {
1944
2322
  task: fakeTask,
1945
2323
  phase: 'develop',
1946
2324
  agent: cfg,
1947
- worktreePath: worktreePathBound,
2325
+ workdir: workdirGuess,
1948
2326
  skillRegistry: this.skillRegistry,
1949
2327
  signalToken: 'preview-signal-token',
1950
2328
  });
@@ -2075,7 +2453,7 @@ export class AgentManager {
2075
2453
  const allowRecoveredReadFile = task.status === 'review'
2076
2454
  && (task.phase === 'spec'
2077
2455
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2078
- || (task.reviewMode === 'server' && task.reviewWorktreeMode === 'base'));
2456
+ || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
2079
2457
  try {
2080
2458
  await this.startPhaseSignalWatch({
2081
2459
  taskId: task.id,
@@ -2193,8 +2571,11 @@ export class AgentManager {
2193
2571
  return this.taskStore.list({ projectId });
2194
2572
  }
2195
2573
  async findTaskByBranch(branch, projectId) {
2196
- const all = await this.taskStore.list({ projectId });
2197
- 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);
2198
2579
  }
2199
2580
  async listTasksByPrNumber(prNumber, projectId) {
2200
2581
  const all = await this.taskStore.list({ projectId });
@@ -2261,13 +2642,11 @@ export class AgentManager {
2261
2642
  createRepoStore(agent, project, runner) {
2262
2643
  const host = resolveAgentHost(this.config.host, agent.host);
2263
2644
  if (this.repoStoreFactory) {
2264
- 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);
2265
2646
  }
2266
- 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);
2267
2648
  }
2268
2649
  async ensureWorkdir(agent, project, runner) {
2269
- if (agent.workdir)
2270
- return { workdir: agent.workdir, repoStore: null };
2271
2650
  const repoStore = this.createRepoStore(agent, project, runner);
2272
2651
  const workdir = await repoStore.ensure();
2273
2652
  return { workdir, repoStore };
@@ -2275,22 +2654,29 @@ export class AgentManager {
2275
2654
  resolveWorkdir(agent, agentState) {
2276
2655
  if (agent.workdir)
2277
2656
  return agent.workdir;
2278
- return agentState?.repoPath ?? null;
2279
- }
2280
- async resolveAutoBaseRef(runner, workdir) {
2281
- const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
2282
- if (result.exitCode !== 0) {
2283
- // Falling back to the shared clone's HEAD would seed the worktree with whatever
2284
- // another agent left checked out — refuse instead of silently cross-contaminating.
2285
- throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
2286
- `run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
2287
- }
2288
- return 'origin/HEAD';
2657
+ return agentState?.workdir ?? null;
2289
2658
  }
2290
2659
  getRepoCache() {
2291
2660
  return this.repoCache;
2292
2661
  }
2293
- 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
+ }
2294
2680
  let rolledBack = null;
2295
2681
  await this.withTaskLock(async () => {
2296
2682
  const task = await this.taskStore.get(taskId);
@@ -2311,17 +2697,7 @@ export class AgentManager {
2311
2697
  note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2312
2698
  });
2313
2699
  }
2314
- const existing = await this.agentStore.get(agentId);
2315
- if (existing && existing.taskId !== taskId) {
2316
- console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
2317
- `skipping agent cleanup — agent already reassigned`);
2318
- return;
2319
- }
2320
- if (isCancelCleanupHold(existing)) {
2321
- console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
2322
- `leaving binding to the owner`);
2323
- return;
2324
- }
2700
+ const lockToken = expectedLockToken;
2325
2701
  const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
2326
2702
  if (!projectId) {
2327
2703
  console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
@@ -2329,16 +2705,23 @@ export class AgentManager {
2329
2705
  }
2330
2706
  else {
2331
2707
  const now = new Date().toISOString();
2332
- await this.agentStore.update(agentId, (latest) => ({
2333
- ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
2334
- id: agentId,
2335
- projectId,
2336
- taskId: undefined,
2337
- worktreePath: undefined,
2338
- updatedAt: now,
2339
- }));
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
+ });
2340
2722
  }
2341
- await this.lockManager.release(agentId);
2723
+ if (lockToken)
2724
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
2342
2725
  }
2343
2726
  async pickAgent(projectId, preferredAgentId) {
2344
2727
  const cfg = this.getAgentConfig(preferredAgentId);
@@ -2369,6 +2752,7 @@ export class AgentManager {
2369
2752
  }
2370
2753
  }
2371
2754
  const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
2755
+ const branchCreatedByBaxian = input.branch === undefined;
2372
2756
  if (input.branch) {
2373
2757
  const existing = await this.findTaskByBranch(input.branch, projectId);
2374
2758
  if (existing) {
@@ -2389,6 +2773,7 @@ export class AgentManager {
2389
2773
  reviewRound: 0,
2390
2774
  status: 'pending',
2391
2775
  branch: taskBranch,
2776
+ branchCreatedByBaxian,
2392
2777
  reviewMode: this.effectiveReviewMode(projectId),
2393
2778
  createdAt: now,
2394
2779
  updatedAt: now,
@@ -2418,6 +2803,7 @@ export class AgentManager {
2418
2803
  reviewRound: 0,
2419
2804
  status: 'pending',
2420
2805
  branch: taskBranch,
2806
+ branchCreatedByBaxian,
2421
2807
  reviewMode: this.effectiveReviewMode(projectId),
2422
2808
  createdAt: now,
2423
2809
  updatedAt: now,
@@ -2439,8 +2825,8 @@ export class AgentManager {
2439
2825
  });
2440
2826
  return queued;
2441
2827
  }
2442
- const acquired = await this.lockManager.acquire(dev.id, taskId);
2443
- if (!acquired) {
2828
+ const lockToken = await this.lockManager.acquire(dev.id, taskId);
2829
+ if (!lockToken) {
2444
2830
  const queued = {
2445
2831
  id: taskId,
2446
2832
  projectId,
@@ -2451,6 +2837,7 @@ export class AgentManager {
2451
2837
  reviewRound: 0,
2452
2838
  status: 'pending',
2453
2839
  branch: taskBranch,
2840
+ branchCreatedByBaxian,
2454
2841
  reviewMode: this.effectiveReviewMode(projectId),
2455
2842
  createdAt: now,
2456
2843
  updatedAt: now,
@@ -2483,6 +2870,7 @@ export class AgentManager {
2483
2870
  reviewRound: 0,
2484
2871
  status: 'in_progress',
2485
2872
  branch: taskBranch,
2873
+ branchCreatedByBaxian,
2486
2874
  reviewMode: this.effectiveReviewMode(projectId),
2487
2875
  createdAt: now,
2488
2876
  updatedAt: now,
@@ -2493,10 +2881,11 @@ export class AgentManager {
2493
2881
  id: dev.id,
2494
2882
  projectId,
2495
2883
  taskId,
2884
+ lockToken,
2496
2885
  bootstrappingTaskId: taskId,
2497
2886
  updatedAt: now,
2498
2887
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2499
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
2888
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2500
2889
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2501
2890
  }));
2502
2891
  await this.safeEmit({
@@ -2516,7 +2905,8 @@ export class AgentManager {
2516
2905
  if (task.status === 'in_progress' && task.agentId) {
2517
2906
  const signalToken = createSignalToken();
2518
2907
  await this.updateTask(task.id, { signalToken });
2519
- 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);
2520
2910
  if (opts.background) {
2521
2911
  void start.catch((err) => {
2522
2912
  console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
@@ -2530,7 +2920,7 @@ export class AgentManager {
2530
2920
  }
2531
2921
  return task;
2532
2922
  }
2533
- async startCreatedTaskSession(taskId, agentId, signalToken) {
2923
+ async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
2534
2924
  let started = false;
2535
2925
  let dispatchErr = null;
2536
2926
  try {
@@ -2591,7 +2981,7 @@ export class AgentManager {
2591
2981
  await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
2592
2982
  phase: 'dispatch-rollback',
2593
2983
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2594
- } : undefined);
2984
+ } : undefined, dispatchLockToken);
2595
2985
  }
2596
2986
  }
2597
2987
  return (await this.taskStore.get(taskId)) ?? null;
@@ -2696,6 +3086,33 @@ export class AgentManager {
2696
3086
  async clearAgent(agentId) {
2697
3087
  return this.sendSlashCommand(agentId, '/clear');
2698
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
+ }
2699
3116
  async persistTaskImages(taskId, images) {
2700
3117
  const dir = join(this.imageStagingRoot, taskId);
2701
3118
  await mkdir(dir, { recursive: true });
@@ -2784,8 +3201,8 @@ export class AgentManager {
2784
3201
  if (!canDispatchWithBinding(state)) {
2785
3202
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
2786
3203
  }
2787
- const acquired = await this.lockManager.acquire(agentId, taskId);
2788
- if (!acquired) {
3204
+ const lockToken = await this.lockManager.acquire(agentId, taskId);
3205
+ if (!lockToken) {
2789
3206
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
2790
3207
  }
2791
3208
  const now = new Date().toISOString();
@@ -2803,10 +3220,11 @@ export class AgentManager {
2803
3220
  id: agentId,
2804
3221
  projectId: cfg.projectId,
2805
3222
  taskId,
3223
+ lockToken,
2806
3224
  bootstrappingTaskId: taskId,
2807
3225
  updatedAt: now,
2808
3226
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2809
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
3227
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2810
3228
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2811
3229
  }));
2812
3230
  await this.safeEmit({
@@ -2818,7 +3236,7 @@ export class AgentManager {
2818
3236
  taskId,
2819
3237
  data: { agentId, manuallyDispatched: true },
2820
3238
  });
2821
- return { task: claimedTask };
3239
+ return { task: claimedTask, lockToken };
2822
3240
  });
2823
3241
  if (claim.errorCode !== undefined)
2824
3242
  return claim;
@@ -2850,7 +3268,7 @@ export class AgentManager {
2850
3268
  await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
2851
3269
  phase: 'dispatch-rollback',
2852
3270
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2853
- } : undefined);
3271
+ } : undefined, claim.lockToken);
2854
3272
  }
2855
3273
  const refreshed = await this.taskStore.get(claimed.id);
2856
3274
  if (dispatchErr === null) {
@@ -2859,6 +3277,50 @@ export class AgentManager {
2859
3277
  const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
2860
3278
  return { task: refreshed, errorCode: 500, error: err };
2861
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
+ }
2862
3324
  async startSession(taskId, agentId, phase, opts = {}) {
2863
3325
  const agent = this.getAgentConfig(agentId);
2864
3326
  if (!agent)
@@ -2898,6 +3360,23 @@ export class AgentManager {
2898
3360
  `${preAgent.taskId} (request ${taskId}); aborting`);
2899
3361
  return false;
2900
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();
2901
3380
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
2902
3381
  let ensure;
2903
3382
  try {
@@ -2908,9 +3387,19 @@ export class AgentManager {
2908
3387
  throw err;
2909
3388
  }
2910
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);
2911
3394
  try {
2912
- const runner = this.createRunnerFor(agent);
2913
- 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
+ }
2914
3403
  }
2915
3404
  catch (cleanupErr) {
2916
3405
  console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
@@ -2920,36 +3409,91 @@ export class AgentManager {
2920
3409
  }
2921
3410
  const { paneId, workdir } = ensure;
2922
3411
  const runner = this.createRunnerFor(agent);
2923
- const worktree = new WorktreeManager(runner);
2924
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);
2925
3419
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2926
3420
  const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
2927
- const customBranch = task.branch && !task.branch.startsWith(BRANCH_PREFIX) ? task.branch : undefined;
2928
- // Server code reviews materialize the captured head; other QA modes stay off origin/HEAD.
2929
- // The fail-fast base resolution only runs where the default base is used.
2930
- const reviewWorktree = isServerCodeReviewPhase && opts.serverContent !== undefined
2931
- ? await worktree.createDetachedForReviewHead(workdir, taskId, {
2932
- patch: opts.serverContent,
2933
- baseSha: opts.serverBaseSha,
2934
- headSha: opts.serverHeadSha,
2935
- headTree: opts.serverHeadTree,
2936
- })
2937
- : undefined;
2938
- const worktreePath = reviewWorktree?.path ?? (isServerQaPhase
2939
- ? await worktree.createDetachedAtBase(workdir, taskId)
2940
- : phase === 'review' || phase === 'recheck'
2941
- ? await worktree.createDetached(workdir, taskId, task.branch)
2942
- : 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
+ };
2943
3489
  await this.agentStore.update(agentId, (stateNow) => {
2944
3490
  if (!stateNow || stateNow.taskId !== taskId)
2945
3491
  return AGENT_STORE_NOOP;
2946
- // A fresh dispatch supersedes any question asked under the previous prompt.
2947
3492
  const { needInputAt: _needInputAt, ...rest } = stateNow;
2948
3493
  return {
2949
3494
  ...rest,
2950
3495
  paneId,
2951
- worktreePath,
2952
- repoPath: workdir,
3496
+ workdir,
2953
3497
  updatedAt: new Date().toISOString(),
2954
3498
  };
2955
3499
  });
@@ -2958,17 +3502,18 @@ export class AgentManager {
2958
3502
  const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
2959
3503
  let prompt;
2960
3504
  try {
3505
+ await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
2961
3506
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2962
3507
  let payloadOpts = {};
2963
3508
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2964
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3509
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
2965
3510
  phase,
2966
3511
  ...(task.phase ? { taskPhase: task.phase } : {}),
2967
3512
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
2968
3513
  reviewRound: task.reviewRound,
2969
3514
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
2970
3515
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2971
- ...(reviewWorktree?.mode === 'head' ? { forceContentFile: true } : {}),
3516
+ ...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
2972
3517
  ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2973
3518
  ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
2974
3519
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
@@ -2979,14 +3524,14 @@ export class AgentManager {
2979
3524
  task,
2980
3525
  phase,
2981
3526
  agent,
2982
- worktreePath,
3527
+ workdir: dispatchWorkdir,
2983
3528
  skillRegistry: this.skillRegistry,
2984
3529
  hasQaPartner,
2985
3530
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2986
3531
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2987
3532
  ...(imagePaths.length ? { imagePaths } : {}),
2988
- ...(reviewWorktree ? { serverReviewWorktree: reviewWorktree.mode } : {}),
2989
- ...(reviewWorktree?.fallbackReason ? { serverReviewFallbackReason: reviewWorktree.fallbackReason } : {}),
3533
+ ...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
3534
+ ...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
2990
3535
  ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
2991
3536
  ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
2992
3537
  ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
@@ -2995,10 +3540,7 @@ export class AgentManager {
2995
3540
  });
2996
3541
  }
2997
3542
  catch (err) {
2998
- try {
2999
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3000
- }
3001
- catch { }
3543
+ await cleanupCheckoutOrHold();
3002
3544
  if (err instanceof PromptSizeError) {
3003
3545
  throw new DispatchTerminalError('prompt_too_large', err.message);
3004
3546
  }
@@ -3009,57 +3551,39 @@ export class AgentManager {
3009
3551
  }
3010
3552
  const taskFresh = await this.taskStore.get(taskId);
3011
3553
  if (!taskFresh) {
3012
- console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; cleaning up worktree before paste`);
3013
- try {
3014
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3015
- }
3016
- catch { }
3554
+ console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
3555
+ await cleanupCheckoutOrHold();
3017
3556
  return false;
3018
3557
  }
3019
3558
  if (TERMINAL_STATUSES.includes(taskFresh.status)) {
3020
3559
  console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} is terminal ` +
3021
- `for phase=${phase}; cleaning up worktree before paste`);
3022
- try {
3023
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3024
- }
3025
- catch { }
3560
+ `for phase=${phase}; parking checkout before paste`);
3561
+ await cleanupCheckoutOrHold();
3026
3562
  return false;
3027
3563
  }
3028
3564
  if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
3029
3565
  console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} not in ` +
3030
- `expected ${expectedStatuses.join('/')} for phase=${phase}; cleaning up worktree before paste`);
3031
- try {
3032
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3033
- }
3034
- catch { }
3566
+ `expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
3567
+ await cleanupCheckoutOrHold();
3035
3568
  return false;
3036
3569
  }
3037
3570
  const agentFresh = await this.agentStore.get(agentId);
3038
3571
  if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
3039
3572
  if (!agentFresh || agentFresh.taskId !== taskId) {
3040
3573
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} not bound to ${taskId} ` +
3041
- `(got taskId=${agentFresh?.taskId}); cleaning up worktree before paste`);
3042
- try {
3043
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3044
- }
3045
- catch { }
3574
+ `(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
3575
+ await cleanupCheckoutOrHold();
3046
3576
  return false;
3047
3577
  }
3048
3578
  }
3049
3579
  else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
3050
3580
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} reassigned to ${agentFresh.taskId} ` +
3051
- `(was ${taskId}); cleaning up worktree before paste`);
3052
- try {
3053
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3054
- }
3055
- catch { }
3581
+ `(was ${taskId}); parking checkout before paste`);
3582
+ await cleanupCheckoutOrHold();
3056
3583
  return false;
3057
3584
  }
3058
- if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewWorktree: reviewWorktree?.mode }))) {
3059
- try {
3060
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3061
- }
3062
- catch { }
3585
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
3586
+ await cleanupCheckoutOrHold();
3063
3587
  return false;
3064
3588
  }
3065
3589
  const now = new Date().toISOString();
@@ -3076,8 +3600,8 @@ export class AgentManager {
3076
3600
  projectId: agent.projectId,
3077
3601
  paneId,
3078
3602
  taskId,
3079
- worktreePath,
3080
- repoPath: workdir,
3603
+ workdir,
3604
+ lockToken,
3081
3605
  startedAt: now,
3082
3606
  bootstrappingTaskId: taskId,
3083
3607
  updatedAt: now,
@@ -3087,10 +3611,7 @@ export class AgentManager {
3087
3611
  if (cancelHoldWon) {
3088
3612
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
3089
3613
  `aborting so cancel can finish interrupt + /clear`);
3090
- try {
3091
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3092
- }
3093
- catch { }
3614
+ await cleanupCheckoutOrHold();
3094
3615
  return false;
3095
3616
  }
3096
3617
  agentMarkedRunning = true;
@@ -3112,7 +3633,7 @@ export class AgentManager {
3112
3633
  projectId: agent.projectId,
3113
3634
  agentId,
3114
3635
  taskId,
3115
- data: { phase, worktreePath },
3636
+ data: { phase, workdir: dispatchWorkdir },
3116
3637
  });
3117
3638
  this.startRuntimeMenuWatch(agentId);
3118
3639
  return true;
@@ -3120,13 +3641,11 @@ export class AgentManager {
3120
3641
  catch (err) {
3121
3642
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
3122
3643
  if (!isAckUnknown) {
3123
- try {
3124
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3125
- }
3126
- catch { }
3644
+ await cleanupCheckoutOrHold();
3127
3645
  if (agentMarkedRunning) {
3128
3646
  try {
3129
3647
  let released = false;
3648
+ let releaseToken;
3130
3649
  await this.agentStore.update(agentId, (agentNow) => {
3131
3650
  if (!agentNow || agentNow.taskId !== taskId) {
3132
3651
  console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
@@ -3139,18 +3658,19 @@ export class AgentManager {
3139
3658
  return AGENT_STORE_NOOP;
3140
3659
  }
3141
3660
  released = true;
3661
+ releaseToken = agentNow.lockToken;
3142
3662
  void err;
3143
3663
  return {
3144
3664
  id: agentId,
3145
3665
  projectId: agent.projectId,
3146
3666
  paneId,
3147
- repoPath: workdir,
3667
+ workdir,
3148
3668
  updatedAt: new Date().toISOString(),
3149
3669
  ...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
3150
3670
  };
3151
3671
  });
3152
- if (released) {
3153
- await this.lockManager.release(agentId);
3672
+ if (released && releaseToken) {
3673
+ await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
3154
3674
  }
3155
3675
  }
3156
3676
  catch (cleanupErr) {
@@ -3163,13 +3683,25 @@ export class AgentManager {
3163
3683
  }
3164
3684
  async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
3165
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
+ }
3166
3692
  await this.acquireCompactGuard(agentId);
3167
3693
  try {
3168
3694
  if (before) {
3169
3695
  const now = await this.agentStore.get(agentId);
3170
- 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)) {
3171
3700
  throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
3172
3701
  }
3702
+ if (before.taskId && beforeLockToken) {
3703
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3704
+ }
3173
3705
  if (isCancelCleanupHold(now)) {
3174
3706
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
3175
3707
  }
@@ -3181,9 +3713,16 @@ export class AgentManager {
3181
3713
  const revalidate = before
3182
3714
  ? async () => {
3183
3715
  const fresh = await this.agentStore.get(agentId);
3184
- 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)) {
3185
3721
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
3186
3722
  }
3723
+ if (before.taskId && beforeLockToken) {
3724
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3725
+ }
3187
3726
  const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
3188
3727
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
3189
3728
  throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
@@ -3300,7 +3839,12 @@ export class AgentManager {
3300
3839
  `(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
3301
3840
  return false;
3302
3841
  }
3303
- 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}`);
3304
3848
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
3305
3849
  let ensure;
3306
3850
  try {
@@ -3311,9 +3855,19 @@ export class AgentManager {
3311
3855
  throw err;
3312
3856
  }
3313
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);
3314
3862
  try {
3315
- const runner = this.createRunnerFor(agent);
3316
- 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
+ }
3317
3871
  }
3318
3872
  catch (cleanupErr) {
3319
3873
  console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
@@ -3324,16 +3878,43 @@ export class AgentManager {
3324
3878
  const { paneId } = ensure;
3325
3879
  const runner = this.createRunnerFor(agent);
3326
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
+ }
3327
3907
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3328
3908
  let prompt;
3329
3909
  try {
3910
+ await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
3330
3911
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3331
3912
  && opts.postApproveRedispatchCount > 0
3332
3913
  && !ensure.freshRuntime;
3333
3914
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3334
3915
  let payloadOpts = {};
3335
3916
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3336
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3917
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
3337
3918
  phase,
3338
3919
  ...(task.phase ? { taskPhase: task.phase } : {}),
3339
3920
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
@@ -3350,7 +3931,7 @@ export class AgentManager {
3350
3931
  task,
3351
3932
  phase,
3352
3933
  agent,
3353
- worktreePath,
3934
+ workdir: taskWorkdir,
3354
3935
  skillRegistry: this.skillRegistry,
3355
3936
  ...(signalToken ? { signalToken } : {}),
3356
3937
  ...(useIncrementalNudge
@@ -3372,7 +3953,6 @@ export class AgentManager {
3372
3953
  }
3373
3954
  throw err;
3374
3955
  }
3375
- const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3376
3956
  const taskFresh = await this.taskStore.get(taskId);
3377
3957
  if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
3378
3958
  console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
@@ -3396,6 +3976,11 @@ export class AgentManager {
3396
3976
  `(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
3397
3977
  return false;
3398
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);
3399
3984
  if (phase === 'post-approve') {
3400
3985
  const completionFresh = await this.getPostApproveCompletion(taskId);
3401
3986
  if (!completionFresh || completionFresh.token !== signalToken) {
@@ -3415,14 +4000,15 @@ export class AgentManager {
3415
4000
  return {
3416
4001
  ...rest,
3417
4002
  paneId,
3418
- worktreePath,
4003
+ workdir: taskWorkdir,
4004
+ lockToken,
3419
4005
  updatedAt: now,
3420
4006
  };
3421
4007
  });
3422
4008
  await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3423
4009
  return true;
3424
4010
  }
3425
- async rollbackUndeliveredBootstrap(state, agentConfig) {
4011
+ async rollbackUndeliveredBootstrap(state) {
3426
4012
  if (!state.taskId
3427
4013
  || state.bootstrappingTaskId !== state.taskId
3428
4014
  || state.awaitingPhase === 'bootstrap-marker-clear-failed') {
@@ -3438,18 +4024,7 @@ export class AgentManager {
3438
4024
  }
3439
4025
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
3440
4026
  `(prompt never ack'd); rolling the task back to pending`);
3441
- if (state.worktreePath) {
3442
- const cleanupDir = this.resolveWorkdir(agentConfig, state);
3443
- if (cleanupDir) {
3444
- try {
3445
- await new WorktreeManager(this.createRunnerFor(agentConfig)).remove(cleanupDir, state.worktreePath);
3446
- }
3447
- catch (worktreeErr) {
3448
- console.warn(`[recover] mid-bootstrap rollback: worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
3449
- }
3450
- }
3451
- }
3452
- await this.rollbackFailedDispatch(state.taskId, state.id);
4027
+ await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
3453
4028
  await this.agentStore.update(state.id, (latest) => {
3454
4029
  if (!latest || latest.status !== 'awaiting_human')
3455
4030
  return AGENT_STORE_NOOP;
@@ -3478,16 +4053,56 @@ export class AgentManager {
3478
4053
  return { ...rest, updatedAt: new Date().toISOString() };
3479
4054
  });
3480
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
+ }
3481
4070
  async recover() {
3482
4071
  const states = await this.agentStore.list();
4072
+ await this.releaseOrphanedLocks(states);
3483
4073
  const deferredCleanups = [];
3484
4074
  for (const state of states) {
3485
4075
  const agentConfig = this.getAgentConfig(state.id);
3486
4076
  if (!agentConfig)
3487
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
+ }
3488
4103
  try {
3489
4104
  const result = await this.ensureSession(state.id, 'recover');
3490
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4105
+ if (await this.rollbackUndeliveredBootstrap(state))
3491
4106
  continue;
3492
4107
  if (state.creationToken && !state.taskId) {
3493
4108
  const ct = state.creationToken;
@@ -3546,18 +4161,16 @@ export class AgentManager {
3546
4161
  }
3547
4162
  const cancelHold = isCancelCleanupHold(state);
3548
4163
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
3549
- if (shouldReleaseBinding && state.worktreePath) {
3550
- const cleanupDir = this.resolveWorkdir(agentConfig, state);
3551
- if (cleanupDir) {
3552
- const runner = this.createRunnerFor(agentConfig);
3553
- const worktree = new WorktreeManager(runner);
3554
- try {
3555
- await worktree.remove(cleanupDir, state.worktreePath);
3556
- }
3557
- catch (worktreeErr) {
3558
- console.warn(`[recover] worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
3559
- }
3560
- }
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;
3561
4174
  }
3562
4175
  const preserveHeld = !shouldReleaseBinding
3563
4176
  && state.status === 'awaiting_human';
@@ -3569,7 +4182,7 @@ export class AgentManager {
3569
4182
  projectId: latest.projectId,
3570
4183
  paneId: result.paneId,
3571
4184
  updatedAt: new Date().toISOString(),
3572
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
4185
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
3573
4186
  status: 'ok',
3574
4187
  };
3575
4188
  if (shouldReleaseBinding) {
@@ -3578,7 +4191,7 @@ export class AgentManager {
3578
4191
  const withBinding = {
3579
4192
  ...base,
3580
4193
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
3581
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
4194
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
3582
4195
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
3583
4196
  };
3584
4197
  if (!preserveHeld)
@@ -3591,16 +4204,13 @@ export class AgentManager {
3591
4204
  ...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
3592
4205
  };
3593
4206
  });
3594
- if (shouldReleaseBinding) {
3595
- await this.lockManager.release(state.id);
3596
- }
3597
4207
  if (state.taskId && !shouldReleaseBinding && !cancelHold) {
3598
4208
  this.startRuntimeMenuWatch(state.id);
3599
4209
  }
3600
4210
  }
3601
4211
  catch (err) {
3602
4212
  if (err instanceof EnsureSessionError && err.partial.dialogPending) {
3603
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4213
+ if (await this.rollbackUndeliveredBootstrap(state))
3604
4214
  continue;
3605
4215
  await this.markDialogPending(state.id, state.creationToken);
3606
4216
  void this.slowPollDialogPending(state.id, state.creationToken, {
@@ -3629,10 +4239,15 @@ export class AgentManager {
3629
4239
  ...latest,
3630
4240
  paneId: undefined,
3631
4241
  creationToken: undefined,
4242
+ ...(latest.taskId ? {
4243
+ status: 'awaiting_human',
4244
+ awaitingPhase: 'recovery-failed',
4245
+ awaitingReason: message,
4246
+ awaitingSince: new Date().toISOString(),
4247
+ } : {}),
3632
4248
  updatedAt: new Date().toISOString(),
3633
4249
  };
3634
4250
  });
3635
- await this.lockManager.release(state.id);
3636
4251
  const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
3637
4252
  deferredCleanups.push({
3638
4253
  failingAgentId: state.id,
@@ -3676,23 +4291,32 @@ export class AgentManager {
3676
4291
  hadBinding = !!existing.taskId;
3677
4292
  taskId = existing.taskId;
3678
4293
  if (!existing.taskId
3679
- && !existing.worktreePath
3680
4294
  && !existing.startedAt
3681
4295
  && !existing.paneId
3682
4296
  && !existing.creationToken) {
3683
4297
  return AGENT_STORE_NOOP;
3684
4298
  }
3685
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
+ }
3686
4311
  return {
3687
4312
  id: existing.id,
3688
4313
  projectId: existing.projectId,
3689
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
4314
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
3690
4315
  updatedAt: timestamp,
3691
4316
  };
3692
4317
  });
3693
4318
  if (!projectId || !changed)
3694
4319
  return null;
3695
- await this.lockManager.release(agentId);
3696
4320
  return { projectId, timestamp, hadBinding, taskId };
3697
4321
  });
3698
4322
  if (!reconciled)
@@ -3747,18 +4371,18 @@ export class AgentManager {
3747
4371
  || (task.status === 'approved' && !!task.publishDispatchedAt);
3748
4372
  if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
3749
4373
  const afterDone = this.resolveAfterDone(task);
3750
- if (afterDone !== null && task.branch) {
4374
+ if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
3751
4375
  publishedCleanup = {
3752
4376
  afterDone,
3753
4377
  branch: task.branch,
3754
- ...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
4378
+ prNumber: task.prNumber,
3755
4379
  devAgentId: task.agentId,
3756
4380
  mayBeInFlight: task.status === 'approved',
3757
4381
  };
3758
4382
  }
3759
4383
  }
3760
4384
  else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
3761
- // github 模式持有开放 PR 的任务(review/fixing/approved/merge-ready/max_rounds 等)取消时关 PR 删分支;
4385
+ // GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
3762
4386
  // server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
3763
4387
  publishedCleanup = {
3764
4388
  afterDone: 'pr',
@@ -3833,40 +4457,18 @@ export class AgentManager {
3833
4457
  phase: 'cancel-published-artifact-cleanup-skipped',
3834
4458
  afterDone: publishedCleanup.afterDone,
3835
4459
  branch: publishedCleanup.branch,
3836
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4460
+ prNumber: publishedCleanup.prNumber,
3837
4461
  reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3838
4462
  });
3839
4463
  return result;
3840
4464
  }
3841
4465
  const project = this.getProjectConfig(result.projectId);
3842
4466
  try {
3843
- if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
4467
+ if (project) {
3844
4468
  const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3845
4469
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
3846
4470
  if (close.exitCode !== 0)
3847
4471
  throw new Error(close.stderr.trim() || close.stdout.trim());
3848
- // Branch deletion is a separate idempotent step, not `--delete-branch`:
3849
- // a retried close that finds the PR already closed exits 0 before gh's
3850
- // branch-deletion block, which would report success while leaking the
3851
- // published branch.
3852
- const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
3853
- encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
3854
- const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
3855
- if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
3856
- throw new Error(del.stderr.trim() || del.stdout.trim());
3857
- }
3858
- }
3859
- else if (publishedCleanup.devAgentId) {
3860
- const dev = this.getAgentConfig(publishedCleanup.devAgentId);
3861
- const state = await this.agentStore.get(publishedCleanup.devAgentId);
3862
- if (dev && state?.repoPath) {
3863
- const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3864
- // The goal is "branch absent": a retried delete whose first round
3865
- // landed reports a missing remote ref, which is success, not failure.
3866
- if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
3867
- throw new Error(del.stderr.trim() || del.stdout.trim());
3868
- }
3869
- }
3870
4472
  }
3871
4473
  }
3872
4474
  catch (err) {
@@ -3875,7 +4477,7 @@ export class AgentManager {
3875
4477
  phase: 'cancel-published-artifact-cleanup-failed',
3876
4478
  afterDone: publishedCleanup.afterDone,
3877
4479
  branch: publishedCleanup.branch,
3878
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4480
+ prNumber: publishedCleanup.prNumber,
3879
4481
  error: err instanceof Error ? err.message : String(err),
3880
4482
  });
3881
4483
  }
@@ -4191,8 +4793,8 @@ export class AgentManager {
4191
4793
  }
4192
4794
  const devAgentId = task.agentId;
4193
4795
  const devState = await this.agentStore.get(devAgentId);
4194
- if (devState?.taskId !== taskId || !devState.worktreePath) {
4195
- 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); ` +
4196
4798
  `use mark-complete to merge the PR as-is, or cancel the task`);
4197
4799
  }
4198
4800
  const prevReviewRound = task.reviewRound;
@@ -4475,7 +5077,7 @@ export class AgentManager {
4475
5077
  : '';
4476
5078
  // A merge is not idempotent from the caller's view (user-triggered, retryable
4477
5079
  // in the UI), so it gets a timeout but no automatic retry.
4478
- 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 });
4479
5081
  if (result.exitCode !== 0) {
4480
5082
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4481
5083
  }
@@ -4608,6 +5210,9 @@ export class AgentManager {
4608
5210
  return;
4609
5211
  }
4610
5212
  if (!state.taskId) {
5213
+ const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
5214
+ if (!lockToken)
5215
+ return;
4611
5216
  await this.agentStore.update(agentId, (existing) => {
4612
5217
  if (!existing)
4613
5218
  return AGENT_STORE_NOOP;
@@ -4619,21 +5224,20 @@ export class AgentManager {
4619
5224
  return AGENT_STORE_NOOP;
4620
5225
  if (existing.paneId !== state.paneId)
4621
5226
  return AGENT_STORE_NOOP;
4622
- 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
+ };
4623
5233
  });
4624
5234
  const fresh = await this.agentStore.get(agentId);
4625
- if (fresh?.taskId !== ctx.taskId)
5235
+ if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
5236
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
4626
5237
  return;
5238
+ }
4627
5239
  }
4628
- await this.removeMergedWorktree(agent, agentId, ctx.taskId);
4629
5240
  const runner = this.createRunnerFor(agent);
4630
- if (state.repoPath) {
4631
- await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
4632
- }
4633
- else {
4634
- console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
4635
- `branch delete for ${ctx.branch} (it may linger locally)`);
4636
- }
4637
5241
  const tmux = new TmuxManager(runner);
4638
5242
  const runtime = agentRuntimeKindFor(agent);
4639
5243
  void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
@@ -4649,56 +5253,6 @@ export class AgentManager {
4649
5253
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
4650
5254
  }
4651
5255
  }
4652
- async removeMergedWorktree(cfg, agentId, expectedTaskId) {
4653
- await this.withTaskLock(async () => {
4654
- const state = await this.agentStore.get(agentId);
4655
- if (!state || state.taskId !== expectedTaskId || !state.worktreePath)
4656
- return;
4657
- const cleanupDir = this.resolveWorkdir(cfg, state);
4658
- if (cleanupDir) {
4659
- const runner = this.createRunnerFor(cfg);
4660
- const worktree = new WorktreeManager(runner);
4661
- try {
4662
- await worktree.remove(cleanupDir, state.worktreePath);
4663
- }
4664
- catch (err) {
4665
- console.warn(`[AgentManager] removeMergedWorktree worktree.remove failed for ${state.worktreePath}:`, err);
4666
- }
4667
- }
4668
- const now = new Date().toISOString();
4669
- await this.agentStore.update(agentId, (existing) => {
4670
- if (!existing || existing.taskId !== expectedTaskId)
4671
- return AGENT_STORE_NOOP;
4672
- const next = { ...existing, updatedAt: now };
4673
- delete next.worktreePath;
4674
- return next;
4675
- });
4676
- });
4677
- }
4678
- async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
4679
- const fetchCmd = `cd ${shellQuote(repoPath)} && git fetch --prune origin && git worktree prune --expire=now`;
4680
- try {
4681
- const fetchResult = await runner.exec(fetchCmd, { timeout: this.postMergeFetchTimeoutMs });
4682
- if (fetchResult.exitCode !== 0) {
4683
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): fetch/prune exit=${fetchResult.exitCode} ` +
4684
- `stderr=${fetchResult.stderr.trim()}`);
4685
- }
4686
- }
4687
- catch (err) {
4688
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) fetch/prune threw:`, err);
4689
- }
4690
- const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
4691
- try {
4692
- const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
4693
- if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
4694
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
4695
- `stderr=${delResult.stderr.trim()}`);
4696
- }
4697
- }
4698
- catch (err) {
4699
- console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
4700
- }
4701
- }
4702
5256
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
4703
5257
  await this.acquireCompactGuard(agentId);
4704
5258
  try {
@@ -4729,33 +5283,59 @@ export class AgentManager {
4729
5283
  return true;
4730
5284
  }
4731
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;
4732
5292
  const bindingStillOurs = async () => {
4733
5293
  const s = await this.agentStore.get(agentId);
4734
- 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);
4735
5299
  };
4736
5300
  if (!await bindingStillOurs())
4737
5301
  return;
4738
5302
  let cleared = false;
5303
+ let clearError;
4739
5304
  try {
4740
5305
  cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
4741
5306
  }
4742
5307
  catch (err) {
4743
- console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear failed:`, err);
5308
+ clearError = err;
4744
5309
  }
4745
5310
  if (!cleared) {
4746
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
5311
+ if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
4747
5312
  cleared = true;
4748
5313
  }
4749
5314
  else if (!await bindingStillOurs()) {
4750
5315
  return;
4751
5316
  }
4752
- 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 });
4753
5333
  return;
4754
5334
  }
4755
5335
  }
4756
5336
  await this.releasePostMergeAgent(agentId, originalTaskId);
4757
5337
  }
4758
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
5338
+ async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
4759
5339
  let paneExists = true;
4760
5340
  try {
4761
5341
  if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
@@ -4769,7 +5349,11 @@ export class AgentManager {
4769
5349
  paneExists = false;
4770
5350
  }
4771
5351
  const state = await this.agentStore.get(agentId);
4772
- 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)))
4773
5357
  return false;
4774
5358
  try {
4775
5359
  if (paneExists)
@@ -4778,20 +5362,24 @@ export class AgentManager {
4778
5362
  if (!ensure.freshRuntime)
4779
5363
  return false;
4780
5364
  await this.agentStore.update(agentId, (existing) => {
4781
- if (!existing || existing.taskId !== taskId)
5365
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
4782
5366
  return AGENT_STORE_NOOP;
4783
- if (existing.paneId === ensure.paneId && existing.repoPath === ensure.workdir) {
5367
+ }
5368
+ if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
4784
5369
  return AGENT_STORE_NOOP;
4785
5370
  }
4786
5371
  return {
4787
5372
  ...existing,
4788
5373
  paneId: ensure.paneId,
4789
- repoPath: ensure.workdir,
5374
+ workdir: ensure.workdir,
4790
5375
  updatedAt: new Date().toISOString(),
4791
5376
  };
4792
5377
  });
4793
5378
  const current = await this.agentStore.get(agentId);
4794
- 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);
4795
5383
  }
4796
5384
  catch (err) {
4797
5385
  console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
@@ -5136,7 +5724,7 @@ export class AgentManager {
5136
5724
  return empty;
5137
5725
  // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
5138
5726
  try {
5139
- await this.refreshWorktreeCacheFor(task.agentId);
5727
+ await this.refreshWorkdirCacheFor(task.agentId);
5140
5728
  const content = await this.getReviewTransport().readContent(task, dev, 'code');
5141
5729
  return {
5142
5730
  round,
@@ -5310,10 +5898,10 @@ export class AgentManager {
5310
5898
  ...(opts.batch
5311
5899
  ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5312
5900
  : { batchIndex: undefined, batchTotal: undefined }),
5313
- // Fresh dispatch: clear any stale mode until startSession materializes the worktree,
5901
+ // Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
5314
5902
  // so a crash in this window recovers conservatively (no read-file) rather than
5315
5903
  // re-arming it for what might be a head-mode review.
5316
- ...(opts.continuation ? {} : { reviewWorktreeMode: undefined }),
5904
+ ...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
5317
5905
  ...roundPatch,
5318
5906
  });
5319
5907
  if (!transition) {
@@ -5323,7 +5911,7 @@ export class AgentManager {
5323
5911
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
5324
5912
  return null;
5325
5913
  }
5326
- let dispatchedWorktreeMode;
5914
+ let dispatchedCheckoutMode;
5327
5915
  const sessionOpts = {
5328
5916
  bypassTaskStatusGate: true,
5329
5917
  signalToken: newToken,
@@ -5338,8 +5926,8 @@ export class AgentManager {
5338
5926
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5339
5927
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5340
5928
  armBeforeInject: (ctx) => {
5341
- dispatchedWorktreeMode = ctx.serverReviewWorktree;
5342
- 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';
5343
5931
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
5344
5932
  },
5345
5933
  };
@@ -5383,10 +5971,20 @@ export class AgentManager {
5383
5971
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
5384
5972
  return null;
5385
5973
  }
5386
- // Persist the materialized worktree mode so a restart can re-arm read-file for a base
5387
- // fallback review (whose prompt depends on it) but not for a head-mode one.
5974
+ // Persist the checkout mode so base-mode recovery can re-arm read-file.
5388
5975
  if (opts.phase !== 'spec' && !opts.continuation) {
5389
- 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
+ }
5390
5988
  }
5391
5989
  return await this.taskStore.get(taskId);
5392
5990
  }
@@ -5506,13 +6104,13 @@ export class AgentManager {
5506
6104
  return { ok: false, reason: 'released' };
5507
6105
  const agent = this.getAgentConfig(task.agentId);
5508
6106
  const agentState = await this.agentStore.get(task.agentId);
5509
- // 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
5510
6108
  // another branch, so the interdiff would be meaningless. Same skip rule as
5511
- // findLineageViolation: worktree must still be bound to THIS task.
5512
- if (!agent || agentState?.taskId !== taskId || !agentState.worktreePath) {
6109
+ // findLineageViolation: the Workdir must still be bound to THIS task.
6110
+ if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
5513
6111
  return { ok: false, reason: 'released' };
5514
6112
  }
5515
- await this.refreshWorktreeCacheFor(task.agentId);
6113
+ await this.refreshWorkdirCacheFor(task.agentId);
5516
6114
  const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
5517
6115
  return { ok: true, diff };
5518
6116
  }
@@ -5522,13 +6120,13 @@ export class AgentManager {
5522
6120
  return null;
5523
6121
  const agent = this.getAgentConfig(task.agentId);
5524
6122
  const agentState = await this.agentStore.get(task.agentId);
5525
- // 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
5526
6124
  // produce verdicts about the wrong branch. Skip; the dispatch path's own
5527
6125
  // binding guards (acquire) surface the real failure.
5528
6126
  if (agentState?.taskId !== taskId)
5529
6127
  return null;
5530
- const worktree = agentState.worktreePath;
5531
- if (!agent || !worktree)
6128
+ const workdir = agentState.workdir;
6129
+ if (!agent || !workdir)
5532
6130
  return null;
5533
6131
  const runner = this.createRunnerFor(agent);
5534
6132
  let base = baseSha;
@@ -5536,13 +6134,13 @@ export class AgentManager {
5536
6134
  // Callers without a baseSha (publish) run long after the review diff was
5537
6135
  // read; refresh origin/HEAD first or upstream commits merged since then
5538
6136
  // would linger in base..HEAD and flag tasks that leak nothing.
5539
- 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`);
5540
6138
  if (fetch.exitCode !== 0) {
5541
- throw new Error(`lineage fetch failed in ${worktree}: ${fetch.stderr.trim()}`);
6139
+ throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
5542
6140
  }
5543
- 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`);
5544
6142
  if (mb.exitCode !== 0) {
5545
- 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()}`);
5546
6144
  }
5547
6145
  base = mb.stdout.trim();
5548
6146
  }
@@ -5559,7 +6157,7 @@ export class AgentManager {
5559
6157
  .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
5560
6158
  if (candidates.length === 0)
5561
6159
  return null;
5562
- return findForeignTaskTip((cmd) => runner.exec(cmd), worktree, base, candidates);
6160
+ return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
5563
6161
  }
5564
6162
  async dispatchServerAfterDone(taskId, kind) {
5565
6163
  const task = await this.taskStore.get(taskId);
@@ -5641,7 +6239,7 @@ export class AgentManager {
5641
6239
  const dev = this.getAgentConfig(task.agentId);
5642
6240
  if (!dev)
5643
6241
  return;
5644
- await this.refreshWorktreeCacheFor(task.agentId);
6242
+ await this.refreshWorkdirCacheFor(task.agentId);
5645
6243
  let body;
5646
6244
  try {
5647
6245
  const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
@@ -5676,6 +6274,11 @@ export class AgentManager {
5676
6274
  if (isCancelCleanupHold(state)) {
5677
6275
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
5678
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
+ }
5679
6282
  const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
5680
6283
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
5681
6284
  throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
@@ -5685,10 +6288,16 @@ export class AgentManager {
5685
6288
  throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5686
6289
  const fresh = await this.agentStore.get(agentId);
5687
6290
  if (!fresh || fresh.paneId !== paneId
5688
- || (opts.expectedTaskId !== undefined && fresh.taskId !== opts.expectedTaskId)
5689
- || 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)) {
5690
6296
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
5691
6297
  }
6298
+ if (taskId && lockToken) {
6299
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
6300
+ }
5692
6301
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5693
6302
  await tmux.injectPrompt(paneId, text, agentId);
5694
6303
  await tmux.sendEnter(paneId);
@@ -5832,14 +6441,14 @@ export class AgentManager {
5832
6441
  if (!dev)
5833
6442
  throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
5834
6443
  const state = await this.agentStore.get(task.agentId);
5835
- const repoPath = state?.repoPath;
5836
- if (!repoPath)
5837
- 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}`);
5838
6447
  const branch = task.branch ?? BRANCH_PREFIX + task.id;
5839
6448
  const runner = this.createRunnerFor(dev);
5840
6449
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
5841
6450
  const run = prev.then(async () => {
5842
- const cd = `cd ${shellQuote(repoPath)} && `;
6451
+ const cd = `cd ${shellQuote(workdir)} && `;
5843
6452
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
5844
6453
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5845
6454
  if (db.exitCode !== 0 || defaultBranch === '') {
@@ -5863,10 +6472,6 @@ export class AgentManager {
5863
6472
  if (push.exitCode !== 0) {
5864
6473
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5865
6474
  }
5866
- const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
5867
- if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
5868
- console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
5869
- }
5870
6475
  });
5871
6476
  this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
5872
6477
  await run;