baxian 1.2.54 → 1.2.56

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 (105) 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 +38 -36
  23. package/dist/agent/manager.d.ts.map +1 -1
  24. package/dist/agent/manager.js +1616 -669
  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 +9 -8
  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 +143 -54
  36. package/dist/agent/repo-store.js.map +1 -1
  37. package/dist/agent/review-transport.d.ts +11 -5
  38. package/dist/agent/review-transport.d.ts.map +1 -1
  39. package/dist/agent/review-transport.js +170 -19
  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/config.d.ts.map +1 -1
  50. package/dist/api/config.js +45 -8
  51. package/dist/api/config.js.map +1 -1
  52. package/dist/api/projects.d.ts.map +1 -1
  53. package/dist/api/projects.js +257 -71
  54. package/dist/api/projects.js.map +1 -1
  55. package/dist/api/tasks.js +2 -2
  56. package/dist/api/tasks.js.map +1 -1
  57. package/dist/config/validator.d.ts.map +1 -1
  58. package/dist/config/validator.js +75 -19
  59. package/dist/config/validator.js.map +1 -1
  60. package/dist/event/handlers.d.ts.map +1 -1
  61. package/dist/event/handlers.js +36 -26
  62. package/dist/event/handlers.js.map +1 -1
  63. package/dist/event/server-handlers.d.ts.map +1 -1
  64. package/dist/event/server-handlers.js +54 -34
  65. package/dist/event/server-handlers.js.map +1 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +1 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/shared/constants.d.ts +3 -2
  70. package/dist/shared/constants.d.ts.map +1 -1
  71. package/dist/shared/constants.js +8 -1
  72. package/dist/shared/constants.js.map +1 -1
  73. package/dist/shared/types.d.ts +35 -8
  74. package/dist/shared/types.d.ts.map +1 -1
  75. package/dist/shared/types.js +8 -0
  76. package/dist/shared/types.js.map +1 -1
  77. package/dist/skills/baxian-pr-feedback/SKILL.md +1 -1
  78. package/dist/skills/baxian-pr-recheck/SKILL.md +1 -1
  79. package/dist/skills/baxian-pr-review/SKILL.md +1 -1
  80. package/dist/skills/baxian-research/SKILL.md +28 -0
  81. package/dist/skills/baxian-server-feedback/SKILL.md +6 -6
  82. package/dist/skills/baxian-server-review/SKILL.md +7 -7
  83. package/dist/skills/baxian-signals/SKILL.md +1 -1
  84. package/dist/skills/baxian-task-check/SKILL.md +4 -4
  85. package/dist/state/agent-store.js +4 -4
  86. package/dist/state/agent-store.js.map +1 -1
  87. package/dist/state/lock.d.ts +15 -2
  88. package/dist/state/lock.d.ts.map +1 -1
  89. package/dist/state/lock.js +104 -21
  90. package/dist/state/lock.js.map +1 -1
  91. package/dist/state/review-store.d.ts +4 -4
  92. package/dist/state/review-store.d.ts.map +1 -1
  93. package/dist/state/review-store.js +57 -6
  94. package/dist/state/review-store.js.map +1 -1
  95. package/dist/state/task-store.d.ts.map +1 -1
  96. package/dist/state/task-store.js +116 -18
  97. package/dist/state/task-store.js.map +1 -1
  98. package/dist/web/assets/index-3Ypk8Gfo.js +13 -0
  99. package/dist/web/index.html +1 -1
  100. package/package.json +1 -1
  101. package/dist/agent/worktree.d.ts +0 -32
  102. package/dist/agent/worktree.d.ts.map +0 -1
  103. package/dist/agent/worktree.js +0 -291
  104. package/dist/agent/worktree.js.map +0 -1
  105. package/dist/web/assets/index-Cx-wa7zd.js +0 -13
@@ -1,18 +1,18 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, normalize } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { createSignalToken } from './phase-signal.js';
5
5
  import { tmuxInstallHint } from './preflight.js';
6
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
6
+ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
7
7
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
8
8
  import { PostApproveStore } from '../state/post-approve-store.js';
9
9
  import { SkillRegistry } from '../skill/registry.js';
10
- import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
10
+ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
11
11
  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':
@@ -107,21 +137,19 @@ const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
107
137
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
108
138
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
109
139
  const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
110
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
140
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
111
141
  const RUNTIME_LIVENESS_SAMPLES = 3;
112
142
  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,39 @@ 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
+ }
272
+ async assertTaskGeneration(agentId, taskId, lockToken, workdir) {
273
+ const state = await this.agentStore.get(agentId);
274
+ if (!state
275
+ || state.taskId !== taskId
276
+ || state.lockToken !== lockToken
277
+ || state.workdir !== workdir) {
278
+ throw new Error(`Agent ${agentId} task generation changed for ${taskId}; operation aborted`);
279
+ }
280
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
281
+ return state;
282
+ }
224
283
  getReviewStore() {
225
284
  return this.reviewStore;
226
285
  }
