baxian 1.2.55 → 1.2.57

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 (69) hide show
  1. package/dist/agent/manager.d.ts +43 -29
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +873 -386
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/net-exec.d.ts +1 -0
  6. package/dist/agent/net-exec.d.ts.map +1 -1
  7. package/dist/agent/net-exec.js +6 -0
  8. package/dist/agent/net-exec.js.map +1 -1
  9. package/dist/agent/prompt.d.ts.map +1 -1
  10. package/dist/agent/prompt.js +2 -1
  11. package/dist/agent/prompt.js.map +1 -1
  12. package/dist/agent/repo-store.d.ts +15 -1
  13. package/dist/agent/repo-store.d.ts.map +1 -1
  14. package/dist/agent/repo-store.js +207 -22
  15. package/dist/agent/repo-store.js.map +1 -1
  16. package/dist/agent/review-transport.d.ts +5 -1
  17. package/dist/agent/review-transport.d.ts.map +1 -1
  18. package/dist/agent/review-transport.js +140 -18
  19. package/dist/agent/review-transport.js.map +1 -1
  20. package/dist/agent/tmux.d.ts +21 -2
  21. package/dist/agent/tmux.d.ts.map +1 -1
  22. package/dist/agent/tmux.js +200 -16
  23. package/dist/agent/tmux.js.map +1 -1
  24. package/dist/api/config.d.ts.map +1 -1
  25. package/dist/api/config.js +45 -8
  26. package/dist/api/config.js.map +1 -1
  27. package/dist/api/projects.d.ts.map +1 -1
  28. package/dist/api/projects.js +31 -26
  29. package/dist/api/projects.js.map +1 -1
  30. package/dist/api/tasks.js +2 -2
  31. package/dist/api/tasks.js.map +1 -1
  32. package/dist/config/validator.js +23 -11
  33. package/dist/config/validator.js.map +1 -1
  34. package/dist/event/handlers.d.ts.map +1 -1
  35. package/dist/event/handlers.js +36 -26
  36. package/dist/event/handlers.js.map +1 -1
  37. package/dist/event/server-handlers.d.ts.map +1 -1
  38. package/dist/event/server-handlers.js +46 -26
  39. package/dist/event/server-handlers.js.map +1 -1
  40. package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
  41. package/dist/lifecycle/restart-sentinel.js +10 -3
  42. package/dist/lifecycle/restart-sentinel.js.map +1 -1
  43. package/dist/shared/constants.d.ts +3 -1
  44. package/dist/shared/constants.d.ts.map +1 -1
  45. package/dist/shared/constants.js +8 -0
  46. package/dist/shared/constants.js.map +1 -1
  47. package/dist/shared/types.d.ts +21 -5
  48. package/dist/shared/types.d.ts.map +1 -1
  49. package/dist/shared/types.js +8 -0
  50. package/dist/shared/types.js.map +1 -1
  51. package/dist/skills/baxian-research/SKILL.md +28 -0
  52. package/dist/skills/baxian-server-feedback/SKILL.md +3 -3
  53. package/dist/state/error-record-store.d.ts.map +1 -1
  54. package/dist/state/error-record-store.js +12 -3
  55. package/dist/state/error-record-store.js.map +1 -1
  56. package/dist/state/process-lock.d.ts.map +1 -1
  57. package/dist/state/process-lock.js +11 -2
  58. package/dist/state/process-lock.js.map +1 -1
  59. package/dist/state/review-store.d.ts +4 -4
  60. package/dist/state/review-store.d.ts.map +1 -1
  61. package/dist/state/review-store.js +57 -6
  62. package/dist/state/review-store.js.map +1 -1
  63. package/dist/state/task-store.d.ts.map +1 -1
  64. package/dist/state/task-store.js +115 -20
  65. package/dist/state/task-store.js.map +1 -1
  66. package/dist/web/assets/index-3Ypk8Gfo.js +13 -0
  67. package/dist/web/index.html +1 -1
  68. package/package.json +1 -1
  69. package/dist/web/assets/index-BmnSiyut.js +0 -13
@@ -1,19 +1,20 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { join, normalize } from 'node:path';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { createSignalToken } from './phase-signal.js';
5
6
  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';
7
+ 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
8
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
8
9
  import { PostApproveStore } from '../state/post-approve-store.js';
9
10
  import { SkillRegistry } from '../skill/registry.js';
10
- import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
11
+ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
11
12
  import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
12
13
  import { findForeignTaskTip } from './lineage.js';
13
14
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
14
15
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
15
16
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
16
- import { RepoStore, createRepoStoreCache } from './repo-store.js';
17
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
17
18
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
18
19
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
19
20
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
@@ -85,6 +86,10 @@ export function buildLaunchCommand(agent) {
85
86
  }
86
87
  return segments.join(' ');
87
88
  }
89
+ // cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
90
+ export function launchCommandIn(dir, agent) {
91
+ return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
92
+ }
88
93
  function agentRuntimeKindFor(agent) {
89
94
  return agent.runtime;
90
95
  }
@@ -137,7 +142,7 @@ const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
137
142
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
138
143
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
139
144
  const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
140
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
145
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
141
146
  const RUNTIME_LIVENESS_SAMPLES = 3;
142
147
  export function canDispatchWithBinding(binding) {
143
148
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
@@ -269,6 +274,17 @@ export class AgentManager {
269
274
  throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
270
275
  }
271
276
  }
277
+ async assertTaskGeneration(agentId, taskId, lockToken, workdir) {
278
+ const state = await this.agentStore.get(agentId);
279
+ if (!state
280
+ || state.taskId !== taskId
281
+ || state.lockToken !== lockToken
282
+ || state.workdir !== workdir) {
283
+ throw new Error(`Agent ${agentId} task generation changed for ${taskId}; operation aborted`);
284
+ }
285
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
286
+ return state;
287
+ }
272
288
  getReviewStore() {
273
289
  return this.reviewStore;
274
290
  }
@@ -300,6 +316,14 @@ export class AgentManager {
300
316
  });
301
317
  return this.reviewTransportInstance;
302
318
  }
319
+ async prepareDispatchArtifacts(agent, workdir, phase, opts) {
320
+ const transport = this.getReviewTransport();
321
+ if (!opts.preserveOutputs)
322
+ await transport.clearDispatchOutputs(agent, workdir, phase);
323
+ if (opts.specDocuments) {
324
+ await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
325
+ }
326
+ }
303
327
  bindingWorkdirCache = new Map();
304
328
  async refreshWorkdirCacheFor(agentId) {
305
329
  const state = await this.agentStore.get(agentId);
@@ -342,9 +366,28 @@ export class AgentManager {
342
366
  }
343
367
  replaceConfig(validated) {
344
368
  const config = prepareConfig(validated);
369
+ const previousConfig = this.config;
370
+ const previousIndex = this.agentIndex;
371
+ const nextIndex = buildAgentIndex(config);
372
+ for (const [ownerKey, agentId] of this.repoCache.owners) {
373
+ const previous = previousIndex.get(agentId);
374
+ const next = nextIndex.get(agentId);
375
+ const previousHost = previous
376
+ ? resolveAgentHost(previousConfig.host, previous.host)
377
+ : undefined;
378
+ const nextHost = next ? resolveAgentHost(config.host, next.host) : undefined;
379
+ const previousOwnerConfig = previous
380
+ ? `${workdirHostGroupKey(previous.mode, previousHost)}\0${previous.workdir ? normalize(previous.workdir) : ''}`
381
+ : null;
382
+ const nextOwnerConfig = next
383
+ ? `${workdirHostGroupKey(next.mode, nextHost)}\0${next.workdir ? normalize(next.workdir) : ''}`
384
+ : null;
385
+ if (previousOwnerConfig === null || previousOwnerConfig !== nextOwnerConfig) {
386
+ this.repoCache.owners.delete(ownerKey);
387
+ }
388
+ }
345
389
  this.config = config;
346
- this.agentIndex = buildAgentIndex(config);
347
- this.repoCache.owners.clear();
390
+ this.agentIndex = nextIndex;
348
391
  }
349
392
  getConfig() {
350
393
  return this.config;
@@ -411,8 +454,11 @@ export class AgentManager {
411
454
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
412
455
  }
413
456
  if (mode === 'create' && alive) {
414
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
415
- `it creates itself — kill it manually or pick a different agent id`);
457
+ const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
458
+ if (!reclaimed) {
459
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
460
+ `it creates itself — kill it manually or pick a different agent id`);
461
+ }
416
462
  }
417
463
  if (alive && (mode === 'runtime' || mode === 'recover')) {
418
464
  return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
@@ -432,8 +478,77 @@ export class AgentManager {
432
478
  // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
433
479
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
434
480
  await this.ensureSkillDirSafe(runner, workdir, subdir);
435
- await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
481
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
482
+ });
483
+ }
484
+ // A rollback only kills if no adoption bumped the generation since its create.
485
+ sessionLifecycleChain = new Map();
486
+ adoptGeneration = new Map();
487
+ runUnderSessionLifecycle(agentId, fn) {
488
+ const prev = this.sessionLifecycleChain.get(agentId) ?? Promise.resolve();
489
+ const cur = prev.then(fn, fn);
490
+ const settled = cur.then(() => undefined, () => undefined);
491
+ this.sessionLifecycleChain.set(agentId, settled);
492
+ void settled.finally(() => {
493
+ if (this.sessionLifecycleChain.get(agentId) === settled)
494
+ this.sessionLifecycleChain.delete(agentId);
436
495
  });
496
+ return cur;
497
+ }
498
+ // Inside the lifecycle lock, a generation bump since create means handoff — stand down.
499
+ async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
500
+ await this.runUnderSessionLifecycle(agentId, async () => {
501
+ try {
502
+ const ref = partial.sessionRef;
503
+ if (!ref) {
504
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: no session ref recorded`);
505
+ return;
506
+ }
507
+ if ((this.adoptGeneration.get(agentId) ?? 0) !== (partial.genAtCreate ?? 0)) {
508
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: session adopted since create — leaving it to its successor`);
509
+ return;
510
+ }
511
+ if (opts.expectCreationToken !== undefined) {
512
+ let fresh;
513
+ try {
514
+ fresh = await this.agentStore.get(agentId);
515
+ }
516
+ catch (storeErr) {
517
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent store read failed — session left in place:`, storeErr);
518
+ return;
519
+ }
520
+ if (!fresh) {
521
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent record gone`);
522
+ return;
523
+ }
524
+ if (fresh.creationToken !== opts.expectCreationToken) {
525
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: creationToken rotated — leaving the session to its successor`);
526
+ return;
527
+ }
528
+ }
529
+ const cfg = this.getAgentConfig(agentId);
530
+ if (!cfg) {
531
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent no longer in config`);
532
+ return;
533
+ }
534
+ const runner = this.createRunnerFor(cfg);
535
+ const outcome = await new TmuxManager(runner).killSessionRef(ref);
536
+ if (outcome === 'stale-server') {
537
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
538
+ }
539
+ else if (outcome === 'killed') {
540
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
541
+ }
542
+ }
543
+ catch (cleanupErr) {
544
+ console.warn(`[AgentManager] created-session rollback (${reason}) failed for ${agentId}:`, cleanupErr);
545
+ }
546
+ });
547
+ }
548
+ async rollbackEnsureSessionFailure(agentId, err, reason) {
549
+ if (!(err instanceof EnsureSessionError) || !err.partial.createdSession)
550
+ return;
551
+ await this.rollbackCreatedSession(agentId, err.partial, reason);
437
552
  }
