baxian 1.2.59 → 1.2.60

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 (103) hide show
  1. package/dist/agent/branch.d.ts.map +1 -1
  2. package/dist/agent/branch.js +19 -13
  3. package/dist/agent/branch.js.map +1 -1
  4. package/dist/agent/dispatch-reconciler.d.ts +45 -0
  5. package/dist/agent/dispatch-reconciler.d.ts.map +1 -0
  6. package/dist/agent/dispatch-reconciler.js +513 -0
  7. package/dist/agent/dispatch-reconciler.js.map +1 -0
  8. package/dist/agent/manager.d.ts +89 -8
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +975 -368
  11. package/dist/agent/manager.js.map +1 -1
  12. package/dist/agent/pane-streamer-manager.d.ts +70 -0
  13. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  14. package/dist/agent/pane-streamer-manager.js +487 -5
  15. package/dist/agent/pane-streamer-manager.js.map +1 -1
  16. package/dist/agent/pane-streamer.d.ts +48 -1
  17. package/dist/agent/pane-streamer.d.ts.map +1 -1
  18. package/dist/agent/pane-streamer.js +338 -15
  19. package/dist/agent/pane-streamer.js.map +1 -1
  20. package/dist/agent/preflight.d.ts.map +1 -1
  21. package/dist/agent/preflight.js +18 -8
  22. package/dist/agent/preflight.js.map +1 -1
  23. package/dist/agent/repo-store.d.ts +3 -0
  24. package/dist/agent/repo-store.d.ts.map +1 -1
  25. package/dist/agent/repo-store.js +192 -67
  26. package/dist/agent/repo-store.js.map +1 -1
  27. package/dist/agent/review-transport.d.ts.map +1 -1
  28. package/dist/agent/review-transport.js +4 -2
  29. package/dist/agent/review-transport.js.map +1 -1
  30. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  31. package/dist/agent/tmux-probe-poller.js +29 -20
  32. package/dist/agent/tmux-probe-poller.js.map +1 -1
  33. package/dist/agent/tmux.d.ts +79 -32
  34. package/dist/agent/tmux.d.ts.map +1 -1
  35. package/dist/agent/tmux.js +435 -225
  36. package/dist/agent/tmux.js.map +1 -1
  37. package/dist/api/config.d.ts.map +1 -1
  38. package/dist/api/config.js +10 -0
  39. package/dist/api/config.js.map +1 -1
  40. package/dist/api/projects.d.ts.map +1 -1
  41. package/dist/api/projects.js +186 -94
  42. package/dist/api/projects.js.map +1 -1
  43. package/dist/api/tasks.d.ts.map +1 -1
  44. package/dist/api/tasks.js +5 -1
  45. package/dist/api/tasks.js.map +1 -1
  46. package/dist/app.d.ts +2 -0
  47. package/dist/app.d.ts.map +1 -1
  48. package/dist/app.js +1 -0
  49. package/dist/app.js.map +1 -1
  50. package/dist/cli.d.ts.map +1 -1
  51. package/dist/cli.js +11 -3
  52. package/dist/cli.js.map +1 -1
  53. package/dist/config/hot-reload.d.ts.map +1 -1
  54. package/dist/config/hot-reload.js +1 -0
  55. package/dist/config/hot-reload.js.map +1 -1
  56. package/dist/config/loader.d.ts.map +1 -1
  57. package/dist/config/loader.js +48 -3
  58. package/dist/config/loader.js.map +1 -1
  59. package/dist/config/validator.js +3 -0
  60. package/dist/config/validator.js.map +1 -1
  61. package/dist/errors.d.ts +2 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +3 -1
  64. package/dist/errors.js.map +1 -1
  65. package/dist/event/handlers.d.ts.map +1 -1
  66. package/dist/event/handlers.js +138 -41
  67. package/dist/event/handlers.js.map +1 -1
  68. package/dist/github/pr-conversation-cache.d.ts +41 -0
  69. package/dist/github/pr-conversation-cache.d.ts.map +1 -0
  70. package/dist/github/pr-conversation-cache.js +105 -0
  71. package/dist/github/pr-conversation-cache.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +14 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/shared/constants.d.ts +6 -0
  76. package/dist/shared/constants.d.ts.map +1 -1
  77. package/dist/shared/constants.js +6 -0
  78. package/dist/shared/constants.js.map +1 -1
  79. package/dist/shared/types.d.ts +4 -0
  80. package/dist/shared/types.d.ts.map +1 -1
  81. package/dist/shared/types.js.map +1 -1
  82. package/dist/state/agent-store.d.ts +5 -1
  83. package/dist/state/agent-store.d.ts.map +1 -1
  84. package/dist/state/agent-store.js +29 -15
  85. package/dist/state/agent-store.js.map +1 -1
  86. package/dist/state/lock.d.ts +9 -0
  87. package/dist/state/lock.d.ts.map +1 -1
  88. package/dist/state/lock.js +24 -1
  89. package/dist/state/lock.js.map +1 -1
  90. package/dist/state/task-store.js +2 -2
  91. package/dist/state/task-store.js.map +1 -1
  92. package/dist/terminal/attach.d.ts +10 -1
  93. package/dist/terminal/attach.d.ts.map +1 -1
  94. package/dist/terminal/attach.js +40 -20
  95. package/dist/terminal/attach.js.map +1 -1
  96. package/dist/terminal/stream-ws.d.ts.map +1 -1
  97. package/dist/terminal/stream-ws.js +27 -0
  98. package/dist/terminal/stream-ws.js.map +1 -1
  99. package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
  100. package/dist/web/assets/index-DtnDvjOU.js +16 -0
  101. package/dist/web/index.html +2 -2
  102. package/package.json +1 -1
  103. package/dist/web/assets/index-DskVWe7e.js +0 -13
@@ -17,9 +17,9 @@ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey,
17
17
  import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
18
18
  import { findForeignTaskTip } from './lineage.js';
19
19
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
20
- import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
20
+ import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
21
21
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
22
- import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
22
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
23
23
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
24
24
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
25
25
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
@@ -147,6 +147,9 @@ export function skillSubdirFor(runtime) {
147
147
  }
148
148
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
149
149
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
150
+ // DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
151
+ // probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
152
+ const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
150
153
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
151
154
  const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
152
155
  // 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