@@ -248,18 +307,27 @@ export class AgentManager {
248
307
  getReviewTransport() {
249
308
  this.reviewTransportInstance ??= new ReviewTransport({
250
309
  createRunnerFor: (agent) => this.createRunnerFor(agent),
251
- resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
310
+ resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
252
311
  });
253
312
  return this.reviewTransportInstance;
254
313
  }
255
- bindingWorktreeCache = new Map();
256
- async refreshWorktreeCacheFor(agentId) {
314
+ async prepareDispatchArtifacts(agent, workdir, phase, opts) {
315
+ const transport = this.getReviewTransport();
316
+ if (!opts.preserveOutputs)
317
+ await transport.clearDispatchOutputs(agent, workdir, phase);
318
+ if (opts.specDocuments) {
319
+ await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
320
+ }
321
+ }
322
+ bindingWorkdirCache = new Map();
323
+ async refreshWorkdirCacheFor(agentId) {
257
324
  const state = await this.agentStore.get(agentId);
258
- if (state?.worktreePath) {
259
- this.bindingWorktreeCache.set(agentId, state.worktreePath);
260
- return state.worktreePath;
325
+ const workdir = state?.workdir;
326
+ if (workdir) {
327
+ this.bindingWorkdirCache.set(agentId, workdir);
328
+ return workdir;
261
329
  }
262
- this.bindingWorktreeCache.delete(agentId);
330
+ this.bindingWorkdirCache.delete(agentId);
263
331
  return undefined;
264
332
  }
265
333
  async safeEmit(event) {
@@ -293,8 +361,28 @@ export class AgentManager {
293
361
  }
294
362
  replaceConfig(validated) {
295
363
  const config = prepareConfig(validated);
364
+ const previousConfig = this.config;
365
+ const previousIndex = this.agentIndex;
366
+ const nextIndex = buildAgentIndex(config);
367
+ for (const [ownerKey, agentId] of this.repoCache.owners) {
368
+ const previous = previousIndex.get(agentId);
369
+ const next = nextIndex.get(agentId);
370
+ const previousHost = previous
371
+ ? resolveAgentHost(previousConfig.host, previous.host)
372
+ : undefined;
373
+ const nextHost = next ? resolveAgentHost(config.host, next.host) : undefined;
374
+ const previousOwnerConfig = previous
375
+ ? `${workdirHostGroupKey(previous.mode, previousHost)}\0${previous.workdir ? normalize(previous.workdir) : ''}`
376
+ : null;
377
+ const nextOwnerConfig = next
378
+ ? `${workdirHostGroupKey(next.mode, nextHost)}\0${next.workdir ? normalize(next.workdir) : ''}`
379
+ : null;
380
+ if (previousOwnerConfig === null || previousOwnerConfig !== nextOwnerConfig) {
381
+ this.repoCache.owners.delete(ownerKey);
382
+ }
383
+ }
296
384
  this.config = config;
297
- this.agentIndex = buildAgentIndex(config);
385
+ this.agentIndex = nextIndex;
298
386
  }
299
387
  getConfig() {
300
388
  return this.config;
@@ -333,17 +421,15 @@ export class AgentManager {
333
421
  try {
334
422
  const resolved = await this.ensureWorkdir(agent, project, runner);
335
423
  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
- }
424
+ await this.agentStore.update(agentId, (existing) => {
425
+ if (!existing)
426
+ return AGENT_STORE_NOOP;
427
+ return {
428
+ ...existing,
429
+ workdir,
430
+ updatedAt: new Date().toISOString(),
431
+ };
432
+ });
347
433
  }
348
434
  catch (err) {
349
435
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -367,9 +453,9 @@ export class AgentManager {
367
453
  `it creates itself — kill it manually or pick a different agent id`);
368
454
  }
369
455
  if (alive && (mode === 'runtime' || mode === 'recover')) {
370
- return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
456
+ return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
371
457
  }
372
- return this.buildFreshSession(tmux, agent, agentId, workdir);
458
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
373
459
  }
374
460
  async provisionRepoSkills(runner, agent, workdir) {
375
461
  if (this.skillRegistry.names().length === 0)
@@ -397,7 +483,12 @@ export class AgentManager {
397
483
  runUnderSkillDirLock(key, fn) {
398
484
  const prev = this.skillDirChain.get(key) ?? Promise.resolve();
399
485
  const run = prev.then(fn, fn);
400
- this.skillDirChain.set(key, run.then(() => undefined, () => undefined));
486
+ const settled = run.then(() => undefined, () => undefined);
487
+ this.skillDirChain.set(key, settled);
488
+ void settled.finally(() => {
489
+ if (this.skillDirChain.get(key) === settled)
490
+ this.skillDirChain.delete(key);
491
+ });
401
492
  return run;
402
493
  }
403
494
  async atomicWriteFile(runner, finalPath, content) {
@@ -437,14 +528,28 @@ export class AgentManager {
437
528
  return tagged !== this.skillRegistry.contentHash();
438
529
  }
439
530
  async excludeInjectedSkills(runner, workdir, subdir) {
440
- const inner = `cd ${shellQuote(workdir)} && if p="$(git rev-parse --git-path info/exclude 2>/dev/null)"; then ` +
531
+ const inner = `cd ${shellQuote(workdir)} && ` +
532
+ `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
533
+ `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
534
+ `if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
535
+ `p="$(git rev-parse --git-path info/exclude)" || { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
441
536
  `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`;
537
+ `{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; } ` +
538
+ `|| { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
539
+ `if git check-ignore -q -- ${shellQuote(`${subdir}/baxian-__probe__/SKILL.md`)}; then echo BX_SKILLS_OK; ` +
540
+ `else echo BX_SKILLS_EXCLUDE_INEFFECTIVE; fi`;
443
541
  const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
444
542
  if (res.exitCode !== 0) {
445
- console.warn(`[AgentManager] skill info/exclude best-effort failed in ${workdir} ` +
446
- `(skills still materialized): ${res.stderr || 'unknown error'}`);
543
+ throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
544
+ }
545
+ const verdict = res.stdout.trim().split('\n').pop() ?? '';
546
+ if (verdict === 'BX_SKILLS_NON_GIT' || verdict === 'BX_SKILLS_OK')
547
+ return;
548
+ if (verdict.startsWith('BX_SKILLS_TRACKED')) {
549
+ throw new Error(`refusing to inject baxian skills into ${workdir}: project tracks ` +
550
+ `${verdict.replace('BX_SKILLS_TRACKED ', '')}`);
447
551
  }
552
+ throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
448
553
  }
449
554
  async pinRuntimeSessionOptions(tmux, agentId) {
450
555
  await tmux.setOption(agentId, 'prefix', 'C-b');
@@ -455,17 +560,18 @@ export class AgentManager {
455
560
  await tmux.setOption(agentId, 'window-size', 'latest');
456
561
  await this.pinRuntimeSessionOptions(tmux, agentId);
457
562
  }
458
- async buildFreshSession(tmux, agent, agentId, workdir) {
459
- return this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir));
563
+ async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
564
+ return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
460
565
  }
461
- async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
566
+ async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
462
567
  let createdSession = false;
463
568
  const runtime = agentRuntimeKindFor(agent);
464
569
  try {
465
- await tmux.createSession(agentId, workdir);
570
+ await tmux.createSession(agentId, sessionDir);
466
571
  createdSession = true;
467
572
  await tmux.setOption(agentId, '@baxian-agent-id', agentId);
468
573
  await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
574
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
469
575
  await tmux.setOption(agentId, 'allow-passthrough', 'on');
470
576
  await tmux.setOption(agentId, 'set-titles', 'on');
471
577
  await this.pinFreshSessionOptions(tmux, agentId);
@@ -1037,7 +1143,7 @@ export class AgentManager {
1037
1143
  signal.addEventListener('abort', onAbort, { once: true });
1038
1144
  });
1039
1145
  }
1040
- async adoptOrRestartSession(tmux, agent, agentId, workdir) {
1146
+ async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1041
1147
  let claim;
1042
1148
  try {
1043
1149
  claim = await tmux.getOption(agentId, '@baxian-agent-id');
@@ -1070,6 +1176,36 @@ export class AgentManager {
1070
1176
  catch (err) {
1071
1177
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1072
1178
  }
1179
+ let currentPath;
1180
+ try {
1181
+ currentPath = await tmux.getPaneCurrentPath(paneId);
1182
+ }
1183
+ catch (err) {
1184
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
1185
+ }
1186
+ let pathMatches;
1187
+ try {
1188
+ pathMatches = await sameDirOnHost(runner, currentPath, sessionDir);
1189
+ }
1190
+ catch (err) {
1191
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path comparison failed: ${err instanceof Error ? err.message : String(err)}`);
1192
+ }
1193
+ if (!pathMatches) {
1194
+ if (state.kind === 'live-runtime') {
1195
+ try {
1196
+ await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
1197
+ }
1198
+ catch (err) {
1199
+ throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
1200
+ }
1201
+ }
1202
+ else if (state.kind !== 'shell') {
1203
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
1204
+ }
1205
+ await tmux.killSession(agentId);
1206
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1207
+ }
1208
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
1073
1209
  switch (state.kind) {
1074
1210
  case 'live-runtime': {
1075
1211
  let stale;
@@ -1080,8 +1216,14 @@ export class AgentManager {
1080
1216
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
1081
1217
  }
1082
1218
  if (stale) {
1083
- await tmux.killSession(agentId).catch(() => { });
1084
- return this.buildFreshSession(tmux, agent, agentId, workdir);
1219
+ return {
1220
+ ok: true,
1221
+ createdSession: false,
1222
+ freshRuntime: false,
1223
+ skillsStale: true,
1224
+ paneId,
1225
+ workdir,
1226
+ };
1085
1227
  }
1086
1228
  return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1087
1229
  }
@@ -1152,7 +1294,10 @@ export class AgentManager {
1152
1294
  if (!cfg)
1153
1295
  throw new Error(`Unknown agent: ${agentId}`);
1154
1296
  const state = await this.agentStore.get(agentId);
1155
- const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
1297
+ const existingToken = state?.taskId === taskId && state
1298
+ ? await this.resolveTaskLockToken(state, taskId)
1299
+ : null;
1300
+ const sameTaskLocked = existingToken !== null;
1156
1301
  const reentryPhases = new Set([
1157
1302
  'fix', 'post-approve', 'code',
1158
1303
  'server-feedback', 'server-after-done',
@@ -1165,17 +1310,17 @@ export class AgentManager {
1165
1310
  if (!sameTaskReentry && !canDispatchWithBinding(state)) {
1166
1311
  return false;
1167
1312
  }
1168
- if (!reuseLock) {
1169
- const ok = await this.lockManager.acquire(agentId, taskId);
1170
- if (!ok)
1171
- return false;
1172
- }
1313
+ const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1314
+ if (!token)
1315
+ return false;
1173
1316
  const now = new Date().toISOString();
1174
1317
  await this.agentStore.update(agentId, (existing) => ({
1175
1318
  ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1176
1319
  id: agentId,
1177
1320
  projectId: cfg.projectId,
1178
1321
  taskId,
1322
+ lockToken: token,
1323
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1179
1324
  updatedAt: now,
1180
1325
  }));
1181
1326
  return true;
@@ -1208,6 +1353,11 @@ export class AgentManager {
1208
1353
  const cfg = this.getAgentConfig(agentId);
1209
1354
  if (!cfg)
1210
1355
  return false;
1356
+ const lockToken = await this.resolveTaskLockToken(state, expectedTaskId);
1357
+ if (!lockToken) {
1358
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} no longer owns task ${expectedTaskId}; skipping`);
1359
+ return false;
1360
+ }
1211
1361
  const now = new Date().toISOString();
1212
1362
  if (mode === 'waiting') {
1213
1363
  await this.agentStore.update(agentId, (latest) => {
@@ -1219,9 +1369,9 @@ export class AgentManager {
1219
1369
  projectId: latest.projectId,
1220
1370
  updatedAt: now,
1221
1371
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
1372
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
1222
1373
  ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
1223
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
1224
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
1374
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1225
1375
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1226
1376
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1227
1377
  };
@@ -1234,37 +1384,329 @@ export class AgentManager {
1234
1384
  });
1235
1385
  return true;
1236
1386
  }
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);
1387
+ let sessionConfirmedAbsent = false;
1388
+ if (state.workdir) {
1389
+ const releaseWorkdir = state.workdir;
1390
+ const runner = this.createRunnerFor(cfg);
1391
+ const tmux = new TmuxManager(runner);
1392
+ const hold = async (reason) => {
1393
+ const latest = await this.agentStore.get(agentId);
1394
+ if (!latest
1395
+ || latest.taskId !== expectedTaskId
1396
+ || latest.lockToken !== lockToken
1397
+ || !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
1398
+ return false;
1399
+ }
1400
+ const pendingAt = new Date().toISOString();
1401
+ if (boundTask && cfg.role === 'dev') {
1402
+ boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
1403
+ boundTask.updatedAt = pendingAt;
1404
+ await this.taskStore.set(boundTask);
1405
+ }
1406
+ await this.agentStore.update(agentId, (latest) => {
1407
+ if (!latest || latest.taskId !== expectedTaskId || latest.lockToken !== lockToken) {
1408
+ return AGENT_STORE_NOOP;
1409
+ }
1410
+ return {
1411
+ ...latest,
1412
+ status: 'awaiting_human',
1413
+ awaitingPhase: 'branch-cleanup-pending',
1414
+ awaitingReason: reason,
1415
+ awaitingSince: pendingAt,
1416
+ updatedAt: pendingAt,
1417
+ };
1418
+ });
1419
+ await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
1420
+ phase: 'branch-cleanup-pending',
1421
+ reason,
1422
+ });
1423
+ return false;
1424
+ };
1425
+ try {
1426
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1427
+ const runtime = await this.inspectReleaseRuntime(tmux, agentId);
1428
+ if (runtime.kind === 'hold')
1429
+ return hold(runtime.reason);
1430
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1431
+ if (runtime.kind === 'pane') {
1432
+ if (state.paneId !== runtime.paneId) {
1433
+ await this.agentStore.update(agentId, (latest) => {
1434
+ if (!latest
1435
+ || latest.taskId !== expectedTaskId
1436
+ || latest.lockToken !== lockToken
1437
+ || latest.workdir !== releaseWorkdir) {
1438
+ return AGENT_STORE_NOOP;
1439
+ }
1440
+ return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1441
+ });
1442
+ const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1443
+ if (refreshed.paneId !== runtime.paneId) {
1444
+ throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1445
+ }
1446
+ }
1447
+ await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1244
1448
  }
1245
- catch (err) {
1246
- console.warn(`[AgentManager] releaseAgentForTask worktree.remove failed for ${state.worktreePath}:`, err);
1449
+ else {
1450
+ sessionConfirmedAbsent = true;
1451
+ }
1452
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1453
+ if (cfg.role === 'dev' && boundTask) {
1454
+ const branches = new BranchManager(runner);
1455
+ const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
1456
+ taskId: boundTask.id,
1457
+ taskBranch: boundTask.branch,
1458
+ branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
1459
+ }, () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
1460
+ let cleanupStateChanged = false;
1461
+ if (cleanup.status === 'pending') {
1462
+ if (boundTask.branchCleanupPending?.agentId !== agentId
1463
+ || boundTask.branchCleanupPending.reason !== cleanup.reason
1464
+ || boundTask.branchCleanupSkipped !== undefined) {
1465
+ boundTask.branchCleanupPending = {
1466
+ agentId,
1467
+ reason: cleanup.reason,
1468
+ updatedAt: new Date().toISOString(),
1469
+ };
1470
+ delete boundTask.branchCleanupSkipped;
1471
+ cleanupStateChanged = true;
1472
+ }
1473
+ }
1474
+ else if (cleanup.status === 'skipped') {
1475
+ if (boundTask.branchCleanupSkipped?.agentId !== agentId
1476
+ || boundTask.branchCleanupSkipped.reason !== cleanup.reason
1477
+ || boundTask.branchCleanupPending !== undefined) {
1478
+ boundTask.branchCleanupSkipped = {
1479
+ agentId,
1480
+ reason: cleanup.reason,
1481
+ updatedAt: new Date().toISOString(),
1482
+ };
1483
+ delete boundTask.branchCleanupPending;
1484
+ cleanupStateChanged = true;
1485
+ }
1486
+ }
1487
+ else {
1488
+ if (boundTask.branchCleanupPending?.agentId === agentId) {
1489
+ delete boundTask.branchCleanupPending;
1490
+ cleanupStateChanged = true;
1491
+ }
1492
+ if (boundTask.branchCleanupSkipped?.agentId === agentId) {
1493
+ delete boundTask.branchCleanupSkipped;
1494
+ cleanupStateChanged = true;
1495
+ }
1496
+ }
1497
+ if (cleanupStateChanged) {
1498
+ boundTask.updatedAt = new Date().toISOString();
1499
+ await this.taskStore.set(boundTask);
1500
+ }
1501
+ if (cleanup.status !== 'deleted')
1502
+ await branches.parkOnDefaultDetached(releaseWorkdir);
1247
1503
  }
1504
+ else {
1505
+ await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
1506
+ }
1507
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1508
+ }
1509
+ catch (err) {
1510
+ const reason = err instanceof DirtyWorkdirError
1511
+ ? err.message
1512
+ : `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
1513
+ return hold(reason);
1248
1514
  }
1249
1515
  }
1516
+ if (state.workdir) {
1517
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, state.workdir);
1518
+ }
1519
+ else {
1520
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1521
+ }
1250
1522
  await this.agentStore.update(agentId, (existing) => {
1251
1523
  if (!existing)
1252
1524
  return AGENT_STORE_NOOP;
1253
- if (existing.taskId !== expectedTaskId)
1525
+ if (existing.taskId !== expectedTaskId
1526
+ || existing.lockToken !== lockToken
1527
+ || (state.workdir !== undefined && existing.workdir !== state.workdir)) {
1254
1528
  return AGENT_STORE_NOOP;
1529
+ }
1255
1530
  return {
1256
1531
  id: existing.id,
1257
1532
  projectId: existing.projectId,
1258
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1259
- ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1533
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1534
+ ...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1260
1535
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1261
1536
  updatedAt: now,
1262
1537
  };
1263
1538
  });
1264
- await this.lockManager.release(agentId);
1265
- return true;
1539
+ return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1266
1540
  });
1267
1541
  }
1542
+ async releaseAgentIfBound(agentId, expectedTaskId, opts) {
1543
+ const state = await this.agentStore.get(agentId);
1544
+ if (state?.taskId !== expectedTaskId)
1545
+ return true;
1546
+ const released = opts
1547
+ ? await this.releaseAgentForTask(agentId, expectedTaskId, 'idle', opts)
1548
+ : await this.releaseAgentForTask(agentId, expectedTaskId, 'idle');
1549
+ if (released)
1550
+ return true;
1551
+ return (await this.agentStore.get(agentId))?.taskId !== expectedTaskId;
1552
+ }
1553
+ async reconcileTaskBranches() {
1554
+ const tasks = await this.taskStore.list();
1555
+ const terminalByBranch = new Map(tasks
1556
+ .filter(task => task.branchCreatedByBaxian === true
1557
+ && TERMINAL_STATUSES.includes(task.status)
1558
+ && task.branch === `${BRANCH_PREFIX}${task.id}`
1559
+ && task.branchCleanupSkipped === undefined)
1560
+ .map(task => [task.branch, task]));
1561
+ const owner = 'maintenance:branch-reconcile';
1562
+ const persistCleanup = async (task, agentId, cleanup) => {
1563
+ await this.withTaskLock(async () => {
1564
+ const latest = await this.taskStore.get(task.id);
1565
+ if (!latest
1566
+ || latest.branch !== task.branch
1567
+ || latest.branchCreatedByBaxian !== true
1568
+ || !TERMINAL_STATUSES.includes(latest.status))
1569
+ return;
1570
+ const now = new Date().toISOString();
1571
+ let changed = false;
1572
+ if (cleanup.status === 'pending') {
1573
+ if (latest.branchCleanupPending?.agentId !== agentId
1574
+ || latest.branchCleanupPending.reason !== cleanup.reason
1575
+ || latest.branchCleanupSkipped !== undefined) {
1576
+ latest.branchCleanupPending = { agentId, reason: cleanup.reason, updatedAt: now };
1577
+ delete latest.branchCleanupSkipped;
1578
+ changed = true;
1579
+ }
1580
+ }
1581
+ else if (cleanup.status === 'skipped') {
1582
+ if (latest.branchCleanupSkipped?.agentId !== agentId
1583
+ || latest.branchCleanupSkipped.reason !== cleanup.reason
1584
+ || latest.branchCleanupPending !== undefined) {
1585
+ latest.branchCleanupSkipped = { agentId, reason: cleanup.reason, updatedAt: now };
1586
+ delete latest.branchCleanupPending;
1587
+ changed = true;
1588
+ }
1589
+ }
1590
+ else {
1591
+ if (latest.branchCleanupPending !== undefined) {
1592
+ delete latest.branchCleanupPending;
1593
+ changed = true;
1594
+ }
1595
+ if (latest.branchCleanupSkipped !== undefined) {
1596
+ delete latest.branchCleanupSkipped;
1597
+ changed = true;
1598
+ }
1599
+ }
1600
+ if (!changed)
1601
+ return;
1602
+ latest.updatedAt = now;
1603
+ await this.taskStore.set(latest);
1604
+ });
1605
+ };
1606
+ for (const cfg of this.agentIndex.values()) {
1607
+ if (cfg.role !== 'dev')
1608
+ continue;
1609
+ const candidateTasks = [...terminalByBranch.values()].filter(task => task.agentId === cfg.id || task.branchCleanupPending?.agentId === cfg.id);
1610
+ if (candidateTasks.length === 0)
1611
+ continue;
1612
+ const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
1613
+ const state = await this.agentStore.get(cfg.id);
1614
+ if (!state || !canDispatchWithBinding(state))
1615
+ continue;
1616
+ const workdir = state.workdir ?? cfg.workdir;
1617
+ if (!workdir)
1618
+ continue;
1619
+ const runner = this.createRunnerFor(cfg);
1620
+ const branches = new BranchManager(runner);
1621
+ let refs;
1622
+ try {
1623
+ refs = await branches.listLocalTaskRefs(workdir);
1624
+ }
1625
+ catch (err) {
1626
+ console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) local ref preflight failed: ` +
1627
+ `${err instanceof Error ? err.message : String(err)}`);
1628
+ continue;
1629
+ }
1630
+ if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
1631
+ continue;
1632
+ const token = await this.lockManager.acquire(cfg.id, owner);
1633
+ if (!token)
1634
+ continue;
1635
+ const cleanupResults = [];
1636
+ try {
1637
+ const lockedState = await this.agentStore.get(cfg.id);
1638
+ if (!lockedState || !canDispatchWithBinding(lockedState))
1639
+ continue;
1640
+ if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1641
+ continue;
1642
+ refs = await branches.listLocalTaskRefs(workdir);
1643
+ refs = refs.filter(ref => candidateByBranch.has(ref.slice('refs/heads/'.length)));
1644
+ if (refs.length === 0)
1645
+ continue;
1646
+ const tmux = new TmuxManager(runner);
1647
+ if (await tmux.hasSession(cfg.id)) {
1648
+ const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
1649
+ if (claim !== cfg.id) {
1650
+ throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
1651
+ }
1652
+ const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
1653
+ await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1654
+ }
1655
+ if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1656
+ continue;
1657
+ for (const actualRef of refs) {
1658
+ const branch = actualRef.slice('refs/heads/'.length);
1659
+ const task = candidateByBranch.get(branch);
1660
+ if (!task)
1661
+ continue;
1662
+ const cleanup = await branches.cleanupTaskBranch(workdir, {
1663
+ taskId: task.id,
1664
+ taskBranch: task.branch,
1665
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
1666
+ }, () => this.assertTaskLockOwner(cfg.id, owner, token));
1667
+ cleanupResults.push({ task, cleanup });
1668
+ }
1669
+ }
1670
+ catch (err) {
1671
+ const message = err instanceof Error ? err.message : String(err);
1672
+ if (err instanceof DirtyWorkdirError) {
1673
+ await this.markAwaitingHuman(cfg.id, 'dirty-workdir', message, { expectedTaskId: null });
1674
+ }
1675
+ else if (err instanceof ReplNotReadyError || message.includes('stayed busy past')) {
1676
+ for (const actualRef of refs) {
1677
+ const task = candidateByBranch.get(actualRef.slice('refs/heads/'.length));
1678
+ if (!task)
1679
+ continue;
1680
+ cleanupResults.push({
1681
+ task,
1682
+ cleanup: {
1683
+ status: 'pending',
1684
+ reason: `runtime is not idle; local branch cleanup deferred: ${message}`,
1685
+ },
1686
+ });
1687
+ }
1688
+ }
1689
+ else {
1690
+ console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) failed: ${message}`);
1691
+ await this.recordError({
1692
+ agentId: cfg.id,
1693
+ projectId: cfg.projectId,
1694
+ operation: 'branch-reconcile',
1695
+ reason: 'BRANCH_RECONCILE_FAILED',
1696
+ message,
1697
+ observation: { phase: 'branch-reconcile' },
1698
+ recommendation: 'Verify the Workdir and remote, then let the next reconciliation retry.',
1699
+ });
1700
+ }
1701
+ }
1702
+ finally {
1703
+ await this.lockManager.releaseIfOwner(cfg.id, owner, token);
1704
+ }
1705
+ for (const result of cleanupResults) {
1706
+ await persistCleanup(result.task, cfg.id, result.cleanup);
1707
+ }
1708
+ }
1709
+ }
1268
1710
  async setAgentNeedInput(agentId, pending, opts = {}) {
1269
1711
  const now = new Date().toISOString();
1270
1712
  try {
@@ -1417,6 +1859,13 @@ export class AgentManager {
1417
1859
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
1418
1860
  return { resumed: false, releasedBinding: false, reason };
1419
1861
  }
1862
+ if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
1863
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
1864
+ const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
1865
+ 'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
1866
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
1867
+ return { resumed: false, releasedBinding: false, reason };
1868
+ }
1420
1869
  if (state.awaitingPhase === 'code-dispatch-failed'
1421
1870
  && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
1422
1871
  && state.taskId) {
@@ -1448,19 +1897,14 @@ export class AgentManager {
1448
1897
  }
1449
1898
  const now = new Date().toISOString();
1450
1899
  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
- }
1900
+ if (shouldReleaseBinding && state.taskId) {
1901
+ return {
1902
+ resumed: true,
1903
+ releasedBinding: false,
1904
+ releaseTaskId: state.taskId,
1905
+ projectId: state.projectId,
1906
+ previousPhase: state.awaitingPhase,
1907
+ };
1464
1908
  }
1465
1909
  await this.agentStore.update(agentId, (existing) => {
1466
1910
  if (!existing)
@@ -1469,7 +1913,7 @@ export class AgentManager {
1469
1913
  id: existing.id,
1470
1914
  projectId: existing.projectId,
1471
1915
  updatedAt: now,
1472
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
1916
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1473
1917
  ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1474
1918
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1475
1919
  status: 'ok',
@@ -1477,33 +1921,64 @@ export class AgentManager {
1477
1921
  if (!shouldReleaseBinding) {
1478
1922
  if (existing.taskId !== undefined)
1479
1923
  next.taskId = existing.taskId;
1480
- if (existing.worktreePath !== undefined)
1481
- next.worktreePath = existing.worktreePath;
1924
+ if (existing.lockToken !== undefined)
1925
+ next.lockToken = existing.lockToken;
1482
1926
  if (existing.startedAt !== undefined)
1483
1927
  next.startedAt = existing.startedAt;
1484
1928
  }
1485
1929
  return next;
1486
1930
  });
1487
- if (shouldReleaseBinding) {
1488
- await this.lockManager.release(agentId);
1489
- }
1490
1931
  await this.emitIntervention(state.projectId, agentId, state.taskId, {
1491
1932
  phase: 'resumed',
1492
1933
  previousPhase: state.awaitingPhase,
1493
- releasedBinding: shouldReleaseBinding,
1934
+ releasedBinding: false,
1494
1935
  });
1495
- return { resumed: true, releasedBinding: shouldReleaseBinding };
1936
+ return { resumed: true, releasedBinding: false };
1496
1937
  });
1938
+ if (result.releaseTaskId) {
1939
+ const released = await this.releaseAgentForTask(agentId, result.releaseTaskId, 'idle', {
1940
+ allowAwaitingHuman: true,
1941
+ fromCancelCleanup: true,
1942
+ });
1943
+ if (!released) {
1944
+ const refreshed = await this.agentStore.get(agentId);
1945
+ return {
1946
+ resumed: false,
1947
+ releasedBinding: false,
1948
+ reason: refreshed?.awaitingReason
1949
+ ?? `Agent ${agentId} could not safely release task ${result.releaseTaskId}.`,
1950
+ };
1951
+ }
1952
+ await this.emitIntervention(result.projectId, agentId, result.releaseTaskId, {
1953
+ phase: 'resumed',
1954
+ previousPhase: result.previousPhase,
1955
+ releasedBinding: true,
1956
+ });
1957
+ return { resumed: true, releasedBinding: true };
1958
+ }
1497
1959
  if (result.redispatchCodeTaskId) {
1498
1960
  try {
1499
- const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
1961
+ const task = await this.taskStore.get(result.redispatchCodeTaskId);
1962
+ const round = task?.specReviewRound;
1963
+ const stored = round === undefined
1964
+ ? null
1965
+ : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
1966
+ if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
1967
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1968
+ return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
1969
+ }
1970
+ const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
1500
1971
  if (!resumed) {
1501
- await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1972
+ const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
1973
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1974
+ return { resumed: false, releasedBinding: false, reason };
1502
1975
  }
1503
1976
  }
1504
1977
  catch (err) {
1505
1978
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
1506
- await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume failed; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1979
+ const reason = `Code-phase redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
1980
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1981
+ return { resumed: false, releasedBinding: false, reason };
1507
1982
  }
1508
1983
  }
1509
1984
  return {
@@ -1523,6 +1998,48 @@ export class AgentManager {
1523
1998
  return null;
1524
1999
  }
1525
2000
  }
2001
+ async inspectReleaseRuntime(tmux, agentId) {
2002
+ let sessionAlive;
2003
+ try {
2004
+ sessionAlive = await tmux.hasSession(agentId);
2005
+ }
2006
+ catch (err) {
2007
+ return {
2008
+ kind: 'hold',
2009
+ reason: `Runtime availability probe failed; refusing checkout cleanup: ` +
2010
+ `${err instanceof Error ? err.message : String(err)}`,
2011
+ };
2012
+ }
2013
+ if (!sessionAlive)
2014
+ return { kind: 'absent' };
2015
+ let claim;
2016
+ try {
2017
+ claim = await tmux.getOption(agentId, '@baxian-agent-id');
2018
+ }
2019
+ catch (err) {
2020
+ return {
2021
+ kind: 'hold',
2022
+ reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
2023
+ `${err instanceof Error ? err.message : String(err)}`,
2024
+ };
2025
+ }
2026
+ if (claim !== agentId) {
2027
+ return {
2028
+ kind: 'hold',
2029
+ reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
2030
+ };
2031
+ }
2032
+ try {
2033
+ return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
2034
+ }
2035
+ catch (err) {
2036
+ return {
2037
+ kind: 'hold',
2038
+ reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
2039
+ `${err instanceof Error ? err.message : String(err)}`,
2040
+ };
2041
+ }
2042
+ }
1526
2043
  async interruptPaneAndWaitReady(state, cfg) {
1527
2044
  const paneId = await this.resolvePaneId(state, cfg);
1528
2045
  if (!paneId)
@@ -1698,7 +2215,7 @@ export class AgentManager {
1698
2215
  const out = [];
1699
2216
  for (const t of tasks) {
1700
2217
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1701
- // spec-ready 不豁免:approve/打回都依赖 dev worktree,dev 丢失后停驻只是假活
2218
+ // spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
1702
2219
  if (t.status === 'ready' || t.status === 'merge-ready')
1703
2220
  continue;
1704
2221
  if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
@@ -1788,18 +2305,16 @@ export class AgentManager {
1788
2305
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
1789
2306
  }
1790
2307
  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
- }
2308
+ if (!project)
2309
+ throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
2310
+ const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
2311
+ const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
2312
+ if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
2313
+ throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
2314
+ 'use retry to rebuild the session safely');
2315
+ }
2316
+ await this.provisionRepoSkills(runner, cfg, workdir);
2317
+ await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
1803
2318
  const runtime = agentRuntimeKindFor(cfg);
1804
2319
  const relaunch = async () => {
1805
2320
  await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
@@ -1810,16 +2325,9 @@ export class AgentManager {
1810
2325
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1811
2326
  scrollback: 0,
1812
2327
  });
1813
- if (provisioned) {
1814
- await this.tagSessionSkillsVersion(tmux, agentId);
1815
- }
2328
+ await this.tagSessionSkillsVersion(tmux, agentId);
1816
2329
  };
1817
- if (workdir !== undefined) {
1818
- await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1819
- }
1820
- else {
1821
- await relaunch();
1822
- }
2330
+ await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
1823
2331
  await this.agentStore.update(agentId, (state) => {
1824
2332
  if (!state)
1825
2333
  return AGENT_STORE_NOOP;
@@ -1830,6 +2338,42 @@ export class AgentManager {
1830
2338
  };
1831
2339
  });
1832
2340
  }
2341
+ async redispatchResearchAfterReplRestart(agentId, taskId) {
2342
+ const agent = this.getAgentConfig(agentId);
2343
+ const task = await this.taskStore.get(taskId);
2344
+ if (!agent || agent.role !== 'research' || !task)
2345
+ return false;
2346
+ if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
2347
+ return false;
2348
+ let phase;
2349
+ let expectedKind;
2350
+ let findings;
2351
+ if (task.phase === 'research' && task.status === 'in_progress') {
2352
+ phase = 'research';
2353
+ expectedKind = 'spec-done';
2354
+ }
2355
+ else if (task.phase === 'spec' && task.status === 'fixing') {
2356
+ const round = task.specReviewRound ?? 1;
2357
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2358
+ if (!stored?.findings)
2359
+ return false;
2360
+ phase = 'server-feedback';
2361
+ expectedKind = 'spec-fixed';
2362
+ findings = JSON.stringify(stored.findings);
2363
+ }
2364
+ else {
2365
+ return false;
2366
+ }
2367
+ await this.clearAwaitingHuman(agentId);
2368
+ return this.continueSession(taskId, agentId, phase, {
2369
+ bypassTaskStatusGate: true,
2370
+ signalToken: task.signalToken,
2371
+ preserveDispatchOutputs: true,
2372
+ ...(findings ? { serverPriorFindings: findings } : {}),
2373
+ ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2374
+ armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
2375
+ });
2376
+ }
1833
2377
  prepareRemoveTargets(agentId) {
1834
2378
  const cfg = this.getAgentConfig(agentId);
1835
2379
  if (!cfg)
@@ -1837,15 +2381,10 @@ export class AgentManager {
1837
2381
  const project = this.getProjectConfig(cfg.projectId);
1838
2382
  if (!project)
1839
2383
  throw new Error(`Unknown project: ${cfg.projectId}`);
1840
- if (cfg.role === 'qa')
2384
+ if (cfg.role !== 'dev')
1841
2385
  return { targets: [agentId] };
1842
- for (const pair of project.agent) {
1843
- if (pair[0]?.id === agentId) {
1844
- const qa = pair[1];
1845
- return { targets: qa ? [agentId, qa.id] : [agentId] };
1846
- }
1847
- }
1848
- return { targets: [agentId] };
2386
+ const group = this.findAgentGroup(agentId);
2387
+ return { targets: group?.map(agent => agent.id) ?? [agentId] };
1849
2388
  }
1850
2389
  async cleanupRemovedAgentRuntime(targets) {
1851
2390
  const failures = [];
@@ -1855,7 +2394,21 @@ export class AgentManager {
1855
2394
  continue;
1856
2395
  const runner = this.createRunnerFor(cfg);
1857
2396
  const tmux = new TmuxManager(runner);
1858
- const worktree = new WorktreeManager(runner);
2397
+ const boundState = await this.agentStore.get(id);
2398
+ if (boundState?.taskId) {
2399
+ try {
2400
+ const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
2401
+ allowAwaitingHuman: true,
2402
+ });
2403
+ if (!released) {
2404
+ throw new Error(`could not safely release task ${boundState.taskId}`);
2405
+ }
2406
+ }
2407
+ catch (err) {
2408
+ failures.push({ agentId: id, step: 'binding.release', error: err });
2409
+ continue;
2410
+ }
2411
+ }
1859
2412
  this.stopRuntimeMenuWatch(id);
1860
2413
  if (this.paneStreamerManager) {
1861
2414
  try {
@@ -1881,18 +2434,6 @@ export class AgentManager {
1881
2434
  catch (err) {
1882
2435
  failures.push({ agentId: id, step: 'tmux', error: err });
1883
2436
  }
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
2437
  }
1897
2438
  if (failures.length > 0) {
1898
2439
  const summary = failures
@@ -1925,8 +2466,13 @@ export class AgentManager {
1925
2466
  workdirForEstimate = cfg.workdir;
1926
2467
  }
1927
2468
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
1928
- const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
1929
2469
  const now = new Date().toISOString();
2470
+ const isResearch = cfg.role === 'research';
2471
+ const group = this.findAgentGroup(cfg.id);
2472
+ const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
2473
+ if (!devAgentId)
2474
+ throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
2475
+ const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
1930
2476
  const fakeTask = {
1931
2477
  id: 'task-9999999999',
1932
2478
  projectId,
@@ -1934,6 +2480,9 @@ export class AgentManager {
1934
2480
  description: input.description,
1935
2481
  preferredAgentId: input.preferredAgentId,
1936
2482
  agentId: cfg.id,
2483
+ devAgentId,
2484
+ ...(qaAgentId ? { qaAgentId } : {}),
2485
+ ...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
1937
2486
  branch: `${BRANCH_PREFIX}task-9999999999`,
1938
2487
  reviewRound: 0,
1939
2488
  status: 'in_progress',
@@ -1942,11 +2491,12 @@ export class AgentManager {
1942
2491
  };
1943
2492
  const fullPrompt = buildPromptInline({
1944
2493
  task: fakeTask,
1945
- phase: 'develop',
2494
+ phase: isResearch ? 'research' : 'develop',
1946
2495
  agent: cfg,
1947
- worktreePath: worktreePathBound,
2496
+ workdir: workdirGuess,
1948
2497
  skillRegistry: this.skillRegistry,
1949
2498
  signalToken: 'preview-signal-token',
2499
+ hasQaPartner: qaAgentId !== undefined,
1950
2500
  });
1951
2501
  return Buffer.byteLength(fullPrompt, 'utf8');
1952
2502
  }
@@ -1959,12 +2509,11 @@ export class AgentManager {
1959
2509
  getProjectByRepo(repo) {
1960
2510
  return this.config.project.find(p => repoSlug(p.repo) === repo);
1961
2511
  }
1962
- findQaPartner(devAgentId) {
2512
+ findAgentGroup(anchorAgentId) {
1963
2513
  for (const project of this.config.project) {
1964
- for (const pair of project.agent) {
1965
- if (pair[0]?.id === devAgentId) {
1966
- return pair[1];
1967
- }
2514
+ for (const group of project.agent) {
2515
+ if (group.some(agent => agent.id === anchorAgentId))
2516
+ return group;
1968
2517
  }
1969
2518
  }
1970
2519
  return undefined;
@@ -2063,19 +2612,20 @@ export class AgentManager {
2063
2612
  if (!mapped)
2064
2613
  continue;
2065
2614
  const { expectedKinds, agentId } = mapped;
2066
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
2067
- : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
2068
- : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2069
- : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
2615
+ const specStage = isSpecStagePhase(task.phase);
2616
+ const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
2617
+ : specStage && task.status === 'fixing' ? 'spec-fixed'
2618
+ : !specStage && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2619
+ : !specStage && task.status === 'fixing' ? 'pr-fixed'
2070
2620
  : undefined;
2071
- const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
2621
+ const isServerProtocol = task.reviewMode === 'server' || specStage;
2072
2622
  const scanSnapshotOnRecover = isServerProtocol
2073
2623
  || (task.phase === undefined && task.status === 'in_progress')
2074
- || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
2624
+ || (!specStage && (task.status === 'review' || task.status === 'fixing'));
2075
2625
  const allowRecoveredReadFile = task.status === 'review'
2076
- && (task.phase === 'spec'
2626
+ && (specStage
2077
2627
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2078
- || (task.reviewMode === 'server' && task.reviewWorktreeMode === 'base'));
2628
+ || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
2079
2629
  try {
2080
2630
  await this.startPhaseSignalWatch({
2081
2631
  taskId: task.id,
@@ -2193,8 +2743,11 @@ export class AgentManager {
2193
2743
  return this.taskStore.list({ projectId });
2194
2744
  }
2195
2745
  async findTaskByBranch(branch, projectId) {
2196
- const all = await this.taskStore.list({ projectId });
2197
- return all.find(t => t.branch === branch);
2746
+ const repoKey = this.projectRepoKey(projectId);
2747
+ if (!repoKey)
2748
+ return undefined;
2749
+ const all = await this.taskStore.list();
2750
+ return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
2198
2751
  }
2199
2752
  async listTasksByPrNumber(prNumber, projectId) {
2200
2753
  const all = await this.taskStore.list({ projectId });
@@ -2261,13 +2814,11 @@ export class AgentManager {
2261
2814
  createRepoStore(agent, project, runner) {
2262
2815
  const host = resolveAgentHost(this.config.host, agent.host);
2263
2816
  if (this.repoStoreFactory) {
2264
- return this.repoStoreFactory(runner, repoSlug(project.repo), agent.mode, host, this.repoCache);
2817
+ return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
2265
2818
  }
2266
- return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
2819
+ return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
2267
2820
  }
2268
2821
  async ensureWorkdir(agent, project, runner) {
2269
- if (agent.workdir)
2270
- return { workdir: agent.workdir, repoStore: null };
2271
2822
  const repoStore = this.createRepoStore(agent, project, runner);
2272
2823
  const workdir = await repoStore.ensure();
2273
2824
  return { workdir, repoStore };
@@ -2275,22 +2826,29 @@ export class AgentManager {
2275
2826
  resolveWorkdir(agent, agentState) {
2276
2827
  if (agent.workdir)
2277
2828
  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';
2829
+ return agentState?.workdir ?? null;
2289
2830
  }
2290
2831
  getRepoCache() {
2291
2832
  return this.repoCache;
2292
2833
  }
2293
- async rollbackFailedDispatch(taskId, agentId, reason) {
2834
+ async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
2835
+ const existing = await this.agentStore.get(agentId);
2836
+ if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
2837
+ console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
2838
+ `skipping rollback — agent already reassigned`);
2839
+ return;
2840
+ }
2841
+ if (isCancelCleanupHold(existing)) {
2842
+ console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
2843
+ `leaving binding to the owner`);
2844
+ return;
2845
+ }
2846
+ if (!expectedLockToken
2847
+ || !(await this.lockManager.isOwner(agentId, taskId, expectedLockToken))
2848
+ || (existing?.taskId === taskId && existing.lockToken !== expectedLockToken)) {
2849
+ console.warn(`[AgentManager] rollback: generation mismatch for ${agentId}/${taskId}; skipping stale rollback`);
2850
+ return;
2851
+ }
2294
2852
  let rolledBack = null;
2295
2853
  await this.withTaskLock(async () => {
2296
2854
  const task = await this.taskStore.get(taskId);
@@ -2311,17 +2869,7 @@ export class AgentManager {
2311
2869
  note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2312
2870
  });
2313
2871
  }
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
- }
2872
+ const lockToken = expectedLockToken;
2325
2873
  const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
2326
2874
  if (!projectId) {
2327
2875
  console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
@@ -2329,16 +2877,23 @@ export class AgentManager {
2329
2877
  }
2330
2878
  else {
2331
2879
  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
- }));
2880
+ await this.agentStore.update(agentId, (latest) => {
2881
+ if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
2882
+ return AGENT_STORE_NOOP;
2883
+ if (latest?.taskId !== undefined && latest.taskId !== taskId)
2884
+ return AGENT_STORE_NOOP;
2885
+ return {
2886
+ ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
2887
+ id: agentId,
2888
+ projectId,
2889
+ taskId: undefined,
2890
+ lockToken: undefined,
2891
+ updatedAt: now,
2892
+ };
2893
+ });
2340
2894
  }
2341
- await this.lockManager.release(agentId);
2895
+ if (lockToken)
2896
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
2342
2897
  }
2343
2898
  async pickAgent(projectId, preferredAgentId) {
2344
2899
  const cfg = this.getAgentConfig(preferredAgentId);
@@ -2347,8 +2902,8 @@ export class AgentManager {
2347
2902
  if (cfg.projectId !== projectId) {
2348
2903
  throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
2349
2904
  }
2350
- if (cfg.role !== 'dev') {
2351
- throw new ApiError(400, `Agent ${preferredAgentId} is not dev role`);
2905
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
2906
+ throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
2352
2907
  }
2353
2908
  const state = await this.agentStore.get(preferredAgentId);
2354
2909
  if (!canDispatchWithBinding(state))
@@ -2356,6 +2911,22 @@ export class AgentManager {
2356
2911
  const { projectId: _projectId, ...rest } = cfg;
2357
2912
  return rest;
2358
2913
  }
2914
+ async persistQueuedTask(task, queueReason, agentId) {
2915
+ await this.taskStore.set(task);
2916
+ await this.safeEmit({
2917
+ id: '',
2918
+ type: 'task.created',
2919
+ timestamp: task.createdAt,
2920
+ projectId: task.projectId,
2921
+ taskId: task.id,
2922
+ data: {
2923
+ queued: true,
2924
+ queueReason,
2925
+ ...(agentId ? { agentId } : {}),
2926
+ },
2927
+ });
2928
+ return task;
2929
+ }
2359
2930
  async createTask(projectId, input) {
2360
2931
  return this.withTaskLock(async () => {
2361
2932
  const taskId = await this.taskStore.nextId();
@@ -2369,6 +2940,7 @@ export class AgentManager {
2369
2940
  }
2370
2941
  }
2371
2942
  const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
2943
+ const branchCreatedByBaxian = input.branch === undefined;
2372
2944
  if (input.branch) {
2373
2945
  const existing = await this.findTaskByBranch(input.branch, projectId);
2374
2946
  if (existing) {
@@ -2378,125 +2950,73 @@ export class AgentManager {
2378
2950
  const imageFilenames = input.images?.length
2379
2951
  ? await this.persistTaskImages(taskId, input.images)
2380
2952
  : undefined;
2381
- if (input.preferredAgentId === '') {
2382
- const unassigned = {
2383
- id: taskId,
2384
- projectId,
2385
- title: input.title,
2386
- description: input.description,
2387
- preferredAgentId: '',
2388
- agentId: '',
2389
- reviewRound: 0,
2390
- status: 'pending',
2391
- branch: taskBranch,
2392
- reviewMode: this.effectiveReviewMode(projectId),
2393
- createdAt: now,
2394
- updatedAt: now,
2395
- ...(imageFilenames ? { images: imageFilenames } : {}),
2396
- };
2397
- await this.taskStore.set(unassigned);
2398
- await this.safeEmit({
2399
- id: '',
2400
- type: 'task.created',
2401
- timestamp: now,
2402
- projectId,
2403
- taskId,
2404
- data: { queued: true, queueReason: 'unassigned' },
2405
- });
2406
- return unassigned;
2407
- }
2408
- const dev = await this.pickAgent(projectId, input.preferredAgentId);
2409
- const qa = this.findQaPartner(input.preferredAgentId);
2410
- if (!dev) {
2411
- const queued = {
2412
- id: taskId,
2413
- projectId,
2414
- title: input.title,
2415
- description: input.description,
2416
- preferredAgentId: input.preferredAgentId,
2417
- agentId: '',
2418
- reviewRound: 0,
2419
- status: 'pending',
2420
- branch: taskBranch,
2421
- reviewMode: this.effectiveReviewMode(projectId),
2422
- createdAt: now,
2423
- updatedAt: now,
2424
- ...(qa ? { qaAgentId: qa.id } : {}),
2425
- ...(imageFilenames ? { images: imageFilenames } : {}),
2426
- };
2427
- await this.taskStore.set(queued);
2428
- await this.safeEmit({
2429
- id: '',
2430
- type: 'task.created',
2431
- timestamp: now,
2432
- projectId,
2433
- taskId,
2434
- data: {
2435
- queued: true,
2436
- queueReason: 'preferred_agent_busy',
2437
- agentId: input.preferredAgentId,
2438
- },
2439
- });
2440
- return queued;
2441
- }
2442
- const acquired = await this.lockManager.acquire(dev.id, taskId);
2443
- if (!acquired) {
2444
- const queued = {
2445
- id: taskId,
2446
- projectId,
2447
- title: input.title,
2448
- description: input.description,
2449
- preferredAgentId: input.preferredAgentId,
2450
- agentId: '',
2451
- reviewRound: 0,
2452
- status: 'pending',
2453
- branch: taskBranch,
2454
- reviewMode: this.effectiveReviewMode(projectId),
2455
- createdAt: now,
2456
- updatedAt: now,
2457
- ...(qa ? { qaAgentId: qa.id } : {}),
2458
- ...(imageFilenames ? { images: imageFilenames } : {}),
2459
- };
2460
- await this.taskStore.set(queued);
2461
- await this.safeEmit({
2462
- id: '',
2463
- type: 'task.created',
2464
- timestamp: now,
2465
- projectId,
2466
- taskId,
2467
- data: {
2468
- queued: true,
2469
- queueReason: 'agent_locked',
2470
- agentId: input.preferredAgentId,
2471
- },
2472
- });
2473
- return queued;
2474
- }
2475
- const task = {
2953
+ const taskBase = {
2476
2954
  id: taskId,
2477
2955
  projectId,
2478
2956
  title: input.title,
2479
2957
  description: input.description,
2480
2958
  preferredAgentId: input.preferredAgentId,
2481
- agentId: dev.id,
2482
- ...(qa ? { qaAgentId: qa.id } : {}),
2483
2959
  reviewRound: 0,
2484
- status: 'in_progress',
2485
2960
  branch: taskBranch,
2961
+ branchCreatedByBaxian,
2486
2962
  reviewMode: this.effectiveReviewMode(projectId),
2487
2963
  createdAt: now,
2488
2964
  updatedAt: now,
2489
2965
  ...(imageFilenames ? { images: imageFilenames } : {}),
2490
2966
  };
2967
+ if (input.preferredAgentId === '') {
2968
+ const unassigned = {
2969
+ ...taskBase,
2970
+ agentId: '',
2971
+ devAgentId: '',
2972
+ status: 'pending',
2973
+ };
2974
+ return this.persistQueuedTask(unassigned, 'unassigned');
2975
+ }
2976
+ const target = await this.pickAgent(projectId, input.preferredAgentId);
2977
+ const targetConfig = this.getAgentConfig(input.preferredAgentId);
2978
+ const group = this.findAgentGroup(input.preferredAgentId);
2979
+ const dev = group?.find(agent => agent.role === 'dev');
2980
+ if (!dev)
2981
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
2982
+ const qa = group?.find(agent => agent.role === 'qa');
2983
+ const research = targetConfig.role === 'research' ? targetConfig : undefined;
2984
+ const researchFields = research
2985
+ ? { researchAgentId: research.id, phase: 'research' }
2986
+ : {};
2987
+ const queued = {
2988
+ ...taskBase,
2989
+ agentId: '',
2990
+ devAgentId: dev.id,
2991
+ ...researchFields,
2992
+ status: 'pending',
2993
+ ...(qa ? { qaAgentId: qa.id } : {}),
2994
+ };
2995
+ if (!target) {
2996
+ return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
2997
+ }
2998
+ const lockToken = await this.lockManager.acquire(target.id, taskId);
2999
+ if (!lockToken) {
3000
+ return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
3001
+ }
3002
+ const task = {
3003
+ ...taskBase,
3004
+ agentId: target.id,
3005
+ devAgentId: dev.id,
3006
+ ...(qa ? { qaAgentId: qa.id } : {}),
3007
+ ...researchFields,
3008
+ status: 'in_progress',
3009
+ };
2491
3010
  await this.taskStore.set(task);
2492
- await this.agentStore.update(dev.id, (existing) => ({
2493
- id: dev.id,
3011
+ await this.agentStore.update(target.id, (existing) => ({
3012
+ id: target.id,
2494
3013
  projectId,
2495
3014
  taskId,
3015
+ lockToken,
2496
3016
  bootstrappingTaskId: taskId,
2497
3017
  updatedAt: now,
2498
3018
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2499
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
3019
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2500
3020
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2501
3021
  }));
2502
3022
  await this.safeEmit({
@@ -2504,9 +3024,9 @@ export class AgentManager {
2504
3024
  type: 'task.assigned',
2505
3025
  timestamp: now,
2506
3026
  projectId,
2507
- agentId: dev.id,
3027
+ agentId: target.id,
2508
3028
  taskId,
2509
- data: { agentId: dev.id },
3029
+ data: { agentId: target.id },
2510
3030
  });
2511
3031
  return task;
2512
3032
  });
@@ -2516,7 +3036,8 @@ export class AgentManager {
2516
3036
  if (task.status === 'in_progress' && task.agentId) {
2517
3037
  const signalToken = createSignalToken();
2518
3038
  await this.updateTask(task.id, { signalToken });
2519
- const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken);
3039
+ const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
3040
+ const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
2520
3041
  if (opts.background) {
2521
3042
  void start.catch((err) => {
2522
3043
  console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
@@ -2530,11 +3051,16 @@ export class AgentManager {
2530
3051
  }
2531
3052
  return task;
2532
3053
  }
2533
- async startCreatedTaskSession(taskId, agentId, signalToken) {
3054
+ async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
3055
+ const initialTask = await this.taskStore.get(taskId);
3056
+ if (!initialTask)
3057
+ return null;
3058
+ const initialDispatch = this.resolveInitialDispatch(initialTask);
3059
+ const dispatchPhase = initialDispatch.phase;
2534
3060
  let started = false;
2535
3061
  let dispatchErr = null;
2536
3062
  try {
2537
- started = await this.startSession(taskId, agentId, 'develop');
3063
+ started = await this.startSession(taskId, agentId, dispatchPhase);
2538
3064
  }
2539
3065
  catch (err) {
2540
3066
  dispatchErr = err;
@@ -2564,7 +3090,7 @@ export class AgentManager {
2564
3090
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
2565
3091
  return null;
2566
3092
  }
2567
- const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
3093
+ const initialKinds = this.resolveInitialDispatch(fresh).kinds;
2568
3094
  try {
2569
3095
  await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
2570
3096
  }
@@ -2578,7 +3104,7 @@ export class AgentManager {
2578
3104
  return null;
2579
3105
  }
2580
3106
  if (dispatchErr instanceof DispatchTerminalError) {
2581
- await this.failTaskForDispatchError(taskId, 'develop', agentId, dispatchErr);
3107
+ await this.failTaskForDispatchError(taskId, dispatchPhase, agentId, dispatchErr);
2582
3108
  }
2583
3109
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
2584
3110
  }
@@ -2591,7 +3117,7 @@ export class AgentManager {
2591
3117
  await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
2592
3118
  phase: 'dispatch-rollback',
2593
3119
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2594
- } : undefined);
3120
+ } : undefined, dispatchLockToken);
2595
3121
  }
2596
3122
  }
2597
3123
  return (await this.taskStore.get(taskId)) ?? null;
@@ -2696,6 +3222,33 @@ export class AgentManager {
2696
3222
  async clearAgent(agentId) {
2697
3223
  return this.sendSlashCommand(agentId, '/clear');
2698
3224
  }
3225
+ async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
3226
+ await this.acquireCompactGuard(agentId);
3227
+ try {
3228
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
3229
+ await revalidate();
3230
+ await tmux.clearComposerDraft(paneId);
3231
+ const baseline = await tmux.captureSettledSnapshot(paneId, {
3232
+ timeoutMs: this.dispatchSettleTimeoutMs,
3233
+ });
3234
+ const baselineTitle = await tmux.readPaneTitle(paneId);
3235
+ await tmux.sendKeysLiteral(paneId, '/clear');
3236
+ await tmux.sendEnter(paneId);
3237
+ await tmux.waitSubmitAck(paneId, baseline, runtime, {
3238
+ timeoutMs: this.dispatchAckTimeoutMs,
3239
+ baselineTitle,
3240
+ acceptComposerChange: true,
3241
+ });
3242
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
3243
+ if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
3244
+ throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
3245
+ }
3246
+ await revalidate();
3247
+ }
3248
+ finally {
3249
+ this.compactInFlight.delete(agentId);
3250
+ }
3251
+ }
2699
3252
  async persistTaskImages(taskId, images) {
2700
3253
  const dir = join(this.imageStagingRoot, taskId);
2701
3254
  await mkdir(dir, { recursive: true });
@@ -2770,8 +3323,8 @@ export class AgentManager {
2770
3323
  if (cfg.projectId !== fresh.projectId) {
2771
3324
  return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
2772
3325
  }
2773
- if (cfg.role !== 'dev') {
2774
- return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev role` };
3326
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
3327
+ return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
2775
3328
  }
2776
3329
  if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
2777
3330
  return {
@@ -2784,29 +3337,45 @@ export class AgentManager {
2784
3337
  if (!canDispatchWithBinding(state)) {
2785
3338
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
2786
3339
  }
2787
- const acquired = await this.lockManager.acquire(agentId, taskId);
2788
- if (!acquired) {
3340
+ const lockToken = await this.lockManager.acquire(agentId, taskId);
3341
+ if (!lockToken) {
2789
3342
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
2790
3343
  }
2791
3344
  const now = new Date().toISOString();
2792
- const qaId = fresh.qaAgentId ?? this.findQaPartner(agentId)?.id;
3345
+ const initiallyUnassigned = fresh.preferredAgentId === '';
3346
+ const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
3347
+ const devId = initiallyUnassigned
3348
+ ? group?.find(agent => agent.role === 'dev')?.id
3349
+ : fresh.devAgentId;
3350
+ if (!devId) {
3351
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
3352
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
3353
+ }
3354
+ const qaId = initiallyUnassigned
3355
+ ? group?.find(agent => agent.role === 'qa')?.id
3356
+ : fresh.qaAgentId;
3357
+ const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
2793
3358
  const claimedTask = {
2794
3359
  ...fresh,
2795
3360
  preferredAgentId: agentId,
2796
3361
  agentId,
3362
+ devAgentId: devId,
3363
+ researchAgentId: researchId,
3364
+ qaAgentId: qaId,
3365
+ phase: researchId ? 'research' : undefined,
2797
3366
  status: 'in_progress',
2798
3367
  updatedAt: now,
2799
- ...(qaId ? { qaAgentId: qaId } : {}),
2800
3368
  };
2801
3369
  await this.taskStore.set(claimedTask);
2802
3370
  await this.agentStore.update(agentId, (existing) => ({
2803
3371
  id: agentId,
2804
3372
  projectId: cfg.projectId,
2805
3373
  taskId,
3374
+ lockToken,
2806
3375
  bootstrappingTaskId: taskId,
2807
3376
  updatedAt: now,
2808
3377
  ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
2809
- ...(existing?.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
3378
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
2810
3379
  ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
2811
3380
  }));
2812
3381
  await this.safeEmit({
@@ -2818,7 +3387,7 @@ export class AgentManager {
2818
3387
  taskId,
2819
3388
  data: { agentId, manuallyDispatched: true },
2820
3389
  });
2821
- return { task: claimedTask };
3390
+ return { task: claimedTask, lockToken };
2822
3391
  });
2823
3392
  if (claim.errorCode !== undefined)
2824
3393
  return claim;
@@ -2829,20 +3398,21 @@ export class AgentManager {
2829
3398
  await this.updateTask(claimed.id, { signalToken });
2830
3399
  let started = false;
2831
3400
  let dispatchErr = null;
3401
+ const initialDispatch = this.resolveInitialDispatch(claimed);
2832
3402
  try {
2833
- started = await this.startSession(claimed.id, claimed.agentId, 'develop');
3403
+ started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
2834
3404
  }
2835
3405
  catch (err) {
2836
3406
  dispatchErr = err;
2837
3407
  console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
2838
3408
  }
2839
3409
  if (started) {
2840
- await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
3410
+ await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, initialDispatch.kinds, signalToken);
2841
3411
  const refreshed = await this.taskStore.get(claimed.id);
2842
3412
  return { task: refreshed ?? claimed };
2843
3413
  }
2844
3414
  if (dispatchErr instanceof DispatchTerminalError) {
2845
- await this.failTaskForDispatchError(claimed.id, 'develop', claimed.agentId, dispatchErr);
3415
+ await this.failTaskForDispatchError(claimed.id, initialDispatch.phase, claimed.agentId, dispatchErr);
2846
3416
  }
2847
3417
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
2848
3418
  }
@@ -2850,7 +3420,7 @@ export class AgentManager {
2850
3420
  await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
2851
3421
  phase: 'dispatch-rollback',
2852
3422
  message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2853
- } : undefined);
3423
+ } : undefined, claim.lockToken);
2854
3424
  }
2855
3425
  const refreshed = await this.taskStore.get(claimed.id);
2856
3426
  if (dispatchErr === null) {
@@ -2859,6 +3429,50 @@ export class AgentManager {
2859
3429
  const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
2860
3430
  return { task: refreshed, errorCode: 500, error: err };
2861
3431
  }
3432
+ async switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner) {
3433
+ if (!task.branch)
3434
+ throw new Error(`Task ${task.id} has no review branch`);
3435
+ const current = await this.taskStore.get(task.id);
3436
+ let expectedHeadSha = current?.latestHeadSha ?? task.latestHeadSha;
3437
+ if (!expectedHeadSha) {
3438
+ const fetched = await this.fetchPrHeadSha(task.id);
3439
+ expectedHeadSha = await this.persistReviewHeadIfCurrent(task.id, undefined, fetched);
3440
+ }
3441
+ await assertOwner();
3442
+ try {
3443
+ await branchManager.switchToRemoteBranchDetached(workdir, task.branch, expectedHeadSha);
3444
+ return;
3445
+ }
3446
+ catch (err) {
3447
+ if (!(err instanceof ReviewHeadMismatchError))
3448
+ throw err;
3449
+ const latest = await this.taskStore.get(task.id);
3450
+ let replacement = latest?.latestHeadSha;
3451
+ if (!replacement || replacement === expectedHeadSha) {
3452
+ const fetched = await this.fetchPrHeadSha(task.id);
3453
+ replacement = await this.persistReviewHeadIfCurrent(task.id, expectedHeadSha, fetched);
3454
+ }
3455
+ if (replacement === expectedHeadSha)
3456
+ throw err;
3457
+ await assertOwner();
3458
+ await branchManager.switchToRemoteBranchDetached(workdir, task.branch, replacement);
3459
+ }
3460
+ }
3461
+ persistReviewHeadIfCurrent(taskId, expected, replacement) {
3462
+ return this.withTaskLock(async () => {
3463
+ const latest = await this.taskStore.get(taskId);
3464
+ if (!latest || TERMINAL_STATUSES.includes(latest.status)) {
3465
+ throw new Error(`Task ${taskId} became terminal while refreshing its review head`);
3466
+ }
3467
+ if (latest.latestHeadSha && latest.latestHeadSha !== expected) {
3468
+ return latest.latestHeadSha;
3469
+ }
3470
+ latest.latestHeadSha = replacement;
3471
+ latest.updatedAt = new Date().toISOString();
3472
+ await this.taskStore.set(latest);
3473
+ return replacement;
3474
+ });
3475
+ }
2862
3476
  async startSession(taskId, agentId, phase, opts = {}) {
2863
3477
  const agent = this.getAgentConfig(agentId);
2864
3478
  if (!agent)
@@ -2898,6 +3512,23 @@ export class AgentManager {
2898
3512
  `${preAgent.taskId} (request ${taskId}); aborting`);
2899
3513
  return false;
2900
3514
  }
3515
+ if (!preAgent || preAgent.taskId !== taskId) {
3516
+ console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} has no exact task binding for lock validation`);
3517
+ return false;
3518
+ }
3519
+ const lockToken = await this.resolveTaskLockToken(preAgent, taskId);
3520
+ if (!lockToken) {
3521
+ console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} does not own the exclusive lock for ${taskId}`);
3522
+ return false;
3523
+ }
3524
+ const assertOwner = async () => {
3525
+ const stateNow = await this.agentStore.get(agentId);
3526
+ if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
3527
+ throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
3528
+ }
3529
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3530
+ };
3531
+ await assertOwner();
2901
3532
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
2902
3533
  let ensure;