438
553
  skillDirChain = new Map();
439
554
  skillDirLockKey(agent, workdir) {
@@ -453,19 +568,21 @@ export class AgentManager {
453
568
  });
454
569
  return run;
455
570
  }
456
- async atomicWriteFile(runner, finalPath, content) {
457
- const tmp = `${finalPath}.baxian-tmp`;
458
- await runner.writeFile(tmp, content);
459
- const mv = `mv -f ${shellQuote(tmp)} ${shellQuote(finalPath)}`;
460
- const res = await runner.exec(`sh -c ${shellQuote(mv)}`);
461
- if (res.exitCode !== 0) {
462
- throw new Error(`atomic skill write failed (${finalPath}): ${res.stderr || 'unknown error'}`);
571
+ async atomicWriteFile(runner, workdir, finalPath, content) {
572
+ const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
573
+ try {
574
+ await stageFileGuarded(runner, workdir, tmp, content);
575
+ await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
576
+ }
577
+ catch (err) {
578
+ throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
463
579
  }
464
580
  }
465
581
  async ensureSkillDirSafe(runner, workdir, subdir) {
466
582
  const top = subdir.split('/')[0];
467
583
  const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
468
- const inner = `cd ${shellQuote(workdir)} && ` +
584
+ const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
585
+ `cd ${shellQuote(workdir)} && ` +
469
586
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
470
587
  `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
471
588
  `exit 3; fi; done && ` +
@@ -478,10 +595,12 @@ export class AgentManager {
478
595
  throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
479
596
  }
480
597
  }
481
- async tagSessionSkillsVersion(tmux, agentId) {
598
+ async tagSessionSkillsVersion(tmux, agentId, ref) {
482
599
  if (this.skillRegistry.names().length === 0)
483
600
  return;
484
- await tmux.setOption(agentId, '@baxian-skills-version', this.skillRegistry.contentHash());
601
+ await this.setSessionOptions(tmux, agentId, ref, [
602
+ ['@baxian-skills-version', this.skillRegistry.contentHash()],
603
+ ]);
485
604
  }
486
605
  async replSkillsStale(tmux, agentId) {
487
606
  if (this.skillRegistry.names().length === 0)
@@ -490,7 +609,8 @@ export class AgentManager {
490
609
  return tagged !== this.skillRegistry.contentHash();
491
610
  }
492
611
  async excludeInjectedSkills(runner, workdir, subdir) {
493
- const inner = `cd ${shellQuote(workdir)} && ` +
612
+ const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
613
+ `cd ${shellQuote(workdir)} && ` +
494
614
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
495
615
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
496
616
  `if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
@@ -513,35 +633,46 @@ export class AgentManager {
513
633
  }
514
634
  throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
515
635
  }
516
- async pinRuntimeSessionOptions(tmux, agentId) {
517
- await tmux.setOption(agentId, 'prefix', 'C-b');
518
- await tmux.setOption(agentId, 'prefix2', 'None');
519
- await tmux.setOption(agentId, 'mouse', 'on');
636
+ runtimePinEntries() {
637
+ return [
638
+ ['prefix', 'C-b'],
639
+ ['prefix2', 'None'],
640
+ ['mouse', 'on'],
641
+ ];
520
642
  }
521
- async pinFreshSessionOptions(tmux, agentId) {
522
- await tmux.setOption(agentId, 'window-size', 'latest');
523
- await this.pinRuntimeSessionOptions(tmux, agentId);
643
+ // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
644
+ // every option write goes through the identity-checked by-ref batch.
645
+ async setSessionOptions(tmux, agentId, ref, entries) {
646
+ const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
647
+ if (outcome === 'gone') {
648
+ throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
649
+ }
524
650
  }
525
651
  async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
526
652
  return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
527
653
  }
528
654
  async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
529
655
  let createdSession = false;
656
+ let createdRef;
657
+ const genAtCreate = this.adoptGeneration.get(agentId) ?? 0;
530
658
  const runtime = agentRuntimeKindFor(agent);
531
659
  try {
532
- await tmux.createSession(agentId, sessionDir);
660
+ createdRef = await tmux.createSession(agentId, sessionDir);
533
661
  createdSession = true;
534
- await tmux.setOption(agentId, '@baxian-agent-id', agentId);
535
- await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
536
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
537
- await tmux.setOption(agentId, 'allow-passthrough', 'on');
538
- await tmux.setOption(agentId, 'set-titles', 'on');
539
- await this.pinFreshSessionOptions(tmux, agentId);
540
- await tmux.setOption(agentId, 'status-right', '');
662
+ await this.setSessionOptions(tmux, agentId, createdRef, [
663
+ ['@baxian-agent-id', agentId],
664
+ ['@baxian-runtime', agent.runtime],
665
+ [WORKDIR_SESSION_OPTION, sessionDir],
666
+ ['allow-passthrough', 'on'],
667
+ ['set-titles', 'on'],
668
+ ['window-size', 'latest'],
669
+ ...this.runtimePinEntries(),
670
+ ['status-right', ''],
671
+ ]);
541
672
  await tmux.setServerOption('extended-keys', 'on');
542
673
  await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
543
- const paneId = await tmux.getSinglePaneId(agentId);
544
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(agent)}\n`);
674
+ const paneId = await tmux.getSinglePaneIdByRef(createdRef);
675
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
545
676
  await tmux.handleTrustDialog(paneId, runtime, {
546
677
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
547
678
  });
@@ -549,11 +680,13 @@ export class AgentManager {
549
680
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
550
681
  scrollback: 0,
551
682
  });
552
- await this.tagSessionSkillsVersion(tmux, agentId);
553
- return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
683
+ await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
684
+ return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
554
685
  }
555
686
  catch (err) {
556
- const partial = { createdSession, agentId };
687
+ const partial = { createdSession, agentId, genAtCreate };
688
+ if (createdRef)
689
+ partial.sessionRef = createdRef;
557
690
  if (err instanceof ReplNotReadyError) {
558
691
  partial.lastScreen = err.lastScreen;
559
692
  if (createdSession && detectStartupDialog(err.lastScreen, runtime)) {
@@ -569,22 +702,12 @@ export class AgentManager {
569
702
  console.warn(`[bootstrap] ${agentId} not in config at bootstrap start — aborting`);
570
703
  return;
571
704
  }
572
- const tryKillOrphanSession = async (reason) => {
573
- try {
574
- const runner = this.createRunnerFor(cfgAtStart);
575
- await new TmuxManager(runner).killSession(agentId);
576
- }
577
- catch (cleanupErr) {
578
- console.warn(`[bootstrap] orphan killSession (${reason}) failed for ${agentId}:`, cleanupErr);
579
- }
580
- };
581
705
  try {
582
706
  const result = await this.ensureSession(agentId, 'create');
583
707
  if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
584
708
  const current = await this.agentStore.get(agentId);
585
709
  if (current && current.creationToken !== creationToken) {
586
- console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — killing orphan session`);
587
- await tryKillOrphanSession('greeting-failure token mismatch');
710
+ console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
588
711
  return;
589
712
  }
590
713
  await this.markGreetingFailed(agentId, creationToken);
@@ -605,8 +728,7 @@ export class AgentManager {
605
728
  };
606
729
  });
607
730
  if (!resolvedExisting) {
608
- console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — killing orphan session`);
609
- await tryKillOrphanSession('post-success token mismatch');
731
+ console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — leaving the session to its successor`);
610
732
  return;
611
733
  }
612
734
  await this.safeEmit({
@@ -624,8 +746,7 @@ export class AgentManager {
624
746
  if (err.partial.createdSession) {
625
747
  const fresh = await this.agentStore.get(agentId);
626
748
  if (!fresh || fresh.creationToken !== creationToken) {
627
- console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — killing orphan session`);
628
- await tryKillOrphanSession('dialog-path token mismatch');
749
+ console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — leaving the session to its successor`);
629
750
  return;
630
751
  }
631
752
  }
@@ -636,7 +757,9 @@ export class AgentManager {
636
757
  return;
637
758
  }
638
759
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
639
- await tryKillOrphanSession('hard-failure rollback');
760
+ await this.rollbackCreatedSession(agentId, err.partial, 'hard-failure rollback', {
761
+ expectCreationToken: creationToken,
762
+ });
640
763
  }
641
764
  const message = err instanceof Error ? err.message : String(err);
642
765
  await this.markBootstrapFailed(agentId, creationToken, message);
@@ -1105,27 +1228,39 @@ export class AgentManager {
1105
1228
  signal.addEventListener('abort', onAbort, { once: true });
1106
1229
  });
1107
1230
  }
1231
+ // Claim snapshot and adoption declaration share one critical section with rollbacks.
1108
1232
  async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1109
- let claim;
1233
+ return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
1234
+ }
1235
+ async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
1236
+ let snapshot;
1110
1237
  try {
1111
- claim = await tmux.getOption(agentId, '@baxian-agent-id');
1238
+ snapshot = await tmux.getSessionSnapshot(agentId);
1112
1239
  }
1113
1240
  catch (err) {
1114
- throw new EnsureSessionError({ createdSession: false, agentId }, `getOption(@baxian-agent-id) failed: ${err instanceof Error ? err.message : String(err)}`);
1241
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1115
1242
  }