@@ -252,6 +255,9 @@ export class AgentManager {
252
255
  manualCompactWaitMs = 5_000;
253
256
  runtimeLivenessProbeMs = 700;
254
257
  cleanComposerWaitMs = 5_000;
258
+ readyStableSamples = 3;
259
+ readyStableSpacingMs = 1_000;
260
+ pendingDispatchRetryByTask = new Map();
255
261
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
256
262
  manualReviewInFlight = new Set();
257
263
  markCompleteInFlight = new Set();
@@ -260,6 +266,9 @@ export class AgentManager {
260
266
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
261
267
  cancelCleanupInFlight = new Map();
262
268
  deletionInFlight = new Set();
269
+ // Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
270
+ divergedAgents = new Set();
271
+ deletionGeneration = new Map();
263
272
  compactInFlight = new Set();
264
273
  constructor(deps) {
265
274
  const config = prepareConfig(deps.config);
@@ -455,14 +464,62 @@ export class AgentManager {
455
464
  this.deletionInFlight.add(id);
456
465
  return null;
457
466
  }
467
+ // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
468
+ async scanActiveThenClaimDeletion(targets) {
469
+ return this.withTaskLock(async () => {
470
+ for (const id of targets) {
471
+ const state = await this.agentStore.get(id);
472
+ const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
473
+ if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
474
+ return { ok: false, code: 'active', agentId: id, taskId: task.id };
475
+ }
476
+ if (state?.status === 'awaiting_human')
477
+ continue;
478
+ if (state?.creationToken)
479
+ return { ok: false, code: 'bootstrapping', agentId: id };
480
+ }
481
+ const targetSet = new Set(targets);
482
+ const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
483
+ && ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
484
+ || (!!t.agentId && targetSet.has(t.agentId))
485
+ || (!!t.devAgentId && targetSet.has(t.devAgentId))
486
+ || (!!t.qaAgentId && targetSet.has(t.qaAgentId))
487
+ || (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
488
+ if (referencing)
489
+ return { ok: false, code: 'referencing', taskId: referencing.id };
490
+ const conflict = this.tryClaimDeletion(targets);
491
+ if (conflict)
492
+ return { ok: false, code: 'already-deleting', agentId: conflict };
493
+ return { ok: true };
494
+ });
495
+ }
458
496
  releaseDeletionClaim(agentIds) {
459
497
  for (const id of agentIds)
460
498
  this.deletionInFlight.delete(id);
461
499
  }
500
+ // A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
501
+ bumpDeletionGeneration(agentId) {
502
+ this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
503
+ }
504
+ deletionGenerationOf(agentId) {
505
+ return this.deletionGeneration.get(agentId) ?? 0;
506
+ }
507
+ // Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
508
+ deletionGateOpen(agentId, genAtEntry) {
509
+ return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
510
+ }
462
511
  isDeletionInFlight(agentId) {
463
- return this.deletionInFlight.has(agentId);
512
+ return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
513
+ }
514
+ // replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
515
+ retainTombstoneForDivergence(agentId) {
516
+ this.divergedAgents.add(agentId);
464
517
  }
465
- async ensureSession(agentId, mode) {
518
+ async ensureSession(agentId, mode, opts = {}) {
519
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
520
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
521
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
522
+ }
466
523
  const agent = this.getAgentConfig(agentId);
467
524
  if (!agent) {
468
525
  throw new EnsureSessionError({ createdSession: false, agentId }, `Unknown agent: ${agentId}`);
@@ -473,48 +530,67 @@ export class AgentManager {
473
530
  }
474
531
  const runner = this.createRunnerFor(agent);
475
532
  const tmux = new TmuxManager(runner);
533
+ // Side-effects run inside the lifecycle section (serialized with DELETE cleanup) behind a re-checked generation gate; the entry gate is only a fast-fail.
534
+ return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
535
+ }
536
+ async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
537
+ const assertGenerationOpen = () => {
538
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
539
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
540
+ }
541
+ };
542
+ assertGenerationOpen();
476
543
  let workdir;
477
544
  try {
478
545
  const resolved = await this.ensureWorkdir(agent, project, runner);
479
546
  workdir = resolved.workdir;
480
- await this.agentStore.update(agentId, (existing) => {
481
- if (!existing)
482
- return AGENT_STORE_NOOP;
483
- return {
484
- ...existing,
485
- workdir,
486
- updatedAt: new Date().toISOString(),
487
- };
488
- });
489
547
  }
490
548
  catch (err) {
491
549
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
492
550
  }
551
+ // Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
552
+ assertGenerationOpen();
553
+ await this.agentStore.update(agentId, (existing) => {
554
+ if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
555
+ return AGENT_STORE_NOOP;
556
+ return { ...existing, workdir, updatedAt: new Date().toISOString() };
557
+ });
558
+ assertGenerationOpen();
493
559
  try {
494
560
  await this.provisionRepoSkills(runner, agent, workdir);
495
561
  }
496
562
  catch (err) {
497
563
  throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
498
564
  }
499
- let alive;
565
+ assertGenerationOpen();
566
+ let snapshot;
500
567
  try {
501
- alive = await tmux.hasSession(agentId);
568
+ snapshot = await tmux.getSessionSnapshot(agentId);
502
569
  }
503
570
  catch (err) {
504
571
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
505
572
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
506
573
  }
507
- if (mode === 'create' && alive) {
508
- const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
509
- if (!reclaimed) {
510
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
511
- `it creates itself — kill it manually or pick a different agent id`);
574
+ // Re-gate after the probe await: a DELETE may have tombstoned mid-probe, and build/adopt would else create a session cleanup can only reap later.
575
+ assertGenerationOpen();
576
+ if (!snapshot) {
577
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
578
+ }
579
+ if (snapshot.claim === null) {
580
+ if (await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
581
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
512
582
  }
583
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
584
+ `it creates itself — kill it manually or pick a different agent id`);
513
585
  }
514
- if (alive && (mode === 'runtime' || mode === 'recover')) {
515
- return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
586
+ if (mode === 'create' || snapshot.claim !== agentId) {
587
+ throw new EnsureSessionError({ createdSession: false, agentId }, mode === 'create'
588
+ ? `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
589
+ `it creates itself — kill it manually or pick a different agent id`
590
+ : `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
591
+ `(got "${snapshot.claim}"); baxian will not adopt foreign session — operator must intervene`);
516
592
  }
517
- return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
593
+ return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
518
594
  }
519
595
  async provisionRepoSkills(runner, agent, workdir) {
520
596
  if (this.skillRegistry.names().length === 0)
@@ -583,9 +659,10 @@ export class AgentManager {
583
659
  return;
584
660
  }
585
661
  const runner = this.createRunnerFor(cfg);
586
- const outcome = await new TmuxManager(runner).killSessionRef(ref);
587
- if (outcome === 'stale-server') {
588
- console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
662
+ // Rollback may run before or after the claim write, so accept empty-or-ours.
663
+ const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
664
+ if (outcome === 'refused') {
665
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
589
666
  }
590
667
  else if (outcome === 'killed') {
591
668
  console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
@@ -632,7 +709,9 @@ export class AgentManager {
632
709
  async ensureSkillDirSafe(runner, workdir, subdir) {
633
710
  const top = subdir.split('/')[0];
634
711
  const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
635
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
712
+ const inner =
713
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
714
+ `${canonicalSelfGuard(workdir)} && ` +
636
715
  `cd ${shellQuote(workdir)} && ` +
637
716
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
638
717
  `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
@@ -653,14 +732,16 @@ export class AgentManager {
653
732
  ['@baxian-skills-version', this.skillRegistry.contentHash()],
654
733
  ]);
655
734
  }
656
- async replSkillsStale(tmux, agentId) {
735
+ async replSkillsStale(tmux, agentId, ref) {
657
736
  if (this.skillRegistry.names().length === 0)
658
737
  return false;
659
- const tagged = await tmux.getOption(agentId, '@baxian-skills-version');
738
+ const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
660
739
  return tagged !== this.skillRegistry.contentHash();
661
740
  }
662
741
  async excludeInjectedSkills(runner, workdir, subdir) {
663
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
742
+ const inner =
743
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
744
+ `${canonicalSelfGuard(workdir)} && ` +
664
745
  `cd ${shellQuote(workdir)} && ` +
665
746
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
666
747
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
@@ -693,12 +774,26 @@ export class AgentManager {
693
774
  }
694
775
  // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
695
776
  // every option write goes through the identity-checked by-ref batch.
696
- async setSessionOptions(tmux, agentId, ref, entries) {
697
- const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
777
+ async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
778
+ const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
698
779
  if (outcome === 'gone') {
699
780
  throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
700
781
  }
701
782
  }
783
+ // Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
784
+ async resolveClaimedPane(tmux, agentId, expectedPaneId) {
785
+ const snapshot = await tmux.getSessionSnapshot(agentId);
786
+ if (!snapshot)
787
+ throw new PaneGoneError(agentId, 'no tmux session');
788
+ if (snapshot.claim !== agentId) {
789
+ throw new PaneGoneError(agentId, `session claim mismatch (got "${snapshot.claim ?? 'null'}")`);
790
+ }
791
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
792
+ if (expectedPaneId !== undefined && pane.paneId !== expectedPaneId) {
793
+ throw new PaneGoneError(agentId, `pane id changed (state ${expectedPaneId} != live ${pane.paneId})`);
794
+ }
795
+ return pane;
796
+ }
702
797
  async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
703
798
  return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
704
799
  }
@@ -710,6 +805,7 @@ export class AgentManager {
710
805
  try {
711
806
  createdRef = await tmux.createSession(agentId, sessionDir);
712
807
  createdSession = true;
808
+ // First batch writes the claim itself, so it must prove the session is still unclaimed.
713
809
  await this.setSessionOptions(tmux, agentId, createdRef, [
714
810
  ['@baxian-agent-id', agentId],
715
811
  ['@baxian-runtime', agent.runtime],
@@ -719,20 +815,21 @@ export class AgentManager {
719
815
  ['window-size', 'latest'],
720
816
  ...this.runtimePinEntries(),
721
817
  ['status-right', ''],
722
- ]);
818
+ ], '');
723
819
  await tmux.setServerOption('extended-keys', 'on');
724
820
  await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
725
- const paneId = await tmux.getSinglePaneIdByRef(createdRef);
726
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
727
- await tmux.handleTrustDialog(paneId, runtime, {
821
+ const pane = await tmux.getSinglePaneByRef(createdRef, agentId);
822
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
823
+ await tmux.sendEnter(pane);
824
+ await tmux.handleTrustDialog(pane, runtime, {
728
825
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
729
826
  });
730
- await tmux.waitReplReady(paneId, runtime, {
827
+ await tmux.waitReplReady(pane, runtime, {
731
828
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
732
829
  scrollback: 0,
733
830
  });
734
831
  await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
735
- return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
832
+ return { ok: true, createdSession: true, freshRuntime: true, paneId: pane.paneId, pane, workdir, sessionRef: createdRef };
736
833
  }
737
834
  catch (err) {
738
835
  const partial = { createdSession, agentId, genAtCreate };
@@ -755,7 +852,7 @@ export class AgentManager {
755
852
  }
756
853
  try {
757
854
  const result = await this.ensureSession(agentId, 'create');
758
- if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
855
+ if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.pane))) {
759
856
  const current = await this.agentStore.get(agentId);
760
857
  if (current && current.creationToken !== creationToken) {
761
858
  console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
@@ -816,7 +913,7 @@ export class AgentManager {
816
913
  await this.markBootstrapFailed(agentId, creationToken, message);
817
914
  }
818
915
  }
819
- async runGreetingHandshake(agentId, agent, paneId) {
916
+ async runGreetingHandshake(agentId, agent, pane) {
820
917
  const watcher = this.phaseSignalWatcher;
821
918
  if (!watcher)
822
919
  return true;
@@ -824,7 +921,7 @@ export class AgentManager {
824
921
  for (let attempt = 1; attempt <= this.greetingMaxAttempts; attempt++) {
825
922
  const token = createSignalToken();
826
923
  try {
827
- await this.injectAndAwaitAckSteps(tmux, paneId, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
924
+ await this.injectAndAwaitAckSteps(tmux, pane, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
828
925
  }
829
926
  catch (err) {
830
927
  console.warn(`[bootstrap] greeting inject failed for ${agentId} (attempt ${attempt}):`, err);
@@ -843,7 +940,7 @@ export class AgentManager {
843
940
  console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
844
941
  if (outcome === 'no-agent')
845
942
  break;
846
- if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, paneId, agentId))) {
943
+ if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
847
944
  break;
848
945
  }
849
946
  }
@@ -885,21 +982,25 @@ export class AgentManager {
885
982
  const agent = this.getAgentConfig(agentId);
886
983
  if (!agent)
887
984
  return false;
985
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
986
+ const genAtEntry = this.deletionGenerationOf(agentId);
888
987
  const state = await this.agentStore.get(agentId);
889
988
  if (state?.awaitingPhase !== 'greeting_failed')
890
989
  return false;
891
990
  const guardSince = state.awaitingSince;
892
- let paneId = state.paneId;
893
- if (!paneId) {
894
- try {
895
- paneId = await new TmuxManager(this.createRunnerFor(agent)).getSinglePaneId(agentId);
896
- }
897
- catch (err) {
898
- console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
899
- return true;
900
- }
991
+ if (!this.deletionGateOpen(agentId, genAtEntry))
992
+ return false;
993
+ let pane;
994
+ try {
995
+ pane = await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(agent)), agentId, state.paneId);
901
996
  }
902
- if (!(await this.runGreetingHandshake(agentId, agent, paneId))) {
997
+ catch (err) {
998
+ console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
999
+ return true;
1000
+ }
1001
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1002
+ return false;
1003
+ if (!(await this.runGreetingHandshake(agentId, agent, pane))) {
903
1004
  return true;
904
1005
  }
905
1006
  await this.agentStore.update(agentId, (fresh) => {
@@ -936,8 +1037,7 @@ export class AgentManager {
936
1037
  let paneId = existing.paneId;
937
1038
  if (cfg && !paneId) {
938
1039
  try {
939
- const runner = this.createRunnerFor(cfg);
940
- paneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1040
+ paneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
941
1041
  }
942
1042
  catch {
943
1043
  }
@@ -1014,8 +1114,10 @@ export class AgentManager {
1014
1114
  if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
1015
1115
  return false;
1016
1116
  }
1117
+ // The error belongs to the caller's incarnation; gate every side-effect so a DELETE→recreate reusing %0 can't write the stale dialog/fail onto the new agent.
1118
+ const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
1017
1119
  let state = await this.agentStore.get(agentId);
1018
- if (!state)
1120
+ if (!state || !gateOpen())
1019
1121
  return false;
1020
1122
  if (state.paneId === undefined && err.partial.createdSession) {
1021
1123
  const cfg = this.getAgentConfig(agentId);
@@ -1023,8 +1125,7 @@ export class AgentManager {
1023
1125
  return false;
1024
1126
  let discoveredPaneId;
1025
1127
  try {
1026
- const runner = this.createRunnerFor(cfg);
1027
- discoveredPaneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1128
+ discoveredPaneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
1028
1129
  }
1029
1130
  catch (probeErr) {
1030
1131
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: tmux probe paneId failed for ${agentId}:`, probeErr);
@@ -1034,7 +1135,7 @@ export class AgentManager {
1034
1135
  return false;
1035
1136
  const probeNow = new Date().toISOString();
1036
1137
  await this.agentStore.update(agentId, (fresh) => {
1037
- if (!fresh)
1138
+ if (!fresh || !gateOpen())
1038
1139
  return AGENT_STORE_NOOP;
1039
1140
  if (fresh.paneId !== undefined)
1040
1141
  return AGENT_STORE_NOOP;
@@ -1048,6 +1149,8 @@ export class AgentManager {
1048
1149
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: ${agentId} has no paneId/taskId snapshot (no generation guard); refusing — caller should rollback`);
1049
1150
  return false;
1050
1151
  }
1152
+ if (!gateOpen())
1153
+ return false;
1051
1154
  await this.markDialogPending(agentId, undefined, {
1052
1155
  runtimePath: true,
1053
1156
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
@@ -1117,15 +1220,16 @@ export class AgentManager {
1117
1220
  return;
1118
1221
  if (generationMismatch(state))
1119
1222
  return;
1120
- let paneId;
1223
+ let pane;
1121
1224
  try {
1122
- paneId = await tmux.getSinglePaneId(agentId);
1225
+ pane = await this.resolveClaimedPane(tmux, agentId);
1123
1226
  }
1124
1227
  catch {
1125
1228
  continue;
1126
1229
  }
1230
+ const paneId = pane.paneId;
1127
1231
  try {
1128
- await tmux.waitReplReady(paneId, runtime, {
1232
+ await tmux.waitReplReady(pane, runtime, {
1129
1233
  timeoutMs: 1_000,
1130
1234
  intervalMs: 200,
1131
1235
  scrollback: 0,
@@ -1143,7 +1247,7 @@ export class AgentManager {
1143
1247
  let projectIdForEmit = '';
1144
1248
  let wrote = false;
1145
1249
  const isBootstrapPath = creationToken !== undefined;
1146
- if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, paneId))) {
1250
+ if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
1147
1251
  await this.markGreetingFailed(agentId, creationToken);
1148
1252
  return;
1149
1253
  }
@@ -1238,7 +1342,8 @@ export class AgentManager {
1238
1342
  continue;
1239
1343
  let stripped;
1240
1344
  try {
1241
- stripped = await tmux.capturePaneById(state.paneId, { ansi: false, scrollback: 0 });
1345
+ const pane = await this.resolveClaimedPane(tmux, agentId, state.paneId);
1346
+ stripped = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
1242
1347
  }
1243
1348
  catch {
1244
1349
  continue;
@@ -1279,29 +1384,9 @@ export class AgentManager {
1279
1384
  signal.addEventListener('abort', onAbort, { once: true });
1280
1385
  });
1281
1386
  }
1282
- // Claim snapshot and adoption declaration share one critical section with rollbacks.
1283
- async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1284
- return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
1285
- }
1286
- async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
1287
- let snapshot;
1288
- try {
1289
- snapshot = await tmux.getSessionSnapshot(agentId);
1290
- }
1291
- catch (err) {
1292
- throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1293
- }
1294
- if (!snapshot) {
1295
- return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1296
- }
1387
+ // Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
1388
+ async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
1297
1389
  const sessionRef = snapshot.ref;
1298
- if (snapshot.claim !== agentId) {
1299
- if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
1300
- return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1301
- }
1302
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
1303
- `(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1304
- }
1305
1390
  this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
1306
1391
  try {
1307
1392
  await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
@@ -1309,24 +1394,25 @@ export class AgentManager {
1309
1394
  catch (err) {
1310
1395
  throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
1311
1396
  }
1312
- let paneId;
1397
+ let pane;
1313
1398
  try {
1314
- paneId = await tmux.getSinglePaneIdByRef(sessionRef);
1399
+ pane = await tmux.getSinglePaneByRef(sessionRef, agentId);
1315
1400
  }
1316
1401
  catch (err) {
1317
1402
  throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
1318
1403
  }
1404
+ const paneId = pane.paneId;
1319
1405
  const runtime = agentRuntimeKindFor(agent);
1320
1406
  let state;
1321
1407
  try {
1322
- state = await tmux.classifyPaneForAdopt(paneId, runtime);
1408
+ state = await tmux.classifyPaneForAdopt(pane, runtime);
1323
1409
  }
1324
1410
  catch (err) {
1325
1411
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1326
1412
  }
1327
1413
  let currentPath;
1328
1414
  try {
1329
- currentPath = await tmux.getPaneCurrentPath(paneId);
1415
+ currentPath = await tmux.getPaneCurrentPath(pane);
1330
1416
  }
1331
1417
  catch (err) {
1332
1418
  throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1341,7 +1427,7 @@ export class AgentManager {
1341
1427
  if (!pathMatches) {
1342
1428
  if (state.kind === 'live-runtime') {
1343
1429
  try {
1344
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
1430
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs);
1345
1431
  }
1346
1432
  catch (err) {
1347
1433
  throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
@@ -1358,7 +1444,7 @@ export class AgentManager {
1358
1444
  case 'live-runtime': {
1359
1445
  let stale;
1360
1446
  try {
1361
- stale = await this.replSkillsStale(tmux, agentId);
1447
+ stale = await this.replSkillsStale(tmux, agentId, sessionRef);
1362
1448
  }
1363
1449
  catch (err) {
1364
1450
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1370,11 +1456,12 @@ export class AgentManager {
1370
1456
  freshRuntime: false,
1371
1457
  skillsStale: true,
1372
1458
  paneId,
1459
+ pane,
1373
1460
  workdir,
1374
1461
  sessionRef,
1375
1462
  };
1376
1463
  }
1377
- return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
1464
+ return { ok: true, createdSession: false, freshRuntime: false, paneId, pane, workdir, sessionRef };
1378
1465
  }
1379
1466
  case 'startup-dialog':
1380
1467
  throw new EnsureSessionError({
@@ -1389,15 +1476,15 @@ export class AgentManager {
1389
1476
  `must reset the pane manually`);
1390
1477
  case 'trust-dialog':
1391
1478
  try {
1392
- await tmux.handleTrustDialog(paneId, runtime, {
1479
+ await tmux.handleTrustDialog(pane, runtime, {
1393
1480
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1394
1481
  });
1395
- await tmux.waitReplReady(paneId, runtime, {
1482
+ await tmux.waitReplReady(pane, runtime, {
1396
1483
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1397
1484
  scrollback: 0,
1398
1485
  });
1399
1486
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1400
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1487
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1401
1488
  }
1402
1489
  catch (trustErr) {
1403
1490
  if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
@@ -1415,16 +1502,17 @@ export class AgentManager {
1415
1502
  break;
1416
1503
  }
1417
1504
  try {
1418
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
1419
- await tmux.handleTrustDialog(paneId, runtime, {
1505
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
1506
+ await tmux.sendEnter(pane);
1507
+ await tmux.handleTrustDialog(pane, runtime, {
1420
1508
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1421
1509
  });
1422
- await tmux.waitReplReady(paneId, runtime, {
1510
+ await tmux.waitReplReady(pane, runtime, {
1423
1511
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1424
1512
  scrollback: 0,
1425
1513
  });
1426
1514
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1427
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1515
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1428
1516
  }
1429
1517
  catch (relErr) {
1430
1518
  if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
@@ -1464,12 +1552,12 @@ export class AgentManager {
1464
1552
  let outcome;
1465
1553
  try {
1466
1554
  // claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
1467
- outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
1555
+ outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
1468
1556
  }
1469
1557
  catch (err) {
1470
1558
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
1471
1559
  }
1472
- if (outcome === 'refused' || outcome === 'stale-server') {
1560
+ if (outcome === 'refused') {
1473
1561
  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`);
1474
1562
  }
1475
1563
  if (outcome === 'killed') {
@@ -1483,13 +1571,13 @@ export class AgentManager {
1483
1571
  async killAdoptTarget(tmux, agentId, sessionRef, reason) {
1484
1572
  let outcome;
1485
1573
  try {
1486
- outcome = await tmux.killSessionRef(sessionRef);
1574
+ outcome = await tmux.killSessionRef(sessionRef, { kind: 'equals', claim: agentId });
1487
1575
  }
1488
1576
  catch (err) {
1489
1577
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
1490
1578
  }
1491
- if (outcome === 'stale-server') {
1492
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
1579
+ if (outcome === 'refused') {
1580
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux generation/claim changed while replacing session (${reason}); retry to observe the new state`);
1493
1581
  }
1494
1582
  if (outcome === 'killed') {
1495
1583
  console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
@@ -1498,7 +1586,17 @@ export class AgentManager {
1498
1586
  console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1499
1587
  }
1500
1588
  }
1501
- async acquireAgentForTask(agentId, taskId, phase) {
1589
+ // Create-on-null only under this predicate (evaluated inside the store's serialized commit): closes the delete→recreate ABA yet keeps lazy first-allocation for config-only agents.
1590
+ bindingInitPredicate(agentId, genAtEntry) {
1591
+ return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
1592
+ }
1593
+ async acquireAgentForTask(agentId, taskId, phase,
1594
+ // 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
1595
+ // release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
1596
+ opts = {}) {
1597
+ const genAtEntry = this.deletionGenerationOf(agentId);
1598
+ if (this.isDeletionInFlight(agentId))
1599
+ return false;
1502
1600
  const cfg = this.getAgentConfig(agentId);
1503
1601
  if (!cfg)
1504
1602
  throw new Error(`Unknown agent: ${agentId}`);
@@ -1522,16 +1620,45 @@ export class AgentManager {
1522
1620
  const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1523
1621
  if (!token)
1524
1622
  return false;
1623
+ const releaseAcquired = async (originalErr) => {
1624
+ if (reuseLock)
1625
+ return;
1626
+ try {
1627
+ await this.lockManager.releaseIfOwner(agentId, taskId, token);
1628
+ }
1629
+ catch (releaseErr) {
1630
+ console.warn(`[AgentManager] acquireAgentForTask(${agentId}, ${taskId}): lock release after ` +
1631
+ `${originalErr === undefined ? 'refused commit' : 'commit failure'} itself failed:`, releaseErr);
1632
+ }
1633
+ };
1525
1634
  const now = new Date().toISOString();
1526
- await this.agentStore.update(agentId, (existing) => ({
1527
- ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1528
- id: agentId,
1529
- projectId: cfg.projectId,
1530
- taskId,
1531
- lockToken: token,
1532
- ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1533
- updatedAt: now,
1534
- }));
1635
+ let commit;
1636
+ try {
1637
+ commit = await this.agentStore.update(agentId, (existing) => {
1638
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
1639
+ return AGENT_STORE_NOOP;
1640
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1641
+ return AGENT_STORE_NOOP;
1642
+ return {
1643
+ ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1644
+ id: agentId,
1645
+ projectId: cfg.projectId,
1646
+ taskId,
1647
+ lockToken: token,
1648
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1649
+ updatedAt: now,
1650
+ };
1651
+ });
1652
+ }
1653
+ catch (err) {
1654
+ await releaseAcquired(err);
1655
+ throw err;
1656
+ }
1657
+ if (commit !== 'committed') {
1658
+ await releaseAcquired();
1659
+ return false;
1660
+ }
1661
+ opts.onAcquired?.(token);
1535
1662
  return true;
1536
1663
  }
1537
1664
  async releaseAgentForTask(agentId, expectedTaskId, mode, opts = {}) {
@@ -1691,7 +1818,7 @@ export class AgentManager {
1691
1818
  return hold(runtime.reason);
1692
1819
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1693
1820
  if (runtime.kind === 'pane') {
1694
- if (state.paneId !== runtime.paneId) {
1821
+ if (state.paneId !== runtime.pane.paneId) {
1695
1822
  await this.agentStore.update(agentId, (latest) => {
1696
1823
  if (!latest
1697
1824
  || latest.taskId !== expectedTaskId
@@ -1699,14 +1826,14 @@ export class AgentManager {
1699
1826
  || latest.workdir !== releaseWorkdir) {
1700
1827
  return AGENT_STORE_NOOP;
1701
1828
  }
1702
- return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1829
+ return { ...latest, paneId: runtime.pane.paneId, updatedAt: new Date().toISOString() };
1703
1830
  });
1704
1831
  const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1705
- if (refreshed.paneId !== runtime.paneId) {
1832
+ if (refreshed.paneId !== runtime.pane.paneId) {
1706
1833
  throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1707
1834
  }
1708
1835
  }
1709
- await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1836
+ await this.waitForReplPromptReady(tmux, runtime.pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1710
1837
  }
1711
1838
  else {
1712
1839
  sessionConfirmedAbsent = true;
@@ -1784,6 +1911,14 @@ export class AgentManager {
1784
1911
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1785
1912
  }
1786
1913
  catch (err) {
1914
+ // REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
1915
+ // branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
1916
+ // 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
1917
+ if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
1918
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
1919
+ `(the pass can be re-queued): ${err.message}`);
1920
+ return false;
1921
+ }
1787
1922
  const reason = err instanceof DirtyWorkdirError
1788
1923
  ? err.message
1789
1924
  : `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -1909,6 +2044,8 @@ export class AgentManager {
1909
2044
  if (candidateTasks.length === 0)
1910
2045
  continue;
1911
2046
  const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
2047
+ // Pin the incarnation before the first state/workdir/refs snapshot so a DELETE→same-id recreate (possibly a different workdir) in the read→gate window is rejected.
2048
+ const genAtEntry = this.deletionGenerationOf(cfg.id);
1912
2049
  const state = await this.agentStore.get(cfg.id);
1913
2050
  if (!state || !canDispatchWithBinding(state))
1914
2051
  continue;
@@ -1928,11 +2065,15 @@ export class AgentManager {
1928
2065
  }
1929
2066
  if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
1930
2067
  continue;
2068
+ if (this.isDeletionInFlight(cfg.id))
2069
+ continue;
1931
2070
  const token = await this.lockManager.acquire(cfg.id, owner);
1932
2071
  if (!token)
1933
2072
  continue;
1934
2073
  const cleanupResults = [];
1935
2074
  try {
2075
+ if (!this.deletionGateOpen(cfg.id, genAtEntry))
2076
+ continue;
1936
2077
  const lockedState = await this.agentStore.get(cfg.id);
1937
2078
  if (!lockedState || !canDispatchWithBinding(lockedState))
1938
2079
  continue;
@@ -1944,12 +2085,8 @@ export class AgentManager {
1944
2085
  continue;
1945
2086
  const tmux = new TmuxManager(runner);
1946
2087
  if (await tmux.hasSession(cfg.id)) {
1947
- const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
1948
- if (claim !== cfg.id) {
1949
- throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
1950
- }
1951
- const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
1952
- await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
2088
+ const pane = await this.resolveClaimedPane(tmux, cfg.id, lockedState.paneId ?? undefined);
2089
+ await this.waitForReplPromptReady(tmux, pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1953
2090
  }
1954
2091
  if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1955
2092
  continue;
@@ -2398,10 +2535,18 @@ export class AgentManager {
2398
2535
  }
2399
2536
  if (result.redispatchReviewTaskId) {
2400
2537
  try {
2538
+ const reviewTask = await this.taskStore.get(result.redispatchReviewTaskId);
2539
+ const resumeEntry = this.getPendingDispatchRetry(result.redispatchReviewTaskId);
2540
+ const resumeQaPhase = resumeEntry?.kind === 'qa-recheck'
2541
+ && resumeEntry.signalToken === reviewTask?.signalToken
2542
+ && resumeEntry.qaPhase !== undefined
2543
+ ? resumeEntry.qaPhase
2544
+ : (reviewTask?.reviewRound === 0 ? 'review' : undefined);
2401
2545
  await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
2402
- bumpRound: false,
2546
+ bumpRound: reviewTask?.reviewRoundPending === true,
2403
2547
  fromStatus: ['review'],
2404
2548
  ...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
2549
+ ...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
2405
2550
  });
2406
2551
  }
2407
2552
  catch (err) {
@@ -2496,14 +2641,12 @@ export class AgentManager {
2496
2641
  ...(result.reason ? { reason: result.reason } : {}),
2497
2642
  };
2498
2643
  }
2499
- async resolvePaneId(state, cfg) {
2500
- if (state.paneId)
2501
- return state.paneId;
2644
+ async resolveClaimedPaneOrNull(state, cfg) {
2502
2645
  try {
2503
- return await new TmuxManager(this.createRunnerFor(cfg)).getSinglePaneId(cfg.id);
2646
+ return await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), cfg.id, state.paneId);
2504
2647
  }
2505
2648
  catch (err) {
2506
- console.warn(`[AgentManager] resolvePaneId: getSinglePaneId failed for ${cfg.id}:`, err);
2649
+ console.warn(`[AgentManager] resolveClaimedPane failed for ${cfg.id}:`, err);
2507
2650
  return null;
2508
2651
  }
2509
2652
  }
@@ -2521,27 +2664,16 @@ export class AgentManager {
2521
2664
  }
2522
2665
  if (!sessionAlive)
2523
2666
  return { kind: 'absent' };
2524
- let claim;
2525
2667
  try {
2526
- claim = await tmux.getOption(agentId, '@baxian-agent-id');
2527
- }
2528
- catch (err) {
2529
- return {
2530
- kind: 'hold',
2531
- reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
2532
- `${err instanceof Error ? err.message : String(err)}`,
2533
- };
2534
- }
2535
- if (claim !== agentId) {
2536
- return {
2537
- kind: 'hold',
2538
- reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
2539
- };
2540
- }
2541
- try {
2542
- return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
2668
+ return { kind: 'pane', pane: await this.resolveClaimedPane(tmux, agentId) };
2543
2669
  }
2544
2670
  catch (err) {
2671
+ if (err instanceof PaneGoneError) {
2672
+ return {
2673
+ kind: 'hold',
2674
+ reason: `Runtime session claim probe/mismatch; refusing checkout cleanup: ${err.message}`,
2675
+ };
2676
+ }
2545
2677
  return {
2546
2678
  kind: 'hold',
2547
2679
  reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
@@ -2550,9 +2682,10 @@ export class AgentManager {
2550
2682
  }
2551
2683
  }
2552
2684
  async interruptPaneAndWaitReady(state, cfg) {
2553
- const paneId = await this.resolvePaneId(state, cfg);
2554
- if (!paneId)
2685
+ const pane = await this.resolveClaimedPaneOrNull(state, cfg);
2686
+ if (!pane)
2555
2687
  return false;
2688
+ const paneId = pane.paneId;
2556
2689
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
2557
2690
  const runtime = agentRuntimeKindFor(cfg);
2558
2691
  if (!(await this.acquireCompactGuardWithin(cfg.id, this.cancelInterruptGuardWaitMs))) {
@@ -2563,43 +2696,44 @@ export class AgentManager {
2563
2696
  }
2564
2697
  try {
2565
2698
  try {
2566
- await tmux.sendKeysToPane(paneId, 'Escape');
2699
+ await tmux.sendKeysToPane(pane, 'Escape');
2567
2700
  await new Promise(r => setTimeout(r, 200));
2568
2701
  }
2569
2702
  catch (err) {
2570
2703
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
2571
2704
  return false;
2572
2705
  }
2573
- if (await this.paneReachedReplReady(tmux, paneId, runtime, 10_000))
2706
+ if (await this.paneReachedReplReady(tmux, pane, runtime, 10_000))
2574
2707
  return true;
2575
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
2708
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2576
2709
  return false;
2577
- if (await this.paneHasLiveTurn(tmux, paneId, runtime)) {
2710
+ if (await this.paneHasLiveTurn(tmux, pane, runtime)) {
2578
2711
  console.warn(`[AgentManager] interruptPaneAndWaitReady: pane ${paneId} still running a turn after ESC; holding`);
2579
2712
  return false;
2580
2713
  }
2581
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
2714
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2582
2715
  return false;
2583
2716
  try {
2584
- await tmux.sendKeysToPane(paneId, 'C-c');
2717
+ await tmux.sendKeysToPane(pane, 'C-c');
2585
2718
  await new Promise(r => setTimeout(r, 200));
2586
2719
  }
2587
2720
  catch (err) {
2588
2721
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c (composer clear) failed for pane ${paneId}:`, err);
2589
2722
  return false;
2590
2723
  }
2591
- return this.paneReachedReplReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
2724
+ return this.paneReachedReplReady(tmux, pane, runtime, this.cleanComposerWaitMs);
2592
2725
  }
2593
2726
  finally {
2594
2727
  this.compactInFlight.delete(cfg.id);
2595
2728
  }
2596
2729
  }
2597
- async paneHasLiveTurn(tmux, paneId, runtime) {
2730
+ async paneHasLiveTurn(tmux, pane, runtime) {
2731
+ const paneId = pane.paneId;
2598
2732
  let first;
2599
2733
  let firstTitle;
2600
2734
  try {
2601
- first = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2602
- firstTitle = await tmux.readPaneTitle(paneId);
2735
+ first = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
2736
+ firstTitle = await tmux.readPaneTitle(pane);
2603
2737
  }
2604
2738
  catch (err) {
2605
2739
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness capture failed for pane ${paneId}:`, err);
@@ -2610,8 +2744,8 @@ export class AgentManager {
2610
2744
  let frame;
2611
2745
  let title;
2612
2746
  try {
2613
- frame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2614
- title = await tmux.readPaneTitle(paneId);
2747
+ frame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
2748
+ title = await tmux.readPaneTitle(pane);
2615
2749
  }
2616
2750
  catch (err) {
2617
2751
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness re-capture failed for pane ${paneId}:`, err);
@@ -2626,10 +2760,11 @@ export class AgentManager {
2626
2760
  }
2627
2761
  return false;
2628
2762
  }
2629
- async paneRunsRuntime(tmux, paneId, runtime) {
2763
+ async paneRunsRuntime(tmux, pane, runtime) {
2764
+ const paneId = pane.paneId;
2630
2765
  let proc;
2631
2766
  try {
2632
- proc = await tmux.displayMessage(paneId, '#{pane_current_command}');
2767
+ proc = await tmux.displayMessage(pane, '#{pane_current_command}');
2633
2768
  }
2634
2769
  catch (err) {
2635
2770
  console.warn(`[AgentManager] interruptPaneAndWaitReady: proc-title read failed for pane ${paneId}:`, err);
@@ -2641,9 +2776,10 @@ export class AgentManager {
2641
2776
  }
2642
2777
  return true;
2643
2778
  }
2644
- async paneReachedReplReady(tmux, paneId, runtime, timeoutMs) {
2779
+ async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
2780
+ const paneId = pane.paneId;
2645
2781
  try {
2646
- await tmux.waitReplReady(paneId, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
2782
+ await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
2647
2783
  return true;
2648
2784
  }
2649
2785
  catch (err) {
@@ -2774,20 +2910,25 @@ export class AgentManager {
2774
2910
  }
2775
2911
  }
2776
2912
  }
2777
- async pollPaneCommandStable(tmux, paneId, opts) {
2913
+ async pollPaneCommandStable(tmux, pane, opts) {
2778
2914
  const deadline = Date.now() + opts.timeoutMs;
2779
2915
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2780
2916
  let last = '';
2781
2917
  while (Date.now() < deadline) {
2782
2918
  await new Promise(r => setTimeout(r, 100));
2783
- const raw = await tmux.displayMessage(paneId, '#{pane_current_command}');
2919
+ const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
2784
2920
  last = raw.trim();
2785
2921
  if (opts.expectShell ? SHELL.test(last) : last !== '')
2786
2922
  return last;
2787
2923
  }
2788
2924
  return last;
2789
2925
  }
2790
- async restartReplOnly(agentId) {
2926
+ async restartReplOnly(agentId, opts = {}) {
2927
+ // Re-verify the entry generation so a request that slept across a DELETE→recreate can't restart the NEW incarnation's REPL (claim check alone can't tell).
2928
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
2929
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2930
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2931
+ }
2791
2932
  const cfg = this.getAgentConfig(agentId);
2792
2933
  if (!cfg)
2793
2934
  throw new Error(`Unknown agent: ${agentId}`);
@@ -2800,14 +2941,17 @@ export class AgentManager {
2800
2941
  if (snapshot.claim !== agentId) {
2801
2942
  throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
2802
2943
  }
2803
- const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
2804
- await tmux.sendKeysToPane(paneId, 'C-c');
2805
- const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
2944
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2945
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2946
+ }
2947
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
2948
+ await tmux.sendKeysToPane(pane, 'C-c');
2949
+ const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
2806
2950
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
2807
2951
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2808
2952
  if (RUNTIME.test(cmd)) {
2809
- await tmux.sendKeysToPane(paneId, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
2810
- await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000, expectShell: true });
2953
+ await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
2954
+ await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
2811
2955
  }
2812
2956
  else if (!SHELL.test(cmd)) {
2813
2957
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
@@ -2815,33 +2959,42 @@ export class AgentManager {
2815
2959
  const project = this.getProjectConfig(cfg.projectId);
2816
2960
  if (!project)
2817
2961
  throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
2962
+ // Re-verify before Workdir/skill/session-option side-effects: pane writes fail-closed on a stale PaneRef, but ensureWorkdir/skills would write old-cfg files under a recreated id.
2963
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2964
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2965
+ }
2818
2966
  const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
2819
- const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
2967
+ const paneWorkdir = await tmux.getPaneCurrentPath(pane);
2820
2968
  if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
2821
2969
  throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
2822
2970
  'use retry to rebuild the session safely');
2823
2971
  }
2972
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2973
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2974
+ }
2824
2975
  await this.provisionRepoSkills(runner, cfg, workdir);
2825
2976
  await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
2826
2977
  const runtime = agentRuntimeKindFor(cfg);
2827
2978
  const relaunch = async () => {
2828
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
2829
- await tmux.handleTrustDialog(paneId, runtime, {
2979
+ await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
2980
+ await tmux.sendEnter(pane);
2981
+ await tmux.handleTrustDialog(pane, runtime, {
2830
2982
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
2831
2983
  });
2832
- await tmux.waitReplReady(paneId, runtime, {
2984
+ await tmux.waitReplReady(pane, runtime, {
2833
2985
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
2834
2986
  scrollback: 0,
2835
2987
  });
2836
2988
  await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
2837
2989
  };
2838
2990
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
2991
+ // Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
2839
2992
  await this.agentStore.update(agentId, (state) => {
2840
- if (!state)
2993
+ if (!state || !this.deletionGateOpen(agentId, genAtEntry))
2841
2994
  return AGENT_STORE_NOOP;
2842
2995
  return {
2843
2996
  ...state,
2844
- paneId,
2997
+ paneId: pane.paneId,
2845
2998
  updatedAt: new Date().toISOString(),
2846
2999
  };
2847
3000
  });
@@ -3221,21 +3374,7 @@ export class AgentManager {
3221
3374
  continue;
3222
3375
  const runner = this.createRunnerFor(cfg);
3223
3376
  const tmux = new TmuxManager(runner);
3224
- const boundState = await this.agentStore.get(id);
3225
- if (boundState?.taskId) {
3226
- try {
3227
- const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
3228
- allowAwaitingHuman: true,
3229
- });
3230
- if (!released) {
3231
- throw new Error(`could not safely release task ${boundState.taskId}`);
3232
- }
3233
- }
3234
- catch (err) {
3235
- failures.push({ agentId: id, step: 'binding.release', error: err });
3236
- continue;
3237
- }
3238
- }
3377
+ // No releaseAgentForTask: the binding dies with the state file in phase 3, and deletability was already enforced by the phase-1 API gates — so cleanup only stops watchers and kills the session.
3239
3378
  this.stopRuntimeMenuWatch(id);
3240
3379
  if (this.paneStreamerManager) {
3241
3380
  try {
@@ -3247,21 +3386,45 @@ export class AgentManager {
3247
3386
  }
3248
3387
  try {
3249
3388
  await this.runUnderSessionLifecycle(id, async () => {
3250
- const snapshot = await tmux.getSessionSnapshot(id);
3389
+ const cleanupOpts = { timeout: DELETE_CLEANUP_TIMEOUT_MS };
3390
+ const snapshot = await tmux.getSessionSnapshot(id, cleanupOpts);
3251
3391
  if (!snapshot)
3252
3392
  return;
3253
- if (snapshot.claim !== id) {
3254
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3255
- `(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
3393
+ if (snapshot.claim === id) {
3394
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'equals', claim: id }, cleanupOpts);
3395
+ if (outcome === 'refused') {
3396
+ throw new Error(`tmux generation/claim changed for ${id} before kill; retry DELETE to observe new state`);
3397
+ }
3398
+ if (outcome === 'killed') {
3399
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
3400
+ }
3256
3401
  return;
3257
3402
  }
3258
- const outcome = await tmux.killSessionRef(snapshot.ref);
3259
- if (outcome === 'stale-server') {
3260
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
3261
- }
3262
- else if (outcome === 'killed') {
3263
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
3403
+ if (snapshot.claim === null) {
3404
+ // A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
3405
+ let nonceOwned;
3406
+ try {
3407
+ nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
3408
+ }
3409
+ catch (err) {
3410
+ throw new Error(`creation-nonce probe failed for ${id}; retry DELETE: ${err instanceof Error ? err.message : String(err)}`);
3411
+ }
3412
+ if (!nonceOwned) {
3413
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping ${id} ` +
3414
+ `(unclaimed, no baxian creation nonce; foreign session)`);
3415
+ return;
3416
+ }
3417
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' }, cleanupOpts);
3418
+ if (outcome === 'refused') {
3419
+ throw new Error(`half-created session for ${id} was claimed or its server changed before kill; retry DELETE`);
3420
+ }
3421
+ if (outcome === 'killed') {
3422
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed half-created leftover ${snapshot.ref.sessionId} for removed agent ${id}`);
3423
+ }
3424
+ return;
3264
3425
  }
3426
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3427
+ `(claim=${snapshot.claim}; not baxian-managed)`);
3265
3428
  });
3266
3429
  }
3267
3430
  catch (err) {
@@ -3801,7 +3964,27 @@ export class AgentManager {
3801
3964
  if (!['review', 'in_progress', 'fixing'].includes(task.status))
3802
3965
  continue;
3803
3966
  try {
3804
- await this.dispatchReviewToQa(task.id, { bumpRound: false });
3967
+ // 列表是旧快照:消费谓词按实时值复核(pending 仍在 + 状态允许),并把代际
3968
+ // CAS(expectSignalToken + expectReviewDispatchPending)压进 dispatch 锁内
3969
+ const live = await this.taskStore.get(task.id);
3970
+ if (!live || live.reviewDispatchPending !== true)
3971
+ continue;
3972
+ if (!['review', 'in_progress', 'fixing'].includes(live.status))
3973
+ continue;
3974
+ const entry = this.getPendingDispatchRetry(live.id);
3975
+ if (entry?.kind === 'qa-recheck' && entry.signalToken === live.signalToken) {
3976
+ // 同代 busy pending 在场:QA 已知忙碌,重派只会空转 release/acquire 并重置预算;
3977
+ // 交给对账的观测门在空闲后消费
3978
+ continue;
3979
+ }
3980
+ const qaPhase = live.reviewRound === 0 ? 'review' : undefined;
3981
+ await this.dispatchReviewToQa(live.id, {
3982
+ bumpRound: live.reviewRoundPending === true,
3983
+ ...(qaPhase !== undefined ? { qaPhase } : {}),
3984
+ ...(live.signalToken !== undefined ? { expectSignalToken: live.signalToken } : {}),
3985
+ expectReviewDispatchPending: true,
3986
+ fromStatus: ['review', 'in_progress', 'fixing'],
3987
+ });
3805
3988
  }
3806
3989
  catch (err) {
3807
3990
  console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
@@ -4461,16 +4644,21 @@ export class AgentManager {
4461
4644
  return { task, previousStatus };
4462
4645
  });
4463
4646
  }
4464
- async bumpReviewRoundIfStillAt(taskId, expectedRound) {
4647
+ // 送达即计轮:flag 是唯一入账凭据,与 verdict 公式(round + flag)按 token CAS 互斥消费,
4648
+ // successor 换代或 verdict 先行都自然跳过,恰好一次
4649
+ // 不设终态守卫:只写轮次不写 status(无复活风险),而终态任务的 recheck 派发是受支持的能力,
4650
+ // 其送达的轮次同样要入账
4651
+ async consumeReviewRoundIntent(taskId, expectedToken) {
4465
4652
  await this.withTaskLock(async () => {
4466
- const task = await this.taskStore.get(taskId);
4467
- if (!task || TERMINAL_STATUSES.includes(task.status))
4468
- return;
4469
- if ((task.reviewRound ?? 0) !== expectedRound)
4653
+ const fresh = await this.taskStore.get(taskId);
4654
+ if (!fresh || fresh.signalToken !== expectedToken || fresh.reviewRoundPending !== true)
4470
4655
  return;
4471
- task.reviewRound = expectedRound + 1;
4472
- task.updatedAt = new Date().toISOString();
4473
- await this.taskStore.set(task);
4656
+ await this.taskStore.set({
4657
+ ...fresh,
4658
+ reviewRound: fresh.reviewRound + 1,
4659
+ reviewRoundPending: undefined,
4660
+ updatedAt: new Date().toISOString(),
4661
+ });
4474
4662
  });
4475
4663
  }
4476
4664
  createRunnerFor(agent) {
@@ -4500,6 +4688,9 @@ export class AgentManager {
4500
4688
  return this.repoCache;
4501
4689
  }
4502
4690
  async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
4691
+ // Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
4692
+ // window must not let the final state write-back resurrect the deleted agent.
4693
+ const genAtEntry = this.deletionGenerationOf(agentId);
4503
4694
  const existing = await this.agentStore.get(agentId);
4504
4695
  if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
4505
4696
  console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
@@ -4546,12 +4737,16 @@ export class AgentManager {
4546
4737
  else {
4547
4738
  const now = new Date().toISOString();
4548
4739
  await this.agentStore.update(agentId, (latest) => {
4549
- if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
4740
+ // Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
4741
+ // since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
4742
+ if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
4550
4743
  return AGENT_STORE_NOOP;
4551
- if (latest?.taskId !== undefined && latest.taskId !== taskId)
4744
+ if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
4745
+ return AGENT_STORE_NOOP;
4746
+ if (latest.taskId !== undefined && latest.taskId !== taskId)
4552
4747
  return AGENT_STORE_NOOP;
4553
4748
  return {
4554
- ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
4749
+ ...latest,
4555
4750
  id: agentId,
4556
4751
  projectId,
4557
4752
  taskId: undefined,
@@ -4642,6 +4837,8 @@ export class AgentManager {
4642
4837
  };
4643
4838
  return this.persistQueuedTask(unassigned, 'unassigned');
4644
4839
  }
4840
+ // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
4841
+ const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
4645
4842
  const target = await this.pickAgent(projectId, input.preferredAgentId);
4646
4843
  const targetConfig = this.getAgentConfig(input.preferredAgentId);
4647
4844
  const group = this.findAgentGroup(input.preferredAgentId);
@@ -4653,6 +4850,12 @@ export class AgentManager {
4653
4850
  const researchFields = research
4654
4851
  ? { researchAgentId: research.id, phase: 'research' }
4655
4852
  : {};
4853
+ // Pin EVERY participant's generation: a member (e.g. QA) deleted→reintroduced after the snapshot leaves isDeletionInFlight false and the preferred generation unchanged, yet the task would still carry its stale id.
4854
+ const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
4855
+ .map(id => [id, this.deletionGenerationOf(id)]));
4856
+ const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
4857
+ && !this.isDeletionInFlight(input.preferredAgentId)
4858
+ && [...participantGens].every(([id, gen]) => this.deletionGenerationOf(id) === gen && !this.isDeletionInFlight(id));
4656
4859
  const queued = {
4657
4860
  ...taskBase,
4658
4861
  agentId: '',
@@ -4661,12 +4864,81 @@ export class AgentManager {
4661
4864
  status: 'pending',
4662
4865
  ...(qa ? { qaAgentId: qa.id } : {}),
4663
4866
  };
4867
+ // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
4868
+ // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
4869
+ const persistQueuedOrReject = (reason) => {
4870
+ if (!participantsFresh()) {
4871
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
4872
+ }
4873
+ return this.persistQueuedTask(queued, reason, input.preferredAgentId);
4874
+ };
4664
4875
  if (!target) {
4665
- return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
4876
+ return persistQueuedOrReject('preferred_agent_busy');
4877
+ }
4878
+ if (this.isDeletionInFlight(target.id)) {
4879
+ return persistQueuedOrReject('agent_locked');
4666
4880
  }
4667
4881
  const lockToken = await this.lockManager.acquire(target.id, taskId);
4668
4882
  if (!lockToken) {
4669
- return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
4883
+ return persistQueuedOrReject('agent_locked');
4884
+ }
4885
+ // Three-step transaction (queued task → binding → in_progress): any failure leaves at worst a queued task and/or a stale binding to it, both reclaimable — never an active task with no binding.
4886
+ const releaseLock = async () => {
4887
+ try {
4888
+ await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
4889
+ }
4890
+ catch (releaseErr) {
4891
+ console.warn(`[AgentManager] createTask(${taskId}): lock release after failed assignment also failed:`, releaseErr);
4892
+ }
4893
+ };
4894
+ try {
4895
+ await this.taskStore.set(queued);
4896
+ }
4897
+ catch (err) {
4898
+ await releaseLock();
4899
+ throw err;
4900
+ }
4901
+ let commit;
4902
+ try {
4903
+ commit = await this.agentStore.update(target.id, (existing) => {
4904
+ if (existing === null && !this.bindingInitPredicate(target.id, genAtEntry))
4905
+ return AGENT_STORE_NOOP;
4906
+ if (!this.deletionGateOpen(target.id, genAtEntry))
4907
+ return AGENT_STORE_NOOP;
4908
+ return {
4909
+ id: target.id,
4910
+ projectId,
4911
+ taskId,
4912
+ lockToken,
4913
+ bootstrappingTaskId: taskId,
4914
+ updatedAt: now,
4915
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
4916
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
4917
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
4918
+ };
4919
+ });
4920
+ }
4921
+ catch (err) {
4922
+ await releaseLock();
4923
+ throw err;
4924
+ }
4925
+ if (commit !== 'committed') {
4926
+ await releaseLock();
4927
+ return (await this.taskStore.get(taskId)) ?? queued;
4928
+ }
4929
+ // Binding-before-active: a DELETE slipping in after the binding commit sees a still-'pending' task and can rotate the lock; re-verify lock ownership + preferred/participant generations before the active write, else roll back.
4930
+ if (this.isDeletionInFlight(target.id)
4931
+ || !this.deletionGateOpen(target.id, genAtEntry)
4932
+ || !participantsFresh()
4933
+ || !(await this.lockManager.isOwner(target.id, taskId, lockToken))) {
4934
+ await this.agentStore.update(target.id, (existing) => {
4935
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
4936
+ return AGENT_STORE_NOOP;
4937
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
4938
+ return { ...rest, updatedAt: new Date().toISOString() };
4939
+ });
4940
+ await releaseLock();
4941
+ return (await this.taskStore.get(taskId)) ?? queued;
4670
4942
  }
4671
4943
  const task = {
4672
4944
  ...taskBase,
@@ -4676,18 +4948,24 @@ export class AgentManager {
4676
4948
  ...researchFields,
4677
4949
  status: 'in_progress',
4678
4950
  };
4679
- await this.taskStore.set(task);
4680
- await this.agentStore.update(target.id, (existing) => ({
4681
- id: target.id,
4682
- projectId,
4683
- taskId,
4684
- lockToken,
4685
- bootstrappingTaskId: taskId,
4686
- updatedAt: now,
4687
- ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
4688
- ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
4689
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
4690
- }));
4951
+ try {
4952
+ await this.taskStore.set(task);
4953
+ }
4954
+ catch (err) {
4955
+ try {
4956
+ await this.agentStore.update(target.id, (existing) => {
4957
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
4958
+ return AGENT_STORE_NOOP;
4959
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
4960
+ return { ...rest, updatedAt: new Date().toISOString() };
4961
+ });
4962
+ }
4963
+ catch (rollbackErr) {
4964
+ console.warn(`[AgentManager] createTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
4965
+ }
4966
+ await releaseLock();
4967
+ throw err;
4968
+ }
4691
4969
  await this.safeEmit({
4692
4970
  id: '',
4693
4971
  type: 'task.assigned',
@@ -4795,6 +5073,9 @@ export class AgentManager {
4795
5073
  const cfg = this.getAgentConfig(agentId);
4796
5074
  if (!cfg)
4797
5075
  throw new ApiError(404, `Unknown agent: ${agentId}`);
5076
+ // Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
5077
+ // DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
5078
+ const genAtEntry = this.deletionGenerationOf(agentId);
4798
5079
  const state = await this.agentStore.get(agentId);
4799
5080
  const paneId = state?.paneId;
4800
5081
  if (!paneId)
@@ -4804,6 +5085,9 @@ export class AgentManager {
4804
5085
  }
4805
5086
  try {
4806
5087
  const assertUploadStillValid = async () => {
5088
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
5089
+ throw new ApiError(409, `Agent ${agentId} was deleted or recreated while uploading; image paste aborted`);
5090
+ }
4807
5091
  const held = await this.agentStore.get(agentId);
4808
5092
  if (!held || held.paneId !== paneId) {
4809
5093
  throw new ApiError(409, `Agent ${agentId} session changed while uploading; image paste aborted`);
@@ -4823,7 +5107,10 @@ export class AgentManager {
4823
5107
  await writeImageToHost(runner, path, bytes);
4824
5108
  await assertUploadStillValid();
4825
5109
  const tmux = new TmuxManager(runner);
4826
- await tmux.injectPrompt(paneId, `${path} `, agentId);
5110
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
5111
+ // resolveClaimedPane resolved by the CURRENT same-name session; re-verify once more before injecting so a DELETE→recreate in that gap can't take the paste.
5112
+ await assertUploadStillValid();
5113
+ await tmux.injectPrompt(pane, `${path} `, agentId);
4827
5114
  return { path };
4828
5115
  }
4829
5116
  finally {
@@ -4855,9 +5142,10 @@ export class AgentManager {
4855
5142
  }
4856
5143
  };
4857
5144
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5145
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
4858
5146
  const waitReady = async () => {
4859
5147
  try {
4860
- await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
5148
+ await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
4861
5149
  }
4862
5150
  catch (err) {
4863
5151
  const detail = err instanceof Error ? err.message : String(err);
@@ -4866,13 +5154,13 @@ export class AgentManager {
4866
5154
  };
4867
5155
  await waitReady();
4868
5156
  await assertSessionUnchanged();
4869
- await tmux.clearComposerDraft(paneId);
5157
+ await tmux.clearComposerDraft(pane);
4870
5158
  await waitReady();
4871
5159
  await assertSessionUnchanged();
4872
- await tmux.sendKeysLiteral(paneId, command);
4873
- await tmux.sendEnter(paneId);
5160
+ await tmux.sendKeysLiteral(pane, command);
5161
+ await tmux.sendEnter(pane);
4874
5162
  guardHandedOff = true;
4875
- void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
5163
+ void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
4876
5164
  .catch(err => {
4877
5165
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
4878
5166
  })
@@ -4891,25 +5179,98 @@ export class AgentManager {
4891
5179
  async clearAgent(agentId) {
4892
5180
  return this.sendSlashCommand(agentId, '/clear');
4893
5181
  }
4894
- async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
5182
+ registerPendingDispatchRetry(taskId, entry, budgetOverride) {
5183
+ const prev = this.pendingDispatchRetryByTask.get(taskId);
5184
+ // 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
5185
+ // budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
5186
+ // fence 注入,外部 successor 的登记结构上拿不到
5187
+ const sameGeneration = prev?.kind === entry.kind
5188
+ && prev.agentId === entry.agentId
5189
+ && prev.signalToken === entry.signalToken;
5190
+ const since = sameGeneration ? prev.since : (budgetOverride?.since ?? Date.now());
5191
+ const budgetAlerted = sameGeneration
5192
+ ? prev.budgetAlerted === true
5193
+ : budgetOverride?.budgetAlerted === true;
5194
+ this.pendingDispatchRetryByTask.set(taskId, {
5195
+ ...entry,
5196
+ since,
5197
+ ...(budgetAlerted ? { budgetAlerted: true } : {}),
5198
+ });
5199
+ }
5200
+ getPendingDispatchRetry(taskId) {
5201
+ return this.pendingDispatchRetryByTask.get(taskId);
5202
+ }
5203
+ listPendingDispatchRetries() {
5204
+ return this.pendingDispatchRetryByTask;
5205
+ }
5206
+ clearPendingDispatchRetry(taskId) {
5207
+ this.pendingDispatchRetryByTask.delete(taskId);
5208
+ }
5209
+ clearPendingDispatchRetryIfMatches(taskId, expected) {
5210
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5211
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5212
+ this.pendingDispatchRetryByTask.delete(taskId);
5213
+ }
5214
+ }
5215
+ markPendingDispatchRetryBudgetAlerted(taskId, expected) {
5216
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5217
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5218
+ this.pendingDispatchRetryByTask.set(taskId, { ...entry, budgetAlerted: true });
5219
+ }
5220
+ }
5221
+ async queueQaBusyPendingRetry(taskId, agentId, phase, createdSession, err, dispatch) {
5222
+ if (!(err instanceof ReplNotReadyError))
5223
+ return null;
5224
+ if (phase !== 'review' && phase !== 'recheck')
5225
+ return null;
5226
+ // 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
5227
+ // 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
5228
+ const passToken = dispatch.passToken;
5229
+ if (!passToken)
5230
+ return null;
5231
+ let current;
5232
+ try {
5233
+ current = await this.taskStore.get(taskId);
5234
+ }
5235
+ catch (readErr) {
5236
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but pass token could not be verified; ` +
5237
+ `falling back to the non-pending failure path:`, readErr);
5238
+ return null;
5239
+ }
5240
+ if (current?.signalToken !== passToken) {
5241
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but the review pass was superseded ` +
5242
+ `(expected ${passToken}, now ${current?.signalToken ?? 'gone'}); not queueing a pending retry`);
5243
+ return null;
5244
+ }
5245
+ this.registerPendingDispatchRetry(taskId, {
5246
+ kind: 'qa-recheck',
5247
+ agentId,
5248
+ signalToken: passToken,
5249
+ qaPhase: phase,
5250
+ }, dispatch.pendingBudget);
5251
+ console.warn(`[AgentManager] startSession[${phase}]: QA pane busy for task ${taskId}; ` +
5252
+ `${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
5253
+ return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
5254
+ }
5255
+ async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
4895
5256
  await this.acquireCompactGuard(agentId);
4896
5257
  try {
4897
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
5258
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
4898
5259
  await revalidate();
4899
- await tmux.clearComposerDraft(paneId);
4900
- const baseline = await tmux.captureSettledSnapshot(paneId, {
5260
+ await tmux.clearComposerDraft(pane);
5261
+ const baseline = await tmux.captureSettledSnapshot(pane, {
4901
5262
  timeoutMs: this.dispatchSettleTimeoutMs,
4902
5263
  });
4903
- const baselineTitle = await tmux.readPaneTitle(paneId);
4904
- await tmux.sendKeysLiteral(paneId, '/clear');
4905
- await tmux.sendEnter(paneId);
4906
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
5264
+ const baselineTitle = await tmux.readPaneTitle(pane);
5265
+ await tmux.sendKeysLiteral(pane, '/clear');
5266
+ await tmux.sendEnter(pane);
5267
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
4907
5268
  timeoutMs: this.dispatchAckTimeoutMs,
4908
5269
  baselineTitle,
4909
5270
  acceptComposerChange: true,
4910
5271
  });
4911
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
4912
- if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
5272
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
5273
+ if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
4913
5274
  throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
4914
5275
  }
4915
5276
  await revalidate();
@@ -4986,6 +5347,8 @@ export class AgentManager {
4986
5347
  error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
4987
5348
  };
4988
5349
  }
5350
+ // Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
5351
+ const genAtEntry = this.deletionGenerationOf(agentId);
4989
5352
  const cfg = this.getAgentConfig(agentId);
4990
5353
  if (!cfg)
4991
5354
  return { task: fresh, errorCode: 400, error: `Unknown agent: ${agentId}` };
@@ -5006,6 +5369,9 @@ export class AgentManager {
5006
5369
  if (!canDispatchWithBinding(state)) {
5007
5370
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
5008
5371
  }
5372
+ if (this.isDeletionInFlight(agentId)) {
5373
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5374
+ }
5009
5375
  const lockToken = await this.lockManager.acquire(agentId, taskId);
5010
5376
  if (!lockToken) {
5011
5377
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
@@ -5024,6 +5390,10 @@ export class AgentManager {
5024
5390
  ? group?.find(agent => agent.role === 'qa')?.id
5025
5391
  : fresh.qaAgentId;
5026
5392
  const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
5393
+ // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
5394
+ const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
5395
+ .map(pid => [pid, this.deletionGenerationOf(pid)]));
5396
+ const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
5027
5397
  const claimedTask = {
5028
5398
  ...fresh,
5029
5399
  preferredAgentId: agentId,
@@ -5035,18 +5405,76 @@ export class AgentManager {
5035
5405
  status: 'in_progress',
5036
5406
  updatedAt: now,
5037
5407
  };
5038
- await this.taskStore.set(claimedTask);
5039
- await this.agentStore.update(agentId, (existing) => ({
5040
- id: agentId,
5041
- projectId: cfg.projectId,
5042
- taskId,
5043
- lockToken,
5044
- bootstrappingTaskId: taskId,
5045
- updatedAt: now,
5046
- ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5047
- ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5048
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5049
- }));
5408
+ // Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
5409
+ // step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
5410
+ const releaseLock = async () => {
5411
+ try {
5412
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
5413
+ }
5414
+ catch (releaseErr) {
5415
+ console.warn(`[AgentManager] claimTask(${taskId}): lock release after failed claim also failed:`, releaseErr);
5416
+ }
5417
+ };
5418
+ let commit;
5419
+ try {
5420
+ commit = await this.agentStore.update(agentId, (existing) => {
5421
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
5422
+ return AGENT_STORE_NOOP;
5423
+ if (!this.deletionGateOpen(agentId, genAtEntry))
5424
+ return AGENT_STORE_NOOP;
5425
+ return {
5426
+ id: agentId,
5427
+ projectId: cfg.projectId,
5428
+ taskId,
5429
+ lockToken,
5430
+ bootstrappingTaskId: taskId,
5431
+ updatedAt: now,
5432
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5433
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5434
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5435
+ };
5436
+ });
5437
+ }
5438
+ catch (err) {
5439
+ await releaseLock();
5440
+ throw err;
5441
+ }
5442
+ if (commit !== 'committed') {
5443
+ await releaseLock();
5444
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5445
+ }
5446
+ // Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
5447
+ if (this.isDeletionInFlight(agentId)
5448
+ || !this.deletionGateOpen(agentId, genAtEntry)
5449
+ || !participantsFresh()
5450
+ || !(await this.lockManager.isOwner(agentId, taskId, lockToken))) {
5451
+ await this.agentStore.update(agentId, (existing) => {
5452
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5453
+ return AGENT_STORE_NOOP;
5454
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5455
+ return { ...rest, updatedAt: new Date().toISOString() };
5456
+ });
5457
+ await releaseLock();
5458
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5459
+ }
5460
+ try {
5461
+ await this.taskStore.set(claimedTask);
5462
+ }
5463
+ catch (err) {
5464
+ try {
5465
+ await this.agentStore.update(agentId, (existing) => {
5466
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5467
+ return AGENT_STORE_NOOP;
5468
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5469
+ return { ...rest, updatedAt: new Date().toISOString() };
5470
+ });
5471
+ }
5472
+ catch (rollbackErr) {
5473
+ console.warn(`[AgentManager] claimTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
5474
+ }
5475
+ await releaseLock();
5476
+ throw err;
5477
+ }
5050
5478
  await this.safeEmit({
5051
5479
  id: '',
5052
5480
  type: 'task.assigned',
@@ -5146,6 +5574,8 @@ export class AgentManager {
5146
5574
  const agent = this.getAgentConfig(agentId);
5147
5575
  if (!agent)
5148
5576
  throw new Error(`Unknown agent: ${agentId}`);
5577
+ // Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
5578
+ const genAtEntry = this.deletionGenerationOf(agentId);
5149
5579
  const task = await this.taskStore.get(taskId);
5150
5580
  if (!task)
5151
5581
  throw new Error(`Unknown task: ${taskId}`);
@@ -5191,6 +5621,11 @@ export class AgentManager {
5191
5621
  return false;
5192
5622
  }
5193
5623
  const assertOwner = async () => {
5624
+ // Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
5625
+ // reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
5626
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
5627
+ throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
5628
+ }
5194
5629
  const stateNow = await this.agentStore.get(agentId);
5195
5630
  if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
5196
5631
  throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
@@ -5201,10 +5636,10 @@ export class AgentManager {
5201
5636
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
5202
5637
  let ensure;
5203
5638
  try {
5204
- ensure = await this.ensureSession(agentId, 'runtime');
5639
+ ensure = await this.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
5205
5640
  }
5206
5641
  catch (err) {
5207
- if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
5642
+ if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses, expectedGeneration: genAtEntry })) {
5208
5643
  throw err;
5209
5644
  }
5210
5645
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
@@ -5222,7 +5657,7 @@ export class AgentManager {
5222
5657
  }
5223
5658
  throw err;
5224
5659
  }
5225
- const { paneId, workdir } = ensure;
5660
+ const { paneId, pane, workdir } = ensure;
5226
5661
  const runner = this.createRunnerFor(agent);
5227
5662
  const tmux = new TmuxManager(runner);
5228
5663
  const branchManager = new BranchManager(runner);
@@ -5230,7 +5665,15 @@ export class AgentManager {
5230
5665
  if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
5231
5666
  return false;
5232
5667
  await assertOwner();
5233
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
5668
+ try {
5669
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
5670
+ }
5671
+ catch (err) {
5672
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5673
+ if (busyPend)
5674
+ throw busyPend;
5675
+ throw err;
5676
+ }
5234
5677
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
5235
5678
  const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
5236
5679
  let reviewCheckout;
@@ -5266,12 +5709,15 @@ export class AgentManager {
5266
5709
  }
5267
5710
  await assertOwner();
5268
5711
  if (!ensure.freshRuntime) {
5269
- await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
5712
+ await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
5270
5713
  if (ensure.skillsStale)
5271
5714
  await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
5272
5715
  }
5273
5716
  }
5274
5717
  catch (err) {
5718
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5719
+ if (busyPend)
5720
+ throw busyPend;
5275
5721
  const message = err instanceof Error ? err.message : String(err);
5276
5722
  const holdPhase = err instanceof DirtyWorkdirError
5277
5723
  ? 'dirty-workdir'
@@ -5416,12 +5862,20 @@ export class AgentManager {
5416
5862
  let agentMarkedRunning = false;
5417
5863
  try {
5418
5864
  let cancelHoldWon = false;
5419
- await this.agentStore.update(agentId, (existing) => {
5865
+ let deletedDuringDispatch = false;
5866
+ const runningCommit = await this.agentStore.update(agentId, (existing) => {
5420
5867
  if (isCancelCleanupHold(existing)) {
5421
5868
  cancelHoldWon = true;
5422
5869
  return AGENT_STORE_NOOP;
5423
5870
  }
5871
+ // Binding must already exist and still hold our lock; never re-create a deleted agent's state nor write under a bumped deletion generation (ABA).
5872
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
5873
+ || !this.deletionGateOpen(agentId, genAtEntry)) {
5874
+ deletedDuringDispatch = true;
5875
+ return AGENT_STORE_NOOP;
5876
+ }
5424
5877
  return {
5878
+ ...existing,
5425
5879
  id: agentId,
5426
5880
  projectId: agent.projectId,
5427
5881
  paneId,
@@ -5431,7 +5885,6 @@ export class AgentManager {
5431
5885
  startedAt: now,
5432
5886
  bootstrappingTaskId: taskId,
5433
5887
  updatedAt: now,
5434
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5435
5888
  };
5436
5889
  });
5437
5890
  if (cancelHoldWon) {
@@ -5440,8 +5893,39 @@ export class AgentManager {
5440
5893
  await cleanupCheckoutOrHold();
5441
5894
  return false;
5442
5895
  }
5896
+ if (deletedDuringDispatch || runningCommit !== 'committed') {
5897
+ console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} binding vanished or was deleted/recreated ` +
5898
+ `during dispatch; not marking running (no orphan state)`);
5899
+ return false;
5900
+ }
5443
5901
  agentMarkedRunning = true;
5444
- await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
5902
+ // armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
5903
+ // push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
5904
+ // 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
5905
+ // cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
5906
+ const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
5907
+ const passStillArmed = opts.dispatchPassToken !== undefined
5908
+ ? async () => {
5909
+ const freshTask = await this.taskStore.get(taskId);
5910
+ if (!freshTask || freshTask.signalToken !== opts.dispatchPassToken)
5911
+ return false;
5912
+ return passDeliverableStatuses.includes(freshTask.status);
5913
+ }
5914
+ : undefined;
5915
+ if (passStillArmed && !(await passStillArmed())) {
5916
+ console.warn(`[AgentManager] startSession[${phase}]: review pass superseded before injection for task ${taskId}; aborting paste`);
5917
+ return false;
5918
+ }
5919
+ const injectResult = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, passStillArmed);
5920
+ if (injectResult.aborted === true) {
5921
+ console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
5922
+ return false;
5923
+ }
5924
+ // prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
5925
+ // 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
5926
+ if (opts.dispatchPassToken !== undefined) {
5927
+ this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
5928
+ }
5445
5929
  try {
5446
5930
  await this.clearBootstrapMarker(agentId, taskId);
5447
5931
  }
@@ -5465,6 +5949,11 @@ export class AgentManager {
5465
5949
  return true;
5466
5950
  }
5467
5951
  catch (err) {
5952
+ // 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
5953
+ // 在途回合还在使用该工作树,park/release 会拽走它
5954
+ const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5955
+ if (injectBusyPend)
5956
+ throw injectBusyPend;
5468
5957
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
5469
5958
  if (!isAckUnknown) {
5470
5959
  await cleanupCheckoutOrHold();
@@ -5507,7 +5996,7 @@ export class AgentManager {
5507
5996
  throw err;
5508
5997
  }
5509
5998
  }
5510
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
5999
+ async injectAndAwaitAck(tmux, pane, prompt, agentId, runtime, guardBeforePaste) {
5511
6000
  const before = await this.agentStore.get(agentId);
5512
6001
  const beforeLockToken = before?.taskId
5513
6002
  ? await this.resolveTaskLockToken(before, before.taskId)
@@ -5559,21 +6048,22 @@ export class AgentManager {
5559
6048
  if (guardBeforePaste && !(await guardBeforePaste())) {
5560
6049
  return { acked: false, composerDelivered: false, aborted: true };
5561
6050
  }
5562
- return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
6051
+ return await this.injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste);
5563
6052
  }
5564
6053
  finally {
5565
6054
  this.compactInFlight.delete(agentId);
5566
6055
  }
5567
6056
  }
5568
- async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6057
+ async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6058
+ const paneId = pane.paneId;
5569
6059
  // 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
5570
- const preTitle = await tmux.readPaneTitle(paneId);
5571
- const preFrame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
6060
+ const preTitle = await tmux.readPaneTitle(pane);
6061
+ const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
5572
6062
  const staticBusy = hasOscTitleWorking(preTitle)
5573
6063
  ? (runtimeBusyCheck(preFrame, runtime) || !hasRuntimeReadyView(preFrame, runtime))
5574
6064
  : runtimeBusyCheck(preFrame, runtime);
5575
- if (staticBusy && await this.paneHasLiveTurn(tmux, paneId, runtime)) {
5576
- throw new Error(`pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
6065
+ if (staticBusy && await this.paneHasLiveTurn(tmux, pane, runtime)) {
6066
+ throw new ReplNotReadyError(paneId, runtime, preFrame, `pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
5577
6067
  }
5578
6068
  await revalidate?.();
5579
6069
  if (guardBeforePaste) {
@@ -5588,7 +6078,7 @@ export class AgentManager {
5588
6078
  pasted = await this.withTaskLock(async () => {
5589
6079
  if (!(await guardBeforePaste()))
5590
6080
  return false;
5591
- await tmux.clearComposerDraft(paneId);
6081
+ await tmux.clearComposerDraft(pane);
5592
6082
  await tmux.pasteStagedBuffer(paneId, staged.buf);
5593
6083
  return true;
5594
6084
  });
@@ -5605,7 +6095,7 @@ export class AgentManager {
5605
6095
  }
5606
6096
  if (bufferConsumed) {
5607
6097
  try {
5608
- await tmux.clearComposerDraft(paneId);
6098
+ await tmux.clearComposerDraft(pane);
5609
6099
  }
5610
6100
  catch (clearErr) {
5611
6101
  console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
@@ -5624,26 +6114,26 @@ export class AgentManager {
5624
6114
  }
5625
6115
  }
5626
6116
  else {
5627
- await tmux.clearComposerDraft(paneId);
6117
+ await tmux.clearComposerDraft(pane);
5628
6118
  await revalidate?.();
5629
- await tmux.injectPrompt(paneId, prompt, agentId);
6119
+ await tmux.injectPrompt(pane, prompt, agentId);
5630
6120
  }
5631
6121
  let baseline;
5632
6122
  let baselineTitle = '';
5633
6123
  try {
5634
- baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
5635
- baselineTitle = await tmux.readPaneTitle(paneId);
6124
+ baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
6125
+ baselineTitle = await tmux.readPaneTitle(pane);
5636
6126
  if (guardBeforePaste) {
5637
6127
  // The Enter is the actual submission; it takes the same fenced queue slot.
5638
6128
  const submitted = await this.withTaskLock(async () => {
5639
6129
  if (!(await guardBeforePaste()))
5640
6130
  return false;
5641
- await tmux.sendEnter(paneId);
6131
+ await tmux.sendEnter(pane);
5642
6132
  return true;
5643
6133
  });
5644
6134
  if (!submitted) {
5645
6135
  try {
5646
- await tmux.clearComposerDraft(paneId);
6136
+ await tmux.clearComposerDraft(pane);
5647
6137
  }
5648
6138
  catch (err) {
5649
6139
  // Fail closed: a submit-ready stale prompt could not be confirmed gone.
@@ -5654,18 +6144,18 @@ export class AgentManager {
5654
6144
  }
5655
6145
  }
5656
6146
  else {
5657
- await tmux.sendEnter(paneId);
6147
+ await tmux.sendEnter(pane);
5658
6148
  }
5659
6149
  }
5660
6150
  catch (preAckErr) {
5661
- if (await this.clearComposerForReuse(tmux, paneId, agentId))
6151
+ if (await this.clearComposerForReuse(tmux, pane, agentId))
5662
6152
  throw preAckErr;
5663
6153
  const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
5664
6154
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
5665
6155
  }
5666
6156
  let staleDuringResend = false;
5667
6157
  try {
5668
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
6158
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
5669
6159
  timeoutMs: this.dispatchAckTimeoutMs,
5670
6160
  baselineTitle,
5671
6161
  resend: guardBeforePaste
@@ -5674,13 +6164,13 @@ export class AgentManager {
5674
6164
  const resent = await this.withTaskLock(async () => {
5675
6165
  if (!(await guardBeforePaste()))
5676
6166
  return false;
5677
- await tmux.sendEnter(paneId);
6167
+ await tmux.sendEnter(pane);
5678
6168
  return true;
5679
6169
  });
5680
6170
  if (!resent)
5681
6171
  staleDuringResend = true;
5682
6172
  }
5683
- : () => tmux.sendEnter(paneId),
6173
+ : () => tmux.sendEnter(pane),
5684
6174
  resendIntervalMs: this.dispatchAckResendIntervalMs,
5685
6175
  });
5686
6176
  return { acked: true, composerDelivered: true };
@@ -5692,7 +6182,7 @@ export class AgentManager {
5692
6182
  }
5693
6183
  if (staleDuringResend) {
5694
6184
  try {
5695
- await tmux.clearComposerDraft(paneId);
6185
+ await tmux.clearComposerDraft(pane);
5696
6186
  }
5697
6187
  catch (scrubErr) {
5698
6188
  const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
@@ -5714,9 +6204,10 @@ export class AgentManager {
5714
6204
  return { acked: false, composerDelivered };
5715
6205
  }
5716
6206
  }
5717
- async clearComposerForReuse(tmux, paneId, agentId) {
6207
+ async clearComposerForReuse(tmux, pane, agentId) {
6208
+ const paneId = pane.paneId;
5718
6209
  try {
5719
- await tmux.clearComposerDraft(paneId);
6210
+ await tmux.clearComposerDraft(pane);
5720
6211
  return true;
5721
6212
  }
5722
6213
  catch (err) {
@@ -5793,7 +6284,7 @@ export class AgentManager {
5793
6284
  }
5794
6285
  throw err;
5795
6286
  }
5796
- const { paneId } = ensure;
6287
+ const { paneId, pane } = ensure;
5797
6288
  const runner = this.createRunnerFor(agent);
5798
6289
  const tmux = new TmuxManager(runner);
5799
6290
  const stateAfterEnsure = await this.agentStore.get(agentId);
@@ -5996,7 +6487,7 @@ export class AgentManager {
5996
6487
  console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
5997
6488
  return false;
5998
6489
  }
5999
- const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
6490
+ const delivery = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, guardBeforeInject);
6000
6491
  if (delivery.aborted) {
6001
6492
  console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
6002
6493
  return false;
@@ -6188,7 +6679,7 @@ export class AgentManager {
6188
6679
  continue;
6189
6680
  if (state.creationToken && !state.taskId) {
6190
6681
  const ct = state.creationToken;
6191
- const pane = result.paneId;
6682
+ const pane = result.pane;
6192
6683
  const cfg = agentConfig;
6193
6684
  const agentId = state.id;
6194
6685
  void (async () => {
@@ -6197,7 +6688,7 @@ export class AgentManager {
6197
6688
  if (!latest || latest.creationToken !== ct)
6198
6689
  return AGENT_STORE_NOOP;
6199
6690
  const { creationToken: _ct, status: _s, awaitingPhase: _ap, awaitingReason: _ar, awaitingSince: _as, ...ready } = latest;
6200
- return { ...ready, paneId: pane, updatedAt: new Date().toISOString() };
6691
+ return { ...ready, paneId: pane.paneId, updatedAt: new Date().toISOString() };
6201
6692
  });
6202
6693
  }
6203
6694
  else {
@@ -6615,19 +7106,22 @@ export class AgentManager {
6615
7106
  async dispatchReviewToQa(taskId, opts = {}) {
6616
7107
  const claim = await this.withTaskLock(async () => {
6617
7108
  if (this.manualReviewInFlight.has(taskId)) {
6618
- throw new ApiError(409, `Manual review already in progress for task ${taskId}`);
7109
+ throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
6619
7110
  }
6620
7111
  if (this.markCompleteInFlight.has(taskId)) {
6621
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
7112
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
6622
7113
  }
6623
7114
  const task = await this.taskStore.get(taskId);
6624
7115
  if (!task)
6625
7116
  throw new ApiError(404, `Task ${taskId} not found`);
6626
7117
  if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
6627
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`);
7118
+ throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
6628
7119
  }
6629
7120
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
6630
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
7121
+ throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
7122
+ }
7123
+ if (opts.expectReviewDispatchPending === true && task.reviewDispatchPending !== true) {
7124
+ throw new ApiError(409, `Task ${taskId} review dispatch pending was already consumed; aborting stale sweep`, 'dispatch-superseded');
6631
7125
  }
6632
7126
  if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
6633
7127
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
@@ -6668,17 +7162,33 @@ export class AgentManager {
6668
7162
  throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
6669
7163
  }
6670
7164
  this.manualReviewInFlight.add(taskId);
6671
- return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
7165
+ return {
7166
+ mode: 'github',
7167
+ qaId,
7168
+ devAgentId: task.agentId,
7169
+ taskStatusAtClaim: task.status,
7170
+ signalTokenAtClaim: task.signalToken,
7171
+ reviewRoundAtClaim: task.reviewRound,
7172
+ };
6672
7173
  });
6673
7174
  if (claim.mode === 'server') {
6674
7175
  return this.runManualServerReview(taskId, claim);
6675
7176
  }
6676
7177
  try {
6677
- const { qaId, devAgentId, taskStatusAtClaim } = claim;
7178
+ const { qaId, devAgentId, taskStatusAtClaim, signalTokenAtClaim, reviewRoundAtClaim } = claim;
6678
7179
  const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
6679
- const qaPhase = taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress'
6680
- ? 'review'
6681
- : 'recheck';
7180
+ // 相位恢复归标准入口自己(手工 review API 与各补派方一视同仁):同代 pending 登记的
7181
+ // 原始相位最权威;重启丢登记时以持久化 round=0 兜底——轮次送达才入账,故 0 即首评从未送达
7182
+ const pendingEntry = this.getPendingDispatchRetry(taskId);
7183
+ const queuedPhase = pendingEntry?.kind === 'qa-recheck'
7184
+ && pendingEntry.signalToken === signalTokenAtClaim
7185
+ ? pendingEntry.qaPhase
7186
+ : undefined;
7187
+ const qaPhase = opts.qaPhase
7188
+ ?? queuedPhase
7189
+ ?? (taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress' || reviewRoundAtClaim === 0
7190
+ ? 'review'
7191
+ : 'recheck');
6682
7192
  const prevQa = await this.agentStore.get(qaId);
6683
7193
  const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
6684
7194
  if (prevQa?.taskId === taskId) {
@@ -6703,12 +7213,13 @@ export class AgentManager {
6703
7213
  throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
6704
7214
  }
6705
7215
  }
6706
- const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
7216
+ let acquiredLockToken;
7217
+ const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
7218
+ onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
7219
+ });
6707
7220
  if (!acquired) {
6708
7221
  throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
6709
7222
  }
6710
- const acquiredClaim = await this.lockManager.claimOf(qaId);
6711
- const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
6712
7223
  const releaseOwnAcquire = acquiredLockToken !== undefined
6713
7224
  ? { expectedLockToken: acquiredLockToken }
6714
7225
  : {};
@@ -6735,18 +7246,21 @@ export class AgentManager {
6735
7246
  signalToken: pre?.signalToken,
6736
7247
  reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
6737
7248
  reviewDispatchedAt: pre?.reviewDispatchedAt,
7249
+ reviewRoundPending: pre?.reviewRoundPending,
6738
7250
  passToken: pre?.passToken,
6739
7251
  failToken: pre?.failToken,
6740
7252
  };
6741
7253
  const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
6742
- // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
6743
- const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
7254
+ // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮。
7255
+ // 显式 bumpRound:true 是补派方读到 reviewRoundPending 后的权威意图(该轮从未计数),越过恢复期抑制
7256
+ const bumpRound = opts.bumpRound === true
7257
+ || (opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review'));
6744
7258
  // 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
6745
7259
  // 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
6746
7260
  const abortDispatch = async (dispatchToken) => {
6747
- const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, bumpRound, dispatchToken);
7261
+ const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, dispatchToken);
6748
7262
  if (!takenOver)
6749
- await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
7263
+ await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
6750
7264
  if (devParked)
6751
7265
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
6752
7266
  };
@@ -6775,7 +7289,7 @@ export class AgentManager {
6775
7289
  if (isGitDispatch && reviewAnchor === undefined) {
6776
7290
  // 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
6777
7291
  // 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
6778
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => false);
7292
+ const released = await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => false);
6779
7293
  if (!released) {
6780
7294
  // 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
6781
7295
  const blockedTask = await this.taskStore.get(taskId);
@@ -6798,6 +7312,9 @@ export class AgentManager {
6798
7312
  reviewHeadAnchorSha: reviewAnchor,
6799
7313
  reviewDispatchedAt: new Date().toISOString(),
6800
7314
  signalToken: createSignalToken(),
7315
+ // 未计轮 intent 与进入 review 同一原子写:两写之间崩溃会留下「有派发可重放、却无轮次可补计」
7316
+ // 的状态,恢复后首评仍停在旧轮
7317
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
6801
7318
  // pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
6802
7319
  ...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
6803
7320
  });
@@ -6806,7 +7323,7 @@ export class AgentManager {
6806
7323
  await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
6807
7324
  if (devParked)
6808
7325
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
6809
- throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
7326
+ throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`, 'dispatch-superseded');
6810
7327
  }
6811
7328
  transitionToken = preDispatched.task.signalToken;
6812
7329
  }
@@ -6827,7 +7344,9 @@ export class AgentManager {
6827
7344
  }
6828
7345
  await this.taskStore.set({
6829
7346
  ...fresh,
6830
- reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
7347
+ // 计轮送达时才落账:claim 段只持久化「未计轮」intent(与 push 直派链路同构),
7348
+ // busy/hold/崩溃等一切未送达出口都保留 intent,任何补派方(含重启后)恰好补计一次
7349
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
6831
7350
  signalToken: armedToken,
6832
7351
  ...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
6833
7352
  updatedAt: new Date().toISOString(),
@@ -6837,8 +7356,9 @@ export class AgentManager {
6837
7356
  if (!claim2.ok) {
6838
7357
  // 未写入 armedToken:task 仍持 transitionToken,或已被接管方 rotate/漂移 → 用 transitionToken 判 takeover
6839
7358
  await abortDispatch(transitionToken);
6840
- throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`);
7359
+ throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`, 'dispatch-superseded');
6841
7360
  }
7361
+ opts.onPassArmed?.(armedToken);
6842
7362
  const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
6843
7363
  const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
6844
7364
  if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
@@ -6853,7 +7373,7 @@ export class AgentManager {
6853
7373
  const preStart = await this.taskStore.get(taskId);
6854
7374
  if (!preStart || !opts.fromStatus.includes(preStart.status) || preStart.signalToken !== armedToken) {
6855
7375
  await abortDispatch(armedToken);
6856
- throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`);
7376
+ throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`, 'dispatch-superseded');
6857
7377
  }
6858
7378
  }
6859
7379
  let started = false;
@@ -6861,6 +7381,8 @@ export class AgentManager {
6861
7381
  started = await this.startSession(taskId, qaId, qaPhase, {
6862
7382
  bypassTaskStatusGate: true,
6863
7383
  dialogFailFromStatuses: [isTerminalAtClaim ? taskStatusAtClaim : 'review'],
7384
+ dispatchPassToken: armedToken,
7385
+ ...(opts.pendingBudget !== undefined ? { dispatchPendingBudget: opts.pendingBudget } : {}),
6864
7386
  });
6865
7387
  }
6866
7388
  catch (err) {
@@ -6874,6 +7396,13 @@ export class AgentManager {
6874
7396
  }
6875
7397
  throw err;
6876
7398
  }
7399
+ if (err instanceof EnsureSessionError && err.partial.busyPending) {
7400
+ // 遇忙不算失败:pass 保持 armed、QA 保持绑定、未计轮 intent 原样在库(送达时才消费),
7401
+ // 由对账按 pending 登记补派
7402
+ console.warn(`[AgentManager] dispatchReviewToQa: QA busy for ${taskId}; dispatch queued (${err.message})`);
7403
+ const pending = await this.taskStore.get(taskId);
7404
+ return pending;
7405
+ }
6877
7406
  if (!(err instanceof EnsureSessionError && err.partial.handled)) {
6878
7407
  await abortDispatch(armedToken);
6879
7408
  }
@@ -6883,6 +7412,7 @@ export class AgentManager {
6883
7412
  await abortDispatch(armedToken);
6884
7413
  throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
6885
7414
  }
7415
+ await this.consumeReviewRoundIntent(taskId, armedToken);
6886
7416
  await this.clearReviewDispatchPending(taskId, armedToken);
6887
7417
  const final = await this.taskStore.get(taskId);
6888
7418
  return final;
@@ -7023,7 +7553,7 @@ export class AgentManager {
7023
7553
  // 返回 qaTakenOverInReview:仍在 review 但 token 已换代 = 另一 dispatcher 已 release+acquire 同一 QA
7024
7554
  // (同 task 同 agent,releaseAgentForTask 的 mismatch 保护不设防),此时调用方不得再 release QA。
7025
7555
  // 判定在回滚同一锁段内基于 fresh 完成,是那一刻的权威结论——不受本函数随后恢复 snapshot token 的影响。
7026
- async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, roundBumped, dispatchToken) {
7556
+ async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, dispatchToken) {
7027
7557
  let qaTakenOverInReview = false;
7028
7558
  await this.withTaskLock(async () => {
7029
7559
  const fresh = await this.taskStore.get(taskId);
@@ -7033,27 +7563,25 @@ export class AgentManager {
7033
7563
  if (!isTerminalAtClaim && TERMINAL_STATUSES.includes(fresh.status))
7034
7564
  return;
7035
7565
  // transition 后的中间态恒为 review 且持有本次 dispatch 的 token;status 漂移(如 REQUEST_CHANGES→fixing)
7036
- // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有,只撤本次 dispatch 的轮次残留
7566
+ // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有。
7567
+ // 计轮送达时才落账,claim 段无轮次残留可撤——drift 后不覆盖接管方的任何写入
7037
7568
  const passTakenOver = dispatchToken !== undefined && fresh.signalToken !== dispatchToken;
7038
7569
  qaTakenOverInReview = !isTerminalAtClaim && fresh.status === 'review' && passTakenOver;
7039
7570
  const drifted = !isTerminalAtClaim && (fresh.status !== 'review' || passTakenOver);
7040
- // 只在本次 dispatch 仍拥有该 pass(未 drift)且确实 bump 过时才撤回 +1;
7041
- // drift 后 reviewRound 归接管方所有(它可能基于此再 bump),一律不减
7042
- const rolledRound = (!drifted && roundBumped) ? Math.max(0, fresh.reviewRound - 1) : fresh.reviewRound;
7043
- const next = drifted
7044
- ? { ...fresh, reviewRound: rolledRound, updatedAt: new Date().toISOString() }
7045
- : {
7046
- ...fresh,
7047
- reviewRound: rolledRound,
7048
- qaAgentId: snapshot.qaAgentId,
7049
- signalToken: snapshot.signalToken,
7050
- reviewDispatchedAt: snapshot.reviewDispatchedAt,
7051
- // 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
7052
- passToken: snapshot.passToken,
7053
- failToken: snapshot.failToken,
7054
- updatedAt: new Date().toISOString(),
7055
- };
7056
- if (!isTerminalAtClaim && !drifted) {
7571
+ if (drifted)
7572
+ return;
7573
+ const next = {
7574
+ ...fresh,
7575
+ qaAgentId: snapshot.qaAgentId,
7576
+ signalToken: snapshot.signalToken,
7577
+ reviewDispatchedAt: snapshot.reviewDispatchedAt,
7578
+ reviewRoundPending: snapshot.reviewRoundPending,
7579
+ // 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
7580
+ passToken: snapshot.passToken,
7581
+ failToken: snapshot.failToken,
7582
+ updatedAt: new Date().toISOString(),
7583
+ };
7584
+ if (!isTerminalAtClaim) {
7057
7585
  next.status = originalStatus;
7058
7586
  next.reviewHeadAnchorSha = snapshot.reviewHeadAnchorSha;
7059
7587
  }
@@ -7451,6 +7979,9 @@ export class AgentManager {
7451
7979
  const agent = this.getAgentConfig(agentId);
7452
7980
  if (!agent)
7453
7981
  return;
7982
+ // Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
7983
+ // agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
7984
+ const genAtEntry = this.deletionGenerationOf(agentId);
7454
7985
  const state = await this.agentStore.get(agentId);
7455
7986
  if (!state)
7456
7987
  return;
@@ -7465,12 +7996,16 @@ export class AgentManager {
7465
7996
  return;
7466
7997
  }
7467
7998
  if (!state.taskId) {
7999
+ if (this.isDeletionInFlight(agentId))
8000
+ return;
7468
8001
  const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
7469
8002
  if (!lockToken)
7470
8003
  return;
7471
- await this.agentStore.update(agentId, (existing) => {
8004
+ const commit = await this.agentStore.update(agentId, (existing) => {
7472
8005
  if (!existing)
7473
8006
  return AGENT_STORE_NOOP;
8007
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8008
+ return AGENT_STORE_NOOP;
7474
8009
  if (existing.taskId && existing.taskId !== ctx.taskId)
7475
8010
  return AGENT_STORE_NOOP;
7476
8011
  if (existing.creationToken)
@@ -7485,17 +8020,35 @@ export class AgentManager {
7485
8020
  lockToken,
7486
8021
  updatedAt: new Date().toISOString(),
7487
8022
  };
8023
+ }).catch(async (err) => {
8024
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken).catch((releaseErr) => {
8025
+ console.warn(`[AgentManager] postMergeCleanup(${agentId}): lock release after commit failure also failed:`, releaseErr);
8026
+ });
8027
+ throw err;
7488
8028
  });
8029
+ if (commit !== 'committed') {
8030
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
8031
+ return;
8032
+ }
7489
8033
  const fresh = await this.agentStore.get(agentId);
7490
8034
  if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
7491
8035
  await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
7492
8036
  return;
7493
8037
  }
7494
8038
  }
8039
+ // A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
8040
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8041
+ return;
7495
8042
  const runner = this.createRunnerFor(agent);
7496
8043
  const tmux = new TmuxManager(runner);
7497
8044
  const runtime = agentRuntimeKindFor(agent);
7498
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
8045
+ const expectedPaneId = state.paneId;
8046
+ void (async () => {
8047
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8048
+ return;
8049
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
8050
+ await this.runPostMergeCompaction(tmux, pane, agentId, ctx.taskId, runtime);
8051
+ })().catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
7499
8052
  }
7500
8053
  async releasePostMergeAgent(agentId, taskId) {
7501
8054
  const state = await this.agentStore.get(agentId);
@@ -7508,10 +8061,10 @@ export class AgentManager {
7508
8061
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
7509
8062
  }
7510
8063
  }
7511
- async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
8064
+ async runPostMergeCompaction(tmux, pane, agentId, originalTaskId, runtime) {
7512
8065
  await this.acquireCompactGuard(agentId);
7513
8066
  try {
7514
- await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime);
8067
+ await this.runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime);
7515
8068
  }
7516
8069
  finally {
7517
8070
  this.compactInFlight.delete(agentId);
@@ -7537,7 +8090,8 @@ export class AgentManager {
7537
8090
  this.compactInFlight.add(agentId);
7538
8091
  return true;
7539
8092
  }
7540
- async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
8093
+ async runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime) {
8094
+ const paneId = pane.paneId;
7541
8095
  const initial = await this.agentStore.get(agentId);
7542
8096
  if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
7543
8097
  return;
@@ -7557,13 +8111,13 @@ export class AgentManager {
7557
8111
  let cleared = false;
7558
8112
  let clearError;
7559
8113
  try {
7560
- cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
8114
+ cleared = await this.sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs);
7561
8115
  }
7562
8116
  catch (err) {
7563
8117
  clearError = err;
7564
8118
  }
7565
8119
  if (!cleared) {
7566
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
8120
+ if (await this.recoverPostMergeExitedRuntime(tmux, pane, agentId, originalTaskId, lockToken, runtime)) {
7567
8121
  cleared = true;
7568
8122
  }
7569
8123
  else if (!await bindingStillOurs()) {
@@ -7572,7 +8126,7 @@ export class AgentManager {
7572
8126
  else if (clearError instanceof Error
7573
8127
  && clearError.message.includes('runtime rejected /clear')) {
7574
8128
  try {
7575
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8129
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7576
8130
  }
7577
8131
  catch (err) {
7578
8132
  clearError = err;
@@ -7590,10 +8144,11 @@ export class AgentManager {
7590
8144
  }
7591
8145
  await this.releasePostMergeAgent(agentId, originalTaskId);
7592
8146
  }
7593
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
8147
+ async recoverPostMergeExitedRuntime(tmux, pane, agentId, taskId, lockToken, runtime) {
8148
+ const paneId = pane.paneId;
7594
8149
  let paneExists = true;
7595
8150
  try {
7596
- if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
8151
+ if (hasReplProcTitle(await tmux.displayMessage(pane, '#{pane_current_command}'), runtime))
7597
8152
  return false;
7598
8153
  }
7599
8154
  catch (err) {
@@ -7612,7 +8167,7 @@ export class AgentManager {
7612
8167
  return false;
7613
8168
  try {
7614
8169
  if (paneExists)
7615
- await tmux.sendKeysToPane(paneId, 'C-c');
8170
+ await tmux.sendKeysToPane(pane, 'C-c');
7616
8171
  const ensure = await this.ensureSession(agentId, 'runtime');
7617
8172
  if (!ensure.freshRuntime)
7618
8173
  return false;
@@ -7641,22 +8196,22 @@ export class AgentManager {
7641
8196
  return false;
7642
8197
  }
7643
8198
  }
7644
- async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs) {
8199
+ async sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs) {
7645
8200
  let rejection;
7646
8201
  for (let attempt = 1; attempt <= 2; attempt++) {
7647
8202
  if (!await bindingStillOurs())
7648
8203
  return false;
7649
- await tmux.clearComposerDraft(paneId);
7650
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8204
+ await tmux.clearComposerDraft(pane);
8205
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7651
8206
  if (!await bindingStillOurs())
7652
8207
  return false;
7653
- await tmux.sendKeysLiteral(paneId, '/clear');
7654
- await tmux.sendEnter(paneId);
8208
+ await tmux.sendKeysLiteral(pane, '/clear');
8209
+ await tmux.sendEnter(pane);
7655
8210
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7656
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8211
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7657
8212
  if (!await bindingStillOurs())
7658
8213
  return false;
7659
- if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear'))
8214
+ if (!await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear'))
7660
8215
  return true;
7661
8216
  rejection = new Error('runtime rejected /clear because a task is still in progress');
7662
8217
  if (attempt < 2) {
@@ -7666,29 +8221,33 @@ export class AgentManager {
7666
8221
  }
7667
8222
  throw rejection ?? new Error('runtime rejected /clear');
7668
8223
  }
7669
- async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
7670
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8224
+ async hasRuntimeSlashCommandRejection(tmux, pane, command) {
8225
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
7671
8226
  return this.runtimeSlashCommandRejectedPattern(command).test(cap);
7672
8227
  }
7673
8228
  runtimeSlashCommandRejectedPattern(command) {
7674
8229
  return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
7675
8230
  }
7676
- async waitForReplPromptReady(tmux, paneId, runtime, timeoutMs) {
8231
+ async waitForReplPromptReady(tmux, pane, runtime, timeoutMs, opts = {}) {
8232
+ if (opts.stableIdle) {
8233
+ return this.waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs);
8234
+ }
8235
+ const paneId = pane.paneId;
7677
8236
  const deadline = Date.now() + timeoutMs;
7678
- await tmux.waitReplReady(paneId, runtime, {
8237
+ await tmux.waitReplReady(pane, runtime, {
7679
8238
  timeoutMs,
7680
8239
  intervalMs: this.compactIdlePollMs,
7681
8240
  titleIdleFastPath: true,
7682
8241
  });
7683
8242
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7684
8243
  while (true) {
7685
- const current = await tmux.displayMessage(paneId, '#{pane_current_command}');
8244
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
7686
8245
  if (!hasReplProcTitle(current, runtime)) {
7687
8246
  throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
7688
8247
  }
7689
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8248
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
7690
8249
  const ready = hasRuntimeReadyView(cap, runtime)
7691
- || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(paneId), runtime));
8250
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
7692
8251
  if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
7693
8252
  throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
7694
8253
  }
@@ -7699,11 +8258,41 @@ export class AgentManager {
7699
8258
  return;
7700
8259
  }
7701
8260
  if (Date.now() >= deadline) {
7702
- throw new Error(`waitForReplPromptReady: pane ${paneId} stayed busy past ${timeoutMs}ms`);
8261
+ // 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
8262
+ // 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
8263
+ throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
7703
8264
  }
7704
8265
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7705
8266
  }
7706
8267
  }
8268
+ // 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
8269
+ // 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
8270
+ async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
8271
+ const paneId = pane.paneId;
8272
+ const samples = Math.max(1, this.readyStableSamples);
8273
+ const spacing = this.readyStableSpacingMs;
8274
+ const deadline = Date.now() + timeoutMs + (samples - 1) * spacing;
8275
+ let streak = 0;
8276
+ while (true) {
8277
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
8278
+ if (!hasReplProcTitle(current, runtime)) {
8279
+ throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
8280
+ }
8281
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
8282
+ const ready = hasRuntimeReadyView(cap, runtime)
8283
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
8284
+ if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
8285
+ throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
8286
+ }
8287
+ streak = ready && !runtimeBusyCheck(cap, runtime) ? streak + 1 : 0;
8288
+ if (streak >= samples)
8289
+ return;
8290
+ if (Date.now() >= deadline) {
8291
+ throw new ReplNotReadyError(paneId, runtime, cap, `stable idle not confirmed within ${timeoutMs}ms (+${(samples - 1) * spacing}ms stability window)`);
8292
+ }
8293
+ await new Promise(r => setTimeout(r, spacing));
8294
+ }
8295
+ }
7707
8296
  stopPhaseSignalWatcher(taskId) {
7708
8297
  this.phaseSignalWatcher?.stop(taskId);
7709
8298
  }
@@ -7805,6 +8394,7 @@ export class AgentManager {
7805
8394
  signalToken: restore.signalToken,
7806
8395
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
7807
8396
  reviewDispatchedAt: restore.reviewDispatchedAt,
8397
+ reviewRoundPending: restore.reviewRoundPending,
7808
8398
  ...(restore.restorePair === true
7809
8399
  ? { passToken: restore.passToken, failToken: restore.failToken }
7810
8400
  : {}),
@@ -8677,7 +9267,7 @@ export class AgentManager {
8677
9267
  // Callers without a baseSha (publish) run long after the review diff was
8678
9268
  // read; refresh origin/HEAD first or upstream commits merged since then
8679
9269
  // would linger in base..HEAD and flag tasks that leak nothing.
8680
- const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
9270
+ const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
8681
9271
  if (fetch.exitCode !== 0) {
8682
9272
  throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
8683
9273
  }
@@ -8808,6 +9398,8 @@ export class AgentManager {
8808
9398
  const cfg = this.getAgentConfig(agentId);
8809
9399
  if (!cfg)
8810
9400
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
9401
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
9402
+ const genAtEntry = this.deletionGenerationOf(agentId);
8811
9403
  await this.acquireCompactGuard(agentId);
8812
9404
  try {
8813
9405
  const state = await this.agentStore.get(agentId);
@@ -8842,8 +9434,18 @@ export class AgentManager {
8842
9434
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
8843
9435
  }
8844
9436
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
8845
- await tmux.injectPrompt(paneId, text, agentId);
8846
- await tmux.sendEnter(paneId);
9437
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
9438
+ // resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
9439
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
9440
+ throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
9441
+ }
9442
+ const afterResolve = await this.agentStore.get(agentId);
9443
+ if (!afterResolve || afterResolve.paneId !== paneId || afterResolve.taskId !== taskId
9444
+ || (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
9445
+ throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
9446
+ }
9447
+ await tmux.injectPrompt(pane, text, agentId);
9448
+ await tmux.sendEnter(pane);
8847
9449
  }
8848
9450
  finally {
8849
9451
  this.compactInFlight.delete(agentId);
@@ -9000,14 +9602,17 @@ export class AgentManager {
9000
9602
  }
9001
9603
  startCompactionThenRelease(agentId, state, taskId) {
9002
9604
  // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
9003
- if (!state.paneId || state.status === 'awaiting_human' || state.creationToken)
9605
+ const expectedPaneId = state.paneId;
9606
+ if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
9004
9607
  return false;
9005
9608
  const cfg = this.getAgentConfig(agentId);
9006
9609
  if (!cfg)
9007
9610
  return false;
9008
9611
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
9009
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, taskId, agentRuntimeKindFor(cfg))
9010
- .catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
9612
+ void (async () => {
9613
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
9614
+ await this.runPostMergeCompaction(tmux, pane, agentId, taskId, agentRuntimeKindFor(cfg));
9615
+ })().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
9011
9616
  return true;
9012
9617
  }
9013
9618
  repoMergeQueue = new Map();
@@ -9023,13 +9628,15 @@ export class AgentManager {
9023
9628
  const runner = this.createRunnerFor(dev);
9024
9629
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
9025
9630
  const run = prev.then(async () => {
9631
+ // fetch/push reach a remote derived from THIS Workdir; a rebound Workdir would redirect the push to a foreign repo, so each mutation carries the canonical self-guard in its own command.
9632
+ const guard = `${canonicalSelfGuard(workdir)} && `;
9026
9633
  const cd = `cd ${shellQuote(workdir)} && `;
9027
9634
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
9028
9635
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
9029
9636
  if (db.exitCode !== 0 || defaultBranch === '') {
9030
9637
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
9031
9638
  }
9032
- const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
9639
+ const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
9033
9640
  if (fetch.exitCode !== 0) {
9034
9641
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
9035
9642
  }
@@ -9043,7 +9650,7 @@ export class AgentManager {
9043
9650
  else {
9044
9651
  throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
9045
9652
  }
9046
- const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
9653
+ const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
9047
9654
  if (push.exitCode !== 0) {
9048
9655
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
9049
9656
  }