2903
3534
  try {
@@ -2907,68 +3538,141 @@ export class AgentManager {
2907
3538
  if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
2908
3539
  throw err;
2909
3540
  }
2910
- if (err instanceof EnsureSessionError && err.partial.createdSession) {
3541
+ if (err instanceof EnsureSessionError && err.partial.createdSession) {
3542
+ const stateNow = await this.agentStore.get(agentId);
3543
+ const stillOwner = stateNow?.taskId === taskId
3544
+ && stateNow.lockToken === lockToken
3545
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
3546
+ try {
3547
+ if (stillOwner) {
3548
+ const runner = this.createRunnerFor(agent);
3549
+ await new TmuxManager(runner).killSession(agentId);
3550
+ }
3551
+ else {
3552
+ console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
3553
+ `${agentId}/${taskId}`);
3554
+ }
3555
+ }
3556
+ catch (cleanupErr) {
3557
+ console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
3558
+ }
3559
+ }
3560
+ throw err;
3561
+ }
3562
+ const { paneId, workdir } = ensure;
3563
+ const runner = this.createRunnerFor(agent);
3564
+ const tmux = new TmuxManager(runner);
3565
+ const branchManager = new BranchManager(runner);
3566
+ const lockState = await this.agentStore.get(agentId);
3567
+ if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
3568
+ return false;
3569
+ await assertOwner();
3570
+ await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
3571
+ const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
3572
+ const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
3573
+ let reviewCheckout;
3574
+ const dispatchWorkdir = workdir;
3575
+ try {
3576
+ if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
3577
+ reviewCheckout = await branchManager.materializeReviewHead(workdir, {
3578
+ patch: opts.serverContent,
3579
+ baseSha: opts.serverBaseSha,
3580
+ headSha: opts.serverHeadSha,
3581
+ headTree: opts.serverHeadTree,
3582
+ });
3583
+ }
3584
+ else if (isServerQaPhase) {
3585
+ await branchManager.switchToDefaultDetached(workdir);
3586
+ }
3587
+ else if (phase === 'review' || phase === 'recheck') {
3588
+ if (!task.branch)
3589
+ throw new Error(`Task ${taskId} has no review branch`);
3590
+ await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
3591
+ }
3592
+ else if (phase === 'research') {
3593
+ await branchManager.switchToDefaultDetached(workdir);
3594
+ }
3595
+ else {
3596
+ if (!task.branch)
3597
+ throw new Error(`Task ${taskId} has no task branch`);
3598
+ await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true);
3599
+ }
3600
+ await assertOwner();
3601
+ if (!ensure.freshRuntime) {
3602
+ await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
3603
+ if (ensure.skillsStale)
3604
+ await this.tagSessionSkillsVersion(tmux, agentId);
3605
+ }
3606
+ }
3607
+ catch (err) {
3608
+ const message = err instanceof Error ? err.message : String(err);
3609
+ const holdPhase = err instanceof DirtyWorkdirError
3610
+ ? 'dirty-workdir'
3611
+ : 'checkout-preparation-failed';
3612
+ await this.markAwaitingHuman(agentId, holdPhase, message, { expectedTaskId: taskId })
3613
+ .catch((holdErr) => {
3614
+ console.error(`[AgentManager] startSession could not persist ${holdPhase} hold for ${agentId}:`, holdErr);
3615
+ });
3616
+ throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout preparation failed for task ${taskId}: ${message}`);
3617
+ }
3618
+ const cleanupCheckout = async () => {
3619
+ try {
3620
+ await assertOwner();
3621
+ }
3622
+ catch {
3623
+ return;
3624
+ }
3625
+ await branchManager.parkOnDefaultDetached(workdir);
3626
+ await assertOwner();
3627
+ };
3628
+ const cleanupCheckoutOrHold = async () => {
3629
+ try {
3630
+ await cleanupCheckout();
3631
+ }
3632
+ catch (err) {
3633
+ const message = err instanceof Error ? err.message : String(err);
3634
+ let holdFailure = '';
2911
3635
  try {
2912
- const runner = this.createRunnerFor(agent);
2913
- await new TmuxManager(runner).killSession(agentId);
3636
+ await this.markAwaitingHuman(agentId, 'checkout-cleanup-failed', `Task checkout could not be parked safely: ${message}`, { expectedTaskId: taskId });
2914
3637
  }
2915
- catch (cleanupErr) {
2916
- console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
3638
+ catch (holdErr) {
3639
+ holdFailure = `; persisting the hold also failed: ${holdErr instanceof Error ? holdErr.message : String(holdErr)}`;
2917
3640
  }
3641
+ throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout cleanup failed for task ${taskId}: ${message}${holdFailure}`);
2918
3642
  }