1116
- if (claim !== agentId) {
1243
+ if (!snapshot) {
1244
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1245
+ }
1246
+ const sessionRef = snapshot.ref;
1247
+ if (snapshot.claim !== agentId) {
1248
+ if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
1249
+ return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1250
+ }
1117
1251
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
1118
- `(got "${claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1252
+ `(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1119
1253
  }
1254
+ this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
1120
1255
  try {
1121
- await this.pinRuntimeSessionOptions(tmux, agentId);
1256
+ await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
1122
1257
  }
1123
1258
  catch (err) {
1124
- throw new EnsureSessionError({ createdSession: false, agentId }, `pinRuntimeSessionOptions failed: ${err instanceof Error ? err.message : String(err)}`);
1259
+ throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
1125
1260
  }
1126
1261
  let paneId;
1127
1262
  try {
1128
- paneId = await tmux.getSinglePaneId(agentId);
1263
+ paneId = await tmux.getSinglePaneIdByRef(sessionRef);
1129
1264
  }
1130
1265
  catch (err) {
1131
1266
  throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1164,10 +1299,10 @@ export class AgentManager {
1164
1299
  else if (state.kind !== 'shell') {
1165
1300
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
1166
1301
  }
1167
- await tmux.killSession(agentId);
1302
+ await this.killAdoptTarget(tmux, agentId, sessionRef, 'workdir change');
1168
1303
  return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1169
1304
  }
1170
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
1305
+ await this.setSessionOptions(tmux, agentId, sessionRef, [[WORKDIR_SESSION_OPTION, sessionDir]]);
1171
1306
  switch (state.kind) {
1172
1307
  case 'live-runtime': {
1173
1308
  let stale;
@@ -1185,9 +1320,10 @@ export class AgentManager {
1185
1320
  skillsStale: true,
1186
1321
  paneId,
1187
1322
  workdir,
1323
+ sessionRef,
1188
1324
  };
1189
1325
  }
1190
- return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
1326
+ return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
1191
1327
  }
1192
1328
  case 'startup-dialog':
1193
1329
  throw new EnsureSessionError({
@@ -1209,8 +1345,8 @@ export class AgentManager {
1209
1345
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1210
1346
  scrollback: 0,
1211
1347
  });
1212
- await this.tagSessionSkillsVersion(tmux, agentId);
1213
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
1348
+ await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1349
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1214
1350
  }
1215
1351
  catch (trustErr) {
1216
1352
  if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
@@ -1228,7 +1364,7 @@ export class AgentManager {
1228
1364
  break;
1229
1365
  }
1230
1366
  try {
1231
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(agent)}\n`);
1367
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
1232
1368
  await tmux.handleTrustDialog(paneId, runtime, {
1233
1369
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1234
1370
  });
@@ -1236,8 +1372,8 @@ export class AgentManager {
1236
1372
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1237
1373
  scrollback: 0,
1238
1374
  });
1239
- await this.tagSessionSkillsVersion(tmux, agentId);
1240
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
1375
+ await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1376
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1241
1377
  }
1242
1378
  catch (relErr) {
1243
1379
  if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
@@ -1251,6 +1387,66 @@ export class AgentManager {
1251
1387
  throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
1252
1388
  }
1253
1389
  }
1390
+ // Frees a bootstrap blocked on its predecessor's half-created session (nonce
1391
+ // env present but claim never written); anything else is left alone.
1392
+ async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
1393
+ let snapshot;
1394
+ try {
1395
+ snapshot = knownSnapshot ?? await tmux.getSessionSnapshot(agentId);
1396
+ }
1397
+ catch (err) {
1398
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1399
+ }
1400
+ if (!snapshot)
1401
+ return true;
1402
+ if (snapshot.claim !== null)
1403
+ return false;
1404
+ let halfCreated;
1405
+ try {
1406
+ halfCreated = await tmux.hasCreationNonce(agentId);
1407
+ }
1408
+ catch (err) {
1409
+ throw new EnsureSessionError({ createdSession: false, agentId }, `creation-nonce probe failed: ${err instanceof Error ? err.message : String(err)}`);
1410
+ }
1411
+ if (!halfCreated)
1412
+ return false;
1413
+ let outcome;
1414
+ try {
1415
+ // claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
1416
+ outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
1417
+ }
1418
+ catch (err) {
1419
+ throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
1420
+ }
1421
+ if (outcome === 'refused' || outcome === 'stale-server') {
1422
+ throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
1423
+ }
1424
+ if (outcome === 'killed') {
1425
+ console.warn(`[AgentManager] ${agentId}: killed half-created leftover session ${snapshot.ref.sessionId} (creation nonce present, claim never written)`);
1426
+ }
1427
+ else {
1428
+ console.warn(`[AgentManager] ${agentId}: half-created leftover session ${snapshot.ref.sessionId} already gone`);
1429
+ }
1430
+ return true;
1431
+ }
1432
+ async killAdoptTarget(tmux, agentId, sessionRef, reason) {
1433
+ let outcome;
1434
+ try {
1435
+ outcome = await tmux.killSessionRef(sessionRef);
1436
+ }
1437
+ catch (err) {
1438
+ throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
1439
+ }
1440
+ if (outcome === 'stale-server') {
1441
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
1442
+ }
1443
+ if (outcome === 'killed') {
1444
+ console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
1445
+ }
1446
+ else {
1447
+ console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1448
+ }
1449
+ }
1254
1450
  async acquireAgentForTask(agentId, taskId, phase) {
1255
1451
  const cfg = this.getAgentConfig(agentId);
1256
1452
  if (!cfg)
@@ -1282,6 +1478,7 @@ export class AgentManager {
1282
1478
  projectId: cfg.projectId,
1283
1479
  taskId,
1284
1480
  lockToken: token,
1481
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1285
1482
  updatedAt: now,
1286
1483
  }));
1287
1484
  return true;
@@ -1345,10 +1542,19 @@ export class AgentManager {
1345
1542
  });
1346
1543
  return true;
1347
1544
  }
1545
+ let sessionConfirmedAbsent = false;
1348
1546
  if (state.workdir) {
1547
+ const releaseWorkdir = state.workdir;
1349
1548
  const runner = this.createRunnerFor(cfg);
1350
1549
  const tmux = new TmuxManager(runner);
1351
1550
  const hold = async (reason) => {
1551
+ const latest = await this.agentStore.get(agentId);
1552
+ if (!latest
1553
+ || latest.taskId !== expectedTaskId
1554
+ || latest.lockToken !== lockToken
1555
+ || !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
1556
+ return false;
1557
+ }
1352
1558
  const pendingAt = new Date().toISOString();
1353
1559
  if (boundTask && cfg.role === 'dev') {
1354
1560
  boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
@@ -1375,32 +1581,40 @@ export class AgentManager {
1375
1581
  return false;
1376
1582
  };
1377
1583
  try {
1378
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1379
- const paneId = await this.resolvePaneId(state, cfg);
1380
- if (paneId) {
1381
- await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1584
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1585
+ const runtime = await this.inspectReleaseRuntime(tmux, agentId);
1586
+ if (runtime.kind === 'hold')
1587
+ return hold(runtime.reason);
1588
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1589
+ if (runtime.kind === 'pane') {
1590
+ if (state.paneId !== runtime.paneId) {
1591
+ await this.agentStore.update(agentId, (latest) => {
1592
+ if (!latest
1593
+ || latest.taskId !== expectedTaskId
1594
+ || latest.lockToken !== lockToken
1595
+ || latest.workdir !== releaseWorkdir) {
1596
+ return AGENT_STORE_NOOP;
1597
+ }
1598
+ return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1599
+ });
1600
+ const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1601
+ if (refreshed.paneId !== runtime.paneId) {
1602
+ throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1603
+ }
1604
+ }
1605
+ await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1382
1606
  }
1383
1607
  else {
1384
- let sessionAlive;
1385
- try {
1386
- sessionAlive = await tmux.hasSession(agentId);
1387
- }
1388
- catch (err) {
1389
- return hold(`Runtime availability probe failed; refusing checkout cleanup: ` +
1390
- `${err instanceof Error ? err.message : String(err)}`);
1391
- }
1392
- if (sessionAlive) {
1393
- return hold('Runtime session still exists but its pane is unavailable; refusing checkout cleanup');
1394
- }
1608
+ sessionConfirmedAbsent = true;
1395
1609
  }
1396
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1610
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1397
1611
  if (cfg.role === 'dev' && boundTask) {
1398
1612
  const branches = new BranchManager(runner);
1399
- const cleanup = await branches.cleanupTaskBranch(state.workdir, {
1613
+ const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
1400
1614
  taskId: boundTask.id,
1401
1615
  taskBranch: boundTask.branch,
1402
1616
  branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
1403
- }, () => this.assertTaskLockOwner(agentId, expectedTaskId, lockToken));
1617
+ }, () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
1404
1618
  let cleanupStateChanged = false;
1405
1619
  if (cleanup.status === 'pending') {
1406
1620
  if (boundTask.branchCleanupPending?.agentId !== agentId
@@ -1443,11 +1657,12 @@ export class AgentManager {
1443
1657
  await this.taskStore.set(boundTask);
1444
1658
  }
1445
1659
  if (cleanup.status !== 'deleted')
1446
- await branches.parkOnDefaultDetached(state.workdir);
1660
+ await branches.parkOnDefaultDetached(releaseWorkdir);
1447
1661
  }
1448
1662
  else {
1449
- await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
1663
+ await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
1450
1664
  }
1665
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1451
1666
  }
1452
1667
  catch (err) {
1453
1668
  const reason = err instanceof DirtyWorkdirError
@@ -1456,17 +1671,25 @@ export class AgentManager {
1456
1671
  return hold(reason);
1457
1672
  }
1458
1673
  }
1459
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1674
+ if (state.workdir) {
1675
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, state.workdir);
1676
+ }
1677
+ else {
1678
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1679
+ }
1460
1680
  await this.agentStore.update(agentId, (existing) => {
1461
1681
  if (!existing)
1462
1682
  return AGENT_STORE_NOOP;
1463
- if (existing.taskId !== expectedTaskId)
1683
+ if (existing.taskId !== expectedTaskId
1684
+ || existing.lockToken !== lockToken
1685
+ || (state.workdir !== undefined && existing.workdir !== state.workdir)) {
1464
1686
  return AGENT_STORE_NOOP;
1687
+ }
1465
1688
  return {
1466
1689
  id: existing.id,
1467
1690
  projectId: existing.projectId,
1468
1691
  ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1469
- ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1692
+ ...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1470
1693
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1471
1694
  updatedAt: now,
1472
1695
  };
@@ -1474,6 +1697,17 @@ export class AgentManager {
1474
1697
  return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1475
1698
  });
1476
1699
  }
1700
+ async releaseAgentIfBound(agentId, expectedTaskId, opts) {
1701
+ const state = await this.agentStore.get(agentId);
1702
+ if (state?.taskId !== expectedTaskId)
1703
+ return true;
1704
+ const released = opts
1705
+ ? await this.releaseAgentForTask(agentId, expectedTaskId, 'idle', opts)
1706
+ : await this.releaseAgentForTask(agentId, expectedTaskId, 'idle');
1707
+ if (released)
1708
+ return true;
1709
+ return (await this.agentStore.get(agentId))?.taskId !== expectedTaskId;
1710
+ }
1477
1711
  async reconcileTaskBranches() {
1478
1712
  const tasks = await this.taskStore.list();
1479
1713
  const terminalByBranch = new Map(tasks
@@ -1882,14 +2116,27 @@ export class AgentManager {
1882
2116
  }
1883
2117
  if (result.redispatchCodeTaskId) {
1884
2118
  try {
1885
- const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
2119
+ const task = await this.taskStore.get(result.redispatchCodeTaskId);
2120
+ const round = task?.specReviewRound;
2121
+ const stored = round === undefined
2122
+ ? null
2123
+ : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
2124
+ if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
2125
+ 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);
2126
+ return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
2127
+ }
2128
+ const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
1886
2129
  if (!resumed) {
1887
- 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);
2130
+ const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
2131
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
2132
+ return { resumed: false, releasedBinding: false, reason };
1888
2133
  }
1889
2134
  }
1890
2135
  catch (err) {
1891
2136
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
1892
- 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);
2137
+ const reason = `Code-phase redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
2138
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
2139
+ return { resumed: false, releasedBinding: false, reason };
1893
2140
  }
1894
2141
  }
1895
2142
  return {
@@ -1909,6 +2156,48 @@ export class AgentManager {
1909
2156
  return null;
1910
2157
  }
1911
2158
  }
2159
+ async inspectReleaseRuntime(tmux, agentId) {
2160
+ let sessionAlive;
2161
+ try {
2162
+ sessionAlive = await tmux.hasSession(agentId);
2163
+ }
2164
+ catch (err) {
2165
+ return {
2166
+ kind: 'hold',
2167
+ reason: `Runtime availability probe failed; refusing checkout cleanup: ` +
2168
+ `${err instanceof Error ? err.message : String(err)}`,
2169
+ };
2170
+ }
2171
+ if (!sessionAlive)
2172
+ return { kind: 'absent' };
2173
+ let claim;
2174
+ try {
2175
+ claim = await tmux.getOption(agentId, '@baxian-agent-id');
2176
+ }
2177
+ catch (err) {
2178
+ return {
2179
+ kind: 'hold',
2180
+ reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
2181
+ `${err instanceof Error ? err.message : String(err)}`,
2182
+ };
2183
+ }
2184
+ if (claim !== agentId) {
2185
+ return {
2186
+ kind: 'hold',
2187
+ reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
2188
+ };
2189
+ }
2190
+ try {
2191
+ return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
2192
+ }
2193
+ catch (err) {
2194
+ return {
2195
+ kind: 'hold',
2196
+ reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
2197
+ `${err instanceof Error ? err.message : String(err)}`,
2198
+ };
2199
+ }
2200
+ }
1912
2201
  async interruptPaneAndWaitReady(state, cfg) {
1913
2202
  const paneId = await this.resolvePaneId(state, cfg);
1914
2203
  if (!paneId)
@@ -2153,15 +2442,14 @@ export class AgentManager {
2153
2442
  throw new Error(`Unknown agent: ${agentId}`);
2154
2443
  const runner = this.createRunnerFor(cfg);
2155
2444
  const tmux = new TmuxManager(runner);
2156
- const alive = await tmux.hasSession(agentId);
2157
- if (!alive) {
2445
+ const snapshot = await tmux.getSessionSnapshot(agentId);
2446
+ if (!snapshot) {
2158
2447
  throw new Error(`restart-repl: tmux session ${agentId} does not exist; use retry to rebuild`);
2159
2448
  }
2160
- const claim = await tmux.getOption(agentId, '@baxian-agent-id');
2161
- if (claim !== agentId) {
2162
- throw new Error(`restart-repl: session claim mismatch (got "${claim ?? 'null'}"); refusing to touch foreign session`);
2449
+ if (snapshot.claim !== agentId) {
2450
+ throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
2163
2451
  }
2164
- const paneId = await tmux.getSinglePaneId(agentId);
2452
+ const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
2165
2453
  await tmux.sendKeysToPane(paneId, 'C-c');
2166
2454
  const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
2167
2455
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
@@ -2183,10 +2471,10 @@ export class AgentManager {
2183
2471
  'use retry to rebuild the session safely');
2184
2472
  }
2185
2473
  await this.provisionRepoSkills(runner, cfg, workdir);
2186
- await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
2474
+ await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
2187
2475
  const runtime = agentRuntimeKindFor(cfg);
2188
2476
  const relaunch = async () => {
2189
- await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
2477
+ await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
2190
2478
  await tmux.handleTrustDialog(paneId, runtime, {
2191
2479
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
2192
2480
  });
@@ -2194,7 +2482,7 @@ export class AgentManager {
2194
2482
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
2195
2483
  scrollback: 0,
2196
2484
  });
2197
- await this.tagSessionSkillsVersion(tmux, agentId);
2485
+ await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
2198
2486
  };
2199
2487
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
2200
2488
  await this.agentStore.update(agentId, (state) => {
@@ -2207,6 +2495,42 @@ export class AgentManager {
2207
2495
  };
2208
2496
  });
2209
2497
  }
2498
+ async redispatchResearchAfterReplRestart(agentId, taskId) {
2499
+ const agent = this.getAgentConfig(agentId);
2500
+ const task = await this.taskStore.get(taskId);
2501
+ if (!agent || agent.role !== 'research' || !task)
2502
+ return false;
2503
+ if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
2504
+ return false;
2505
+ let phase;
2506
+ let expectedKind;
2507
+ let findings;
2508
+ if (task.phase === 'research' && task.status === 'in_progress') {
2509
+ phase = 'research';
2510
+ expectedKind = 'spec-done';
2511
+ }
2512
+ else if (task.phase === 'spec' && task.status === 'fixing') {
2513
+ const round = task.specReviewRound ?? 1;
2514
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2515
+ if (!stored?.findings)
2516
+ return false;
2517
+ phase = 'server-feedback';
2518
+ expectedKind = 'spec-fixed';
2519
+ findings = JSON.stringify(stored.findings);
2520
+ }
2521
+ else {
2522
+ return false;
2523
+ }
2524
+ await this.clearAwaitingHuman(agentId);
2525
+ return this.continueSession(taskId, agentId, phase, {
2526
+ bypassTaskStatusGate: true,
2527
+ signalToken: task.signalToken,
2528
+ preserveDispatchOutputs: true,
2529
+ ...(findings ? { serverPriorFindings: findings } : {}),
2530
+ ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2531
+ armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
2532
+ });
2533
+ }
2210
2534
  prepareRemoveTargets(agentId) {
2211
2535
  const cfg = this.getAgentConfig(agentId);
2212
2536
  if (!cfg)
@@ -2214,15 +2538,10 @@ export class AgentManager {
2214
2538
  const project = this.getProjectConfig(cfg.projectId);
2215
2539
  if (!project)
2216
2540
  throw new Error(`Unknown project: ${cfg.projectId}`);
2217
- if (cfg.role === 'qa')
2541
+ if (cfg.role !== 'dev')
2218
2542
  return { targets: [agentId] };
2219
- for (const pair of project.agent) {
2220
- if (pair[0]?.id === agentId) {
2221
- const qa = pair[1];
2222
- return { targets: qa ? [agentId, qa.id] : [agentId] };
2223
- }
2224
- }
2225
- return { targets: [agentId] };
2543
+ const group = this.findAgentGroup(agentId);
2544
+ return { targets: group?.map(agent => agent.id) ?? [agentId] };
2226
2545
  }
2227
2546
  async cleanupRemovedAgentRuntime(targets) {
2228
2547
  const failures = [];
@@ -2257,17 +2576,23 @@ export class AgentManager {
2257
2576
  }
2258
2577
  }
2259
2578
  try {
2260
- const alive = await tmux.hasSession(id);
2261
- if (alive) {
2262
- const claim = await tmux.getOption(id, '@baxian-agent-id');
2263
- if (claim === id) {
2264
- await tmux.killSession(id);
2265
- }
2266
- else {
2579
+ await this.runUnderSessionLifecycle(id, async () => {
2580
+ const snapshot = await tmux.getSessionSnapshot(id);
2581
+ if (!snapshot)
2582
+ return;
2583
+ if (snapshot.claim !== id) {
2267
2584
  console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
2268
- `(claim=${claim ?? 'null'}; not baxian-managed)`);
2585
+ `(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
2586
+ return;
2269
2587
  }
2270
- }
2588
+ const outcome = await tmux.killSessionRef(snapshot.ref);
2589
+ if (outcome === 'stale-server') {
2590
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
2591
+ }
2592
+ else if (outcome === 'killed') {
2593
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
2594
+ }
2595
+ });
2271
2596
  }
2272
2597
  catch (err) {
2273
2598
  failures.push({ agentId: id, step: 'tmux', error: err });
@@ -2305,6 +2630,12 @@ export class AgentManager {
2305
2630
  }
2306
2631
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
2307
2632
  const now = new Date().toISOString();
2633
+ const isResearch = cfg.role === 'research';
2634
+ const group = this.findAgentGroup(cfg.id);
2635
+ const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
2636
+ if (!devAgentId)
2637
+ throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
2638
+ const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
2308
2639
  const fakeTask = {
2309
2640
  id: 'task-9999999999',
2310
2641
  projectId,
@@ -2312,6 +2643,9 @@ export class AgentManager {
2312
2643
  description: input.description,
2313
2644
  preferredAgentId: input.preferredAgentId,
2314
2645
  agentId: cfg.id,
2646
+ devAgentId,
2647
+ ...(qaAgentId ? { qaAgentId } : {}),
2648
+ ...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
2315
2649
  branch: `${BRANCH_PREFIX}task-9999999999`,
2316
2650
  reviewRound: 0,
2317
2651
  status: 'in_progress',
@@ -2320,11 +2654,12 @@ export class AgentManager {
2320
2654
  };
2321
2655
  const fullPrompt = buildPromptInline({
2322
2656
  task: fakeTask,
2323
- phase: 'develop',
2657
+ phase: isResearch ? 'research' : 'develop',
2324
2658
  agent: cfg,
2325
2659
  workdir: workdirGuess,
2326
2660
  skillRegistry: this.skillRegistry,
2327
2661
  signalToken: 'preview-signal-token',
2662
+ hasQaPartner: qaAgentId !== undefined,
2328
2663
  });
2329
2664
  return Buffer.byteLength(fullPrompt, 'utf8');
2330
2665
  }
@@ -2337,12 +2672,11 @@ export class AgentManager {
2337
2672
  getProjectByRepo(repo) {
2338
2673
  return this.config.project.find(p => repoSlug(p.repo) === repo);
2339
2674
  }
2340
- findQaPartner(devAgentId) {
2675
+ findAgentGroup(anchorAgentId) {
2341
2676
  for (const project of this.config.project) {
2342
- for (const pair of project.agent) {
2343
- if (pair[0]?.id === devAgentId) {
2344
- return pair[1];
2345
- }
2677
+ for (const group of project.agent) {
2678
+ if (group.some(agent => agent.id === anchorAgentId))
2679
+ return group;
2346
2680
  }
2347
2681
  }
2348
2682
  return undefined;
@@ -2441,17 +2775,18 @@ export class AgentManager {
2441
2775
  if (!mapped)
2442
2776
  continue;
2443
2777
  const { expectedKinds, agentId } = mapped;
2444
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
2445
- : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
2446
- : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2447
- : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
2778
+ const specStage = isSpecStagePhase(task.phase);
2779
+ const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
2780
+ : specStage && task.status === 'fixing' ? 'spec-fixed'
2781
+ : !specStage && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2782
+ : !specStage && task.status === 'fixing' ? 'pr-fixed'
2448
2783
  : undefined;
2449
- const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
2784
+ const isServerProtocol = task.reviewMode === 'server' || specStage;
2450
2785
  const scanSnapshotOnRecover = isServerProtocol
2451
2786
  || (task.phase === undefined && task.status === 'in_progress')
2452
- || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
2787
+ || (!specStage && (task.status === 'review' || task.status === 'fixing'));
2453
2788
  const allowRecoveredReadFile = task.status === 'review'
2454
- && (task.phase === 'spec'
2789
+ && (specStage
2455
2790
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2456
2791
  || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
2457
2792
  try {
@@ -2730,8 +3065,8 @@ export class AgentManager {
2730
3065
  if (cfg.projectId !== projectId) {
2731
3066
  throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
2732
3067
  }
2733
- if (cfg.role !== 'dev') {
2734
- throw new ApiError(400, `Agent ${preferredAgentId} is not dev role`);
3068
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
3069
+ throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
2735
3070
  }
2736
3071
  const state = await this.agentStore.get(preferredAgentId);
2737
3072
  if (!canDispatchWithBinding(state))
@@ -2739,6 +3074,22 @@ export class AgentManager {
2739
3074
  const { projectId: _projectId, ...rest } = cfg;
2740
3075
  return rest;
2741
3076
  }
3077
+ async persistQueuedTask(task, queueReason, agentId) {
3078
+ await this.taskStore.set(task);
3079
+ await this.safeEmit({
3080
+ id: '',
3081
+ type: 'task.created',
3082
+ timestamp: task.createdAt,
3083
+ projectId: task.projectId,
3084
+ taskId: task.id,
3085
+ data: {
3086
+ queued: true,
3087
+ queueReason,
3088
+ ...(agentId ? { agentId } : {}),
3089
+ },
3090
+ });
3091
+ return task;
3092
+ }
2742
3093
  async createTask(projectId, input) {
2743
3094
  return this.withTaskLock(async () => {
2744
3095
  const taskId = await this.taskStore.nextId();
@@ -2762,113 +3113,13 @@ export class AgentManager {
2762
3113
  const imageFilenames = input.images?.length
2763
3114
  ? await this.persistTaskImages(taskId, input.images)
2764
3115
  : undefined;
2765
- if (input.preferredAgentId === '') {
2766
- const unassigned = {
2767
- id: taskId,
2768
- projectId,
2769
- title: input.title,
2770
- description: input.description,
2771
- preferredAgentId: '',
2772
- agentId: '',
2773
- reviewRound: 0,
2774
- status: 'pending',
2775
- branch: taskBranch,
2776
- branchCreatedByBaxian,
2777
- reviewMode: this.effectiveReviewMode(projectId),
2778
- createdAt: now,
2779
- updatedAt: now,
2780
- ...(imageFilenames ? { images: imageFilenames } : {}),
2781
- };
2782
- await this.taskStore.set(unassigned);
2783
- await this.safeEmit({
2784
- id: '',
2785
- type: 'task.created',
2786
- timestamp: now,
2787
- projectId,
2788
- taskId,
2789
- data: { queued: true, queueReason: 'unassigned' },
2790
- });
2791
- return unassigned;
2792
- }
2793
- const dev = await this.pickAgent(projectId, input.preferredAgentId);
2794
- const qa = this.findQaPartner(input.preferredAgentId);
2795
- if (!dev) {
2796
- const queued = {
2797
- id: taskId,
2798
- projectId,
2799
- title: input.title,
2800
- description: input.description,
2801
- preferredAgentId: input.preferredAgentId,
2802
- agentId: '',
2803
- reviewRound: 0,
2804
- status: 'pending',
2805
- branch: taskBranch,
2806
- branchCreatedByBaxian,
2807
- reviewMode: this.effectiveReviewMode(projectId),
2808
- createdAt: now,
2809
- updatedAt: now,
2810
- ...(qa ? { qaAgentId: qa.id } : {}),
2811
- ...(imageFilenames ? { images: imageFilenames } : {}),
2812
- };
2813
- await this.taskStore.set(queued);
2814
- await this.safeEmit({
2815
- id: '',
2816
- type: 'task.created',
2817
- timestamp: now,
2818
- projectId,
2819
- taskId,
2820
- data: {
2821
- queued: true,
2822
- queueReason: 'preferred_agent_busy',
2823
- agentId: input.preferredAgentId,
2824
- },
2825
- });
2826
- return queued;
2827
- }
2828
- const lockToken = await this.lockManager.acquire(dev.id, taskId);
2829
- if (!lockToken) {
2830
- const queued = {
2831
- id: taskId,
2832
- projectId,
2833
- title: input.title,
2834
- description: input.description,
2835
- preferredAgentId: input.preferredAgentId,
2836
- agentId: '',
2837
- reviewRound: 0,
2838
- status: 'pending',
2839
- branch: taskBranch,
2840
- branchCreatedByBaxian,
2841
- reviewMode: this.effectiveReviewMode(projectId),
2842
- createdAt: now,
2843
- updatedAt: now,
2844
- ...(qa ? { qaAgentId: qa.id } : {}),
2845
- ...(imageFilenames ? { images: imageFilenames } : {}),
2846
- };
2847
- await this.taskStore.set(queued);
2848
- await this.safeEmit({
2849
- id: '',
2850
- type: 'task.created',
2851
- timestamp: now,
2852
- projectId,
2853
- taskId,
2854
- data: {
2855
- queued: true,
2856
- queueReason: 'agent_locked',
2857
- agentId: input.preferredAgentId,
2858
- },
2859
- });
2860
- return queued;
2861
- }
2862
- const task = {
3116
+ const taskBase = {
2863
3117
  id: taskId,
2864
3118
  projectId,
2865
3119
  title: input.title,
2866
3120
  description: input.description,
2867
3121
  preferredAgentId: input.preferredAgentId,
2868
- agentId: dev.id,
2869
- ...(qa ? { qaAgentId: qa.id } : {}),
2870
3122
  reviewRound: 0,
2871
- status: 'in_progress',
2872
3123
  branch: taskBranch,
2873
3124
  branchCreatedByBaxian,
2874
3125
  reviewMode: this.effectiveReviewMode(projectId),
@@ -2876,9 +3127,52 @@ export class AgentManager {
2876
3127
  updatedAt: now,
2877
3128
  ...(imageFilenames ? { images: imageFilenames } : {}),
2878
3129
  };
3130
+ if (input.preferredAgentId === '') {
3131
+ const unassigned = {
3132
+ ...taskBase,
3133
+ agentId: '',
3134
+ devAgentId: '',
3135
+ status: 'pending',
3136
+ };
3137
+ return this.persistQueuedTask(unassigned, 'unassigned');
3138
+ }
3139
+ const target = await this.pickAgent(projectId, input.preferredAgentId);
3140
+ const targetConfig = this.getAgentConfig(input.preferredAgentId);
3141
+ const group = this.findAgentGroup(input.preferredAgentId);
3142
+ const dev = group?.find(agent => agent.role === 'dev');
3143
+ if (!dev)
3144
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
3145
+ const qa = group?.find(agent => agent.role === 'qa');
3146
+ const research = targetConfig.role === 'research' ? targetConfig : undefined;
3147
+ const researchFields = research
3148
+ ? { researchAgentId: research.id, phase: 'research' }
3149
+ : {};
3150
+ const queued = {
3151
+ ...taskBase,
3152
+ agentId: '',
3153
+ devAgentId: dev.id,
3154
+ ...researchFields,
3155
+ status: 'pending',
3156
+ ...(qa ? { qaAgentId: qa.id } : {}),
3157
+ };
3158
+ if (!target) {
3159
+ return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
3160
+ }
3161
+ const lockToken = await this.lockManager.acquire(target.id, taskId);
3162
+ if (!lockToken) {
3163
+ return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
3164
+ }
3165
+ const task = {
3166
+ ...taskBase,
3167
+ agentId: target.id,
3168
+ devAgentId: dev.id,
3169
+ ...(qa ? { qaAgentId: qa.id } : {}),
3170
+ ...researchFields,
3171
+ status: 'in_progress',
3172
+ };
2879
3173
  await this.taskStore.set(task);
2880
- await this.agentStore.update(dev.id, (existing) => ({
2881
- id: dev.id,
3174
+ await this.agentStore.update(target.id, (existing) => ({
3175
+ id: target.id,
2882
3176
  projectId,
2883
3177
  taskId,
2884
3178
  lockToken,
@@ -2893,9 +3187,9 @@ export class AgentManager {
2893
3187
  type: 'task.assigned',
2894
3188
  timestamp: now,
2895
3189
  projectId,
2896
- agentId: dev.id,
3190
+ agentId: target.id,
2897
3191
  taskId,
2898
- data: { agentId: dev.id },
3192
+ data: { agentId: target.id },
2899
3193
  });
2900
3194
  return task;
2901
3195
  });
@@ -2921,10 +3215,15 @@ export class AgentManager {
2921
3215
  return task;
2922
3216
  }
2923
3217
  async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
3218
+ const initialTask = await this.taskStore.get(taskId);
3219
+ if (!initialTask)
3220
+ return null;
3221
+ const initialDispatch = this.resolveInitialDispatch(initialTask);
3222
+ const dispatchPhase = initialDispatch.phase;
2924
3223
  let started = false;
2925
3224
  let dispatchErr = null;
2926
3225
  try {
2927
- started = await this.startSession(taskId, agentId, 'develop');
3226
+ started = await this.startSession(taskId, agentId, dispatchPhase);
2928
3227
  }
2929
3228
  catch (err) {
2930
3229
  dispatchErr = err;
@@ -2954,7 +3253,7 @@ export class AgentManager {
2954
3253
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
2955
3254
  return null;
2956
3255
  }
2957
- const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
3256
+ const initialKinds = this.resolveInitialDispatch(fresh).kinds;
2958
3257
  try {
2959
3258
  await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
2960
3259
  }
@@ -2968,7 +3267,7 @@ export class AgentManager {
2968
3267
  return null;
2969
3268
  }
2970
3269
  if (dispatchErr instanceof DispatchTerminalError) {
2971
- await this.failTaskForDispatchError(taskId, 'develop', agentId, dispatchErr);
3270
+ await this.failTaskForDispatchError(taskId, dispatchPhase, agentId, dispatchErr);
2972
3271
  }
2973
3272
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
2974
3273
  }
@@ -3187,8 +3486,8 @@ export class AgentManager {
3187
3486
  if (cfg.projectId !== fresh.projectId) {
3188
3487
  return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
3189
3488
  }
3190
- if (cfg.role !== 'dev') {
3191
- return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev role` };
3489
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
3490
+ return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
3192
3491
  }
3193
3492
  if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
3194
3493
  return {
@@ -3206,14 +3505,29 @@ export class AgentManager {
3206
3505
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
3207
3506
  }
3208
3507
  const now = new Date().toISOString();
3209
- const qaId = fresh.qaAgentId ?? this.findQaPartner(agentId)?.id;
3508
+ const initiallyUnassigned = fresh.preferredAgentId === '';
3509
+ const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
3510
+ const devId = initiallyUnassigned
3511
+ ? group?.find(agent => agent.role === 'dev')?.id
3512
+ : fresh.devAgentId;
3513
+ if (!devId) {
3514
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
3515
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
3516
+ }
3517
+ const qaId = initiallyUnassigned
3518
+ ? group?.find(agent => agent.role === 'qa')?.id
3519
+ : fresh.qaAgentId;
3520
+ const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
3210
3521
  const claimedTask = {
3211
3522
  ...fresh,
3212
3523
  preferredAgentId: agentId,
3213
3524
  agentId,
3525
+ devAgentId: devId,
3526
+ researchAgentId: researchId,
3527
+ qaAgentId: qaId,
3528
+ phase: researchId ? 'research' : undefined,
3214
3529
  status: 'in_progress',
3215
3530
  updatedAt: now,
3216
- ...(qaId ? { qaAgentId: qaId } : {}),
3217
3531
  };
3218
3532
  await this.taskStore.set(claimedTask);
3219
3533
  await this.agentStore.update(agentId, (existing) => ({
@@ -3247,20 +3561,21 @@ export class AgentManager {
3247
3561
  await this.updateTask(claimed.id, { signalToken });
3248
3562
  let started = false;
3249
3563
  let dispatchErr = null;
3564
+ const initialDispatch = this.resolveInitialDispatch(claimed);
3250
3565
  try {
3251
- started = await this.startSession(claimed.id, claimed.agentId, 'develop');
3566
+ started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
3252
3567
  }
3253
3568
  catch (err) {
3254
3569
  dispatchErr = err;
3255
3570
  console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
3256
3571
  }
3257
3572
  if (started) {
3258
- await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
3573
+ await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, initialDispatch.kinds, signalToken);
3259
3574
  const refreshed = await this.taskStore.get(claimed.id);
3260
3575
  return { task: refreshed ?? claimed };
3261
3576
  }
3262
3577
  if (dispatchErr instanceof DispatchTerminalError) {
3263
- await this.failTaskForDispatchError(claimed.id, 'develop', claimed.agentId, dispatchErr);
3578
+ await this.failTaskForDispatchError(claimed.id, initialDispatch.phase, claimed.agentId, dispatchErr);
3264
3579
  }
3265
3580
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
3266
3581
  }
@@ -3391,18 +3706,12 @@ export class AgentManager {
3391
3706
  const stillOwner = stateNow?.taskId === taskId
3392
3707
  && stateNow.lockToken === lockToken
3393
3708
  && await this.lockManager.isOwner(agentId, taskId, lockToken);
3394
- try {
3395
- if (stillOwner) {
3396
- const runner = this.createRunnerFor(agent);
3397
- await new TmuxManager(runner).killSession(agentId);
3398
- }
3399
- else {
3400
- console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
3401
- `${agentId}/${taskId}`);
3402
- }
3709
+ if (stillOwner) {
3710
+ await this.rollbackCreatedSession(agentId, err.partial, 'startSession ensure rollback');
3403
3711
  }
3404
- catch (cleanupErr) {
3405
- console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
3712
+ else {
3713
+ console.warn(`[AgentManager] startSession ensureSession rollback skipped for stale owner ` +
3714
+ `${agentId}/${taskId}`);
3406
3715
  }
3407
3716
  }
3408
3717
  throw err;
@@ -3437,6 +3746,9 @@ export class AgentManager {
3437
3746
  throw new Error(`Task ${taskId} has no review branch`);
3438
3747
  await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
3439
3748
  }
3749
+ else if (phase === 'research') {
3750
+ await branchManager.switchToDefaultDetached(workdir);
3751
+ }
3440
3752
  else {
3441
3753
  if (!task.branch)
3442
3754
  throw new Error(`Task ${taskId} has no task branch`);
@@ -3446,7 +3758,7 @@ export class AgentManager {
3446
3758
  if (!ensure.freshRuntime) {
3447
3759
  await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
3448
3760
  if (ensure.skillsStale)
3449
- await this.tagSessionSkillsVersion(tmux, agentId);
3761
+ await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
3450
3762
  }
3451
3763
  }
3452
3764
  catch (err) {
@@ -3499,16 +3811,20 @@ export class AgentManager {
3499
3811
  });
3500
3812
  const promptSignalToken = opts.signalToken ?? task.signalToken;
3501
3813
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3502
- const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
3814
+ const hasQaPartner = task.qaAgentId !== undefined;
3503
3815
  let prompt;
3504
3816
  try {
3505
- await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
3817
+ await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
3818
+ specDocuments: opts.specDocuments,
3819
+ preserveOutputs: false,
3820
+ assertOwner,
3821
+ });
3506
3822
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3507
3823
  let payloadOpts = {};
3508
3824
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3509
3825
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
3510
3826
  phase,
3511
- ...(task.phase ? { taskPhase: task.phase } : {}),
3827
+ taskPhase: task.phase,
3512
3828
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
3513
3829
  reviewRound: task.reviewRound,
3514
3830
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
@@ -3642,7 +3958,7 @@ export class AgentManager {
3642
3958
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
3643
3959
  if (!isAckUnknown) {
3644
3960
  await cleanupCheckoutOrHold();
3645
- if (agentMarkedRunning) {
3961
+ if (agentMarkedRunning && !opts.preserveBindingOnFailure) {
3646
3962
  try {
3647
3963
  let released = false;
3648
3964
  let releaseToken;
@@ -3859,18 +4175,12 @@ export class AgentManager {
3859
4175
  const stillOwner = stateNow?.taskId === taskId
3860
4176
  && stateNow.lockToken === lockToken
3861
4177
  && await this.lockManager.isOwner(agentId, taskId, lockToken);
3862
- try {
3863
- if (stillOwner) {
3864
- const runner = this.createRunnerFor(agent);
3865
- await new TmuxManager(runner).killSession(agentId);
3866
- }
3867
- else {
3868
- console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
3869
- `${agentId}/${taskId}`);
3870
- }
4178
+ if (stillOwner) {
4179
+ await this.rollbackCreatedSession(agentId, err.partial, 'continueSession ensure rollback');
3871
4180
  }
3872
- catch (cleanupErr) {
3873
- console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
4181
+ else {
4182
+ console.warn(`[AgentManager] continueSession ensureSession rollback skipped for stale owner ` +
4183
+ `${agentId}/${taskId}`);
3874
4184
  }
3875
4185
  }
3876
4186
  throw err;
@@ -3884,6 +4194,16 @@ export class AgentManager {
3884
4194
  return false;
3885
4195
  }
3886
4196
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
4197
+ const verifiedWorkdir = stateAfterEnsure.workdir;
4198
+ if (!verifiedWorkdir
4199
+ || verifiedWorkdir !== ensure.workdir
4200
+ || verifiedWorkdir !== taskWorkdir) {
4201
+ const reason = `Workdir changed during continueSession: ` +
4202
+ `before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
4203
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4204
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4205
+ return false;
4206
+ }
3887
4207
  const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3888
4208
  const taskAfterEnsure = await this.taskStore.get(taskId);
3889
4209
  if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
@@ -3895,28 +4215,39 @@ export class AgentManager {
3895
4215
  `expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
3896
4216
  return false;
3897
4217
  }
3898
- if (agentState.workdir && agent.role === 'dev' && task.branch) {
4218
+ if (agent.role === 'dev' && task.branch) {
3899
4219
  const branches = new BranchManager(runner);
3900
- await branches.assertClean(taskWorkdir);
3901
- const actualRef = await branches.currentRef(taskWorkdir);
4220
+ await branches.assertClean(verifiedWorkdir);
4221
+ const actualRef = await branches.currentRef(verifiedWorkdir);
3902
4222
  if (actualRef !== `refs/heads/${task.branch}`) {
3903
4223
  throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
3904
4224
  `expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
3905
4225
  }
3906
4226
  }
4227
+ else if (agent.role === 'research') {
4228
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4229
+ await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
4230
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4231
+ }
3907
4232
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3908
4233
  let prompt;
3909
4234
  try {
3910
- await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
4235
+ await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
4236
+ specDocuments: opts.specDocuments,
4237
+ preserveOutputs: opts.preserveDispatchOutputs ?? false,
4238
+ assertOwner: async () => {
4239
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4240
+ },
4241
+ });
3911
4242
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3912
4243
  && opts.postApproveRedispatchCount > 0
3913
4244
  && !ensure.freshRuntime;
3914
4245
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3915
4246
  let payloadOpts = {};
3916
4247
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3917
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
4248
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
3918
4249
  phase,
3919
- ...(task.phase ? { taskPhase: task.phase } : {}),
4250
+ taskPhase: task.phase,
3920
4251
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
3921
4252
  reviewRound: task.reviewRound,
3922
4253
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
@@ -3931,7 +4262,7 @@ export class AgentManager {
3931
4262
  task,
3932
4263
  phase,
3933
4264
  agent,
3934
- workdir: taskWorkdir,
4265
+ workdir: verifiedWorkdir,
3935
4266
  skillRegistry: this.skillRegistry,
3936
4267
  ...(signalToken ? { signalToken } : {}),
3937
4268
  ...(useIncrementalNudge
@@ -3980,6 +4311,13 @@ export class AgentManager {
3980
4311
  console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
3981
4312
  return false;
3982
4313
  }
4314
+ if (agentFresh.workdir !== verifiedWorkdir) {
4315
+ const reason = `Workdir changed before prompt injection: ` +
4316
+ `verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
4317
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4318
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4319
+ return false;
4320
+ }
3983
4321
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
3984
4322
  if (phase === 'post-approve') {
3985
4323
  const completionFresh = await this.getPostApproveCompletion(taskId);
@@ -3993,18 +4331,35 @@ export class AgentManager {
3993
4331
  }
3994
4332
  const now = new Date().toISOString();
3995
4333
  await this.agentStore.update(agentId, (latest) => {
3996
- if (!latest)
4334
+ if (!latest
4335
+ || latest.taskId !== taskId
4336
+ || latest.lockToken !== lockToken
4337
+ || latest.workdir !== verifiedWorkdir) {
3997
4338
  return AGENT_STORE_NOOP;
4339
+ }
3998
4340
  // The continuation prompt supersedes any question asked under the previous one.
3999
4341
  const { needInputAt: _needInputAt, ...rest } = latest;
4000
4342
  return {
4001
4343
  ...rest,
4002
4344
  paneId,
4003
- workdir: taskWorkdir,
4345
+ workdir: verifiedWorkdir,
4004
4346
  lockToken,
4005
4347
  updatedAt: now,
4006
4348
  };
4007
4349
  });
4350
+ const stateBeforeInject = await this.agentStore.get(agentId);
4351
+ if (stateBeforeInject?.taskId !== taskId || stateBeforeInject.lockToken !== lockToken) {
4352
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership changed before prompt injection; skipping`);
4353
+ return false;
4354
+ }
4355
+ if (stateBeforeInject.workdir !== verifiedWorkdir) {
4356
+ const reason = `Workdir changed before prompt injection: ` +
4357
+ `verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
4358
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4359
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4360
+ return false;
4361
+ }
4362
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
4008
4363
  await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
4009
4364
  return true;
4010
4365
  }
@@ -4015,13 +4370,47 @@ export class AgentManager {
4015
4370
  return false;
4016
4371
  }
4017
4372
  const boundTask = await this.taskStore.get(state.taskId);
4018
- if (!boundTask || boundTask.phase || boundTask.status !== 'in_progress' || boundTask.agentId !== state.id) {
4373
+ if (!boundTask)
4374
+ return false;
4375
+ if (boundTask.status === 'spec-ready' && isSpecStagePhase(boundTask.phase)) {
4376
+ if (state.id === boundTask.devAgentId && state.id !== boundTask.agentId) {
4377
+ const lockToken = state.lockToken;
4378
+ await this.agentStore.update(state.id, (latest) => {
4379
+ if (!latest
4380
+ || latest.taskId !== state.taskId
4381
+ || latest.bootstrappingTaskId !== state.taskId
4382
+ || latest.lockToken !== lockToken) {
4383
+ return AGENT_STORE_NOOP;
4384
+ }
4385
+ return {
4386
+ id: latest.id,
4387
+ projectId: latest.projectId,
4388
+ ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
4389
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
4390
+ ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
4391
+ updatedAt: new Date().toISOString(),
4392
+ };
4393
+ });
4394
+ if (lockToken)
4395
+ await this.lockManager.releaseIfOwner(state.id, state.taskId, lockToken);
4396
+ return true;
4397
+ }
4398
+ await this.clearBootstrapMarker(state.id, state.taskId);
4019
4399
  return false;
4020
4400
  }
4021
- if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt)) {
4401
+ if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
4402
+ return false;
4403
+ const dispatchPhase = boundTask.phase === 'research'
4404
+ ? 'research'
4405
+ : boundTask.specReviewRound !== undefined ? 'code' : 'develop';
4406
+ if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
4022
4407
  await this.clearBootstrapMarker(state.id, state.taskId);
4023
4408
  return false;
4024
4409
  }
4410
+ if (dispatchPhase === 'code') {
4411
+ 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 });
4412
+ return true;
4413
+ }
4025
4414
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
4026
4415
  `(prompt never ack'd); rolling the task back to pending`);
4027
4416
  await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
@@ -4033,12 +4422,15 @@ export class AgentManager {
4033
4422
  });
4034
4423
  return true;
4035
4424
  }
4036
- async bootstrapPromptWasDelivered(taskId, createdAtIso) {
4425
+ async bootstrapPromptWasDelivered(taskId, createdAtIso, agentId, phase) {
4037
4426
  const today = new Date().toISOString().slice(0, 10);
4038
4427
  const from = createdAtIso.slice(0, 10);
4039
4428
  try {
4040
4429
  const events = await this.eventBus.readRange(from, today);
4041
- return events.some((e) => e.type === 'session.started' && e.taskId === taskId);
4430
+ return events.some((event) => event.type === 'session.started'
4431
+ && event.taskId === taskId
4432
+ && event.agentId === agentId
4433
+ && event.data.phase === phase);
4042
4434
  }
4043
4435
  catch (err) {
4044
4436
  console.warn(`[recover] bootstrapPromptWasDelivered read failed for task=${taskId}:`, err);
@@ -4222,13 +4614,7 @@ export class AgentManager {
4222
4614
  continue;
4223
4615
  }
4224
4616
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
4225
- try {
4226
- const runner = this.createRunnerFor(agentConfig);
4227
- await new TmuxManager(runner).killSession(state.id);
4228
- }
4229
- catch (cleanupErr) {
4230
- console.warn(`[recover] killSession rollback failed for agent=${state.id}:`, cleanupErr);
4231
- }
4617
+ await this.rollbackCreatedSession(state.id, err.partial, 'recover ensure rollback');
4232
4618
  }
4233
4619
  const message = err instanceof Error ? err.message : String(err);
4234
4620
  console.warn(`[recover] ensureSession failed for agent=${state.id}: ${message}`);
@@ -4493,8 +4879,8 @@ export class AgentManager {
4493
4879
  if (cfg.projectId !== projectId) {
4494
4880
  throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
4495
4881
  }
4496
- if (cfg.role !== 'dev') {
4497
- throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev role`);
4882
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
4883
+ throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
4498
4884
  }
4499
4885
  }
4500
4886
  let previewBytes;
@@ -4529,24 +4915,23 @@ export class AgentManager {
4529
4915
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
4530
4916
  throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
4531
4917
  }
4532
- if (task.reviewMode === 'server' || task.phase === 'spec') {
4918
+ if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
4533
4919
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
4534
4920
  throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
4535
4921
  }
4536
4922
  if (!this.serverReviewDriver) {
4537
4923
  throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
4538
4924
  }
4539
- // in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
4540
4925
  if (task.status === 'in_progress' && task.phase === undefined) {
4541
4926
  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`);
4542
4927
  }
4543
4928
  // 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
4544
- const qaAvailable = (task.qaAgentId !== undefined && !!this.getAgentConfig(task.qaAgentId))
4545
- || !!this.findQaPartner(task.agentId);
4929
+ const qaAvailable = task.qaAgentId !== undefined
4930
+ && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
4546
4931
  if (!qaAvailable) {
4547
4932
  throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
4548
4933
  }
4549
- const isSpec = task.phase === 'spec';
4934
+ const isSpec = isSpecStagePhase(task.phase);
4550
4935
  const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
4551
4936
  const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
4552
4937
  if (round + 1 > cap) {
@@ -4561,18 +4946,12 @@ export class AgentManager {
4561
4946
  if (!task.branch) {
4562
4947
  throw new ApiError(400, `Task ${taskId} has no branch; cannot dispatch review`);
4563
4948
  }
4564
- let qaId = task.qaAgentId;
4565
- if (qaId && !this.getAgentConfig(qaId)) {
4566
- console.warn(`[dispatchReviewToQa] task ${taskId}.qaAgentId="${qaId}" no longer in config; ` +
4567
- `falling back to findQaPartner(${task.agentId})`);
4568
- qaId = undefined;
4569
- }
4949
+ const qaId = task.qaAgentId;
4570
4950
  if (!qaId) {
4571
- const qa = this.findQaPartner(task.agentId);
4572
- if (!qa) {
4573
- throw new ApiError(400, `Dev ${task.agentId} has no QA partner configured; cannot dispatch review`);
4574
- }
4575
- qaId = qa.id;
4951
+ throw new ApiError(400, `Task ${taskId} has no QA participant; cannot dispatch review`);
4952
+ }
4953
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
4954
+ throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
4576
4955
  }
4577
4956
  this.manualReviewInFlight.add(taskId);
4578
4957
  return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
@@ -4658,7 +5037,6 @@ export class AgentManager {
4658
5037
  await this.taskStore.set({
4659
5038
  ...fresh,
4660
5039
  reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
4661
- qaAgentId: qaId,
4662
5040
  signalToken: armedToken,
4663
5041
  updatedAt: new Date().toISOString(),
4664
5042
  });
@@ -4741,7 +5119,7 @@ export class AgentManager {
4741
5119
  if (task.status !== 'max_rounds') {
4742
5120
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4743
5121
  }
4744
- if (task.phase === 'spec') {
5122
+ if (isSpecStagePhase(task.phase)) {
4745
5123
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
4746
5124
  }
4747
5125
  if (task.reviewMode === 'server') {
@@ -4937,31 +5315,40 @@ export class AgentManager {
4937
5315
  ? ['spec-done', 'code-done']
4938
5316
  : ['spec-done', 'pr-created'];
4939
5317
  }
5318
+ resolveInitialDispatch(task) {
5319
+ if (task.phase === 'research')
5320
+ return { phase: 'research', kinds: ['spec-done'] };
5321
+ return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
5322
+ }
4940
5323
  mapTaskStateToExpectedWatcher(task) {
4941
5324
  if (task.reviewMode === 'server')
4942
5325
  return this.mapServerTaskToExpectedWatcher(task);
4943
- if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
4944
- return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
4945
- }
4946
- if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
4947
- return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
4948
- }
4949
- if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
4950
- return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
4951
- }
4952
- if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
4953
- return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
5326
+ const specStage = isSpecStagePhase(task.phase);
5327
+ if (task.status === 'review' && task.qaAgentId) {
5328
+ return {
5329
+ expectedKinds: specStage ? ['spec-reviewed'] : ['pr-approved', 'pr-changes-requested'],
5330
+ agentId: task.qaAgentId,
5331
+ };
4954
5332
  }
4955
- if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
4956
- return { expectedKinds: ['pr-created'], agentId: task.agentId };
5333
+ if (task.status === 'fixing' && task.agentId) {
5334
+ return {
5335
+ expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
5336
+ agentId: task.agentId,
5337
+ };
4957
5338
  }
4958
- if (task.phase !== 'spec' && task.status === 'review' && task.qaAgentId) {
4959
- return { expectedKinds: ['pr-approved', 'pr-changes-requested'], agentId: task.qaAgentId };
5339
+ if (task.status === 'in_progress' && task.agentId) {
5340
+ if (task.phase === 'research')
5341
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5342
+ if (task.phase === 'code')
5343
+ return { expectedKinds: ['pr-created'], agentId: task.agentId };
5344
+ if (task.phase === undefined) {
5345
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
5346
+ }
4960
5347
  }
4961
5348
  return undefined;
4962
5349
  }
4963
5350
  mapServerTaskToExpectedWatcher(task) {
4964
- const isSpec = task.phase === 'spec';
5351
+ const isSpec = isSpecStagePhase(task.phase);
4965
5352
  if (task.status === 'review' && task.qaAgentId) {
4966
5353
  return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
4967
5354
  }
@@ -4969,8 +5356,11 @@ export class AgentManager {
4969
5356
  return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
4970
5357
  }
4971
5358
  if (task.status === 'in_progress' && task.agentId) {
4972
- if (task.phase === 'code')
5359
+ if (task.phase === 'research')
5360
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5361
+ if (task.phase === 'code') {
4973
5362
  return { expectedKinds: ['code-done'], agentId: task.agentId };
5363
+ }
4974
5364
  return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
4975
5365
  }
4976
5366
  if (task.status === 'approved' && task.agentId) {
@@ -5002,7 +5392,7 @@ export class AgentManager {
5002
5392
  if (!t)
5003
5393
  throw new ApiError(404, 'Task not found');
5004
5394
  const retryable = TERMINAL_STATUSES.includes(t.status)
5005
- || (t.status === 'max_rounds' && t.phase === 'spec');
5395
+ || (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
5006
5396
  if (!retryable) {
5007
5397
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
5008
5398
  }
@@ -5037,7 +5427,11 @@ export class AgentManager {
5037
5427
  if (patch.preferredAgentId !== undefined && patch.preferredAgentId !== task.preferredAgentId) {
5038
5428
  if (patch.preferredAgentId === '') {
5039
5429
  task.preferredAgentId = '';
5430
+ task.agentId = '';
5431
+ task.devAgentId = '';
5432
+ delete task.phase;
5040
5433
  delete task.qaAgentId;
5434
+ delete task.researchAgentId;
5041
5435
  }
5042
5436
  else {
5043
5437
  const cfg = this.getAgentConfig(patch.preferredAgentId);
@@ -5046,10 +5440,23 @@ export class AgentManager {
5046
5440
  if (cfg.projectId !== task.projectId) {
5047
5441
  throw new ApiError(400, `Agent not in project ${task.projectId}`);
5048
5442
  }
5049
- if (cfg.role !== 'dev')
5050
- throw new ApiError(400, `Agent is not dev role`);
5443
+ if (!TASK_OWNER_ROLES.has(cfg.role))
5444
+ throw new ApiError(400, `Agent is not dev or research role`);
5445
+ const group = this.findAgentGroup(cfg.id);
5446
+ const dev = group?.find(agent => agent.role === 'dev');
5447
+ if (!dev)
5448
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
5051
5449
  task.preferredAgentId = patch.preferredAgentId;
5052
- const qaId = this.findQaPartner(patch.preferredAgentId)?.id;
5450
+ task.devAgentId = dev.id;
5451
+ if (cfg.role === 'research') {
5452
+ task.phase = 'research';
5453
+ task.researchAgentId = cfg.id;
5454
+ }
5455
+ else {
5456
+ delete task.phase;
5457
+ delete task.researchAgentId;
5458
+ }
5459
+ const qaId = group?.find(agent => agent.role === 'qa')?.id;
5053
5460
  if (qaId) {
5054
5461
  task.qaAgentId = qaId;
5055
5462
  }
@@ -5095,7 +5502,7 @@ export class AgentManager {
5095
5502
  if (!serverApprovedRetry && task.status !== 'max_rounds') {
5096
5503
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
5097
5504
  }
5098
- if (task.phase === 'spec') {
5505
+ if (isSpecStagePhase(task.phase)) {
5099
5506
  throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
5100
5507
  }
5101
5508
  if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
@@ -5515,7 +5922,6 @@ export class AgentManager {
5515
5922
  signalToken: restore.signalToken,
5516
5923
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
5517
5924
  reviewDispatchedAt: restore.reviewDispatchedAt,
5518
- qaAgentId: undefined,
5519
5925
  updatedAt: new Date().toISOString(),
5520
5926
  });
5521
5927
  return true;
@@ -5543,11 +5949,8 @@ export class AgentManager {
5543
5949
  const task = await this.taskStore.get(taskId);
5544
5950
  if (!task)
5545
5951
  return null;
5546
- const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] },
5547
- // QA 在停驻期解绑,残留 qaAgentId 会让后续 approve/打回误发释放;复审经 findQaPartner 回绑
5548
- {
5952
+ const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
5549
5953
  phase: 'spec',
5550
- qaAgentId: undefined,
5551
5954
  ...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
5552
5955
  });
5553
5956
  if (!transition)
@@ -5580,6 +5983,9 @@ export class AgentManager {
5580
5983
  if (fresh.status !== 'spec-ready') {
5581
5984
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
5582
5985
  }
5986
+ if (verdict === 'archive' && !fresh.researchAgentId) {
5987
+ throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
5988
+ }
5583
5989
  if (this.specVerdictInFlight.has(taskId)) {
5584
5990
  throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
5585
5991
  }
@@ -5601,13 +6007,45 @@ export class AgentManager {
5601
6007
  const round = task.specReviewRound ?? 1;
5602
6008
  const at = new Date().toISOString();
5603
6009
  const roundData = await store.getRound(taskId, 'spec', round);
5604
- // 停驻路径必然已存轮次;缺失时补一条空轮次,保证 userDecision 留痕与 fix 闭环
5605
- const base = roundData ?? { round, phase: 'spec', content: '', startedAt: at };
6010
+ if (!roundData || roundData.phase !== 'spec') {
6011
+ throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
6012
+ }
6013
+ const base = roundData;
6014
+ if (verdict === 'archive') {
6015
+ await store.putRound(taskId, 'spec', {
6016
+ ...base,
6017
+ userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
6018
+ });
6019
+ const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
6020
+ if (!transition)
6021
+ throw new ApiError(409, `Task ${taskId} changed while archiving`);
6022
+ this.stopPhaseSignalWatcher(taskId);
6023
+ const participants = new Set([
6024
+ task.agentId,
6025
+ task.devAgentId,
6026
+ task.qaAgentId,
6027
+ task.researchAgentId,
6028
+ ].filter((id) => typeof id === 'string' && id !== ''));
6029
+ for (const agentId of participants) {
6030
+ const state = await this.agentStore.get(agentId);
6031
+ if (state?.taskId !== taskId)
6032
+ continue;
6033
+ const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
6034
+ if (!released) {
6035
+ await this.emitIntervention(task.projectId, agentId, taskId, {
6036
+ phase: 'spec-archive-agent-release-failed',
6037
+ agentId,
6038
+ });
6039
+ }
6040
+ }
6041
+ return (await this.taskStore.get(taskId));
6042
+ }
5606
6043
  if (verdict === 'approve') {
5607
6044
  await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
5608
6045
  const result = await this.transitionToCodePhase(taskId);
5609
- if (!result)
5610
- throw new ApiError(500, `Failed to dispatch code phase for task ${taskId}`);
6046
+ if (!result) {
6047
+ throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
6048
+ }
5611
6049
  return result;
5612
6050
  }
5613
6051
  // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
@@ -5647,7 +6085,7 @@ export class AgentManager {
5647
6085
  if (fresh.status !== 'ready') {
5648
6086
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
5649
6087
  }
5650
- if (fresh.phase === 'spec') {
6088
+ if (isSpecStagePhase(fresh.phase)) {
5651
6089
  throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
5652
6090
  }
5653
6091
  if (fresh.reviewMode !== 'server') {
@@ -5740,40 +6178,82 @@ export class AgentManager {
5740
6178
  return empty;
5741
6179
  }
5742
6180
  }
6181
+ isResearchHandoff(task, devAgentId) {
6182
+ return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
6183
+ }
6184
+ dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
6185
+ const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
6186
+ const dispatchOpts = {
6187
+ signalToken,
6188
+ specDocuments,
6189
+ ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
6190
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
6191
+ };
6192
+ return this.isResearchHandoff(task, devAgentId)
6193
+ ? this.startSession(task.id, devAgentId, 'code', {
6194
+ ...dispatchOpts,
6195
+ preserveBindingOnFailure: true,
6196
+ })
6197
+ : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
6198
+ }
5743
6199
  async transitionToCodePhase(taskId) {
5744
6200
  const task = await this.taskStore.get(taskId);
5745
6201
  if (!task)
5746
6202
  return null;
5747
- const devAgentId = task.agentId;
5748
- if (!devAgentId)
5749
- return null;
5750
- const newToken = createSignalToken();
5751
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { phase: 'code', signalToken: newToken });
5752
- if (!transition)
6203
+ const devAgentId = task.devAgentId;
6204
+ const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
6205
+ if (!devAgentId || dev?.role !== 'dev') {
6206
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6207
+ phase: 'code-dev-missing',
6208
+ devAgentId,
6209
+ });
5753
6210
  return null;
5754
- this.stopPhaseSignalWatcher(taskId);
5755
- const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
5756
- const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
5757
- if (!codeArmed && task.reviewMode === 'server') {
5758
- await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
6211
+ }
6212
+ const round = task.specReviewRound ?? 1;
6213
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
6214
+ if (!stored || stored.phase !== 'spec') {
6215
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6216
+ phase: 'code-spec-round-missing',
6217
+ round,
6218
+ });
5759
6219
  return null;
5760
6220
  }
5761
- if (task.qaAgentId) {
5762
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
6221
+ const researchAgentId = task.researchAgentId;
6222
+ const researchHandoff = this.isResearchHandoff(task, devAgentId);
6223
+ const releaseParticipant = async (agentId, failurePhase) => {
6224
+ const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
5763
6225
  .catch(() => false);
5764
6226
  if (!released) {
5765
- await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId });
6227
+ await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
5766
6228
  }
6229
+ return released;
6230
+ };
6231
+ if (researchHandoff && researchAgentId) {
6232
+ if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
6233
+ return null;
6234
+ }
6235
+ if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
6236
+ return null;
5767
6237
  }
5768
6238
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5769
6239
  if (!acquired) {
5770
- 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);
5771
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-dev-acquire-failed', devAgentId });
6240
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6241
+ phase: 'code-dev-acquire-failed',
6242
+ devAgentId,
6243
+ });
5772
6244
  return null;
5773
6245
  }
5774
- let resumed = false;
6246
+ const newToken = createSignalToken();
6247
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
6248
+ if (!transition) {
6249
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
6250
+ .catch(() => undefined);
6251
+ return null;
6252
+ }
6253
+ this.stopPhaseSignalWatcher(taskId);
6254
+ let started = false;
5775
6255
  try {
5776
- resumed = await this.continueSession(taskId, devAgentId, 'code');
6256
+ started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
5777
6257
  }
5778
6258
  catch (err) {
5779
6259
  if (err instanceof DispatchTerminalError) {
@@ -5782,12 +6262,15 @@ export class AgentManager {
5782
6262
  else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5783
6263
  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);
5784
6264
  }
5785
- console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
6265
+ console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
5786
6266
  throw err;
5787
6267
  }
5788
- if (!resumed) {
6268
+ if (!started) {
5789
6269
  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);
5790
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-resume-failed', devAgentId });
6270
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6271
+ phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
6272
+ devAgentId,
6273
+ });
5791
6274
  return null;
5792
6275
  }
5793
6276
  return await this.taskStore.get(taskId);
@@ -5804,13 +6287,7 @@ export class AgentManager {
5804
6287
  if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5805
6288
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5806
6289
  }
5807
- let recordedQaId = task.qaAgentId;
5808
- if (recordedQaId && !this.getAgentConfig(recordedQaId)) {
5809
- console.warn(`[dispatchServerReviewToQa] task ${taskId}.qaAgentId="${recordedQaId}" no longer in config; ` +
5810
- `falling back to findQaPartner(${task.agentId})`);
5811
- recordedQaId = undefined;
5812
- }
5813
- const qaId = recordedQaId ?? this.findQaPartner(task.agentId)?.id;
6290
+ const qaId = task.qaAgentId;
5814
6291
  if (!qaId) {
5815
6292
  const entryKind = task.status === 'fixing'
5816
6293
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -5819,6 +6296,17 @@ export class AgentManager {
5819
6296
  await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
5820
6297
  return null;
5821
6298
  }
6299
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
6300
+ const entryKind = task.status === 'fixing'
6301
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
6302
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
6303
+ await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
6304
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6305
+ phase: 'server-review-qa-unavailable',
6306
+ qaAgentId: qaId,
6307
+ });
6308
+ return null;
6309
+ }
5822
6310
  const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
5823
6311
  return {
5824
6312
  qaId,
@@ -5892,7 +6380,6 @@ export class AgentManager {
5892
6380
  : { reviewRound: newRound };
5893
6381
  const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
5894
6382
  signalToken: newToken,
5895
- qaAgentId: qaId,
5896
6383
  reviewDispatchedAt: new Date().toISOString(),
5897
6384
  ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
5898
6385
  ...(opts.batch
@@ -5993,7 +6480,7 @@ export class AgentManager {
5993
6480
  const task = await this.taskStore.get(taskId);
5994
6481
  if (!task)
5995
6482
  throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5996
- if (task.reviewMode !== 'server' && task.phase !== 'spec') {
6483
+ if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
5997
6484
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5998
6485
  }
5999
6486
  if (!task.agentId)
@@ -6003,7 +6490,7 @@ export class AgentManager {
6003
6490
  qaAgentId: task.qaAgentId,
6004
6491
  projectId: task.projectId,
6005
6492
  newToken: createSignalToken(),
6006
- taskPhase: (task.phase ?? 'code'),
6493
+ taskPhase: task.phase,
6007
6494
  currentSpecRound: task.specReviewRound,
6008
6495
  originalStatus: task.status,
6009
6496
  originalToken: task.signalToken,
@@ -6029,7 +6516,7 @@ export class AgentManager {
6029
6516
  return null;
6030
6517
  }
6031
6518
  if (qaAgentId) {
6032
- const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
6519
+ const released = await this.releaseAgentIfBound(qaAgentId, taskId)
6033
6520
  .catch(() => false);
6034
6521
  if (!released) {
6035
6522
  await rearmReviewedSignal();