2919
- throw err;
2920
- }
2921
- const { paneId, workdir } = ensure;
2922
- const runner = this.createRunnerFor(agent);
2923
- const worktree = new WorktreeManager(runner);
2924
- const tmux = new TmuxManager(runner);
2925
- const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2926
- 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));
3643
+ };
2943
3644
  await this.agentStore.update(agentId, (stateNow) => {
2944
3645
  if (!stateNow || stateNow.taskId !== taskId)
2945
3646
  return AGENT_STORE_NOOP;
2946
- // A fresh dispatch supersedes any question asked under the previous prompt.
2947
3647
  const { needInputAt: _needInputAt, ...rest } = stateNow;
2948
3648
  return {
2949
3649
  ...rest,
2950
3650
  paneId,
2951
- worktreePath,
2952
- repoPath: workdir,
3651
+ workdir,
2953
3652
  updatedAt: new Date().toISOString(),
2954
3653
  };
2955
3654
  });
2956
3655
  const promptSignalToken = opts.signalToken ?? task.signalToken;
2957
3656
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
2958
- const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
3657
+ const hasQaPartner = task.qaAgentId !== undefined;
2959
3658
  let prompt;
2960
3659
  try {
3660
+ await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
3661
+ specDocuments: opts.specDocuments,
3662
+ preserveOutputs: false,
3663
+ assertOwner,
3664
+ });
2961
3665
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
2962
3666
  let payloadOpts = {};
2963
3667
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
2964
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
3668
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
2965
3669
  phase,
2966
- ...(task.phase ? { taskPhase: task.phase } : {}),
3670
+ taskPhase: task.phase,
2967
3671
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
2968
3672
  reviewRound: task.reviewRound,
2969
3673
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
2970
3674
  ...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
2971
- ...(reviewWorktree?.mode === 'head' ? { forceContentFile: true } : {}),
3675
+ ...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
2972
3676
  ...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
2973
3677
  ...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
2974
3678
  ...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
@@ -2979,14 +3683,14 @@ export class AgentManager {
2979
3683
  task,
2980
3684
  phase,
2981
3685
  agent,
2982
- worktreePath,
3686
+ workdir: dispatchWorkdir,
2983
3687
  skillRegistry: this.skillRegistry,
2984
3688
  hasQaPartner,
2985
3689
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
2986
3690
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
2987
3691
  ...(imagePaths.length ? { imagePaths } : {}),
2988
- ...(reviewWorktree ? { serverReviewWorktree: reviewWorktree.mode } : {}),
2989
- ...(reviewWorktree?.fallbackReason ? { serverReviewFallbackReason: reviewWorktree.fallbackReason } : {}),
3692
+ ...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
3693
+ ...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
2990
3694
  ...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
2991
3695
  ...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
2992
3696
  ...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
@@ -2995,10 +3699,7 @@ export class AgentManager {
2995
3699
  });
2996
3700
  }
2997
3701
  catch (err) {
2998
- try {
2999
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3000
- }
3001
- catch { }
3702
+ await cleanupCheckoutOrHold();
3002
3703
  if (err instanceof PromptSizeError) {
3003
3704
  throw new DispatchTerminalError('prompt_too_large', err.message);
3004
3705
  }
@@ -3009,57 +3710,39 @@ export class AgentManager {
3009
3710
  }
3010
3711
  const taskFresh = await this.taskStore.get(taskId);
3011
3712
  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 { }
3713
+ console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
3714
+ await cleanupCheckoutOrHold();
3017
3715
  return false;
3018
3716
  }
3019
3717
  if (TERMINAL_STATUSES.includes(taskFresh.status)) {
3020
3718
  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 { }
3719
+ `for phase=${phase}; parking checkout before paste`);
3720
+ await cleanupCheckoutOrHold();
3026
3721
  return false;
3027
3722
  }
3028
3723
  if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
3029
3724
  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 { }
3725
+ `expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
3726
+ await cleanupCheckoutOrHold();
3035
3727
  return false;
3036
3728
  }
3037
3729
  const agentFresh = await this.agentStore.get(agentId);
3038
3730
  if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
3039
3731
  if (!agentFresh || agentFresh.taskId !== taskId) {
3040
3732
  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 { }
3733
+ `(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
3734
+ await cleanupCheckoutOrHold();
3046
3735
  return false;
3047
3736
  }
3048
3737
  }
3049
3738
  else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
3050
3739
  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 { }
3740
+ `(was ${taskId}); parking checkout before paste`);
3741
+ await cleanupCheckoutOrHold();
3056
3742
  return false;
3057
3743
  }
3058
- if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewWorktree: reviewWorktree?.mode }))) {
3059
- try {
3060
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3061
- }
3062
- catch { }
3744
+ if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
3745
+ await cleanupCheckoutOrHold();
3063
3746
  return false;
3064
3747
  }
3065
3748
  const now = new Date().toISOString();
@@ -3076,8 +3759,8 @@ export class AgentManager {
3076
3759
  projectId: agent.projectId,
3077
3760
  paneId,
3078
3761
  taskId,
3079
- worktreePath,
3080
- repoPath: workdir,
3762
+ workdir,
3763
+ lockToken,
3081
3764
  startedAt: now,
3082
3765
  bootstrappingTaskId: taskId,
3083
3766
  updatedAt: now,
@@ -3087,10 +3770,7 @@ export class AgentManager {
3087
3770
  if (cancelHoldWon) {
3088
3771
  console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
3089
3772
  `aborting so cancel can finish interrupt + /clear`);
3090
- try {
3091
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3092
- }
3093
- catch { }
3773
+ await cleanupCheckoutOrHold();
3094
3774
  return false;
3095
3775
  }
3096
3776
  agentMarkedRunning = true;
@@ -3112,7 +3792,7 @@ export class AgentManager {
3112
3792
  projectId: agent.projectId,
3113
3793
  agentId,
3114
3794
  taskId,
3115
- data: { phase, worktreePath },
3795
+ data: { phase, workdir: dispatchWorkdir },
3116
3796
  });
3117
3797
  this.startRuntimeMenuWatch(agentId);
3118
3798
  return true;
@@ -3120,13 +3800,11 @@ export class AgentManager {
3120
3800
  catch (err) {
3121
3801
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
3122
3802
  if (!isAckUnknown) {
3123
- try {
3124
- await worktree.removeWithBranch(workdir, worktreePath, customBranch);
3125
- }
3126
- catch { }
3127
- if (agentMarkedRunning) {
3803
+ await cleanupCheckoutOrHold();
3804
+ if (agentMarkedRunning && !opts.preserveBindingOnFailure) {
3128
3805
  try {
3129
3806
  let released = false;
3807
+ let releaseToken;
3130
3808
  await this.agentStore.update(agentId, (agentNow) => {
3131
3809
  if (!agentNow || agentNow.taskId !== taskId) {
3132
3810
  console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
@@ -3139,18 +3817,19 @@ export class AgentManager {
3139
3817
  return AGENT_STORE_NOOP;
3140
3818
  }
3141
3819
  released = true;
3820
+ releaseToken = agentNow.lockToken;
3142
3821
  void err;
3143
3822
  return {
3144
3823
  id: agentId,
3145
3824
  projectId: agent.projectId,
3146
3825
  paneId,
3147
- repoPath: workdir,
3826
+ workdir,
3148
3827
  updatedAt: new Date().toISOString(),
3149
3828
  ...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
3150
3829
  };
3151
3830
  });
3152
- if (released) {
3153
- await this.lockManager.release(agentId);
3831
+ if (released && releaseToken) {
3832
+ await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
3154
3833
  }
3155
3834
  }
3156
3835
  catch (cleanupErr) {
@@ -3163,13 +3842,25 @@ export class AgentManager {
3163
3842
  }
3164
3843
  async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
3165
3844
  const before = await this.agentStore.get(agentId);
3845
+ const beforeLockToken = before?.taskId
3846
+ ? await this.resolveTaskLockToken(before, before.taskId)
3847
+ : null;
3848
+ if (before?.taskId && !beforeLockToken) {
3849
+ throw new Error(`dispatch aborted: agent ${agentId} no longer owns task ${before.taskId}`);
3850
+ }
3166
3851
  await this.acquireCompactGuard(agentId);
3167
3852
  try {
3168
3853
  if (before) {
3169
3854
  const now = await this.agentStore.get(agentId);
3170
- if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
3855
+ if (!now
3856
+ || now.paneId !== before.paneId
3857
+ || now.taskId !== before.taskId
3858
+ || (beforeLockToken !== null && now.lockToken !== beforeLockToken)) {
3171
3859
  throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
3172
3860
  }
3861
+ if (before.taskId && beforeLockToken) {
3862
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3863
+ }
3173
3864
  if (isCancelCleanupHold(now)) {
3174
3865
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
3175
3866
  }
@@ -3181,9 +3872,16 @@ export class AgentManager {
3181
3872
  const revalidate = before
3182
3873
  ? async () => {
3183
3874
  const fresh = await this.agentStore.get(agentId);
3184
- if (!fresh || fresh.paneId !== before.paneId || fresh.taskId !== before.taskId || isCancelCleanupHold(fresh)) {
3875
+ if (!fresh
3876
+ || fresh.paneId !== before.paneId
3877
+ || fresh.taskId !== before.taskId
3878
+ || (beforeLockToken !== null && fresh.lockToken !== beforeLockToken)
3879
+ || isCancelCleanupHold(fresh)) {
3185
3880
  throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
3186
3881
  }
3882
+ if (before.taskId && beforeLockToken) {
3883
+ await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
3884
+ }
3187
3885
  const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
3188
3886
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
3189
3887
  throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
@@ -3300,7 +3998,12 @@ export class AgentManager {
3300
3998
  `(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
3301
3999
  return false;
3302
4000
  }
3303
- const worktreePath = agentState.worktreePath;
4001
+ const taskWorkdir = agentState.workdir;
4002
+ if (!taskWorkdir)
4003
+ throw new Error(`Agent ${agentId} has no Workdir`);
4004
+ const lockToken = await this.resolveTaskLockToken(agentState, taskId);
4005
+ if (!lockToken)
4006
+ throw new Error(`Agent ${agentId} no longer owns task ${taskId}`);
3304
4007
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
3305
4008
  let ensure;
3306
4009
  try {
@@ -3311,9 +4014,19 @@ export class AgentManager {
3311
4014
  throw err;
3312
4015
  }
3313
4016
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
4017
+ const stateNow = await this.agentStore.get(agentId);
4018
+ const stillOwner = stateNow?.taskId === taskId
4019
+ && stateNow.lockToken === lockToken
4020
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
3314
4021
  try {
3315
- const runner = this.createRunnerFor(agent);
3316
- await new TmuxManager(runner).killSession(agentId);
4022
+ if (stillOwner) {
4023
+ const runner = this.createRunnerFor(agent);
4024
+ await new TmuxManager(runner).killSession(agentId);
4025
+ }
4026
+ else {
4027
+ console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
4028
+ `${agentId}/${taskId}`);
4029
+ }
3317
4030
  }
3318
4031
  catch (cleanupErr) {
3319
4032
  console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
@@ -3324,18 +4037,66 @@ export class AgentManager {
3324
4037
  const { paneId } = ensure;
3325
4038
  const runner = this.createRunnerFor(agent);
3326
4039
  const tmux = new TmuxManager(runner);
4040
+ const stateAfterEnsure = await this.agentStore.get(agentId);
4041
+ if (stateAfterEnsure?.taskId !== taskId || stateAfterEnsure.lockToken !== lockToken) {
4042
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership changed during ensureSession; skipping`);
4043
+ return false;
4044
+ }
4045
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
4046
+ const verifiedWorkdir = stateAfterEnsure.workdir;
4047
+ if (!verifiedWorkdir
4048
+ || verifiedWorkdir !== ensure.workdir
4049
+ || verifiedWorkdir !== taskWorkdir) {
4050
+ const reason = `Workdir changed during continueSession: ` +
4051
+ `before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
4052
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4053
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4054
+ return false;
4055
+ }
4056
+ const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
4057
+ const taskAfterEnsure = await this.taskStore.get(taskId);
4058
+ if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
4059
+ console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure?.status} terminal/missing after ensure; skipping`);
4060
+ return false;
4061
+ }
4062
+ if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskAfterEnsure.status)) {
4063
+ console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure.status} not in ` +
4064
+ `expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
4065
+ return false;
4066
+ }
4067
+ if (agent.role === 'dev' && task.branch) {
4068
+ const branches = new BranchManager(runner);
4069
+ await branches.assertClean(verifiedWorkdir);
4070
+ const actualRef = await branches.currentRef(verifiedWorkdir);
4071
+ if (actualRef !== `refs/heads/${task.branch}`) {
4072
+ throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
4073
+ `expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
4074
+ }
4075
+ }
4076
+ else if (agent.role === 'research') {
4077
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4078
+ await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
4079
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4080
+ }
3327
4081
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3328
4082
  let prompt;
3329
4083
  try {
4084
+ await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
4085
+ specDocuments: opts.specDocuments,
4086
+ preserveOutputs: opts.preserveDispatchOutputs ?? false,
4087
+ assertOwner: async () => {
4088
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4089
+ },
4090
+ });
3330
4091
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3331
4092
  && opts.postApproveRedispatchCount > 0
3332
4093
  && !ensure.freshRuntime;
3333
4094
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3334
4095
  let payloadOpts = {};
3335
4096
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3336
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, worktreePath, {
4097
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
3337
4098
  phase,
3338
- ...(task.phase ? { taskPhase: task.phase } : {}),
4099
+ taskPhase: task.phase,
3339
4100
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
3340
4101
  reviewRound: task.reviewRound,
3341
4102
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
@@ -3350,7 +4111,7 @@ export class AgentManager {
3350
4111
  task,
3351
4112
  phase,
3352
4113
  agent,
3353
- worktreePath,
4114
+ workdir: verifiedWorkdir,
3354
4115
  skillRegistry: this.skillRegistry,
3355
4116
  ...(signalToken ? { signalToken } : {}),
3356
4117
  ...(useIncrementalNudge
@@ -3372,7 +4133,6 @@ export class AgentManager {
3372
4133
  }
3373
4134
  throw err;
3374
4135
  }
3375
- const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3376
4136
  const taskFresh = await this.taskStore.get(taskId);
3377
4137
  if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
3378
4138
  console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
@@ -3396,6 +4156,18 @@ export class AgentManager {
3396
4156
  `(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
3397
4157
  return false;
3398
4158
  }
4159
+ if (!agentFresh || agentFresh.lockToken !== lockToken) {
4160
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
4161
+ return false;
4162
+ }
4163
+ if (agentFresh.workdir !== verifiedWorkdir) {
4164
+ const reason = `Workdir changed before prompt injection: ` +
4165
+ `verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
4166
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4167
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4168
+ return false;
4169
+ }
4170
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3399
4171
  if (phase === 'post-approve') {
3400
4172
  const completionFresh = await this.getPostApproveCompletion(taskId);
3401
4173
  if (!completionFresh || completionFresh.token !== signalToken) {
@@ -3408,48 +4180,89 @@ export class AgentManager {
3408
4180
  }
3409
4181
  const now = new Date().toISOString();
3410
4182
  await this.agentStore.update(agentId, (latest) => {
3411
- if (!latest)
4183
+ if (!latest
4184
+ || latest.taskId !== taskId
4185
+ || latest.lockToken !== lockToken
4186
+ || latest.workdir !== verifiedWorkdir) {
3412
4187
  return AGENT_STORE_NOOP;
4188
+ }
3413
4189
  // The continuation prompt supersedes any question asked under the previous one.
3414
4190
  const { needInputAt: _needInputAt, ...rest } = latest;
3415
4191
  return {
3416
4192
  ...rest,
3417
4193
  paneId,
3418
- worktreePath,
4194
+ workdir: verifiedWorkdir,
4195
+ lockToken,
3419
4196
  updatedAt: now,
3420
4197
  };
3421
4198
  });
4199
+ const stateBeforeInject = await this.agentStore.get(agentId);
4200
+ if (stateBeforeInject?.taskId !== taskId || stateBeforeInject.lockToken !== lockToken) {
4201
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership changed before prompt injection; skipping`);
4202
+ return false;
4203
+ }
4204
+ if (stateBeforeInject.workdir !== verifiedWorkdir) {
4205
+ const reason = `Workdir changed before prompt injection: ` +
4206
+ `verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
4207
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4208
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4209
+ return false;
4210
+ }
4211
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
3422
4212
  await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
3423
4213
  return true;
3424
4214
  }
3425
- async rollbackUndeliveredBootstrap(state, agentConfig) {
4215
+ async rollbackUndeliveredBootstrap(state) {
3426
4216
  if (!state.taskId
3427
4217
  || state.bootstrappingTaskId !== state.taskId
3428
4218
  || state.awaitingPhase === 'bootstrap-marker-clear-failed') {
3429
4219
  return false;
3430
4220
  }
3431
4221
  const boundTask = await this.taskStore.get(state.taskId);
3432
- if (!boundTask || boundTask.phase || boundTask.status !== 'in_progress' || boundTask.agentId !== state.id) {
4222
+ if (!boundTask)
4223
+ return false;
4224
+ if (boundTask.status === 'spec-ready' && isSpecStagePhase(boundTask.phase)) {
4225
+ if (state.id === boundTask.devAgentId && state.id !== boundTask.agentId) {
4226
+ const lockToken = state.lockToken;
4227
+ await this.agentStore.update(state.id, (latest) => {
4228
+ if (!latest
4229
+ || latest.taskId !== state.taskId
4230
+ || latest.bootstrappingTaskId !== state.taskId
4231
+ || latest.lockToken !== lockToken) {
4232
+ return AGENT_STORE_NOOP;
4233
+ }
4234
+ return {
4235
+ id: latest.id,
4236
+ projectId: latest.projectId,
4237
+ ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
4238
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
4239
+ ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
4240
+ updatedAt: new Date().toISOString(),
4241
+ };
4242
+ });
4243
+ if (lockToken)
4244
+ await this.lockManager.releaseIfOwner(state.id, state.taskId, lockToken);
4245
+ return true;
4246
+ }
4247
+ await this.clearBootstrapMarker(state.id, state.taskId);
3433
4248
  return false;
3434
4249
  }
3435
- if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt)) {
4250
+ if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
4251
+ return false;
4252
+ const dispatchPhase = boundTask.phase === 'research'
4253
+ ? 'research'
4254
+ : boundTask.specReviewRound !== undefined ? 'code' : 'develop';
4255
+ if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
3436
4256
  await this.clearBootstrapMarker(state.id, state.taskId);
3437
4257
  return false;
3438
4258
  }
4259
+ if (dispatchPhase === 'code') {
4260
+ await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the persisted Spec documents.', { expectedTaskId: state.taskId });
4261
+ return true;
4262
+ }
3439
4263
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
3440
4264
  `(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);
4265
+ await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
3453
4266
  await this.agentStore.update(state.id, (latest) => {
3454
4267
  if (!latest || latest.status !== 'awaiting_human')
3455
4268
  return AGENT_STORE_NOOP;
@@ -3458,12 +4271,15 @@ export class AgentManager {
3458
4271
  });
3459
4272
  return true;
3460
4273
  }
3461
- async bootstrapPromptWasDelivered(taskId, createdAtIso) {
4274
+ async bootstrapPromptWasDelivered(taskId, createdAtIso, agentId, phase) {
3462
4275
  const today = new Date().toISOString().slice(0, 10);
3463
4276
  const from = createdAtIso.slice(0, 10);
3464
4277
  try {
3465
4278
  const events = await this.eventBus.readRange(from, today);
3466
- return events.some((e) => e.type === 'session.started' && e.taskId === taskId);
4279
+ return events.some((event) => event.type === 'session.started'
4280
+ && event.taskId === taskId
4281
+ && event.agentId === agentId
4282
+ && event.data.phase === phase);
3467
4283
  }
3468
4284
  catch (err) {
3469
4285
  console.warn(`[recover] bootstrapPromptWasDelivered read failed for task=${taskId}:`, err);
@@ -3478,16 +4294,56 @@ export class AgentManager {
3478
4294
  return { ...rest, updatedAt: new Date().toISOString() };
3479
4295
  });
3480
4296
  }
4297
+ async releaseOrphanedLocks(states) {
4298
+ const bindings = new Map(states.map(state => [state.id, state]));
4299
+ const claims = await this.lockManager.listClaims();
4300
+ for (const claim of claims) {
4301
+ const binding = bindings.get(claim.agentId);
4302
+ const exactBinding = binding?.taskId === claim.taskId && binding.lockToken === claim.token;
4303
+ if (!claim.taskId.startsWith('maintenance:') && exactBinding)
4304
+ continue;
4305
+ const released = await this.lockManager.releaseIfOwner(claim.agentId, claim.taskId, claim.token);
4306
+ if (!released) {
4307
+ console.warn(`[recover] lock changed while reclaiming ${claim.agentId}/${claim.taskId}; preserving the newer claim`);
4308
+ }
4309
+ }
4310
+ }
3481
4311
  async recover() {
3482
4312
  const states = await this.agentStore.list();
4313
+ await this.releaseOrphanedLocks(states);
3483
4314
  const deferredCleanups = [];
3484
4315
  for (const state of states) {
3485
4316
  const agentConfig = this.getAgentConfig(state.id);
3486
4317
  if (!agentConfig)
3487
4318
  continue;
4319
+ if (state.taskId) {
4320
+ let token = null;
4321
+ try {
4322
+ token = await this.resolveTaskLockToken(state, state.taskId);
4323
+ }
4324
+ catch (err) {
4325
+ console.warn(`[recover] lock validation failed for ${state.id}/${state.taskId}:`, err);
4326
+ }
4327
+ if (!token) {
4328
+ const reason = `Exclusive lock ownership cannot be proven for task ${state.taskId}; runtime recovery was not attempted.`;
4329
+ await this.markAwaitingHuman(state.id, 'recovery-lock-invalid', reason, { expectedTaskId: state.taskId });
4330
+ await this.recordError({
4331
+ agentId: state.id,
4332
+ projectId: state.projectId,
4333
+ taskId: state.taskId,
4334
+ operation: 'recovery',
4335
+ reason: 'LOCK_OWNERSHIP_INVALID',
4336
+ message: reason,
4337
+ observation: { phase: 'pre-runtime-recovery' },
4338
+ recommendation: 'Inspect the persisted lock and task binding, then cancel or delete the Agent if ownership cannot be restored.',
4339
+ });
4340
+ continue;
4341
+ }
4342
+ state.lockToken = token;
4343
+ }
3488
4344
  try {
3489
4345
  const result = await this.ensureSession(state.id, 'recover');
3490
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4346
+ if (await this.rollbackUndeliveredBootstrap(state))
3491
4347
  continue;
3492
4348
  if (state.creationToken && !state.taskId) {
3493
4349
  const ct = state.creationToken;
@@ -3546,18 +4402,16 @@ export class AgentManager {
3546
4402
  }
3547
4403
  const cancelHold = isCancelCleanupHold(state);
3548
4404
  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
- }
4405
+ if (shouldReleaseBinding && state.taskId) {
4406
+ await this.agentStore.update(state.id, (latest) => {
4407
+ if (!latest || latest.taskId !== state.taskId)
4408
+ return AGENT_STORE_NOOP;
4409
+ return { ...latest, paneId: result.paneId, updatedAt: new Date().toISOString() };
4410
+ });
4411
+ await this.releaseAgentForTask(state.id, state.taskId, 'idle', {
4412
+ allowAwaitingHuman: true,
4413
+ });
4414
+ continue;
3561
4415
  }
3562
4416
  const preserveHeld = !shouldReleaseBinding
3563
4417
  && state.status === 'awaiting_human';
@@ -3569,7 +4423,7 @@ export class AgentManager {
3569
4423
  projectId: latest.projectId,
3570
4424
  paneId: result.paneId,
3571
4425
  updatedAt: new Date().toISOString(),
3572
- ...(latest.repoPath !== undefined ? { repoPath: latest.repoPath } : {}),
4426
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
3573
4427
  status: 'ok',
3574
4428
  };
3575
4429
  if (shouldReleaseBinding) {
@@ -3578,7 +4432,7 @@ export class AgentManager {
3578
4432
  const withBinding = {
3579
4433
  ...base,
3580
4434
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
3581
- ...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
4435
+ ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
3582
4436
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
3583
4437
  };
3584
4438
  if (!preserveHeld)
@@ -3591,16 +4445,13 @@ export class AgentManager {
3591
4445
  ...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
3592
4446
  };
3593
4447
  });
3594
- if (shouldReleaseBinding) {
3595
- await this.lockManager.release(state.id);
3596
- }
3597
4448
  if (state.taskId && !shouldReleaseBinding && !cancelHold) {
3598
4449
  this.startRuntimeMenuWatch(state.id);
3599
4450
  }
3600
4451
  }
3601
4452
  catch (err) {
3602
4453
  if (err instanceof EnsureSessionError && err.partial.dialogPending) {
3603
- if (await this.rollbackUndeliveredBootstrap(state, agentConfig))
4454
+ if (await this.rollbackUndeliveredBootstrap(state))
3604
4455
  continue;
3605
4456
  await this.markDialogPending(state.id, state.creationToken);
3606
4457
  void this.slowPollDialogPending(state.id, state.creationToken, {
@@ -3629,10 +4480,15 @@ export class AgentManager {
3629
4480
  ...latest,
3630
4481
  paneId: undefined,
3631
4482
  creationToken: undefined,
4483
+ ...(latest.taskId ? {
4484
+ status: 'awaiting_human',
4485
+ awaitingPhase: 'recovery-failed',
4486
+ awaitingReason: message,
4487
+ awaitingSince: new Date().toISOString(),
4488
+ } : {}),
3632
4489
  updatedAt: new Date().toISOString(),
3633
4490
  };
3634
4491
  });
3635
- await this.lockManager.release(state.id);
3636
4492
  const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
3637
4493
  deferredCleanups.push({
3638
4494
  failingAgentId: state.id,
@@ -3676,23 +4532,32 @@ export class AgentManager {
3676
4532
  hadBinding = !!existing.taskId;
3677
4533
  taskId = existing.taskId;
3678
4534
  if (!existing.taskId
3679
- && !existing.worktreePath
3680
4535
  && !existing.startedAt
3681
4536
  && !existing.paneId
3682
4537
  && !existing.creationToken) {
3683
4538
  return AGENT_STORE_NOOP;
3684
4539
  }
3685
4540
  changed = true;
4541
+ if (existing.taskId) {
4542
+ return {
4543
+ ...existing,
4544
+ paneId: undefined,
4545
+ status: 'awaiting_human',
4546
+ awaitingPhase: 'runtime-missing',
4547
+ awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
4548
+ awaitingSince: timestamp,
4549
+ updatedAt: timestamp,
4550
+ };
4551
+ }
3686
4552
  return {
3687
4553
  id: existing.id,
3688
4554
  projectId: existing.projectId,
3689
- ...(existing.repoPath !== undefined ? { repoPath: existing.repoPath } : {}),
4555
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
3690
4556
  updatedAt: timestamp,
3691
4557
  };
3692
4558
  });
3693
4559
  if (!projectId || !changed)
3694
4560
  return null;
3695
- await this.lockManager.release(agentId);
3696
4561
  return { projectId, timestamp, hadBinding, taskId };
3697
4562
  });
3698
4563
  if (!reconciled)
@@ -3747,18 +4612,18 @@ export class AgentManager {
3747
4612
  || (task.status === 'approved' && !!task.publishDispatchedAt);
3748
4613
  if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
3749
4614
  const afterDone = this.resolveAfterDone(task);
3750
- if (afterDone !== null && task.branch) {
4615
+ if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
3751
4616
  publishedCleanup = {
3752
4617
  afterDone,
3753
4618
  branch: task.branch,
3754
- ...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
4619
+ prNumber: task.prNumber,
3755
4620
  devAgentId: task.agentId,
3756
4621
  mayBeInFlight: task.status === 'approved',
3757
4622
  };
3758
4623
  }
3759
4624
  }
3760
4625
  else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
3761
- // github 模式持有开放 PR 的任务(review/fixing/approved/merge-ready/max_rounds 等)取消时关 PR 删分支;
4626
+ // GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
3762
4627
  // server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
3763
4628
  publishedCleanup = {
3764
4629
  afterDone: 'pr',
@@ -3833,40 +4698,18 @@ export class AgentManager {
3833
4698
  phase: 'cancel-published-artifact-cleanup-skipped',
3834
4699
  afterDone: publishedCleanup.afterDone,
3835
4700
  branch: publishedCleanup.branch,
3836
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4701
+ prNumber: publishedCleanup.prNumber,
3837
4702
  reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
3838
4703
  });
3839
4704
  return result;
3840
4705
  }
3841
4706
  const project = this.getProjectConfig(result.projectId);
3842
4707
  try {
3843
- if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
4708
+ if (project) {
3844
4709
  const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3845
4710
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
3846
4711
  if (close.exitCode !== 0)
3847
4712
  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
4713
  }
3871
4714
  }
3872
4715
  catch (err) {
@@ -3875,7 +4718,7 @@ export class AgentManager {
3875
4718
  phase: 'cancel-published-artifact-cleanup-failed',
3876
4719
  afterDone: publishedCleanup.afterDone,
3877
4720
  branch: publishedCleanup.branch,
3878
- ...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
4721
+ prNumber: publishedCleanup.prNumber,
3879
4722
  error: err instanceof Error ? err.message : String(err),
3880
4723
  });
3881
4724
  }
@@ -3891,8 +4734,8 @@ export class AgentManager {
3891
4734
  if (cfg.projectId !== projectId) {
3892
4735
  throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
3893
4736
  }
3894
- if (cfg.role !== 'dev') {
3895
- throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev role`);
4737
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
4738
+ throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
3896
4739
  }
3897
4740
  }
3898
4741
  let previewBytes;
@@ -3927,24 +4770,23 @@ export class AgentManager {
3927
4770
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
3928
4771
  throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
3929
4772
  }
3930
- if (task.reviewMode === 'server' || task.phase === 'spec') {
4773
+ if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
3931
4774
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
3932
4775
  throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
3933
4776
  }
3934
4777
  if (!this.serverReviewDriver) {
3935
4778
  throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
3936
4779
  }
3937
- // in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
3938
4780
  if (task.status === 'in_progress' && task.phase === undefined) {
3939
4781
  throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
3940
4782
  }
3941
4783
  // 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
3942
- const qaAvailable = (task.qaAgentId !== undefined && !!this.getAgentConfig(task.qaAgentId))
3943
- || !!this.findQaPartner(task.agentId);
4784
+ const qaAvailable = task.qaAgentId !== undefined
4785
+ && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
3944
4786
  if (!qaAvailable) {
3945
4787
  throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
3946
4788
  }
3947
- const isSpec = task.phase === 'spec';
4789
+ const isSpec = isSpecStagePhase(task.phase);
3948
4790
  const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
3949
4791
  const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
3950
4792
  if (round + 1 > cap) {
@@ -3959,18 +4801,12 @@ export class AgentManager {
3959
4801
  if (!task.branch) {
3960
4802
  throw new ApiError(400, `Task ${taskId} has no branch; cannot dispatch review`);
3961
4803
  }
3962
- let qaId = task.qaAgentId;
3963
- if (qaId && !this.getAgentConfig(qaId)) {
3964
- console.warn(`[dispatchReviewToQa] task ${taskId}.qaAgentId="${qaId}" no longer in config; ` +
3965
- `falling back to findQaPartner(${task.agentId})`);
3966
- qaId = undefined;
3967
- }
4804
+ const qaId = task.qaAgentId;
3968
4805
  if (!qaId) {
3969
- const qa = this.findQaPartner(task.agentId);
3970
- if (!qa) {
3971
- throw new ApiError(400, `Dev ${task.agentId} has no QA partner configured; cannot dispatch review`);
3972
- }
3973
- qaId = qa.id;
4806
+ throw new ApiError(400, `Task ${taskId} has no QA participant; cannot dispatch review`);
4807
+ }
4808
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
4809
+ throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
3974
4810
  }
3975
4811
  this.manualReviewInFlight.add(taskId);
3976
4812
  return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
@@ -4056,7 +4892,6 @@ export class AgentManager {
4056
4892
  await this.taskStore.set({
4057
4893
  ...fresh,
4058
4894
  reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
4059
- qaAgentId: qaId,
4060
4895
  signalToken: armedToken,
4061
4896
  updatedAt: new Date().toISOString(),
4062
4897
  });
@@ -4139,7 +4974,7 @@ export class AgentManager {
4139
4974
  if (task.status !== 'max_rounds') {
4140
4975
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4141
4976
  }
4142
- if (task.phase === 'spec') {
4977
+ if (isSpecStagePhase(task.phase)) {
4143
4978
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
4144
4979
  }
4145
4980
  if (task.reviewMode === 'server') {
@@ -4191,8 +5026,8 @@ export class AgentManager {
4191
5026
  }
4192
5027
  const devAgentId = task.agentId;
4193
5028
  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); ` +
5029
+ if (devState?.taskId !== taskId || !devState.workdir) {
5030
+ throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
4196
5031
  `use mark-complete to merge the PR as-is, or cancel the task`);
4197
5032
  }
4198
5033
  const prevReviewRound = task.reviewRound;
@@ -4335,31 +5170,40 @@ export class AgentManager {
4335
5170
  ? ['spec-done', 'code-done']
4336
5171
  : ['spec-done', 'pr-created'];
4337
5172
  }
5173
+ resolveInitialDispatch(task) {
5174
+ if (task.phase === 'research')
5175
+ return { phase: 'research', kinds: ['spec-done'] };
5176
+ return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
5177
+ }
4338
5178
  mapTaskStateToExpectedWatcher(task) {
4339
5179
  if (task.reviewMode === 'server')
4340
5180
  return this.mapServerTaskToExpectedWatcher(task);
4341
- if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
4342
- return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
4343
- }
4344
- if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
4345
- return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
4346
- }
4347
- if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
4348
- return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
4349
- }
4350
- if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
4351
- return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
5181
+ const specStage = isSpecStagePhase(task.phase);
5182
+ if (task.status === 'review' && task.qaAgentId) {
5183
+ return {
5184
+ expectedKinds: specStage ? ['spec-reviewed'] : ['pr-approved', 'pr-changes-requested'],
5185
+ agentId: task.qaAgentId,
5186
+ };
4352
5187
  }
4353
- if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
4354
- return { expectedKinds: ['pr-created'], agentId: task.agentId };
5188
+ if (task.status === 'fixing' && task.agentId) {
5189
+ return {
5190
+ expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
5191
+ agentId: task.agentId,
5192
+ };
4355
5193
  }
4356
- if (task.phase !== 'spec' && task.status === 'review' && task.qaAgentId) {
4357
- return { expectedKinds: ['pr-approved', 'pr-changes-requested'], agentId: task.qaAgentId };
5194
+ if (task.status === 'in_progress' && task.agentId) {
5195
+ if (task.phase === 'research')
5196
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5197
+ if (task.phase === 'code')
5198
+ return { expectedKinds: ['pr-created'], agentId: task.agentId };
5199
+ if (task.phase === undefined) {
5200
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
5201
+ }
4358
5202
  }
4359
5203
  return undefined;
4360
5204
  }
4361
5205
  mapServerTaskToExpectedWatcher(task) {
4362
- const isSpec = task.phase === 'spec';
5206
+ const isSpec = isSpecStagePhase(task.phase);
4363
5207
  if (task.status === 'review' && task.qaAgentId) {
4364
5208
  return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
4365
5209
  }
@@ -4367,8 +5211,11 @@ export class AgentManager {
4367
5211
  return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
4368
5212
  }
4369
5213
  if (task.status === 'in_progress' && task.agentId) {
4370
- if (task.phase === 'code')
5214
+ if (task.phase === 'research')
5215
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5216
+ if (task.phase === 'code') {
4371
5217
  return { expectedKinds: ['code-done'], agentId: task.agentId };
5218
+ }
4372
5219
  return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
4373
5220
  }
4374
5221
  if (task.status === 'approved' && task.agentId) {
@@ -4400,7 +5247,7 @@ export class AgentManager {
4400
5247
  if (!t)
4401
5248
  throw new ApiError(404, 'Task not found');
4402
5249
  const retryable = TERMINAL_STATUSES.includes(t.status)
4403
- || (t.status === 'max_rounds' && t.phase === 'spec');
5250
+ || (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
4404
5251
  if (!retryable) {
4405
5252
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
4406
5253
  }
@@ -4435,7 +5282,11 @@ export class AgentManager {
4435
5282
  if (patch.preferredAgentId !== undefined && patch.preferredAgentId !== task.preferredAgentId) {
4436
5283
  if (patch.preferredAgentId === '') {
4437
5284
  task.preferredAgentId = '';
5285
+ task.agentId = '';
5286
+ task.devAgentId = '';
5287
+ delete task.phase;
4438
5288
  delete task.qaAgentId;
5289
+ delete task.researchAgentId;
4439
5290
  }
4440
5291
  else {
4441
5292
  const cfg = this.getAgentConfig(patch.preferredAgentId);
@@ -4444,10 +5295,23 @@ export class AgentManager {
4444
5295
  if (cfg.projectId !== task.projectId) {
4445
5296
  throw new ApiError(400, `Agent not in project ${task.projectId}`);
4446
5297
  }
4447
- if (cfg.role !== 'dev')
4448
- throw new ApiError(400, `Agent is not dev role`);
5298
+ if (!TASK_OWNER_ROLES.has(cfg.role))
5299
+ throw new ApiError(400, `Agent is not dev or research role`);
5300
+ const group = this.findAgentGroup(cfg.id);
5301
+ const dev = group?.find(agent => agent.role === 'dev');
5302
+ if (!dev)
5303
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
4449
5304
  task.preferredAgentId = patch.preferredAgentId;
4450
- const qaId = this.findQaPartner(patch.preferredAgentId)?.id;
5305
+ task.devAgentId = dev.id;
5306
+ if (cfg.role === 'research') {
5307
+ task.phase = 'research';
5308
+ task.researchAgentId = cfg.id;
5309
+ }
5310
+ else {
5311
+ delete task.phase;
5312
+ delete task.researchAgentId;
5313
+ }
5314
+ const qaId = group?.find(agent => agent.role === 'qa')?.id;
4451
5315
  if (qaId) {
4452
5316
  task.qaAgentId = qaId;
4453
5317
  }
@@ -4475,7 +5339,7 @@ export class AgentManager {
4475
5339
  : '';
4476
5340
  // A merge is not idempotent from the caller's view (user-triggered, retryable
4477
5341
  // 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 });
5342
+ const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash`, { retries: 0 });
4479
5343
  if (result.exitCode !== 0) {
4480
5344
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4481
5345
  }
@@ -4493,7 +5357,7 @@ export class AgentManager {
4493
5357
  if (!serverApprovedRetry && task.status !== 'max_rounds') {
4494
5358
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4495
5359
  }
4496
- if (task.phase === 'spec') {
5360
+ if (isSpecStagePhase(task.phase)) {
4497
5361
  throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
4498
5362
  }
4499
5363
  if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
@@ -4608,6 +5472,9 @@ export class AgentManager {
4608
5472
  return;
4609
5473
  }
4610
5474
  if (!state.taskId) {
5475
+ const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
5476
+ if (!lockToken)
5477
+ return;
4611
5478
  await this.agentStore.update(agentId, (existing) => {
4612
5479
  if (!existing)
4613
5480
  return AGENT_STORE_NOOP;
@@ -4619,21 +5486,20 @@ export class AgentManager {
4619
5486
  return AGENT_STORE_NOOP;
4620
5487
  if (existing.paneId !== state.paneId)
4621
5488
  return AGENT_STORE_NOOP;
4622
- return { ...existing, taskId: ctx.taskId, updatedAt: new Date().toISOString() };
5489
+ return {
5490
+ ...existing,
5491
+ taskId: ctx.taskId,
5492
+ lockToken,
5493
+ updatedAt: new Date().toISOString(),
5494
+ };
4623
5495
  });
4624
5496
  const fresh = await this.agentStore.get(agentId);
4625
- if (fresh?.taskId !== ctx.taskId)
5497
+ if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
5498
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
4626
5499
  return;
5500
+ }
4627
5501
  }
4628
- await this.removeMergedWorktree(agent, agentId, ctx.taskId);
4629
5502
  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
5503
  const tmux = new TmuxManager(runner);
4638
5504
  const runtime = agentRuntimeKindFor(agent);
4639
5505
  void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
@@ -4649,56 +5515,6 @@ export class AgentManager {
4649
5515
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
4650
5516
  }
4651
5517
  }
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
5518
  async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
4703
5519
  await this.acquireCompactGuard(agentId);
4704
5520
  try {
@@ -4729,33 +5545,59 @@ export class AgentManager {
4729
5545
  return true;
4730
5546
  }
4731
5547
  async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
5548
+ const initial = await this.agentStore.get(agentId);
5549
+ if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
5550
+ return;
5551
+ const lockToken = await this.resolveTaskLockToken(initial, originalTaskId);
5552
+ if (!lockToken)
5553
+ return;
4732
5554
  const bindingStillOurs = async () => {
4733
5555
  const s = await this.agentStore.get(agentId);
4734
- return !!s && s.taskId === originalTaskId && s.paneId === paneId;
5556
+ return !!s
5557
+ && s.taskId === originalTaskId
5558
+ && s.lockToken === lockToken
5559
+ && s.paneId === paneId
5560
+ && await this.lockManager.isOwner(agentId, originalTaskId, lockToken);
4735
5561
  };
4736
5562
  if (!await bindingStillOurs())
4737
5563
  return;
4738
5564
  let cleared = false;
5565
+ let clearError;
4739
5566
  try {
4740
5567
  cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
4741
5568
  }
4742
5569
  catch (err) {
4743
- console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear failed:`, err);
5570
+ clearError = err;
4744
5571
  }
4745
5572
  if (!cleared) {
4746
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
5573
+ if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
4747
5574
  cleared = true;
4748
5575
  }
4749
5576
  else if (!await bindingStillOurs()) {
4750
5577
  return;
4751
5578
  }
4752
- else if (!await this.clearComposerForReuse(tmux, paneId, agentId)) {
5579
+ else if (clearError instanceof Error
5580
+ && clearError.message.includes('runtime rejected /clear')) {
5581
+ try {
5582
+ await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
5583
+ }
5584
+ catch (err) {
5585
+ clearError = err;
5586
+ }
5587
+ if (clearError instanceof Error && !clearError.message.includes('runtime rejected /clear')) {
5588
+ 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 });
5589
+ return;
5590
+ }
5591
+ }
5592
+ else {
5593
+ const reason = clearError instanceof Error ? clearError.message : String(clearError ?? 'unknown error');
5594
+ 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
5595
  return;
4754
5596
  }
4755
5597
  }
4756
5598
  await this.releasePostMergeAgent(agentId, originalTaskId);
4757
5599
  }
4758
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
5600
+ async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
4759
5601
  let paneExists = true;
4760
5602
  try {
4761
5603
  if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
@@ -4769,7 +5611,11 @@ export class AgentManager {
4769
5611
  paneExists = false;
4770
5612
  }
4771
5613
  const state = await this.agentStore.get(agentId);
4772
- if (!state || state.taskId !== taskId || state.paneId !== paneId)
5614
+ if (!state
5615
+ || state.taskId !== taskId
5616
+ || state.lockToken !== lockToken
5617
+ || state.paneId !== paneId
5618
+ || !(await this.lockManager.isOwner(agentId, taskId, lockToken)))
4773
5619
  return false;
4774
5620
  try {
4775
5621
  if (paneExists)
@@ -4778,20 +5624,24 @@ export class AgentManager {
4778
5624
  if (!ensure.freshRuntime)
4779
5625
  return false;
4780
5626
  await this.agentStore.update(agentId, (existing) => {
4781
- if (!existing || existing.taskId !== taskId)
5627
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
4782
5628
  return AGENT_STORE_NOOP;
4783
- if (existing.paneId === ensure.paneId && existing.repoPath === ensure.workdir) {
5629
+ }
5630
+ if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
4784
5631
  return AGENT_STORE_NOOP;
4785
5632
  }
4786
5633
  return {
4787
5634
  ...existing,
4788
5635
  paneId: ensure.paneId,
4789
- repoPath: ensure.workdir,
5636
+ workdir: ensure.workdir,
4790
5637
  updatedAt: new Date().toISOString(),
4791
5638
  };
4792
5639
  });
4793
5640
  const current = await this.agentStore.get(agentId);
4794
- return current?.taskId === taskId && current.paneId === ensure.paneId;
5641
+ return current?.taskId === taskId
5642
+ && current.lockToken === lockToken
5643
+ && current.paneId === ensure.paneId
5644
+ && await this.lockManager.isOwner(agentId, taskId, lockToken);
4795
5645
  }
4796
5646
  catch (err) {
4797
5647
  console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
@@ -4927,7 +5777,6 @@ export class AgentManager {
4927
5777
  signalToken: restore.signalToken,
4928
5778
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
4929
5779
  reviewDispatchedAt: restore.reviewDispatchedAt,
4930
- qaAgentId: undefined,
4931
5780
  updatedAt: new Date().toISOString(),
4932
5781
  });
4933
5782
  return true;
@@ -4955,11 +5804,8 @@ export class AgentManager {
4955
5804
  const task = await this.taskStore.get(taskId);
4956
5805
  if (!task)
4957
5806
  return null;
4958
- const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] },
4959
- // QA 在停驻期解绑,残留 qaAgentId 会让后续 approve/打回误发释放;复审经 findQaPartner 回绑
4960
- {
5807
+ const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
4961
5808
  phase: 'spec',
4962
- qaAgentId: undefined,
4963
5809
  ...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
4964
5810
  });
4965
5811
  if (!transition)
@@ -4992,6 +5838,9 @@ export class AgentManager {
4992
5838
  if (fresh.status !== 'spec-ready') {
4993
5839
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
4994
5840
  }
5841
+ if (verdict === 'archive' && !fresh.researchAgentId) {
5842
+ throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
5843
+ }
4995
5844
  if (this.specVerdictInFlight.has(taskId)) {
4996
5845
  throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
4997
5846
  }
@@ -5013,13 +5862,45 @@ export class AgentManager {
5013
5862
  const round = task.specReviewRound ?? 1;
5014
5863
  const at = new Date().toISOString();
5015
5864
  const roundData = await store.getRound(taskId, 'spec', round);
5016
- // 停驻路径必然已存轮次;缺失时补一条空轮次,保证 userDecision 留痕与 fix 闭环
5017
- const base = roundData ?? { round, phase: 'spec', content: '', startedAt: at };
5865
+ if (!roundData || roundData.phase !== 'spec') {
5866
+ throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
5867
+ }
5868
+ const base = roundData;
5869
+ if (verdict === 'archive') {
5870
+ await store.putRound(taskId, 'spec', {
5871
+ ...base,
5872
+ userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
5873
+ });
5874
+ const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
5875
+ if (!transition)
5876
+ throw new ApiError(409, `Task ${taskId} changed while archiving`);
5877
+ this.stopPhaseSignalWatcher(taskId);
5878
+ const participants = new Set([
5879
+ task.agentId,
5880
+ task.devAgentId,
5881
+ task.qaAgentId,
5882
+ task.researchAgentId,
5883
+ ].filter((id) => typeof id === 'string' && id !== ''));
5884
+ for (const agentId of participants) {
5885
+ const state = await this.agentStore.get(agentId);
5886
+ if (state?.taskId !== taskId)
5887
+ continue;
5888
+ const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
5889
+ if (!released) {
5890
+ await this.emitIntervention(task.projectId, agentId, taskId, {
5891
+ phase: 'spec-archive-agent-release-failed',
5892
+ agentId,
5893
+ });
5894
+ }
5895
+ }
5896
+ return (await this.taskStore.get(taskId));
5897
+ }
5018
5898
  if (verdict === 'approve') {
5019
5899
  await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
5020
5900
  const result = await this.transitionToCodePhase(taskId);
5021
- if (!result)
5022
- throw new ApiError(500, `Failed to dispatch code phase for task ${taskId}`);
5901
+ if (!result) {
5902
+ throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
5903
+ }
5023
5904
  return result;
5024
5905
  }
5025
5906
  // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
@@ -5059,7 +5940,7 @@ export class AgentManager {
5059
5940
  if (fresh.status !== 'ready') {
5060
5941
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
5061
5942
  }
5062
- if (fresh.phase === 'spec') {
5943
+ if (isSpecStagePhase(fresh.phase)) {
5063
5944
  throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
5064
5945
  }
5065
5946
  if (fresh.reviewMode !== 'server') {
@@ -5136,7 +6017,7 @@ export class AgentManager {
5136
6017
  return empty;
5137
6018
  // 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
5138
6019
  try {
5139
- await this.refreshWorktreeCacheFor(task.agentId);
6020
+ await this.refreshWorkdirCacheFor(task.agentId);
5140
6021
  const content = await this.getReviewTransport().readContent(task, dev, 'code');
5141
6022
  return {
5142
6023
  round,
@@ -5152,40 +6033,82 @@ export class AgentManager {
5152
6033
  return empty;
5153
6034
  }
5154
6035
  }
6036
+ isResearchHandoff(task, devAgentId) {
6037
+ return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
6038
+ }
6039
+ dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
6040
+ const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
6041
+ const dispatchOpts = {
6042
+ signalToken,
6043
+ specDocuments,
6044
+ ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
6045
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
6046
+ };
6047
+ return this.isResearchHandoff(task, devAgentId)
6048
+ ? this.startSession(task.id, devAgentId, 'code', {
6049
+ ...dispatchOpts,
6050
+ preserveBindingOnFailure: true,
6051
+ })
6052
+ : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
6053
+ }
5155
6054
  async transitionToCodePhase(taskId) {
5156
6055
  const task = await this.taskStore.get(taskId);
5157
6056
  if (!task)
5158
6057
  return null;
5159
- const devAgentId = task.agentId;
5160
- if (!devAgentId)
5161
- return null;
5162
- const newToken = createSignalToken();
5163
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { phase: 'code', signalToken: newToken });
5164
- if (!transition)
6058
+ const devAgentId = task.devAgentId;
6059
+ const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
6060
+ if (!devAgentId || dev?.role !== 'dev') {
6061
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6062
+ phase: 'code-dev-missing',
6063
+ devAgentId,
6064
+ });
5165
6065
  return null;
5166
- this.stopPhaseSignalWatcher(taskId);
5167
- const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
5168
- const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
5169
- if (!codeArmed && task.reviewMode === 'server') {
5170
- await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
6066
+ }
6067
+ const round = task.specReviewRound ?? 1;
6068
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
6069
+ if (!stored || stored.phase !== 'spec') {
6070
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6071
+ phase: 'code-spec-round-missing',
6072
+ round,
6073
+ });
5171
6074
  return null;
5172
6075
  }
5173
- if (task.qaAgentId) {
5174
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
6076
+ const researchAgentId = task.researchAgentId;
6077
+ const researchHandoff = this.isResearchHandoff(task, devAgentId);
6078
+ const releaseParticipant = async (agentId, failurePhase) => {
6079
+ const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
5175
6080
  .catch(() => false);
5176
6081
  if (!released) {
5177
- await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId });
6082
+ await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
5178
6083
  }
6084
+ return released;
6085
+ };
6086
+ if (researchHandoff && researchAgentId) {
6087
+ if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
6088
+ return null;
6089
+ }
6090
+ if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
6091
+ return null;
5179
6092
  }
5180
6093
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5181
6094
  if (!acquired) {
5182
- await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Dev could not be acquired for the code phase after spec approval; the task looks in_progress but the code prompt was never dispatched. Resume the agent to redispatch or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5183
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-dev-acquire-failed', devAgentId });
6095
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6096
+ phase: 'code-dev-acquire-failed',
6097
+ devAgentId,
6098
+ });
5184
6099
  return null;
5185
6100
  }
5186
- let resumed = false;
6101
+ const newToken = createSignalToken();
6102
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
6103
+ if (!transition) {
6104
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
6105
+ .catch(() => undefined);
6106
+ return null;
6107
+ }
6108
+ this.stopPhaseSignalWatcher(taskId);
6109
+ let started = false;
5187
6110
  try {
5188
- resumed = await this.continueSession(taskId, devAgentId, 'code');
6111
+ started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
5189
6112
  }
5190
6113
  catch (err) {
5191
6114
  if (err instanceof DispatchTerminalError) {
@@ -5194,12 +6117,15 @@ export class AgentManager {
5194
6117
  else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5195
6118
  await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5196
6119
  }
5197
- console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
6120
+ console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
5198
6121
  throw err;
5199
6122
  }
5200
- if (!resumed) {
6123
+ if (!started) {
5201
6124
  await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5202
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-resume-failed', devAgentId });
6125
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6126
+ phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
6127
+ devAgentId,
6128
+ });
5203
6129
  return null;
5204
6130
  }
5205
6131
  return await this.taskStore.get(taskId);
@@ -5216,13 +6142,7 @@ export class AgentManager {
5216
6142
  if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5217
6143
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5218
6144
  }
5219
- let recordedQaId = task.qaAgentId;
5220
- if (recordedQaId && !this.getAgentConfig(recordedQaId)) {
5221
- console.warn(`[dispatchServerReviewToQa] task ${taskId}.qaAgentId="${recordedQaId}" no longer in config; ` +
5222
- `falling back to findQaPartner(${task.agentId})`);
5223
- recordedQaId = undefined;
5224
- }
5225
- const qaId = recordedQaId ?? this.findQaPartner(task.agentId)?.id;
6145
+ const qaId = task.qaAgentId;
5226
6146
  if (!qaId) {
5227
6147
  const entryKind = task.status === 'fixing'
5228
6148
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -5231,6 +6151,17 @@ export class AgentManager {
5231
6151
  await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
5232
6152
  return null;
5233
6153
  }
6154
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
6155
+ const entryKind = task.status === 'fixing'
6156
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
6157
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
6158
+ await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
6159
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6160
+ phase: 'server-review-qa-unavailable',
6161
+ qaAgentId: qaId,
6162
+ });
6163
+ return null;
6164
+ }
5234
6165
  const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
5235
6166
  return {
5236
6167
  qaId,
@@ -5304,16 +6235,15 @@ export class AgentManager {
5304
6235
  : { reviewRound: newRound };
5305
6236
  const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
5306
6237
  signalToken: newToken,
5307
- qaAgentId: qaId,
5308
6238
  reviewDispatchedAt: new Date().toISOString(),
5309
6239
  ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
5310
6240
  ...(opts.batch
5311
6241
  ? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
5312
6242
  : { batchIndex: undefined, batchTotal: undefined }),
5313
- // Fresh dispatch: clear any stale mode until startSession materializes the worktree,
6243
+ // Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
5314
6244
  // so a crash in this window recovers conservatively (no read-file) rather than
5315
6245
  // re-arming it for what might be a head-mode review.
5316
- ...(opts.continuation ? {} : { reviewWorktreeMode: undefined }),
6246
+ ...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
5317
6247
  ...roundPatch,
5318
6248
  });
5319
6249
  if (!transition) {
@@ -5323,7 +6253,7 @@ export class AgentManager {
5323
6253
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
5324
6254
  return null;
5325
6255
  }
5326
- let dispatchedWorktreeMode;
6256
+ let dispatchedCheckoutMode;
5327
6257
  const sessionOpts = {
5328
6258
  bypassTaskStatusGate: true,
5329
6259
  signalToken: newToken,
@@ -5338,8 +6268,8 @@ export class AgentManager {
5338
6268
  ...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
5339
6269
  ...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
5340
6270
  armBeforeInject: (ctx) => {
5341
- dispatchedWorktreeMode = ctx.serverReviewWorktree;
5342
- const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewWorktree === 'base';
6271
+ dispatchedCheckoutMode = ctx.serverReviewCheckout;
6272
+ const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
5343
6273
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
5344
6274
  },
5345
6275
  };
@@ -5383,10 +6313,20 @@ export class AgentManager {
5383
6313
  await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
5384
6314
  return null;
5385
6315
  }
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.
6316
+ // Persist the checkout mode so base-mode recovery can re-arm read-file.
5388
6317
  if (opts.phase !== 'spec' && !opts.continuation) {
5389
- await this.updateTask(taskId, { reviewWorktreeMode: dispatchedWorktreeMode }).catch(() => { });
6318
+ try {
6319
+ await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
6320
+ }
6321
+ catch (err) {
6322
+ const message = err instanceof Error ? err.message : String(err);
6323
+ console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
6324
+ await this.emitIntervention(projectId, qaId, taskId, {
6325
+ phase: 'review-checkout-mode-persist-failed',
6326
+ error: message,
6327
+ note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
6328
+ });
6329
+ }
5390
6330
  }
5391
6331
  return await this.taskStore.get(taskId);
5392
6332
  }
@@ -5395,7 +6335,7 @@ export class AgentManager {
5395
6335
  const task = await this.taskStore.get(taskId);
5396
6336
  if (!task)
5397
6337
  throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5398
- if (task.reviewMode !== 'server' && task.phase !== 'spec') {
6338
+ if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
5399
6339
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5400
6340
  }
5401
6341
  if (!task.agentId)
@@ -5405,7 +6345,7 @@ export class AgentManager {
5405
6345
  qaAgentId: task.qaAgentId,
5406
6346
  projectId: task.projectId,
5407
6347
  newToken: createSignalToken(),
5408
- taskPhase: (task.phase ?? 'code'),
6348
+ taskPhase: task.phase,
5409
6349
  currentSpecRound: task.specReviewRound,
5410
6350
  originalStatus: task.status,
5411
6351
  originalToken: task.signalToken,
@@ -5431,7 +6371,7 @@ export class AgentManager {
5431
6371
  return null;
5432
6372
  }
5433
6373
  if (qaAgentId) {
5434
- const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
6374
+ const released = await this.releaseAgentIfBound(qaAgentId, taskId)
5435
6375
  .catch(() => false);
5436
6376
  if (!released) {
5437
6377
  await rearmReviewedSignal();
@@ -5506,13 +6446,13 @@ export class AgentManager {
5506
6446
  return { ok: false, reason: 'released' };
5507
6447
  const agent = this.getAgentConfig(task.agentId);
5508
6448
  const agentState = await this.agentStore.get(task.agentId);
5509
- // A rebound agent's worktree belongs to its new task — its git state describes
6449
+ // A rebound agent's Workdir checkout belongs to its new task — its git state describes
5510
6450
  // 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) {
6451
+ // findLineageViolation: the Workdir must still be bound to THIS task.
6452
+ if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
5513
6453
  return { ok: false, reason: 'released' };
5514
6454
  }
5515
- await this.refreshWorktreeCacheFor(task.agentId);
6455
+ await this.refreshWorkdirCacheFor(task.agentId);
5516
6456
  const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
5517
6457
  return { ok: true, diff };
5518
6458
  }
@@ -5522,13 +6462,13 @@ export class AgentManager {
5522
6462
  return null;
5523
6463
  const agent = this.getAgentConfig(task.agentId);
5524
6464
  const agentState = await this.agentStore.get(task.agentId);
5525
- // A rebound agent's worktree belongs to its new task — checking it would
6465
+ // A rebound agent's Workdir checkout belongs to its new task — checking it would
5526
6466
  // produce verdicts about the wrong branch. Skip; the dispatch path's own
5527
6467
  // binding guards (acquire) surface the real failure.
5528
6468
  if (agentState?.taskId !== taskId)
5529
6469
  return null;
5530
- const worktree = agentState.worktreePath;
5531
- if (!agent || !worktree)
6470
+ const workdir = agentState.workdir;
6471
+ if (!agent || !workdir)
5532
6472
  return null;
5533
6473
  const runner = this.createRunnerFor(agent);
5534
6474
  let base = baseSha;
@@ -5536,13 +6476,13 @@ export class AgentManager {
5536
6476
  // Callers without a baseSha (publish) run long after the review diff was
5537
6477
  // read; refresh origin/HEAD first or upstream commits merged since then
5538
6478
  // 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`);
6479
+ const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
5540
6480
  if (fetch.exitCode !== 0) {
5541
- throw new Error(`lineage fetch failed in ${worktree}: ${fetch.stderr.trim()}`);
6481
+ throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
5542
6482
  }
5543
- const mb = await runner.exec(`git -C ${shellQuote(worktree)} merge-base origin/HEAD HEAD`);
6483
+ const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
5544
6484
  if (mb.exitCode !== 0) {
5545
- throw new Error(`lineage merge-base failed in ${worktree}: ${mb.stderr.trim()}`);
6485
+ throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
5546
6486
  }
5547
6487
  base = mb.stdout.trim();
5548
6488
  }
@@ -5559,7 +6499,7 @@ export class AgentManager {
5559
6499
  .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
5560
6500
  if (candidates.length === 0)
5561
6501
  return null;
5562
- return findForeignTaskTip((cmd) => runner.exec(cmd), worktree, base, candidates);
6502
+ return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
5563
6503
  }
5564
6504
  async dispatchServerAfterDone(taskId, kind) {
5565
6505
  const task = await this.taskStore.get(taskId);
@@ -5641,7 +6581,7 @@ export class AgentManager {
5641
6581
  const dev = this.getAgentConfig(task.agentId);
5642
6582
  if (!dev)
5643
6583
  return;
5644
- await this.refreshWorktreeCacheFor(task.agentId);
6584
+ await this.refreshWorkdirCacheFor(task.agentId);
5645
6585
  let body;
5646
6586
  try {
5647
6587
  const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
@@ -5676,6 +6616,11 @@ export class AgentManager {
5676
6616
  if (isCancelCleanupHold(state)) {
5677
6617
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
5678
6618
  }
6619
+ const taskId = state?.taskId;
6620
+ const lockToken = taskId ? await this.resolveTaskLockToken(state, taskId) : null;
6621
+ if (taskId && !lockToken) {
6622
+ throw new Error(`injectTextToAgent: agent ${agentId} no longer owns the exclusive lock for task ${taskId}`);
6623
+ }
5679
6624
  const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
5680
6625
  if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
5681
6626
  throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
@@ -5685,10 +6630,16 @@ export class AgentManager {
5685
6630
  throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
5686
6631
  const fresh = await this.agentStore.get(agentId);
5687
6632
  if (!fresh || fresh.paneId !== paneId
5688
- || (opts.expectedTaskId !== undefined && fresh.taskId !== opts.expectedTaskId)
5689
- || isCancelCleanupHold(fresh)) {
6633
+ || fresh.taskId !== taskId
6634
+ || (taskId !== undefined && fresh.lockToken !== lockToken)) {
6635
+ throw new Error(`injectTextToAgent: agent ${agentId} binding or exclusive lock changed before paste`);
6636
+ }
6637
+ if (isCancelCleanupHold(fresh)) {
5690
6638
  throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
5691
6639
  }
6640
+ if (taskId && lockToken) {
6641
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
6642
+ }
5692
6643
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5693
6644
  await tmux.injectPrompt(paneId, text, agentId);
5694
6645
  await tmux.sendEnter(paneId);
@@ -5832,14 +6783,14 @@ export class AgentManager {
5832
6783
  if (!dev)
5833
6784
  throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
5834
6785
  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}`);
6786
+ const workdir = state?.workdir;
6787
+ if (!workdir)
6788
+ throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
5838
6789
  const branch = task.branch ?? BRANCH_PREFIX + task.id;
5839
6790
  const runner = this.createRunnerFor(dev);
5840
6791
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
5841
6792
  const run = prev.then(async () => {
5842
- const cd = `cd ${shellQuote(repoPath)} && `;
6793
+ const cd = `cd ${shellQuote(workdir)} && `;
5843
6794
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
5844
6795
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
5845
6796
  if (db.exitCode !== 0 || defaultBranch === '') {
@@ -5863,10 +6814,6 @@ export class AgentManager {
5863
6814
  if (push.exitCode !== 0) {
5864
6815
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5865
6816
  }
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
6817
  });
5871
6818
  this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
5872
6819
  await run;