baxian 1.2.59 → 1.2.61

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 (168) 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 +123 -12
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +1607 -420
  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/phase-signal-watcher.d.ts +43 -2
  21. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  22. package/dist/agent/phase-signal-watcher.js +301 -43
  23. package/dist/agent/phase-signal-watcher.js.map +1 -1
  24. package/dist/agent/phase-signal.d.ts +9 -0
  25. package/dist/agent/phase-signal.d.ts.map +1 -1
  26. package/dist/agent/phase-signal.js +63 -5
  27. package/dist/agent/phase-signal.js.map +1 -1
  28. package/dist/agent/preflight.d.ts +20 -1
  29. package/dist/agent/preflight.d.ts.map +1 -1
  30. package/dist/agent/preflight.js +235 -27
  31. package/dist/agent/preflight.js.map +1 -1
  32. package/dist/agent/prompt.d.ts +8 -0
  33. package/dist/agent/prompt.d.ts.map +1 -1
  34. package/dist/agent/prompt.js +45 -2
  35. package/dist/agent/prompt.js.map +1 -1
  36. package/dist/agent/repo-store.d.ts +5 -1
  37. package/dist/agent/repo-store.d.ts.map +1 -1
  38. package/dist/agent/repo-store.js +199 -70
  39. package/dist/agent/repo-store.js.map +1 -1
  40. package/dist/agent/review-transport.d.ts.map +1 -1
  41. package/dist/agent/review-transport.js +4 -2
  42. package/dist/agent/review-transport.js.map +1 -1
  43. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  44. package/dist/agent/tmux-probe-poller.js +29 -20
  45. package/dist/agent/tmux-probe-poller.js.map +1 -1
  46. package/dist/agent/tmux.d.ts +79 -32
  47. package/dist/agent/tmux.d.ts.map +1 -1
  48. package/dist/agent/tmux.js +435 -225
  49. package/dist/agent/tmux.js.map +1 -1
  50. package/dist/api/config.d.ts.map +1 -1
  51. package/dist/api/config.js +18 -0
  52. package/dist/api/config.js.map +1 -1
  53. package/dist/api/projects.d.ts.map +1 -1
  54. package/dist/api/projects.js +241 -98
  55. package/dist/api/projects.js.map +1 -1
  56. package/dist/api/tasks.d.ts.map +1 -1
  57. package/dist/api/tasks.js +29 -2
  58. package/dist/api/tasks.js.map +1 -1
  59. package/dist/app.d.ts +2 -0
  60. package/dist/app.d.ts.map +1 -1
  61. package/dist/app.js +1 -0
  62. package/dist/app.js.map +1 -1
  63. package/dist/cli.d.ts.map +1 -1
  64. package/dist/cli.js +18 -3
  65. package/dist/cli.js.map +1 -1
  66. package/dist/config/hot-reload.d.ts.map +1 -1
  67. package/dist/config/hot-reload.js +1 -0
  68. package/dist/config/hot-reload.js.map +1 -1
  69. package/dist/config/loader.d.ts.map +1 -1
  70. package/dist/config/loader.js +48 -3
  71. package/dist/config/loader.js.map +1 -1
  72. package/dist/config/validator.js +11 -1
  73. package/dist/config/validator.js.map +1 -1
  74. package/dist/errors.d.ts +2 -1
  75. package/dist/errors.d.ts.map +1 -1
  76. package/dist/errors.js +3 -1
  77. package/dist/errors.js.map +1 -1
  78. package/dist/event/handlers.d.ts.map +1 -1
  79. package/dist/event/handlers.js +138 -41
  80. package/dist/event/handlers.js.map +1 -1
  81. package/dist/event/log.d.ts.map +1 -1
  82. package/dist/event/log.js +17 -2
  83. package/dist/event/log.js.map +1 -1
  84. package/dist/github/pr-conversation-cache.d.ts +48 -0
  85. package/dist/github/pr-conversation-cache.d.ts.map +1 -0
  86. package/dist/github/pr-conversation-cache.js +199 -0
  87. package/dist/github/pr-conversation-cache.js.map +1 -0
  88. package/dist/github/pr-conversation.d.ts +2 -2
  89. package/dist/github/pr-conversation.d.ts.map +1 -1
  90. package/dist/github/pr-conversation.js.map +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +16 -1
  93. package/dist/index.js.map +1 -1
  94. package/dist/platform/driver-spec.d.ts.map +1 -1
  95. package/dist/platform/driver-spec.js +33 -0
  96. package/dist/platform/driver-spec.js.map +1 -1
  97. package/dist/platform/git-driver.d.ts +7 -1
  98. package/dist/platform/git-driver.d.ts.map +1 -1
  99. package/dist/platform/git-driver.js +33 -2
  100. package/dist/platform/git-driver.js.map +1 -1
  101. package/dist/platform/platform-poller.d.ts +8 -0
  102. package/dist/platform/platform-poller.d.ts.map +1 -1
  103. package/dist/platform/platform-poller.js +80 -1
  104. package/dist/platform/platform-poller.js.map +1 -1
  105. package/dist/platform/plugin-registry.d.ts.map +1 -1
  106. package/dist/platform/plugin-registry.js +14 -2
  107. package/dist/platform/plugin-registry.js.map +1 -1
  108. package/dist/platform/plugins/github/driver.json +3 -0
  109. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +16 -7
  110. package/dist/platform/preflight-exec.d.ts +21 -0
  111. package/dist/platform/preflight-exec.d.ts.map +1 -0
  112. package/dist/platform/preflight-exec.js +62 -0
  113. package/dist/platform/preflight-exec.js.map +1 -0
  114. package/dist/platform/review-timeline.d.ts +16 -0
  115. package/dist/platform/review-timeline.d.ts.map +1 -0
  116. package/dist/platform/review-timeline.js +195 -0
  117. package/dist/platform/review-timeline.js.map +1 -0
  118. package/dist/platform/startup.d.ts +5 -1
  119. package/dist/platform/startup.d.ts.map +1 -1
  120. package/dist/platform/startup.js +25 -0
  121. package/dist/platform/startup.js.map +1 -1
  122. package/dist/platform/types.d.ts +2 -0
  123. package/dist/platform/types.d.ts.map +1 -1
  124. package/dist/platform/types.js +1 -1
  125. package/dist/platform/types.js.map +1 -1
  126. package/dist/shared/constants.d.ts +6 -0
  127. package/dist/shared/constants.d.ts.map +1 -1
  128. package/dist/shared/constants.js +6 -0
  129. package/dist/shared/constants.js.map +1 -1
  130. package/dist/shared/git-url.d.ts +1 -0
  131. package/dist/shared/git-url.d.ts.map +1 -1
  132. package/dist/shared/git-url.js +3 -0
  133. package/dist/shared/git-url.js.map +1 -1
  134. package/dist/shared/types.d.ts +23 -6
  135. package/dist/shared/types.d.ts.map +1 -1
  136. package/dist/shared/types.js.map +1 -1
  137. package/dist/skill/registry.d.ts +9 -2
  138. package/dist/skill/registry.d.ts.map +1 -1
  139. package/dist/skill/registry.js +88 -54
  140. package/dist/skill/registry.js.map +1 -1
  141. package/dist/skills/baxian-pr-feedback/SKILL.md +2 -0
  142. package/dist/skills/baxian-pr-recheck/SKILL.md +2 -0
  143. package/dist/skills/baxian-pr-review/SKILL.md +2 -0
  144. package/dist/skills/baxian-research/SKILL.md +1 -1
  145. package/dist/skills/baxian-signals/SKILL.md +19 -4
  146. package/dist/skills/baxian-task-check/SKILL.md +4 -2
  147. package/dist/state/agent-store.d.ts +5 -1
  148. package/dist/state/agent-store.d.ts.map +1 -1
  149. package/dist/state/agent-store.js +56 -17
  150. package/dist/state/agent-store.js.map +1 -1
  151. package/dist/state/lock.d.ts +9 -0
  152. package/dist/state/lock.d.ts.map +1 -1
  153. package/dist/state/lock.js +24 -1
  154. package/dist/state/lock.js.map +1 -1
  155. package/dist/state/task-store.js +4 -4
  156. package/dist/state/task-store.js.map +1 -1
  157. package/dist/terminal/attach.d.ts +10 -1
  158. package/dist/terminal/attach.d.ts.map +1 -1
  159. package/dist/terminal/attach.js +41 -20
  160. package/dist/terminal/attach.js.map +1 -1
  161. package/dist/terminal/stream-ws.d.ts.map +1 -1
  162. package/dist/terminal/stream-ws.js +27 -0
  163. package/dist/terminal/stream-ws.js.map +1 -1
  164. package/dist/web/assets/{index-DfcbGriX.css → index-BPv6vv3U.css} +1 -1
  165. package/dist/web/assets/index-CUJtd0bC.js +16 -0
  166. package/dist/web/index.html +2 -2
  167. package/package.json +1 -1
  168. package/dist/web/assets/index-DskVWe7e.js +0 -13
@@ -6,7 +6,7 @@ import { createSignalToken } from './phase-signal.js';
6
6
  import { tmuxInstallHint } from './preflight.js';
7
7
  import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
8
8
  import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
- import { DriverOpError } from '../platform/git-driver.js';
9
+ import { DriverOpError, buildDriverRunContext, GitDriver } from '../platform/git-driver.js';
10
10
  import { checkOpenPrBinding } from '../platform/pr-binding.js';
11
11
  import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
12
  import { recheckPassProvenance } from '../platform/verdict-engine.js';
@@ -17,15 +17,15 @@ 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';
23
- import { PhaseSignalWatcher } from './phase-signal-watcher.js';
22
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
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';
26
26
  import { ApiError } from '../errors.js';
27
27
  import { prepareConfig } from '../config/loader.js';
28
- import { resolveProjectTool } from '../config/validator.js';
28
+ import { projectReviewMode, resolveProjectTool } from '../config/validator.js';
29
29
  import { SHA_HEX_SOURCE } from '../platform/types.js';
30
30
  export class EnsureSessionError extends Error {
31
31
  partial;
@@ -146,7 +146,11 @@ export function skillSubdirFor(runtime) {
146
146
  }
147
147
  }
148
148
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
149
+ const DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS = 5_000;
149
150
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
151
+ // DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
152
+ // probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
153
+ const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
150
154
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
151
155
  const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
152
156
  // 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
@@ -230,6 +234,7 @@ export class AgentManager {
230
234
  paneStreamerManager;
231
235
  postApproveStore;
232
236
  pluginRegistry;
237
+ platformDefaultBranchOf;
233
238
  platformDriverExec;
234
239
  phaseSignalWatcher;
235
240
  errorRecordStore;
@@ -237,6 +242,7 @@ export class AgentManager {
237
242
  reviewTransportInstance;
238
243
  serverReviewDriver;
239
244
  dispatchAckTimeoutMs;
245
+ needInputRetryIntervalMs;
240
246
  dispatchSettleTimeoutMs;
241
247
  dispatchAckResendIntervalMs = 3_000;
242
248
  taskMutationQueue = Promise.resolve();
@@ -252,6 +258,9 @@ export class AgentManager {
252
258
  manualCompactWaitMs = 5_000;
253
259
  runtimeLivenessProbeMs = 700;
254
260
  cleanComposerWaitMs = 5_000;
261
+ readyStableSamples = 3;
262
+ readyStableSpacingMs = 1_000;
263
+ pendingDispatchRetryByTask = new Map();
255
264
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
256
265
  manualReviewInFlight = new Set();
257
266
  markCompleteInFlight = new Set();
@@ -260,6 +269,9 @@ export class AgentManager {
260
269
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
261
270
  cancelCleanupInFlight = new Map();
262
271
  deletionInFlight = new Set();
272
+ // Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
273
+ divergedAgents = new Set();
274
+ deletionGeneration = new Map();
263
275
  compactInFlight = new Set();
264
276
  constructor(deps) {
265
277
  const config = prepareConfig(deps.config);
@@ -274,6 +286,7 @@ export class AgentManager {
274
286
  this.paneStreamerManager = deps.paneStreamerManager;
275
287
  this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
276
288
  this.pluginRegistry = deps.pluginRegistry;
289
+ this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
277
290
  this.errorRecordStore = deps.errorRecordStore;
278
291
  this.phaseSignalWatcher = deps.phaseSignalWatcher
279
292
  ?? (deps.paneStreamerManager
@@ -281,8 +294,10 @@ export class AgentManager {
281
294
  paneStreamerManager: deps.paneStreamerManager,
282
295
  eventBus: deps.eventBus,
283
296
  resolveAgent: (id) => this.getAgentConfig(id),
297
+ commitNeedInputWatermark: (intent) => this.commitNeedInputWatermark(intent),
284
298
  })
285
299
  : undefined);
300
+ this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
286
301
  this.reviewStore = deps.reviewStore;
287
302
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
288
303
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
@@ -455,14 +470,62 @@ export class AgentManager {
455
470
  this.deletionInFlight.add(id);
456
471
  return null;
457
472
  }
473
+ // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
474
+ async scanActiveThenClaimDeletion(targets) {
475
+ return this.withTaskLock(async () => {
476
+ for (const id of targets) {
477
+ const state = await this.agentStore.get(id);
478
+ const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
479
+ if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
480
+ return { ok: false, code: 'active', agentId: id, taskId: task.id };
481
+ }
482
+ if (state?.status === 'awaiting_human')
483
+ continue;
484
+ if (state?.creationToken)
485
+ return { ok: false, code: 'bootstrapping', agentId: id };
486
+ }
487
+ const targetSet = new Set(targets);
488
+ const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
489
+ && ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
490
+ || (!!t.agentId && targetSet.has(t.agentId))
491
+ || (!!t.devAgentId && targetSet.has(t.devAgentId))
492
+ || (!!t.qaAgentId && targetSet.has(t.qaAgentId))
493
+ || (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
494
+ if (referencing)
495
+ return { ok: false, code: 'referencing', taskId: referencing.id };
496
+ const conflict = this.tryClaimDeletion(targets);
497
+ if (conflict)
498
+ return { ok: false, code: 'already-deleting', agentId: conflict };
499
+ return { ok: true };
500
+ });
501
+ }
458
502
  releaseDeletionClaim(agentIds) {
459
503
  for (const id of agentIds)
460
504
  this.deletionInFlight.delete(id);
461
505
  }
506
+ // A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
507
+ bumpDeletionGeneration(agentId) {
508
+ this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
509
+ }
510
+ deletionGenerationOf(agentId) {
511
+ return this.deletionGeneration.get(agentId) ?? 0;
512
+ }
513
+ // Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
514
+ deletionGateOpen(agentId, genAtEntry) {
515
+ return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
516
+ }
462
517
  isDeletionInFlight(agentId) {
463
- return this.deletionInFlight.has(agentId);
518
+ return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
519
+ }
520
+ // replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
521
+ retainTombstoneForDivergence(agentId) {
522
+ this.divergedAgents.add(agentId);
464
523
  }
465
- async ensureSession(agentId, mode) {
524
+ async ensureSession(agentId, mode, opts = {}) {
525
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
526
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
527
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
528
+ }
466
529
  const agent = this.getAgentConfig(agentId);
467
530
  if (!agent) {
468
531
  throw new EnsureSessionError({ createdSession: false, agentId }, `Unknown agent: ${agentId}`);
@@ -473,52 +536,81 @@ export class AgentManager {
473
536
  }
474
537
  const runner = this.createRunnerFor(agent);
475
538
  const tmux = new TmuxManager(runner);
539
+ // 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.
540
+ return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
541
+ }
542
+ async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
543
+ const assertGenerationOpen = () => {
544
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
545
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
546
+ }
547
+ };
548
+ assertGenerationOpen();
476
549
  let workdir;
477
550
  try {
478
551
  const resolved = await this.ensureWorkdir(agent, project, runner);
479
552
  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
553
  }
490
554
  catch (err) {
491
555
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
492
556
  }
557
+ // Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
558
+ assertGenerationOpen();
559
+ await this.agentStore.update(agentId, (existing) => {
560
+ if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
561
+ return AGENT_STORE_NOOP;
562
+ return { ...existing, workdir, updatedAt: new Date().toISOString() };
563
+ });
564
+ assertGenerationOpen();
493
565
  try {
494
566
  await this.provisionRepoSkills(runner, agent, workdir);
495
567
  }
496
568
  catch (err) {
497
569
  throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
498
570
  }
499
- let alive;
571
+ assertGenerationOpen();
572
+ let snapshot;
500
573
  try {
501
- alive = await tmux.hasSession(agentId);
574
+ snapshot = await tmux.getSessionSnapshot(agentId);
502
575
  }
503
576
  catch (err) {
504
577
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
505
578
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
506
579
  }
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`);
580
+ // 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.
581
+ assertGenerationOpen();
582
+ if (!snapshot) {
583
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
584
+ }
585
+ if (snapshot.claim === null) {
586
+ if (await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
587
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
512
588
  }
589
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
590
+ `it creates itself — kill it manually or pick a different agent id`);
591
+ }
592
+ if (mode === 'create' || snapshot.claim !== agentId) {
593
+ throw new EnsureSessionError({ createdSession: false, agentId }, mode === 'create'
594
+ ? `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
595
+ `it creates itself — kill it manually or pick a different agent id`
596
+ : `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
597
+ `(got "${snapshot.claim}"); baxian will not adopt foreign session — operator must intervene`);
513
598
  }
514
- if (alive && (mode === 'runtime' || mode === 'recover')) {
515
- return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
599
+ return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
600
+ }
601
+ agentSkillScope(agentId) {
602
+ const projectId = this.getAgentConfig(agentId)?.projectId;
603
+ if (projectId === undefined || this.effectiveReviewMode(projectId) !== 'git') {
604
+ return { pluginTools: [] };
516
605
  }
517
- return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
606
+ const project = this.getProjectConfig(projectId);
607
+ const tool = project === undefined ? undefined : resolveProjectTool(project);
608
+ return { pluginTools: tool === undefined ? [] : [tool] };
518
609
  }
519
610
  async provisionRepoSkills(runner, agent, workdir) {
520
611
  if (this.skillRegistry.names().length === 0)
521
612
  return;
613
+ const scope = this.agentSkillScope(agent.id);
522
614
  const subdir = skillSubdirFor(agent.runtime);
523
615
  const destRoot = `${workdir}/${subdir}`;
524
616
  await this.excludeInjectedSkills(runner, workdir, subdir);
@@ -528,8 +620,11 @@ export class AgentManager {
528
620
  // would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
529
621
  // before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
530
622
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
531
- await this.ensureSkillDirSafe(runner, workdir, subdir);
532
- await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
623
+ await this.ensureSkillDirSafe(runner, workdir, subdir, [
624
+ ...this.skillRegistry.names(),
625
+ ...this.skillRegistry.pluginSkillNames(scope),
626
+ ]);
627
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
533
628
  });
534
629
  }
535
630
  // A rollback only kills if no adoption bumped the generation since its create.
@@ -583,9 +678,10 @@ export class AgentManager {
583
678
  return;
584
679
  }
585
680
  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`);
681
+ // Rollback may run before or after the claim write, so accept empty-or-ours.
682
+ const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
683
+ if (outcome === 'refused') {
684
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
589
685
  }
590
686
  else if (outcome === 'killed') {
591
687
  console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
@@ -629,10 +725,12 @@ export class AgentManager {
629
725
  throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
630
726
  }
631
727
  }
632
- async ensureSkillDirSafe(runner, workdir, subdir) {
728
+ async ensureSkillDirSafe(runner, workdir, subdir, keepNames) {
633
729
  const top = subdir.split('/')[0];
634
- const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
635
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
730
+ const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
731
+ const inner =
732
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
733
+ `${canonicalSelfGuard(workdir)} && ` +
636
734
  `cd ${shellQuote(workdir)} && ` +
637
735
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
638
736
  `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
@@ -650,17 +748,19 @@ export class AgentManager {
650
748
  if (this.skillRegistry.names().length === 0)
651
749
  return;
652
750
  await this.setSessionOptions(tmux, agentId, ref, [
653
- ['@baxian-skills-version', this.skillRegistry.contentHash()],
751
+ ['@baxian-skills-version', this.skillRegistry.contentHash(this.agentSkillScope(agentId))],
654
752
  ]);
655
753
  }
656
- async replSkillsStale(tmux, agentId) {
754
+ async replSkillsStale(tmux, agentId, ref) {
657
755
  if (this.skillRegistry.names().length === 0)
658
756
  return false;
659
- const tagged = await tmux.getOption(agentId, '@baxian-skills-version');
660
- return tagged !== this.skillRegistry.contentHash();
757
+ const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
758
+ return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
661
759
  }
662
760
  async excludeInjectedSkills(runner, workdir, subdir) {
663
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
761
+ const inner =
762
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
763
+ `${canonicalSelfGuard(workdir)} && ` +
664
764
  `cd ${shellQuote(workdir)} && ` +
665
765
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
666
766
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
@@ -693,12 +793,26 @@ export class AgentManager {
693
793
  }
694
794
  // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
695
795
  // 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);
796
+ async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
797
+ const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
698
798
  if (outcome === 'gone') {
699
799
  throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
700
800
  }
701
801
  }
802
+ // Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
803
+ async resolveClaimedPane(tmux, agentId, expectedPaneId) {
804
+ const snapshot = await tmux.getSessionSnapshot(agentId);
805
+ if (!snapshot)
806
+ throw new PaneGoneError(agentId, 'no tmux session');
807
+ if (snapshot.claim !== agentId) {
808
+ throw new PaneGoneError(agentId, `session claim mismatch (got "${snapshot.claim ?? 'null'}")`);
809
+ }
810
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
811
+ if (expectedPaneId !== undefined && pane.paneId !== expectedPaneId) {
812
+ throw new PaneGoneError(agentId, `pane id changed (state ${expectedPaneId} != live ${pane.paneId})`);
813
+ }
814
+ return pane;
815
+ }
702
816
  async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
703
817
  return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
704
818
  }
@@ -710,6 +824,7 @@ export class AgentManager {
710
824
  try {
711
825
  createdRef = await tmux.createSession(agentId, sessionDir);
712
826
  createdSession = true;
827
+ // First batch writes the claim itself, so it must prove the session is still unclaimed.
713
828
  await this.setSessionOptions(tmux, agentId, createdRef, [
714
829
  ['@baxian-agent-id', agentId],
715
830
  ['@baxian-runtime', agent.runtime],
@@ -719,20 +834,21 @@ export class AgentManager {
719
834
  ['window-size', 'latest'],
720
835
  ...this.runtimePinEntries(),
721
836
  ['status-right', ''],
722
- ]);
837
+ ], '');
723
838
  await tmux.setServerOption('extended-keys', 'on');
724
839
  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, {
840
+ const pane = await tmux.getSinglePaneByRef(createdRef, agentId);
841
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
842
+ await tmux.sendEnter(pane);
843
+ await tmux.handleTrustDialog(pane, runtime, {
728
844
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
729
845
  });
730
- await tmux.waitReplReady(paneId, runtime, {
846
+ await tmux.waitReplReady(pane, runtime, {
731
847
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
732
848
  scrollback: 0,
733
849
  });
734
850
  await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
735
- return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
851
+ return { ok: true, createdSession: true, freshRuntime: true, paneId: pane.paneId, pane, workdir, sessionRef: createdRef };
736
852
  }
737
853
  catch (err) {
738
854
  const partial = { createdSession, agentId, genAtCreate };
@@ -755,7 +871,7 @@ export class AgentManager {
755
871
  }
756
872
  try {
757
873
  const result = await this.ensureSession(agentId, 'create');
758
- if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
874
+ if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.pane))) {
759
875
  const current = await this.agentStore.get(agentId);
760
876
  if (current && current.creationToken !== creationToken) {
761
877
  console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
@@ -816,7 +932,7 @@ export class AgentManager {
816
932
  await this.markBootstrapFailed(agentId, creationToken, message);
817
933
  }
818
934
  }
819
- async runGreetingHandshake(agentId, agent, paneId) {
935
+ async runGreetingHandshake(agentId, agent, pane) {
820
936
  const watcher = this.phaseSignalWatcher;
821
937
  if (!watcher)
822
938
  return true;
@@ -824,7 +940,7 @@ export class AgentManager {
824
940
  for (let attempt = 1; attempt <= this.greetingMaxAttempts; attempt++) {
825
941
  const token = createSignalToken();
826
942
  try {
827
- await this.injectAndAwaitAckSteps(tmux, paneId, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
943
+ await this.injectAndAwaitAckSteps(tmux, pane, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
828
944
  }
829
945
  catch (err) {
830
946
  console.warn(`[bootstrap] greeting inject failed for ${agentId} (attempt ${attempt}):`, err);
@@ -843,7 +959,7 @@ export class AgentManager {
843
959
  console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
844
960
  if (outcome === 'no-agent')
845
961
  break;
846
- if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, paneId, agentId))) {
962
+ if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
847
963
  break;
848
964
  }
849
965
  }
@@ -885,21 +1001,25 @@ export class AgentManager {
885
1001
  const agent = this.getAgentConfig(agentId);
886
1002
  if (!agent)
887
1003
  return false;
1004
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
1005
+ const genAtEntry = this.deletionGenerationOf(agentId);
888
1006
  const state = await this.agentStore.get(agentId);
889
1007
  if (state?.awaitingPhase !== 'greeting_failed')
890
1008
  return false;
891
1009
  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
- }
1010
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1011
+ return false;
1012
+ let pane;
1013
+ try {
1014
+ pane = await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(agent)), agentId, state.paneId);
1015
+ }
1016
+ catch (err) {
1017
+ console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
1018
+ return true;
901
1019
  }
902
- if (!(await this.runGreetingHandshake(agentId, agent, paneId))) {
1020
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1021
+ return false;
1022
+ if (!(await this.runGreetingHandshake(agentId, agent, pane))) {
903
1023
  return true;
904
1024
  }
905
1025
  await this.agentStore.update(agentId, (fresh) => {
@@ -936,8 +1056,7 @@ export class AgentManager {
936
1056
  let paneId = existing.paneId;
937
1057
  if (cfg && !paneId) {
938
1058
  try {
939
- const runner = this.createRunnerFor(cfg);
940
- paneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1059
+ paneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
941
1060
  }
942
1061
  catch {
943
1062
  }
@@ -1014,8 +1133,10 @@ export class AgentManager {
1014
1133
  if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
1015
1134
  return false;
1016
1135
  }
1136
+ // 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.
1137
+ const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
1017
1138
  let state = await this.agentStore.get(agentId);
1018
- if (!state)
1139
+ if (!state || !gateOpen())
1019
1140
  return false;
1020
1141
  if (state.paneId === undefined && err.partial.createdSession) {
1021
1142
  const cfg = this.getAgentConfig(agentId);
@@ -1023,8 +1144,7 @@ export class AgentManager {
1023
1144
  return false;
1024
1145
  let discoveredPaneId;
1025
1146
  try {
1026
- const runner = this.createRunnerFor(cfg);
1027
- discoveredPaneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1147
+ discoveredPaneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
1028
1148
  }
1029
1149
  catch (probeErr) {
1030
1150
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: tmux probe paneId failed for ${agentId}:`, probeErr);
@@ -1034,7 +1154,7 @@ export class AgentManager {
1034
1154
  return false;
1035
1155
  const probeNow = new Date().toISOString();
1036
1156
  await this.agentStore.update(agentId, (fresh) => {
1037
- if (!fresh)
1157
+ if (!fresh || !gateOpen())
1038
1158
  return AGENT_STORE_NOOP;
1039
1159
  if (fresh.paneId !== undefined)
1040
1160
  return AGENT_STORE_NOOP;
@@ -1048,6 +1168,8 @@ export class AgentManager {
1048
1168
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: ${agentId} has no paneId/taskId snapshot (no generation guard); refusing — caller should rollback`);
1049
1169
  return false;
1050
1170
  }
1171
+ if (!gateOpen())
1172
+ return false;
1051
1173
  await this.markDialogPending(agentId, undefined, {
1052
1174
  runtimePath: true,
1053
1175
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
@@ -1117,15 +1239,16 @@ export class AgentManager {
1117
1239
  return;
1118
1240
  if (generationMismatch(state))
1119
1241
  return;
1120
- let paneId;
1242
+ let pane;
1121
1243
  try {
1122
- paneId = await tmux.getSinglePaneId(agentId);
1244
+ pane = await this.resolveClaimedPane(tmux, agentId);
1123
1245
  }
1124
1246
  catch {
1125
1247
  continue;
1126
1248
  }
1249
+ const paneId = pane.paneId;
1127
1250
  try {
1128
- await tmux.waitReplReady(paneId, runtime, {
1251
+ await tmux.waitReplReady(pane, runtime, {
1129
1252
  timeoutMs: 1_000,
1130
1253
  intervalMs: 200,
1131
1254
  scrollback: 0,
@@ -1143,7 +1266,7 @@ export class AgentManager {
1143
1266
  let projectIdForEmit = '';
1144
1267
  let wrote = false;
1145
1268
  const isBootstrapPath = creationToken !== undefined;
1146
- if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, paneId))) {
1269
+ if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
1147
1270
  await this.markGreetingFailed(agentId, creationToken);
1148
1271
  return;
1149
1272
  }
@@ -1238,7 +1361,8 @@ export class AgentManager {
1238
1361
  continue;
1239
1362
  let stripped;
1240
1363
  try {
1241
- stripped = await tmux.capturePaneById(state.paneId, { ansi: false, scrollback: 0 });
1364
+ const pane = await this.resolveClaimedPane(tmux, agentId, state.paneId);
1365
+ stripped = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
1242
1366
  }
1243
1367
  catch {
1244
1368
  continue;
@@ -1279,29 +1403,9 @@ export class AgentManager {
1279
1403
  signal.addEventListener('abort', onAbort, { once: true });
1280
1404
  });
1281
1405
  }
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
- }
1406
+ // Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
1407
+ async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
1297
1408
  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
1409
  this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
1306
1410
  try {
1307
1411
  await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
@@ -1309,24 +1413,25 @@ export class AgentManager {
1309
1413
  catch (err) {
1310
1414
  throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
1311
1415
  }
1312
- let paneId;
1416
+ let pane;
1313
1417
  try {
1314
- paneId = await tmux.getSinglePaneIdByRef(sessionRef);
1418
+ pane = await tmux.getSinglePaneByRef(sessionRef, agentId);
1315
1419
  }
1316
1420
  catch (err) {
1317
1421
  throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
1318
1422
  }
1423
+ const paneId = pane.paneId;
1319
1424
  const runtime = agentRuntimeKindFor(agent);
1320
1425
  let state;
1321
1426
  try {
1322
- state = await tmux.classifyPaneForAdopt(paneId, runtime);
1427
+ state = await tmux.classifyPaneForAdopt(pane, runtime);
1323
1428
  }
1324
1429
  catch (err) {
1325
1430
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1326
1431
  }
1327
1432
  let currentPath;
1328
1433
  try {
1329
- currentPath = await tmux.getPaneCurrentPath(paneId);
1434
+ currentPath = await tmux.getPaneCurrentPath(pane);
1330
1435
  }
1331
1436
  catch (err) {
1332
1437
  throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1341,7 +1446,7 @@ export class AgentManager {
1341
1446
  if (!pathMatches) {
1342
1447
  if (state.kind === 'live-runtime') {
1343
1448
  try {
1344
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
1449
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs);
1345
1450
  }
1346
1451
  catch (err) {
1347
1452
  throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
@@ -1358,7 +1463,7 @@ export class AgentManager {
1358
1463
  case 'live-runtime': {
1359
1464
  let stale;
1360
1465
  try {
1361
- stale = await this.replSkillsStale(tmux, agentId);
1466
+ stale = await this.replSkillsStale(tmux, agentId, sessionRef);
1362
1467
  }
1363
1468
  catch (err) {
1364
1469
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1370,11 +1475,12 @@ export class AgentManager {
1370
1475
  freshRuntime: false,
1371
1476
  skillsStale: true,
1372
1477
  paneId,
1478
+ pane,
1373
1479
  workdir,
1374
1480
  sessionRef,
1375
1481
  };
1376
1482
  }
1377
- return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
1483
+ return { ok: true, createdSession: false, freshRuntime: false, paneId, pane, workdir, sessionRef };
1378
1484
  }
1379
1485
  case 'startup-dialog':
1380
1486
  throw new EnsureSessionError({
@@ -1389,15 +1495,15 @@ export class AgentManager {
1389
1495
  `must reset the pane manually`);
1390
1496
  case 'trust-dialog':
1391
1497
  try {
1392
- await tmux.handleTrustDialog(paneId, runtime, {
1498
+ await tmux.handleTrustDialog(pane, runtime, {
1393
1499
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1394
1500
  });
1395
- await tmux.waitReplReady(paneId, runtime, {
1501
+ await tmux.waitReplReady(pane, runtime, {
1396
1502
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1397
1503
  scrollback: 0,
1398
1504
  });
1399
1505
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1400
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1506
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1401
1507
  }
1402
1508
  catch (trustErr) {
1403
1509
  if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
@@ -1415,16 +1521,17 @@ export class AgentManager {
1415
1521
  break;
1416
1522
  }
1417
1523
  try {
1418
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
1419
- await tmux.handleTrustDialog(paneId, runtime, {
1524
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
1525
+ await tmux.sendEnter(pane);
1526
+ await tmux.handleTrustDialog(pane, runtime, {
1420
1527
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1421
1528
  });
1422
- await tmux.waitReplReady(paneId, runtime, {
1529
+ await tmux.waitReplReady(pane, runtime, {
1423
1530
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1424
1531
  scrollback: 0,
1425
1532
  });
1426
1533
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1427
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1534
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1428
1535
  }
1429
1536
  catch (relErr) {
1430
1537
  if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
@@ -1464,12 +1571,12 @@ export class AgentManager {
1464
1571
  let outcome;
1465
1572
  try {
1466
1573
  // 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 });
1574
+ outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
1468
1575
  }
1469
1576
  catch (err) {
1470
1577
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
1471
1578
  }
1472
- if (outcome === 'refused' || outcome === 'stale-server') {
1579
+ if (outcome === 'refused') {
1473
1580
  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
1581
  }
1475
1582
  if (outcome === 'killed') {
@@ -1483,13 +1590,13 @@ export class AgentManager {
1483
1590
  async killAdoptTarget(tmux, agentId, sessionRef, reason) {
1484
1591
  let outcome;
1485
1592
  try {
1486
- outcome = await tmux.killSessionRef(sessionRef);
1593
+ outcome = await tmux.killSessionRef(sessionRef, { kind: 'equals', claim: agentId });
1487
1594
  }
1488
1595
  catch (err) {
1489
1596
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
1490
1597
  }
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`);
1598
+ if (outcome === 'refused') {
1599
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux generation/claim changed while replacing session (${reason}); retry to observe the new state`);
1493
1600
  }
1494
1601
  if (outcome === 'killed') {
1495
1602
  console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
@@ -1498,7 +1605,17 @@ export class AgentManager {
1498
1605
  console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1499
1606
  }
1500
1607
  }
1501
- async acquireAgentForTask(agentId, taskId, phase) {
1608
+ // 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.
1609
+ bindingInitPredicate(agentId, genAtEntry) {
1610
+ return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
1611
+ }
1612
+ async acquireAgentForTask(agentId, taskId, phase,
1613
+ // 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
1614
+ // release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
1615
+ opts = {}) {
1616
+ const genAtEntry = this.deletionGenerationOf(agentId);
1617
+ if (this.isDeletionInFlight(agentId))
1618
+ return false;
1502
1619
  const cfg = this.getAgentConfig(agentId);
1503
1620
  if (!cfg)
1504
1621
  throw new Error(`Unknown agent: ${agentId}`);
@@ -1522,16 +1639,45 @@ export class AgentManager {
1522
1639
  const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1523
1640
  if (!token)
1524
1641
  return false;
1642
+ const releaseAcquired = async (originalErr) => {
1643
+ if (reuseLock)
1644
+ return;
1645
+ try {
1646
+ await this.lockManager.releaseIfOwner(agentId, taskId, token);
1647
+ }
1648
+ catch (releaseErr) {
1649
+ console.warn(`[AgentManager] acquireAgentForTask(${agentId}, ${taskId}): lock release after ` +
1650
+ `${originalErr === undefined ? 'refused commit' : 'commit failure'} itself failed:`, releaseErr);
1651
+ }
1652
+ };
1525
1653
  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
- }));
1654
+ let commit;
1655
+ try {
1656
+ commit = await this.agentStore.update(agentId, (existing) => {
1657
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
1658
+ return AGENT_STORE_NOOP;
1659
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1660
+ return AGENT_STORE_NOOP;
1661
+ return {
1662
+ ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1663
+ id: agentId,
1664
+ projectId: cfg.projectId,
1665
+ taskId,
1666
+ lockToken: token,
1667
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1668
+ updatedAt: now,
1669
+ };
1670
+ });
1671
+ }
1672
+ catch (err) {
1673
+ await releaseAcquired(err);
1674
+ throw err;
1675
+ }
1676
+ if (commit !== 'committed') {
1677
+ await releaseAcquired();
1678
+ return false;
1679
+ }
1680
+ opts.onAcquired?.(token);
1535
1681
  return true;
1536
1682
  }
1537
1683
  async releaseAgentForTask(agentId, expectedTaskId, mode, opts = {}) {
@@ -1618,11 +1764,15 @@ export class AgentManager {
1618
1764
  ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
1619
1765
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
1620
1766
  ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
1767
+ // Release voids the open question: strip the watermark, bump the epoch so
1768
+ // any in-flight/queued commit of the old generation fences server-side.
1769
+ needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
1621
1770
  };
1622
1771
  return cleared;
1623
1772
  }
1624
1773
  return {
1625
1774
  ...latest,
1775
+ needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
1626
1776
  updatedAt: now,
1627
1777
  };
1628
1778
  });
@@ -1630,6 +1780,7 @@ export class AgentManager {
1630
1780
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
1631
1781
  return false;
1632
1782
  }
1783
+ this.clearNeedInputRetryFor(agentId, expectedTaskId);
1633
1784
  return true;
1634
1785
  }
1635
1786
  let sessionConfirmedAbsent = false;
@@ -1691,7 +1842,7 @@ export class AgentManager {
1691
1842
  return hold(runtime.reason);
1692
1843
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1693
1844
  if (runtime.kind === 'pane') {
1694
- if (state.paneId !== runtime.paneId) {
1845
+ if (state.paneId !== runtime.pane.paneId) {
1695
1846
  await this.agentStore.update(agentId, (latest) => {
1696
1847
  if (!latest
1697
1848
  || latest.taskId !== expectedTaskId
@@ -1699,14 +1850,14 @@ export class AgentManager {
1699
1850
  || latest.workdir !== releaseWorkdir) {
1700
1851
  return AGENT_STORE_NOOP;
1701
1852
  }
1702
- return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1853
+ return { ...latest, paneId: runtime.pane.paneId, updatedAt: new Date().toISOString() };
1703
1854
  });
1704
1855
  const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1705
- if (refreshed.paneId !== runtime.paneId) {
1856
+ if (refreshed.paneId !== runtime.pane.paneId) {
1706
1857
  throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1707
1858
  }
1708
1859
  }
1709
- await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1860
+ await this.waitForReplPromptReady(tmux, runtime.pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1710
1861
  }
1711
1862
  else {
1712
1863
  sessionConfirmedAbsent = true;
@@ -1784,6 +1935,14 @@ export class AgentManager {
1784
1935
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1785
1936
  }
1786
1937
  catch (err) {
1938
+ // REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
1939
+ // branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
1940
+ // 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
1941
+ if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
1942
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
1943
+ `(the pass can be re-queued): ${err.message}`);
1944
+ return false;
1945
+ }
1787
1946
  const reason = err instanceof DirtyWorkdirError
1788
1947
  ? err.message
1789
1948
  : `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -1816,6 +1975,7 @@ export class AgentManager {
1816
1975
  ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1817
1976
  ...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1818
1977
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1978
+ needInput: { epoch: (existing.needInput?.epoch ?? 0) + 1 },
1819
1979
  updatedAt: now,
1820
1980
  };
1821
1981
  });
@@ -1823,6 +1983,7 @@ export class AgentManager {
1823
1983
  console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
1824
1984
  return false;
1825
1985
  }
1986
+ this.clearNeedInputRetryFor(agentId, expectedTaskId);
1826
1987
  return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1827
1988
  });
1828
1989
  }
@@ -1909,6 +2070,8 @@ export class AgentManager {
1909
2070
  if (candidateTasks.length === 0)
1910
2071
  continue;
1911
2072
  const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
2073
+ // 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.
2074
+ const genAtEntry = this.deletionGenerationOf(cfg.id);
1912
2075
  const state = await this.agentStore.get(cfg.id);
1913
2076
  if (!state || !canDispatchWithBinding(state))
1914
2077
  continue;
@@ -1928,11 +2091,15 @@ export class AgentManager {
1928
2091
  }
1929
2092
  if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
1930
2093
  continue;
2094
+ if (this.isDeletionInFlight(cfg.id))
2095
+ continue;
1931
2096
  const token = await this.lockManager.acquire(cfg.id, owner);
1932
2097
  if (!token)
1933
2098
  continue;
1934
2099
  const cleanupResults = [];
1935
2100
  try {
2101
+ if (!this.deletionGateOpen(cfg.id, genAtEntry))
2102
+ continue;
1936
2103
  const lockedState = await this.agentStore.get(cfg.id);
1937
2104
  if (!lockedState || !canDispatchWithBinding(lockedState))
1938
2105
  continue;
@@ -1944,12 +2111,8 @@ export class AgentManager {
1944
2111
  continue;
1945
2112
  const tmux = new TmuxManager(runner);
1946
2113
  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);
2114
+ const pane = await this.resolveClaimedPane(tmux, cfg.id, lockedState.paneId ?? undefined);
2115
+ await this.waitForReplPromptReady(tmux, pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1953
2116
  }
1954
2117
  if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1955
2118
  continue;
@@ -2006,48 +2169,469 @@ export class AgentManager {
2006
2169
  }
2007
2170
  }
2008
2171
  }
2009
- async setAgentNeedInput(agentId, pending, opts = {}) {
2010
- const now = new Date().toISOString();
2172
+ // ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
2173
+ needInputRetry = new Map();
2174
+ needInputRetryInFlight = new Set();
2175
+ // Watermark writes currently in the store chain. A restore bump snapshots these along
2176
+ // with the queue: an answer whose write has not settled yet would otherwise be invisible
2177
+ // to the migration and its error-enqueue would land after the arm already cleared the
2178
+ // queue — deleting the only proof the user answered.
2179
+ needInputWritesInFlight = new Map();
2180
+ needInputWriteSeq = 0;
2181
+ // Serializes the probe→bump→queue-clear section of concurrent arms for one
2182
+ // (agentId, taskId): without it a stale replay probed before a successor's bump can
2183
+ // itself bump afterwards, ghost-fencing the installed successor. All locked steps are
2184
+ // bounded (memory probe + one store update); subscribe stays outside the lock.
2185
+ needInputArmLocks = new Map();
2186
+ needInputRetryTimer = null;
2187
+ needInputRetryKey(agentId, taskId, epoch) {
2188
+ return `${agentId} ${taskId} ${epoch}`;
2189
+ }
2190
+ parseNeedInputRetryKey(key) {
2191
+ const [agentId, taskId, epochRaw] = key.split(' ');
2192
+ if (!agentId || !taskId || epochRaw === undefined)
2193
+ return null;
2194
+ const epoch = Number.parseInt(epochRaw, 10);
2195
+ if (!Number.isFinite(epoch))
2196
+ return null;
2197
+ return { agentId, taskId, epoch };
2198
+ }
2199
+ // Standard commit entry: one agentStore.update, NEVER called from inside another
2200
+ // updater (the store's per-id chain is non-reentrant).
2201
+ // The ledger entry lives until the result is fully handed over — an error is IN the
2202
+ // retry queue before deregistration, in the same synchronous continuation. Splitting
2203
+ // those two across promise jobs would open a window where neither the ledger nor the
2204
+ // queue holds the intent and a concurrent restore snapshot loses the answer.
2205
+ async commitNeedInputWatermark(intent) {
2206
+ const writeId = ++this.needInputWriteSeq;
2207
+ this.needInputWritesInFlight.set(writeId, intent);
2208
+ const key = this.needInputRetryKey(intent.agentId, intent.taskId, intent.epoch);
2011
2209
  try {
2012
- await this.agentStore.update(agentId, (existing) => {
2013
- if (!existing)
2210
+ const result = await this.writeNeedInputWatermark(intent);
2211
+ if (result === 'error') {
2212
+ this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
2213
+ }
2214
+ else {
2215
+ this.settleNeedInputRetry(key, intent.askSeq, intent.answeredSeq, result);
2216
+ }
2217
+ return result;
2218
+ }
2219
+ catch (err) {
2220
+ this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
2221
+ throw err;
2222
+ }
2223
+ finally {
2224
+ this.needInputWritesInFlight.delete(writeId);
2225
+ }
2226
+ }
2227
+ async writeNeedInputWatermark(intent) {
2228
+ let outcome = 'fenced';
2229
+ try {
2230
+ await this.agentStore.update(intent.agentId, (existing) => {
2231
+ if (!existing || existing.taskId !== intent.taskId) {
2232
+ outcome = 'fenced';
2014
2233
  return AGENT_STORE_NOOP;
2015
- // A stale watcher callback must not stamp a rebound agent: the badge belongs
2016
- // to the watch's task, so a taskId mismatch means the binding moved on.
2017
- if (opts.taskId !== undefined && existing.taskId !== opts.taskId)
2234
+ }
2235
+ const cur = existing.needInput;
2236
+ if ((cur?.epoch ?? 0) !== intent.epoch) {
2237
+ outcome = 'fenced';
2018
2238
  return AGENT_STORE_NOOP;
2019
- if (pending) {
2020
- if (existing.needInputAt !== undefined)
2021
- return AGENT_STORE_NOOP;
2022
- return { ...existing, needInputAt: now, updatedAt: now };
2023
2239
  }
2024
- if (existing.needInputAt === undefined)
2240
+ const askSeq = Math.max(cur?.askSeq ?? 0, intent.askSeq);
2241
+ const answeredSeq = Math.max(cur?.answeredSeq ?? 0, intent.answeredSeq);
2242
+ outcome = 'ok';
2243
+ if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq) {
2244
+ return AGENT_STORE_NOOP;
2245
+ }
2246
+ const now = new Date().toISOString();
2247
+ const at = askSeq > answeredSeq ? (cur?.at ?? now) : undefined;
2248
+ return {
2249
+ ...existing,
2250
+ needInput: {
2251
+ epoch: intent.epoch, askSeq, answeredSeq,
2252
+ ...(at !== undefined ? { at } : {}),
2253
+ ...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
2254
+ },
2255
+ updatedAt: now,
2256
+ };
2257
+ });
2258
+ }
2259
+ catch (err) {
2260
+ console.warn(`[AgentManager] needInput watermark write failed (agent=${intent.agentId} epoch=${intent.epoch}):`, err);
2261
+ return 'error';
2262
+ }
2263
+ return outcome;
2264
+ }
2265
+ enqueueNeedInputRetry(key, askSeq, answeredSeq) {
2266
+ const cur = this.needInputRetry.get(key);
2267
+ this.needInputRetry.set(key, {
2268
+ askSeq: Math.max(cur?.askSeq ?? 0, askSeq),
2269
+ answeredSeq: Math.max(cur?.answeredSeq ?? 0, answeredSeq),
2270
+ });
2271
+ this.ensureNeedInputRetryTimer();
2272
+ }
2273
+ // ok deletes only when the queued item is still covered by what was written
2274
+ // (a concurrent edge may have raised it mid-flight); fenced is terminal either way.
2275
+ settleNeedInputRetry(key, wroteAskSeq, wroteAnsweredSeq, result) {
2276
+ const cur = this.needInputRetry.get(key);
2277
+ if (!cur)
2278
+ return;
2279
+ if (result === 'fenced' || (cur.askSeq <= wroteAskSeq && cur.answeredSeq <= wroteAnsweredSeq)) {
2280
+ this.needInputRetry.delete(key);
2281
+ this.ensureNeedInputRetryTimer();
2282
+ }
2283
+ }
2284
+ clearNeedInputRetryFor(agentId, taskId) {
2285
+ for (const key of [...this.needInputRetry.keys()]) {
2286
+ const parsed = this.parseNeedInputRetryKey(key);
2287
+ if (parsed && parsed.agentId === agentId && parsed.taskId === taskId) {
2288
+ this.needInputRetry.delete(key);
2289
+ }
2290
+ }
2291
+ this.ensureNeedInputRetryTimer();
2292
+ }
2293
+ ensureNeedInputRetryTimer() {
2294
+ if (this.needInputRetry.size === 0) {
2295
+ if (this.needInputRetryTimer) {
2296
+ clearInterval(this.needInputRetryTimer);
2297
+ this.needInputRetryTimer = null;
2298
+ }
2299
+ return;
2300
+ }
2301
+ if (this.needInputRetryTimer)
2302
+ return;
2303
+ this.needInputRetryTimer = setInterval(() => {
2304
+ void this.needInputRetryPass();
2305
+ }, this.needInputRetryIntervalMs);
2306
+ this.needInputRetryTimer.unref?.();
2307
+ }
2308
+ async needInputRetryPass() {
2309
+ for (const [key, snapshot] of [...this.needInputRetry.entries()]) {
2310
+ if (this.needInputRetryInFlight.has(key))
2311
+ continue;
2312
+ const parsed = this.parseNeedInputRetryKey(key);
2313
+ if (!parsed) {
2314
+ this.needInputRetry.delete(key);
2315
+ continue;
2316
+ }
2317
+ this.needInputRetryInFlight.add(key);
2318
+ try {
2319
+ const result = await this.writeNeedInputWatermark({
2320
+ agentId: parsed.agentId,
2321
+ taskId: parsed.taskId,
2322
+ epoch: parsed.epoch,
2323
+ askSeq: snapshot.askSeq,
2324
+ answeredSeq: snapshot.answeredSeq,
2325
+ });
2326
+ if (result !== 'error') {
2327
+ this.settleNeedInputRetry(key, snapshot.askSeq, snapshot.answeredSeq, result);
2328
+ }
2329
+ }
2330
+ finally {
2331
+ this.needInputRetryInFlight.delete(key);
2332
+ }
2333
+ }
2334
+ this.ensureNeedInputRetryTimer();
2335
+ }
2336
+ collectSameEpochNeedInput(agentId, taskId, epoch) {
2337
+ let owed = null;
2338
+ const merge = (wm) => {
2339
+ owed = {
2340
+ askSeq: Math.max(owed?.askSeq ?? 0, wm.askSeq),
2341
+ answeredSeq: Math.max(owed?.answeredSeq ?? 0, wm.answeredSeq),
2342
+ };
2343
+ };
2344
+ for (const [key, wm] of this.needInputRetry) {
2345
+ const parsed = this.parseNeedInputRetryKey(key);
2346
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2347
+ continue;
2348
+ if (parsed.epoch === epoch)
2349
+ merge(wm);
2350
+ }
2351
+ for (const intent of this.needInputWritesInFlight.values()) {
2352
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2353
+ continue;
2354
+ if (intent.epoch === epoch)
2355
+ merge(intent);
2356
+ }
2357
+ return owed;
2358
+ }
2359
+ // Only the generation this arm directly succeeds (bumps are +1 and serialized per key).
2360
+ // Anything older was already superseded — a fresh arm in between stripped its question
2361
+ // on purpose, and transplanting it would relight a prompt nobody asked.
2362
+ collectPredecessorNeedInputDelta(agentId, taskId, epoch) {
2363
+ let delta = null;
2364
+ const merge = (wm) => {
2365
+ delta = {
2366
+ askSeq: Math.max(delta?.askSeq ?? 0, wm.askSeq),
2367
+ answeredSeq: Math.max(delta?.answeredSeq ?? 0, wm.answeredSeq),
2368
+ };
2369
+ };
2370
+ for (const [key, wm] of this.needInputRetry) {
2371
+ const parsed = this.parseNeedInputRetryKey(key);
2372
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2373
+ continue;
2374
+ if (parsed.epoch === epoch - 1)
2375
+ merge(wm);
2376
+ }
2377
+ for (const intent of this.needInputWritesInFlight.values()) {
2378
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2379
+ continue;
2380
+ if (intent.epoch === epoch - 1)
2381
+ merge(intent);
2382
+ }
2383
+ return delta;
2384
+ }
2385
+ async withNeedInputArmLock(agentId, taskId, fn) {
2386
+ const key = `${agentId} ${taskId}`;
2387
+ const prev = this.needInputArmLocks.get(key) ?? Promise.resolve();
2388
+ const run = prev.then(fn);
2389
+ const settled = run.then(() => undefined, () => undefined);
2390
+ this.needInputArmLocks.set(key, settled);
2391
+ void settled.finally(() => {
2392
+ if (this.needInputArmLocks.get(key) === settled)
2393
+ this.needInputArmLocks.delete(key);
2394
+ });
2395
+ return run;
2396
+ }
2397
+ // 'restore' must also merge queued retry seqs of the CURRENT persisted epoch: after
2398
+ // session-gone the queue can hold the only proof an answer happened (the entry is
2399
+ // gone, so needInputInherit has nothing to merge), and the arm clears the queue.
2400
+ async bumpNeedInputEpochForArm(agentId, taskId, mode, opts = {}) {
2401
+ // Contributions are collected INSIDE the updater: it runs when the store chain
2402
+ // reaches this bump, so an answer whose commit started while the bump was queued
2403
+ // (registered in the ledger, or already error-queued) is still visible. A snapshot
2404
+ // taken at call time would miss exactly that window.
2405
+ const collectContributions = () => {
2406
+ const queued = new Map();
2407
+ if (mode !== 'restore')
2408
+ return queued;
2409
+ const mergeContribution = (epoch, wm) => {
2410
+ const cur = queued.get(epoch);
2411
+ queued.set(epoch, {
2412
+ askSeq: Math.max(cur?.askSeq ?? 0, wm.askSeq),
2413
+ answeredSeq: Math.max(cur?.answeredSeq ?? 0, wm.answeredSeq),
2414
+ });
2415
+ };
2416
+ for (const [key, qwm] of this.needInputRetry) {
2417
+ const parsed = this.parseNeedInputRetryKey(key);
2418
+ if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
2419
+ continue;
2420
+ mergeContribution(parsed.epoch, qwm);
2421
+ }
2422
+ for (const intent of this.needInputWritesInFlight.values()) {
2423
+ if (intent.agentId !== agentId || intent.taskId !== taskId)
2424
+ continue;
2425
+ mergeContribution(intent.epoch, intent);
2426
+ }
2427
+ return queued;
2428
+ };
2429
+ let wm = null;
2430
+ let snapshotBlind = false;
2431
+ // Scoped to THIS arm's token: a marker left by another token's replay says nothing
2432
+ // about the pane's ambiguity for us, so it is neither honoured nor carried forward.
2433
+ const keepCutover = (cur) => (opts.paneCutover
2434
+ ? opts.armToken
2435
+ : cur !== undefined && cur.cutoverToken === opts.armToken ? cur.cutoverToken : undefined);
2436
+ try {
2437
+ await this.agentStore.update(agentId, (existing) => {
2438
+ if (!existing || existing.taskId !== taskId)
2025
2439
  return AGENT_STORE_NOOP;
2026
- const { needInputAt: _needInputAt, ...rest } = existing;
2027
- return { ...rest, updatedAt: now };
2440
+ const cur = existing.needInput;
2441
+ const contrib = collectContributions().get(cur?.epoch ?? 0);
2442
+ const epoch = (cur?.epoch ?? 0) + 1;
2443
+ const askSeq = mode === 'restore' ? Math.max(cur?.askSeq ?? 0, contrib?.askSeq ?? 0) : 0;
2444
+ const answeredSeq = mode === 'restore'
2445
+ ? Math.max(cur?.answeredSeq ?? 0, contrib?.answeredSeq ?? 0)
2446
+ : 0;
2447
+ wm = { epoch, askSeq, answeredSeq };
2448
+ const cutoverToken = keepCutover(cur);
2449
+ snapshotBlind = cutoverToken !== undefined;
2450
+ const at = askSeq > answeredSeq ? (cur?.at ?? new Date().toISOString()) : undefined;
2451
+ return {
2452
+ ...existing,
2453
+ needInput: {
2454
+ epoch, askSeq, answeredSeq,
2455
+ ...(at !== undefined ? { at } : {}),
2456
+ ...(cutoverToken !== undefined ? { cutoverToken } : {}),
2457
+ },
2458
+ updatedAt: new Date().toISOString(),
2459
+ };
2028
2460
  });
2029
2461
  }
2030
2462
  catch (err) {
2031
- console.warn(`[AgentManager] setAgentNeedInput(${agentId}, ${pending}) failed:`, err);
2463
+ console.warn(`[AgentManager] needInput epoch bump failed (agent=${agentId} task=${taskId}):`, err);
2464
+ // Fall back to the persisted watermark for a restore: without it the entry would
2465
+ // start at 0/0 and swallow the answer to a question that is still open in the
2466
+ // store, leaving the badge lit forever. No bump means no generation advance —
2467
+ // the caller must not treat this as a fresh generation.
2468
+ if (mode === 'restore') {
2469
+ try {
2470
+ const cur = await this.agentStore.get(agentId);
2471
+ if (cur?.taskId === taskId && cur.needInput) {
2472
+ // Same-generation intents still owed to the store hold questions the
2473
+ // persisted watermark has not caught up with; merge them in.
2474
+ const owed = this.collectSameEpochNeedInput(agentId, taskId, cur.needInput.epoch);
2475
+ return {
2476
+ wm: {
2477
+ epoch: cur.needInput.epoch,
2478
+ askSeq: Math.max(cur.needInput.askSeq ?? 0, owed?.askSeq ?? 0),
2479
+ answeredSeq: Math.max(cur.needInput.answeredSeq ?? 0, owed?.answeredSeq ?? 0),
2480
+ },
2481
+ bumped: false,
2482
+ snapshotBlind: cur.needInput.cutoverToken === opts.armToken,
2483
+ };
2484
+ }
2485
+ }
2486
+ catch (readErr) {
2487
+ console.warn(`[AgentManager] needInput watermark read-back failed (agent=${agentId}):`, readErr);
2488
+ }
2489
+ }
2490
+ return { wm: null, bumped: false, snapshotBlind: true };
2032
2491
  }
2492
+ return { wm, bumped: wm !== null, snapshotBlind: wm === null ? true : snapshotBlind };
2033
2493
  }
2034
- // Single entry for arming phase-signal watches: every path (dispatch, post-approve,
2035
- // recover, rollback) must carry the need-input callback, or that watch silently
2036
- // drops the side-channel and the badge never lights for its phase.
2037
- startPhaseSignalWatch(args) {
2494
+ // Single entry for arming phase-signal watches — every path must flow through the
2495
+ // epoch bump, or stale-generation commits could survive the re-arm.
2496
+ async startPhaseSignalWatch(args) {
2038
2497
  if (!this.phaseSignalWatcher)
2039
- return Promise.resolve(true);
2040
- return this.phaseSignalWatcher.start({
2041
- ...args,
2042
- onNeedInput: (pending) => {
2043
- void this.setAgentNeedInput(args.agentId, pending, { taskId: args.taskId });
2044
- },
2498
+ return true;
2499
+ const { needInputMode, needInputPaneCutover: _paneCutover, ...rest } = args;
2500
+ const mode = needInputMode ?? 'restore';
2501
+ const watcher = this.phaseSignalWatcher;
2502
+ // Claim ownership before bumping: a rejected arm must not bump (it would fence the
2503
+ // owner), and the claim keeps this arm visible to later probes while it subscribes.
2504
+ const gateResult = await this.withNeedInputArmLock(args.agentId, args.taskId, async () => {
2505
+ const claim = watcher.claimArm?.({
2506
+ taskId: args.taskId,
2507
+ agentId: args.agentId,
2508
+ token: args.token,
2509
+ ...(args.onlyReplaceOwnToken !== undefined ? { onlyReplaceOwnToken: args.onlyReplaceOwnToken } : {}),
2510
+ ...(args.replaceScope !== undefined ? { replaceScope: args.replaceScope } : {}),
2511
+ });
2512
+ if (claim === null)
2513
+ return 'rejected';
2514
+ const bump = await this.bumpNeedInputEpochForArm(args.agentId, args.taskId, mode, {
2515
+ armToken: args.token,
2516
+ ...(args.needInputPaneCutover ? { paneCutover: true } : {}),
2517
+ });
2518
+ // Only a real generation advance may drop the queue: same-generation intents
2519
+ // (bump failed, watermark read back) are still owed to the store.
2520
+ if (bump.bumped)
2521
+ this.clearNeedInputRetryFor(args.agentId, args.taskId);
2522
+ return { ...bump, claim };
2045
2523
  });
2524
+ if (gateResult === 'rejected')
2525
+ return false;
2526
+ const { wm, bumped, snapshotBlind, claim } = gateResult;
2527
+ // Same synchronous section as the bump result: old-generation answers must not slip
2528
+ // between the bump and the handoff (start() migrates the predecessor synchronously).
2529
+ if (wm !== null && bumped && mode === 'restore') {
2530
+ const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
2531
+ if (delta) {
2532
+ void this.commitNeedInputWatermark({
2533
+ agentId: args.agentId,
2534
+ taskId: args.taskId,
2535
+ epoch: wm.epoch,
2536
+ askSeq: Math.max(delta.askSeq, wm.askSeq),
2537
+ answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
2538
+ });
2539
+ }
2540
+ }
2541
+ // wm === null (no binding / store error): arm anyway with badge commits disabled —
2542
+ // the badge is display-only, the signal watch drives task progress.
2543
+ try {
2544
+ return await watcher.start({
2545
+ ...rest,
2546
+ ...(claim != null ? { armClaimId: claim } : {}),
2547
+ // Inherit is about restore semantics, not about having a generation: a degraded
2548
+ // restore still has to keep the predecessor's pending question in memory.
2549
+ needInputInherit: mode === 'restore',
2550
+ ...(snapshotBlind ? { needInputSnapshotBlind: true } : {}),
2551
+ ...(wm !== null ? { needInput: wm } : {}),
2552
+ });
2553
+ }
2554
+ finally {
2555
+ watcher.releaseArm?.(claim);
2556
+ }
2557
+ }
2558
+ // Web-fallback confirm: settles the newest known question (store ∪ retry queue) as
2559
+ // answered in ONE store update — usable when no watch entry exists (pre-recovery,
2560
+ // arm failure, post-stop). Queue contributions are snapshotted synchronously; the
2561
+ // updater derives everything else from `existing`, so no nested store calls.
2562
+ async confirmNeedInputAnswered(agentId) {
2563
+ const queueAsk = new Map();
2564
+ for (const [key, wm] of this.needInputRetry) {
2565
+ const parsed = this.parseNeedInputRetryKey(key);
2566
+ if (!parsed || parsed.agentId !== agentId)
2567
+ continue;
2568
+ const mapKey = `${parsed.taskId} ${parsed.epoch}`;
2569
+ queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, wm.askSeq));
2570
+ }
2571
+ for (const intent of this.needInputWritesInFlight.values()) {
2572
+ if (intent.agentId !== agentId)
2573
+ continue;
2574
+ const mapKey = `${intent.taskId} ${intent.epoch}`;
2575
+ queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, intent.askSeq));
2576
+ }
2577
+ let settled = null;
2578
+ try {
2579
+ await this.agentStore.update(agentId, (existing) => {
2580
+ if (!existing?.taskId)
2581
+ return AGENT_STORE_NOOP;
2582
+ const cur = existing.needInput;
2583
+ const epoch = cur?.epoch ?? 0;
2584
+ const qAsk = queueAsk.get(`${existing.taskId} ${epoch}`) ?? 0;
2585
+ const askSeq = Math.max(cur?.askSeq ?? 0, qAsk);
2586
+ const answeredSeq = Math.max(cur?.answeredSeq ?? 0, askSeq);
2587
+ settled = { taskId: existing.taskId, epoch, askSeq };
2588
+ if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq && cur.at === undefined) {
2589
+ return AGENT_STORE_NOOP;
2590
+ }
2591
+ if (!cur && askSeq === 0)
2592
+ return AGENT_STORE_NOOP;
2593
+ return {
2594
+ ...existing,
2595
+ needInput: {
2596
+ epoch, askSeq, answeredSeq,
2597
+ ...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
2598
+ },
2599
+ updatedAt: new Date().toISOString(),
2600
+ };
2601
+ });
2602
+ }
2603
+ catch (err) {
2604
+ console.warn(`[AgentManager] confirmNeedInputAnswered(${agentId}) failed:`, err);
2605
+ if (settled !== null) {
2606
+ const s = settled;
2607
+ this.enqueueNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq);
2608
+ }
2609
+ return;
2610
+ }
2611
+ if (settled !== null) {
2612
+ const s = settled;
2613
+ this.settleNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq, 'ok');
2614
+ }
2046
2615
  }
2047
- // The user typed into the agent's terminal — its pending question is being answered.
2616
+ // The user typed into the agent's terminal — its open question counts as answered.
2617
+ // The store fallback runs only when no watch entry exists (pre-recovery, arm failure,
2618
+ // post-stop): with a live entry it would race the entry's in-flight writes and could
2619
+ // confirm an ask that arrived after this input.
2048
2620
  async notifyHumanTerminalInput(agentId) {
2049
- this.phaseSignalWatcher?.rearmNeedInput(agentId);
2050
- await this.setAgentNeedInput(agentId, false);
2621
+ // Rearm first: it settles entry state synchronously, so a question printed during
2622
+ // this call cannot be confirmed by mistake. The binding read only decides whether a
2623
+ // watcher of the CURRENT task took the input — a leftover entry of a finished task
2624
+ // must not suppress the store fallback.
2625
+ const handled = await this.phaseSignalWatcher?.rearmNeedInput(agentId);
2626
+ let taskId;
2627
+ try {
2628
+ taskId = (await this.agentStore.get(agentId))?.taskId;
2629
+ }
2630
+ catch (err) {
2631
+ console.warn(`[AgentManager] notifyHumanTerminalInput(${agentId}) binding read failed:`, err);
2632
+ }
2633
+ if (taskId === undefined || !handled?.has(taskId))
2634
+ await this.confirmNeedInputAnswered(agentId);
2051
2635
  }
2052
2636
  async clearAwaitingHuman(agentId, opts = {}) {
2053
2637
  const now = new Date().toISOString();
@@ -2308,6 +2892,8 @@ export class AgentManager {
2308
2892
  next.lockToken = existing.lockToken;
2309
2893
  if (existing.startedAt !== undefined)
2310
2894
  next.startedAt = existing.startedAt;
2895
+ if (existing.needInput !== undefined)
2896
+ next.needInput = existing.needInput;
2311
2897
  }
2312
2898
  return next;
2313
2899
  });
@@ -2398,10 +2984,18 @@ export class AgentManager {
2398
2984
  }
2399
2985
  if (result.redispatchReviewTaskId) {
2400
2986
  try {
2987
+ const reviewTask = await this.taskStore.get(result.redispatchReviewTaskId);
2988
+ const resumeEntry = this.getPendingDispatchRetry(result.redispatchReviewTaskId);
2989
+ const resumeQaPhase = resumeEntry?.kind === 'qa-recheck'
2990
+ && resumeEntry.signalToken === reviewTask?.signalToken
2991
+ && resumeEntry.qaPhase !== undefined
2992
+ ? resumeEntry.qaPhase
2993
+ : (reviewTask?.reviewRound === 0 ? 'review' : undefined);
2401
2994
  await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
2402
- bumpRound: false,
2995
+ bumpRound: reviewTask?.reviewRoundPending === true,
2403
2996
  fromStatus: ['review'],
2404
2997
  ...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
2998
+ ...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
2405
2999
  });
2406
3000
  }
2407
3001
  catch (err) {
@@ -2496,14 +3090,12 @@ export class AgentManager {
2496
3090
  ...(result.reason ? { reason: result.reason } : {}),
2497
3091
  };
2498
3092
  }
2499
- async resolvePaneId(state, cfg) {
2500
- if (state.paneId)
2501
- return state.paneId;
3093
+ async resolveClaimedPaneOrNull(state, cfg) {
2502
3094
  try {
2503
- return await new TmuxManager(this.createRunnerFor(cfg)).getSinglePaneId(cfg.id);
3095
+ return await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), cfg.id, state.paneId);
2504
3096
  }
2505
3097
  catch (err) {
2506
- console.warn(`[AgentManager] resolvePaneId: getSinglePaneId failed for ${cfg.id}:`, err);
3098
+ console.warn(`[AgentManager] resolveClaimedPane failed for ${cfg.id}:`, err);
2507
3099
  return null;
2508
3100
  }
2509
3101
  }
@@ -2521,27 +3113,16 @@ export class AgentManager {
2521
3113
  }
2522
3114
  if (!sessionAlive)
2523
3115
  return { kind: 'absent' };
2524
- let claim;
2525
3116
  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) };
3117
+ return { kind: 'pane', pane: await this.resolveClaimedPane(tmux, agentId) };
2543
3118
  }
2544
3119
  catch (err) {
3120
+ if (err instanceof PaneGoneError) {
3121
+ return {
3122
+ kind: 'hold',
3123
+ reason: `Runtime session claim probe/mismatch; refusing checkout cleanup: ${err.message}`,
3124
+ };
3125
+ }
2545
3126
  return {
2546
3127
  kind: 'hold',
2547
3128
  reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
@@ -2550,9 +3131,10 @@ export class AgentManager {
2550
3131
  }
2551
3132
  }
2552
3133
  async interruptPaneAndWaitReady(state, cfg) {
2553
- const paneId = await this.resolvePaneId(state, cfg);
2554
- if (!paneId)
3134
+ const pane = await this.resolveClaimedPaneOrNull(state, cfg);
3135
+ if (!pane)
2555
3136
  return false;
3137
+ const paneId = pane.paneId;
2556
3138
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
2557
3139
  const runtime = agentRuntimeKindFor(cfg);
2558
3140
  if (!(await this.acquireCompactGuardWithin(cfg.id, this.cancelInterruptGuardWaitMs))) {
@@ -2563,43 +3145,44 @@ export class AgentManager {
2563
3145
  }
2564
3146
  try {
2565
3147
  try {
2566
- await tmux.sendKeysToPane(paneId, 'Escape');
3148
+ await tmux.sendKeysToPane(pane, 'Escape');
2567
3149
  await new Promise(r => setTimeout(r, 200));
2568
3150
  }
2569
3151
  catch (err) {
2570
3152
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
2571
3153
  return false;
2572
3154
  }
2573
- if (await this.paneReachedReplReady(tmux, paneId, runtime, 10_000))
3155
+ if (await this.paneReachedReplReady(tmux, pane, runtime, 10_000))
2574
3156
  return true;
2575
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
3157
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2576
3158
  return false;
2577
- if (await this.paneHasLiveTurn(tmux, paneId, runtime)) {
3159
+ if (await this.paneHasLiveTurn(tmux, pane, runtime)) {
2578
3160
  console.warn(`[AgentManager] interruptPaneAndWaitReady: pane ${paneId} still running a turn after ESC; holding`);
2579
3161
  return false;
2580
3162
  }
2581
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
3163
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2582
3164
  return false;
2583
3165
  try {
2584
- await tmux.sendKeysToPane(paneId, 'C-c');
3166
+ await tmux.sendKeysToPane(pane, 'C-c');
2585
3167
  await new Promise(r => setTimeout(r, 200));
2586
3168
  }
2587
3169
  catch (err) {
2588
3170
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c (composer clear) failed for pane ${paneId}:`, err);
2589
3171
  return false;
2590
3172
  }
2591
- return this.paneReachedReplReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
3173
+ return this.paneReachedReplReady(tmux, pane, runtime, this.cleanComposerWaitMs);
2592
3174
  }
2593
3175
  finally {
2594
3176
  this.compactInFlight.delete(cfg.id);
2595
3177
  }
2596
3178
  }
2597
- async paneHasLiveTurn(tmux, paneId, runtime) {
3179
+ async paneHasLiveTurn(tmux, pane, runtime) {
3180
+ const paneId = pane.paneId;
2598
3181
  let first;
2599
3182
  let firstTitle;
2600
3183
  try {
2601
- first = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2602
- firstTitle = await tmux.readPaneTitle(paneId);
3184
+ first = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
3185
+ firstTitle = await tmux.readPaneTitle(pane);
2603
3186
  }
2604
3187
  catch (err) {
2605
3188
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness capture failed for pane ${paneId}:`, err);
@@ -2610,8 +3193,8 @@ export class AgentManager {
2610
3193
  let frame;
2611
3194
  let title;
2612
3195
  try {
2613
- frame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2614
- title = await tmux.readPaneTitle(paneId);
3196
+ frame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
3197
+ title = await tmux.readPaneTitle(pane);
2615
3198
  }
2616
3199
  catch (err) {
2617
3200
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness re-capture failed for pane ${paneId}:`, err);
@@ -2626,10 +3209,11 @@ export class AgentManager {
2626
3209
  }
2627
3210
  return false;
2628
3211
  }
2629
- async paneRunsRuntime(tmux, paneId, runtime) {
3212
+ async paneRunsRuntime(tmux, pane, runtime) {
3213
+ const paneId = pane.paneId;
2630
3214
  let proc;
2631
3215
  try {
2632
- proc = await tmux.displayMessage(paneId, '#{pane_current_command}');
3216
+ proc = await tmux.displayMessage(pane, '#{pane_current_command}');
2633
3217
  }
2634
3218
  catch (err) {
2635
3219
  console.warn(`[AgentManager] interruptPaneAndWaitReady: proc-title read failed for pane ${paneId}:`, err);
@@ -2641,9 +3225,10 @@ export class AgentManager {
2641
3225
  }
2642
3226
  return true;
2643
3227
  }
2644
- async paneReachedReplReady(tmux, paneId, runtime, timeoutMs) {
3228
+ async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
3229
+ const paneId = pane.paneId;
2645
3230
  try {
2646
- await tmux.waitReplReady(paneId, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
3231
+ await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
2647
3232
  return true;
2648
3233
  }
2649
3234
  catch (err) {
@@ -2774,20 +3359,25 @@ export class AgentManager {
2774
3359
  }
2775
3360
  }
2776
3361
  }
2777
- async pollPaneCommandStable(tmux, paneId, opts) {
3362
+ async pollPaneCommandStable(tmux, pane, opts) {
2778
3363
  const deadline = Date.now() + opts.timeoutMs;
2779
3364
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2780
3365
  let last = '';
2781
3366
  while (Date.now() < deadline) {
2782
3367
  await new Promise(r => setTimeout(r, 100));
2783
- const raw = await tmux.displayMessage(paneId, '#{pane_current_command}');
3368
+ const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
2784
3369
  last = raw.trim();
2785
3370
  if (opts.expectShell ? SHELL.test(last) : last !== '')
2786
3371
  return last;
2787
3372
  }
2788
3373
  return last;
2789
3374
  }
2790
- async restartReplOnly(agentId) {
3375
+ async restartReplOnly(agentId, opts = {}) {
3376
+ // 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).
3377
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
3378
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
3379
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3380
+ }
2791
3381
  const cfg = this.getAgentConfig(agentId);
2792
3382
  if (!cfg)
2793
3383
  throw new Error(`Unknown agent: ${agentId}`);
@@ -2800,14 +3390,17 @@ export class AgentManager {
2800
3390
  if (snapshot.claim !== agentId) {
2801
3391
  throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
2802
3392
  }
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 });
3393
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
3394
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3395
+ }
3396
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
3397
+ await tmux.sendKeysToPane(pane, 'C-c');
3398
+ const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
2806
3399
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
2807
3400
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2808
3401
  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 });
3402
+ await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
3403
+ await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
2811
3404
  }
2812
3405
  else if (!SHELL.test(cmd)) {
2813
3406
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
@@ -2815,33 +3408,42 @@ export class AgentManager {
2815
3408
  const project = this.getProjectConfig(cfg.projectId);
2816
3409
  if (!project)
2817
3410
  throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
3411
+ // 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.
3412
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
3413
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3414
+ }
2818
3415
  const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
2819
- const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
3416
+ const paneWorkdir = await tmux.getPaneCurrentPath(pane);
2820
3417
  if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
2821
3418
  throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
2822
3419
  'use retry to rebuild the session safely');
2823
3420
  }
3421
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
3422
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3423
+ }
2824
3424
  await this.provisionRepoSkills(runner, cfg, workdir);
2825
3425
  await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
2826
3426
  const runtime = agentRuntimeKindFor(cfg);
2827
3427
  const relaunch = async () => {
2828
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
2829
- await tmux.handleTrustDialog(paneId, runtime, {
3428
+ await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
3429
+ await tmux.sendEnter(pane);
3430
+ await tmux.handleTrustDialog(pane, runtime, {
2830
3431
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
2831
3432
  });
2832
- await tmux.waitReplReady(paneId, runtime, {
3433
+ await tmux.waitReplReady(pane, runtime, {
2833
3434
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
2834
3435
  scrollback: 0,
2835
3436
  });
2836
3437
  await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
2837
3438
  };
2838
3439
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
3440
+ // 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
3441
  await this.agentStore.update(agentId, (state) => {
2840
- if (!state)
3442
+ if (!state || !this.deletionGateOpen(agentId, genAtEntry))
2841
3443
  return AGENT_STORE_NOOP;
2842
3444
  return {
2843
3445
  ...state,
2844
- paneId,
3446
+ paneId: pane.paneId,
2845
3447
  updatedAt: new Date().toISOString(),
2846
3448
  };
2847
3449
  });
@@ -2920,6 +3522,8 @@ export class AgentManager {
2920
3522
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
2921
3523
  skipSnapshot: true,
2922
3524
  onlyReplaceOwnToken: true,
3525
+ needInputMode: 'fresh',
3526
+ needInputPaneCutover: true,
2923
3527
  // git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
2924
3528
  // sibling(reconciliation)的不同 token 否决本应合法的重布防
2925
3529
  ...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
@@ -3217,25 +3821,11 @@ export class AgentManager {
3217
3821
  const failures = [];
3218
3822
  for (const id of targets) {
3219
3823
  const cfg = this.getAgentConfig(id);
3220
- if (!cfg)
3221
- continue;
3222
- const runner = this.createRunnerFor(cfg);
3223
- 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
- }
3824
+ if (!cfg)
3825
+ continue;
3826
+ const runner = this.createRunnerFor(cfg);
3827
+ const tmux = new TmuxManager(runner);
3828
+ // 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
3829
  this.stopRuntimeMenuWatch(id);
3240
3830
  if (this.paneStreamerManager) {
3241
3831
  try {
@@ -3247,21 +3837,45 @@ export class AgentManager {
3247
3837
  }
3248
3838
  try {
3249
3839
  await this.runUnderSessionLifecycle(id, async () => {
3250
- const snapshot = await tmux.getSessionSnapshot(id);
3840
+ const cleanupOpts = { timeout: DELETE_CLEANUP_TIMEOUT_MS };
3841
+ const snapshot = await tmux.getSessionSnapshot(id, cleanupOpts);
3251
3842
  if (!snapshot)
3252
3843
  return;
3253
- if (snapshot.claim !== id) {
3254
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3255
- `(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
3844
+ if (snapshot.claim === id) {
3845
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'equals', claim: id }, cleanupOpts);
3846
+ if (outcome === 'refused') {
3847
+ throw new Error(`tmux generation/claim changed for ${id} before kill; retry DELETE to observe new state`);
3848
+ }
3849
+ if (outcome === 'killed') {
3850
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
3851
+ }
3256
3852
  return;
3257
3853
  }
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}`);
3854
+ if (snapshot.claim === null) {
3855
+ // A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
3856
+ let nonceOwned;
3857
+ try {
3858
+ nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
3859
+ }
3860
+ catch (err) {
3861
+ throw new Error(`creation-nonce probe failed for ${id}; retry DELETE: ${err instanceof Error ? err.message : String(err)}`);
3862
+ }
3863
+ if (!nonceOwned) {
3864
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping ${id} ` +
3865
+ `(unclaimed, no baxian creation nonce; foreign session)`);
3866
+ return;
3867
+ }
3868
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' }, cleanupOpts);
3869
+ if (outcome === 'refused') {
3870
+ throw new Error(`half-created session for ${id} was claimed or its server changed before kill; retry DELETE`);
3871
+ }
3872
+ if (outcome === 'killed') {
3873
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed half-created leftover ${snapshot.ref.sessionId} for removed agent ${id}`);
3874
+ }
3875
+ return;
3264
3876
  }
3877
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3878
+ `(claim=${snapshot.claim}; not baxian-managed)`);
3265
3879
  });
3266
3880
  }
3267
3881
  catch (err) {
@@ -3451,6 +4065,7 @@ export class AgentManager {
3451
4065
  agentId: task.agentId,
3452
4066
  expectedKinds: 'pr-merge-ready',
3453
4067
  token: value.token,
4068
+ needInputMode: 'fresh',
3454
4069
  });
3455
4070
  if (!armed) {
3456
4071
  // completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
@@ -3752,6 +4367,30 @@ export class AgentManager {
3752
4367
  }
3753
4368
  // 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
3754
4369
  // createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
4370
+ // 启动期按「未引用」跳过 skill 池的插件,被热配置首次引用时同步重扫;仍不可物化即拒绝提交——
4371
+ // 可解析但物化为空的 tool 会让派发要求加载一个不存在的 skill。
4372
+ async ensurePluginSkillPools(next) {
4373
+ if (!this.pluginRegistry)
4374
+ return;
4375
+ for (const project of next.project) {
4376
+ if (projectReviewMode(next, project) !== 'git')
4377
+ continue;
4378
+ const tool = resolveProjectTool(project);
4379
+ if (tool === undefined)
4380
+ continue;
4381
+ const plugin = this.pluginRegistry.resolveTool(tool);
4382
+ // 静默放行会存下一个「无 driver、无平台 skill、重启即 fatal」的配置(启动期同判据)
4383
+ if (plugin === undefined) {
4384
+ throw new Error(`project '${project.id}': no git-driver plugin provides tool '${tool}' — install it under ~/.baxian/plugins/ first`);
4385
+ }
4386
+ if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length > 0)
4387
+ continue;
4388
+ await this.skillRegistry.scanPluginSkills(tool, join(plugin.pluginPath, 'skills'));
4389
+ if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length === 0) {
4390
+ throw new Error(`plugin '${plugin.manifest.name}' provides no materializable skills for tool '${tool}'`);
4391
+ }
4392
+ }
4393
+ }
3755
4394
  async guardGitConfigCommit(current, next, scan, commit) {
3756
4395
  return this.withTaskLock(async () => {
3757
4396
  const blockers = await scan(this, current, next);
@@ -3801,7 +4440,27 @@ export class AgentManager {
3801
4440
  if (!['review', 'in_progress', 'fixing'].includes(task.status))
3802
4441
  continue;
3803
4442
  try {
3804
- await this.dispatchReviewToQa(task.id, { bumpRound: false });
4443
+ // 列表是旧快照:消费谓词按实时值复核(pending 仍在 + 状态允许),并把代际
4444
+ // CAS(expectSignalToken + expectReviewDispatchPending)压进 dispatch 锁内
4445
+ const live = await this.taskStore.get(task.id);
4446
+ if (!live || live.reviewDispatchPending !== true)
4447
+ continue;
4448
+ if (!['review', 'in_progress', 'fixing'].includes(live.status))
4449
+ continue;
4450
+ const entry = this.getPendingDispatchRetry(live.id);
4451
+ if (entry?.kind === 'qa-recheck' && entry.signalToken === live.signalToken) {
4452
+ // 同代 busy pending 在场:QA 已知忙碌,重派只会空转 release/acquire 并重置预算;
4453
+ // 交给对账的观测门在空闲后消费
4454
+ continue;
4455
+ }
4456
+ const qaPhase = live.reviewRound === 0 ? 'review' : undefined;
4457
+ await this.dispatchReviewToQa(live.id, {
4458
+ bumpRound: live.reviewRoundPending === true,
4459
+ ...(qaPhase !== undefined ? { qaPhase } : {}),
4460
+ ...(live.signalToken !== undefined ? { expectSignalToken: live.signalToken } : {}),
4461
+ expectReviewDispatchPending: true,
4462
+ fromStatus: ['review', 'in_progress', 'fixing'],
4463
+ });
3805
4464
  }
3806
4465
  catch (err) {
3807
4466
  console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
@@ -4153,6 +4812,82 @@ export class AgentManager {
4153
4812
  return {};
4154
4813
  return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
4155
4814
  }
4815
+ // agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
4816
+ agentGitPreflightContext(projectId) {
4817
+ if (this.effectiveReviewMode(projectId) !== 'git')
4818
+ return undefined;
4819
+ const project = this.getProjectConfig(projectId);
4820
+ if (!project || !this.pluginRegistry)
4821
+ return undefined;
4822
+ const tool = resolveProjectTool(project);
4823
+ if (tool === undefined)
4824
+ return undefined;
4825
+ const plugin = this.pluginRegistry.resolveTool(tool);
4826
+ if (plugin === undefined)
4827
+ return undefined;
4828
+ const renderCtx = buildDriverRunContext(project.repo, tool);
4829
+ return {
4830
+ tool,
4831
+ minToolVersion: plugin.manifest.minToolVersion,
4832
+ steps: plugin.spec.preflight,
4833
+ agentCommands: plugin.spec.agentCommands,
4834
+ renderCtx,
4835
+ driverFor: (exec) => new GitDriver(plugin, renderCtx, exec),
4836
+ };
4837
+ }
4838
+ // cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
4839
+ // 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
4840
+ platformCliContextOf(task) {
4841
+ if (task.reviewMode !== 'git')
4842
+ return undefined;
4843
+ this.assertPlatformBinding(task);
4844
+ const project = this.getProjectConfig(task.projectId);
4845
+ if (!project)
4846
+ return undefined;
4847
+ const tool = resolveProjectTool(project);
4848
+ if (tool === undefined)
4849
+ return undefined;
4850
+ const ctx = buildDriverRunContext(project.repo, tool);
4851
+ return {
4852
+ tool,
4853
+ host: ctx.host,
4854
+ repo: ctx.repoPath,
4855
+ repoEncoded: encodeURIComponent(ctx.repoPath),
4856
+ ...(project.gitCli?.notes !== undefined ? { notes: project.gitCli.notes } : {}),
4857
+ };
4858
+ }
4859
+ // base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
4860
+ async ensureGitBaseSnapshot(task, phase) {
4861
+ if (task.reviewMode !== 'git' || (phase !== 'develop' && phase !== 'code'))
4862
+ return task;
4863
+ if (task.baseBranch !== undefined)
4864
+ return task;
4865
+ // 读默认分支与落快照都以 binding 为前提:失配任务先写入新仓库的 base 会永久污染不可变快照
4866
+ this.assertPlatformBinding(task);
4867
+ const base = this.platformDefaultBranchOf?.(task.projectId);
4868
+ if (base === undefined)
4869
+ return task;
4870
+ return this.withTaskLock(async () => {
4871
+ const fresh = await this.taskStore.get(task.id);
4872
+ if (!fresh)
4873
+ return task;
4874
+ if (fresh.baseBranch !== undefined)
4875
+ return fresh;
4876
+ this.assertPlatformBinding(fresh);
4877
+ const next = { ...fresh, baseBranch: base, updatedAt: new Date().toISOString() };
4878
+ await this.taskStore.set(next);
4879
+ return next;
4880
+ });
4881
+ }
4882
+ async noteReviewConversationRevision(taskId) {
4883
+ await this.withTaskLock(async () => {
4884
+ const task = await this.taskStore.get(taskId);
4885
+ if (!task || TERMINAL_STATUSES.includes(task.status))
4886
+ return;
4887
+ const now = new Date().toISOString();
4888
+ await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
4889
+ });
4890
+ }
4156
4891
  // 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
4157
4892
  assertPlatformBinding(task) {
4158
4893
  const binding = task.platformBinding;
@@ -4461,16 +5196,21 @@ export class AgentManager {
4461
5196
  return { task, previousStatus };
4462
5197
  });
4463
5198
  }
4464
- async bumpReviewRoundIfStillAt(taskId, expectedRound) {
5199
+ // 送达即计轮:flag 是唯一入账凭据,与 verdict 公式(round + flag)按 token CAS 互斥消费,
5200
+ // successor 换代或 verdict 先行都自然跳过,恰好一次
5201
+ // 不设终态守卫:只写轮次不写 status(无复活风险),而终态任务的 recheck 派发是受支持的能力,
5202
+ // 其送达的轮次同样要入账
5203
+ async consumeReviewRoundIntent(taskId, expectedToken) {
4465
5204
  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)
5205
+ const fresh = await this.taskStore.get(taskId);
5206
+ if (!fresh || fresh.signalToken !== expectedToken || fresh.reviewRoundPending !== true)
4470
5207
  return;
4471
- task.reviewRound = expectedRound + 1;
4472
- task.updatedAt = new Date().toISOString();
4473
- await this.taskStore.set(task);
5208
+ await this.taskStore.set({
5209
+ ...fresh,
5210
+ reviewRound: fresh.reviewRound + 1,
5211
+ reviewRoundPending: undefined,
5212
+ updatedAt: new Date().toISOString(),
5213
+ });
4474
5214
  });
4475
5215
  }
4476
5216
  createRunnerFor(agent) {
@@ -4484,7 +5224,10 @@ export class AgentManager {
4484
5224
  if (this.repoStoreFactory) {
4485
5225
  return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
4486
5226
  }
4487
- return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
5227
+ const cloneViaGh = this.effectiveReviewMode(project.id) === 'git'
5228
+ ? resolveProjectTool(project) === 'gh'
5229
+ : undefined;
5230
+ return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
4488
5231
  }
4489
5232
  async ensureWorkdir(agent, project, runner) {
4490
5233
  const repoStore = this.createRepoStore(agent, project, runner);
@@ -4500,6 +5243,9 @@ export class AgentManager {
4500
5243
  return this.repoCache;
4501
5244
  }
4502
5245
  async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
5246
+ // Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
5247
+ // window must not let the final state write-back resurrect the deleted agent.
5248
+ const genAtEntry = this.deletionGenerationOf(agentId);
4503
5249
  const existing = await this.agentStore.get(agentId);
4504
5250
  if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
4505
5251
  console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
@@ -4546,12 +5292,16 @@ export class AgentManager {
4546
5292
  else {
4547
5293
  const now = new Date().toISOString();
4548
5294
  await this.agentStore.update(agentId, (latest) => {
4549
- if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
5295
+ // Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
5296
+ // since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
5297
+ if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
5298
+ return AGENT_STORE_NOOP;
5299
+ if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
4550
5300
  return AGENT_STORE_NOOP;
4551
- if (latest?.taskId !== undefined && latest.taskId !== taskId)
5301
+ if (latest.taskId !== undefined && latest.taskId !== taskId)
4552
5302
  return AGENT_STORE_NOOP;
4553
5303
  return {
4554
- ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
5304
+ ...latest,
4555
5305
  id: agentId,
4556
5306
  projectId,
4557
5307
  taskId: undefined,
@@ -4642,6 +5392,8 @@ export class AgentManager {
4642
5392
  };
4643
5393
  return this.persistQueuedTask(unassigned, 'unassigned');
4644
5394
  }
5395
+ // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
5396
+ const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
4645
5397
  const target = await this.pickAgent(projectId, input.preferredAgentId);
4646
5398
  const targetConfig = this.getAgentConfig(input.preferredAgentId);
4647
5399
  const group = this.findAgentGroup(input.preferredAgentId);
@@ -4653,6 +5405,12 @@ export class AgentManager {
4653
5405
  const researchFields = research
4654
5406
  ? { researchAgentId: research.id, phase: 'research' }
4655
5407
  : {};
5408
+ // 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.
5409
+ const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
5410
+ .map(id => [id, this.deletionGenerationOf(id)]));
5411
+ const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
5412
+ && !this.isDeletionInFlight(input.preferredAgentId)
5413
+ && [...participantGens].every(([id, gen]) => this.deletionGenerationOf(id) === gen && !this.isDeletionInFlight(id));
4656
5414
  const queued = {
4657
5415
  ...taskBase,
4658
5416
  agentId: '',
@@ -4661,12 +5419,83 @@ export class AgentManager {
4661
5419
  status: 'pending',
4662
5420
  ...(qa ? { qaAgentId: qa.id } : {}),
4663
5421
  };
5422
+ // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
5423
+ // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
5424
+ const persistQueuedOrReject = (reason) => {
5425
+ if (!participantsFresh()) {
5426
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
5427
+ }
5428
+ return this.persistQueuedTask(queued, reason, input.preferredAgentId);
5429
+ };
4664
5430
  if (!target) {
4665
- return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
5431
+ return persistQueuedOrReject('preferred_agent_busy');
5432
+ }
5433
+ if (this.isDeletionInFlight(target.id)) {
5434
+ return persistQueuedOrReject('agent_locked');
4666
5435
  }
4667
5436
  const lockToken = await this.lockManager.acquire(target.id, taskId);
4668
5437
  if (!lockToken) {
4669
- return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
5438
+ return persistQueuedOrReject('agent_locked');
5439
+ }
5440
+ // 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.
5441
+ const releaseLock = async () => {
5442
+ try {
5443
+ await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
5444
+ }
5445
+ catch (releaseErr) {
5446
+ console.warn(`[AgentManager] createTask(${taskId}): lock release after failed assignment also failed:`, releaseErr);
5447
+ }
5448
+ };
5449
+ try {
5450
+ await this.taskStore.set(queued);
5451
+ }
5452
+ catch (err) {
5453
+ await releaseLock();
5454
+ throw err;
5455
+ }
5456
+ let commit;
5457
+ try {
5458
+ commit = await this.agentStore.update(target.id, (existing) => {
5459
+ if (existing === null && !this.bindingInitPredicate(target.id, genAtEntry))
5460
+ return AGENT_STORE_NOOP;
5461
+ if (!this.deletionGateOpen(target.id, genAtEntry))
5462
+ return AGENT_STORE_NOOP;
5463
+ return {
5464
+ id: target.id,
5465
+ projectId,
5466
+ taskId,
5467
+ lockToken,
5468
+ bootstrappingTaskId: taskId,
5469
+ updatedAt: now,
5470
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5471
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5472
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5473
+ // A new task never inherits the previous one's question: void the watermark.
5474
+ needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
5475
+ };
5476
+ });
5477
+ }
5478
+ catch (err) {
5479
+ await releaseLock();
5480
+ throw err;
5481
+ }
5482
+ if (commit !== 'committed') {
5483
+ await releaseLock();
5484
+ return (await this.taskStore.get(taskId)) ?? queued;
5485
+ }
5486
+ // 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.
5487
+ if (this.isDeletionInFlight(target.id)
5488
+ || !this.deletionGateOpen(target.id, genAtEntry)
5489
+ || !participantsFresh()
5490
+ || !(await this.lockManager.isOwner(target.id, taskId, lockToken))) {
5491
+ await this.agentStore.update(target.id, (existing) => {
5492
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5493
+ return AGENT_STORE_NOOP;
5494
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5495
+ return { ...rest, updatedAt: new Date().toISOString() };
5496
+ });
5497
+ await releaseLock();
5498
+ return (await this.taskStore.get(taskId)) ?? queued;
4670
5499
  }
4671
5500
  const task = {
4672
5501
  ...taskBase,
@@ -4676,18 +5505,24 @@ export class AgentManager {
4676
5505
  ...researchFields,
4677
5506
  status: 'in_progress',
4678
5507
  };
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
- }));
5508
+ try {
5509
+ await this.taskStore.set(task);
5510
+ }
5511
+ catch (err) {
5512
+ try {
5513
+ await this.agentStore.update(target.id, (existing) => {
5514
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5515
+ return AGENT_STORE_NOOP;
5516
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5517
+ return { ...rest, updatedAt: new Date().toISOString() };
5518
+ });
5519
+ }
5520
+ catch (rollbackErr) {
5521
+ console.warn(`[AgentManager] createTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
5522
+ }
5523
+ await releaseLock();
5524
+ throw err;
5525
+ }
4691
5526
  await this.safeEmit({
4692
5527
  id: '',
4693
5528
  type: 'task.assigned',
@@ -4795,6 +5630,9 @@ export class AgentManager {
4795
5630
  const cfg = this.getAgentConfig(agentId);
4796
5631
  if (!cfg)
4797
5632
  throw new ApiError(404, `Unknown agent: ${agentId}`);
5633
+ // Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
5634
+ // DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
5635
+ const genAtEntry = this.deletionGenerationOf(agentId);
4798
5636
  const state = await this.agentStore.get(agentId);
4799
5637
  const paneId = state?.paneId;
4800
5638
  if (!paneId)
@@ -4804,6 +5642,9 @@ export class AgentManager {
4804
5642
  }
4805
5643
  try {
4806
5644
  const assertUploadStillValid = async () => {
5645
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
5646
+ throw new ApiError(409, `Agent ${agentId} was deleted or recreated while uploading; image paste aborted`);
5647
+ }
4807
5648
  const held = await this.agentStore.get(agentId);
4808
5649
  if (!held || held.paneId !== paneId) {
4809
5650
  throw new ApiError(409, `Agent ${agentId} session changed while uploading; image paste aborted`);
@@ -4823,7 +5664,10 @@ export class AgentManager {
4823
5664
  await writeImageToHost(runner, path, bytes);
4824
5665
  await assertUploadStillValid();
4825
5666
  const tmux = new TmuxManager(runner);
4826
- await tmux.injectPrompt(paneId, `${path} `, agentId);
5667
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
5668
+ // 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.
5669
+ await assertUploadStillValid();
5670
+ await tmux.injectPrompt(pane, `${path} `, agentId);
4827
5671
  return { path };
4828
5672
  }
4829
5673
  finally {
@@ -4855,9 +5699,10 @@ export class AgentManager {
4855
5699
  }
4856
5700
  };
4857
5701
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5702
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
4858
5703
  const waitReady = async () => {
4859
5704
  try {
4860
- await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
5705
+ await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
4861
5706
  }
4862
5707
  catch (err) {
4863
5708
  const detail = err instanceof Error ? err.message : String(err);
@@ -4866,13 +5711,13 @@ export class AgentManager {
4866
5711
  };
4867
5712
  await waitReady();
4868
5713
  await assertSessionUnchanged();
4869
- await tmux.clearComposerDraft(paneId);
5714
+ await tmux.clearComposerDraft(pane);
4870
5715
  await waitReady();
4871
5716
  await assertSessionUnchanged();
4872
- await tmux.sendKeysLiteral(paneId, command);
4873
- await tmux.sendEnter(paneId);
5717
+ await tmux.sendKeysLiteral(pane, command);
5718
+ await tmux.sendEnter(pane);
4874
5719
  guardHandedOff = true;
4875
- void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
5720
+ void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
4876
5721
  .catch(err => {
4877
5722
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
4878
5723
  })
@@ -4891,25 +5736,98 @@ export class AgentManager {
4891
5736
  async clearAgent(agentId) {
4892
5737
  return this.sendSlashCommand(agentId, '/clear');
4893
5738
  }
4894
- async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
5739
+ registerPendingDispatchRetry(taskId, entry, budgetOverride) {
5740
+ const prev = this.pendingDispatchRetryByTask.get(taskId);
5741
+ // 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
5742
+ // budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
5743
+ // fence 注入,外部 successor 的登记结构上拿不到
5744
+ const sameGeneration = prev?.kind === entry.kind
5745
+ && prev.agentId === entry.agentId
5746
+ && prev.signalToken === entry.signalToken;
5747
+ const since = sameGeneration ? prev.since : (budgetOverride?.since ?? Date.now());
5748
+ const budgetAlerted = sameGeneration
5749
+ ? prev.budgetAlerted === true
5750
+ : budgetOverride?.budgetAlerted === true;
5751
+ this.pendingDispatchRetryByTask.set(taskId, {
5752
+ ...entry,
5753
+ since,
5754
+ ...(budgetAlerted ? { budgetAlerted: true } : {}),
5755
+ });
5756
+ }
5757
+ getPendingDispatchRetry(taskId) {
5758
+ return this.pendingDispatchRetryByTask.get(taskId);
5759
+ }
5760
+ listPendingDispatchRetries() {
5761
+ return this.pendingDispatchRetryByTask;
5762
+ }
5763
+ clearPendingDispatchRetry(taskId) {
5764
+ this.pendingDispatchRetryByTask.delete(taskId);
5765
+ }
5766
+ clearPendingDispatchRetryIfMatches(taskId, expected) {
5767
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5768
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5769
+ this.pendingDispatchRetryByTask.delete(taskId);
5770
+ }
5771
+ }
5772
+ markPendingDispatchRetryBudgetAlerted(taskId, expected) {
5773
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5774
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5775
+ this.pendingDispatchRetryByTask.set(taskId, { ...entry, budgetAlerted: true });
5776
+ }
5777
+ }
5778
+ async queueQaBusyPendingRetry(taskId, agentId, phase, createdSession, err, dispatch) {
5779
+ if (!(err instanceof ReplNotReadyError))
5780
+ return null;
5781
+ if (phase !== 'review' && phase !== 'recheck')
5782
+ return null;
5783
+ // 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
5784
+ // 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
5785
+ const passToken = dispatch.passToken;
5786
+ if (!passToken)
5787
+ return null;
5788
+ let current;
5789
+ try {
5790
+ current = await this.taskStore.get(taskId);
5791
+ }
5792
+ catch (readErr) {
5793
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but pass token could not be verified; ` +
5794
+ `falling back to the non-pending failure path:`, readErr);
5795
+ return null;
5796
+ }
5797
+ if (current?.signalToken !== passToken) {
5798
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but the review pass was superseded ` +
5799
+ `(expected ${passToken}, now ${current?.signalToken ?? 'gone'}); not queueing a pending retry`);
5800
+ return null;
5801
+ }
5802
+ this.registerPendingDispatchRetry(taskId, {
5803
+ kind: 'qa-recheck',
5804
+ agentId,
5805
+ signalToken: passToken,
5806
+ qaPhase: phase,
5807
+ }, dispatch.pendingBudget);
5808
+ console.warn(`[AgentManager] startSession[${phase}]: QA pane busy for task ${taskId}; ` +
5809
+ `${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
5810
+ return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
5811
+ }
5812
+ async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
4895
5813
  await this.acquireCompactGuard(agentId);
4896
5814
  try {
4897
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
5815
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
4898
5816
  await revalidate();
4899
- await tmux.clearComposerDraft(paneId);
4900
- const baseline = await tmux.captureSettledSnapshot(paneId, {
5817
+ await tmux.clearComposerDraft(pane);
5818
+ const baseline = await tmux.captureSettledSnapshot(pane, {
4901
5819
  timeoutMs: this.dispatchSettleTimeoutMs,
4902
5820
  });
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, {
5821
+ const baselineTitle = await tmux.readPaneTitle(pane);
5822
+ await tmux.sendKeysLiteral(pane, '/clear');
5823
+ await tmux.sendEnter(pane);
5824
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
4907
5825
  timeoutMs: this.dispatchAckTimeoutMs,
4908
5826
  baselineTitle,
4909
5827
  acceptComposerChange: true,
4910
5828
  });
4911
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
4912
- if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
5829
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
5830
+ if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
4913
5831
  throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
4914
5832
  }
4915
5833
  await revalidate();
@@ -4986,6 +5904,8 @@ export class AgentManager {
4986
5904
  error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
4987
5905
  };
4988
5906
  }
5907
+ // Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
5908
+ const genAtEntry = this.deletionGenerationOf(agentId);
4989
5909
  const cfg = this.getAgentConfig(agentId);
4990
5910
  if (!cfg)
4991
5911
  return { task: fresh, errorCode: 400, error: `Unknown agent: ${agentId}` };
@@ -5006,6 +5926,9 @@ export class AgentManager {
5006
5926
  if (!canDispatchWithBinding(state)) {
5007
5927
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
5008
5928
  }
5929
+ if (this.isDeletionInFlight(agentId)) {
5930
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5931
+ }
5009
5932
  const lockToken = await this.lockManager.acquire(agentId, taskId);
5010
5933
  if (!lockToken) {
5011
5934
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
@@ -5024,6 +5947,10 @@ export class AgentManager {
5024
5947
  ? group?.find(agent => agent.role === 'qa')?.id
5025
5948
  : fresh.qaAgentId;
5026
5949
  const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
5950
+ // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
5951
+ const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
5952
+ .map(pid => [pid, this.deletionGenerationOf(pid)]));
5953
+ const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
5027
5954
  const claimedTask = {
5028
5955
  ...fresh,
5029
5956
  preferredAgentId: agentId,
@@ -5035,18 +5962,78 @@ export class AgentManager {
5035
5962
  status: 'in_progress',
5036
5963
  updatedAt: now,
5037
5964
  };
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
- }));
5965
+ // Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
5966
+ // step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
5967
+ const releaseLock = async () => {
5968
+ try {
5969
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
5970
+ }
5971
+ catch (releaseErr) {
5972
+ console.warn(`[AgentManager] claimTask(${taskId}): lock release after failed claim also failed:`, releaseErr);
5973
+ }
5974
+ };
5975
+ let commit;
5976
+ try {
5977
+ commit = await this.agentStore.update(agentId, (existing) => {
5978
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
5979
+ return AGENT_STORE_NOOP;
5980
+ if (!this.deletionGateOpen(agentId, genAtEntry))
5981
+ return AGENT_STORE_NOOP;
5982
+ return {
5983
+ id: agentId,
5984
+ projectId: cfg.projectId,
5985
+ taskId,
5986
+ lockToken,
5987
+ bootstrappingTaskId: taskId,
5988
+ updatedAt: now,
5989
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5990
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5991
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5992
+ // A new task never inherits the previous one's question: void the watermark.
5993
+ needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
5994
+ };
5995
+ });
5996
+ }
5997
+ catch (err) {
5998
+ await releaseLock();
5999
+ throw err;
6000
+ }
6001
+ if (commit !== 'committed') {
6002
+ await releaseLock();
6003
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
6004
+ }
6005
+ // Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
6006
+ if (this.isDeletionInFlight(agentId)
6007
+ || !this.deletionGateOpen(agentId, genAtEntry)
6008
+ || !participantsFresh()
6009
+ || !(await this.lockManager.isOwner(agentId, taskId, lockToken))) {
6010
+ await this.agentStore.update(agentId, (existing) => {
6011
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
6012
+ return AGENT_STORE_NOOP;
6013
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
6014
+ return { ...rest, updatedAt: new Date().toISOString() };
6015
+ });
6016
+ await releaseLock();
6017
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
6018
+ }
6019
+ try {
6020
+ await this.taskStore.set(claimedTask);
6021
+ }
6022
+ catch (err) {
6023
+ try {
6024
+ await this.agentStore.update(agentId, (existing) => {
6025
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
6026
+ return AGENT_STORE_NOOP;
6027
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
6028
+ return { ...rest, updatedAt: new Date().toISOString() };
6029
+ });
6030
+ }
6031
+ catch (rollbackErr) {
6032
+ console.warn(`[AgentManager] claimTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
6033
+ }
6034
+ await releaseLock();
6035
+ throw err;
6036
+ }
5050
6037
  await this.safeEmit({
5051
6038
  id: '',
5052
6039
  type: 'task.assigned',
@@ -5146,6 +6133,8 @@ export class AgentManager {
5146
6133
  const agent = this.getAgentConfig(agentId);
5147
6134
  if (!agent)
5148
6135
  throw new Error(`Unknown agent: ${agentId}`);
6136
+ // Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
6137
+ const genAtEntry = this.deletionGenerationOf(agentId);
5149
6138
  const task = await this.taskStore.get(taskId);
5150
6139
  if (!task)
5151
6140
  throw new Error(`Unknown task: ${taskId}`);
@@ -5191,6 +6180,11 @@ export class AgentManager {
5191
6180
  return false;
5192
6181
  }
5193
6182
  const assertOwner = async () => {
6183
+ // Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
6184
+ // reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
6185
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
6186
+ throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
6187
+ }
5194
6188
  const stateNow = await this.agentStore.get(agentId);
5195
6189
  if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
5196
6190
  throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
@@ -5201,10 +6195,10 @@ export class AgentManager {
5201
6195
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
5202
6196
  let ensure;
5203
6197
  try {
5204
- ensure = await this.ensureSession(agentId, 'runtime');
6198
+ ensure = await this.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
5205
6199
  }
5206
6200
  catch (err) {
5207
- if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
6201
+ if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses, expectedGeneration: genAtEntry })) {
5208
6202
  throw err;
5209
6203
  }
5210
6204
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
@@ -5222,7 +6216,7 @@ export class AgentManager {
5222
6216
  }
5223
6217
  throw err;
5224
6218
  }
5225
- const { paneId, workdir } = ensure;
6219
+ const { paneId, pane, workdir } = ensure;
5226
6220
  const runner = this.createRunnerFor(agent);
5227
6221
  const tmux = new TmuxManager(runner);
5228
6222
  const branchManager = new BranchManager(runner);
@@ -5230,7 +6224,15 @@ export class AgentManager {
5230
6224
  if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
5231
6225
  return false;
5232
6226
  await assertOwner();
5233
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
6227
+ try {
6228
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
6229
+ }
6230
+ catch (err) {
6231
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
6232
+ if (busyPend)
6233
+ throw busyPend;
6234
+ throw err;
6235
+ }
5234
6236
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
5235
6237
  const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
5236
6238
  let reviewCheckout;
@@ -5266,12 +6268,15 @@ export class AgentManager {
5266
6268
  }
5267
6269
  await assertOwner();
5268
6270
  if (!ensure.freshRuntime) {
5269
- await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
6271
+ await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
5270
6272
  if (ensure.skillsStale)
5271
6273
  await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
5272
6274
  }
5273
6275
  }
5274
6276
  catch (err) {
6277
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
6278
+ if (busyPend)
6279
+ throw busyPend;
5275
6280
  const message = err instanceof Error ? err.message : String(err);
5276
6281
  const holdPhase = err instanceof DirtyWorkdirError
5277
6282
  ? 'dirty-workdir'
@@ -5311,9 +6316,9 @@ export class AgentManager {
5311
6316
  await this.agentStore.update(agentId, (stateNow) => {
5312
6317
  if (!stateNow || stateNow.taskId !== taskId)
5313
6318
  return AGENT_STORE_NOOP;
5314
- const { needInputAt: _needInputAt, ...rest } = stateNow;
6319
+ // Watermark stays: the arm-time epoch bump owns superseding the old question.
5315
6320
  return {
5316
- ...rest,
6321
+ ...stateNow,
5317
6322
  paneId,
5318
6323
  workdir,
5319
6324
  updatedAt: new Date().toISOString(),
@@ -5346,13 +6351,16 @@ export class AgentManager {
5346
6351
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
5347
6352
  });
5348
6353
  }
6354
+ const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
6355
+ const platformCli = this.platformCliContextOf(taskForPrompt);
5349
6356
  prompt = buildPromptInline({
5350
- task,
6357
+ task: taskForPrompt,
5351
6358
  phase,
5352
6359
  agent,
5353
6360
  workdir: dispatchWorkdir,
5354
6361
  skillRegistry: this.skillRegistry,
5355
6362
  hasQaPartner,
6363
+ ...(platformCli ? { platformCli } : {}),
5356
6364
  ...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
5357
6365
  ...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
5358
6366
  ...(imagePaths.length ? { imagePaths } : {}),
@@ -5416,12 +6424,20 @@ export class AgentManager {
5416
6424
  let agentMarkedRunning = false;
5417
6425
  try {
5418
6426
  let cancelHoldWon = false;
5419
- await this.agentStore.update(agentId, (existing) => {
6427
+ let deletedDuringDispatch = false;
6428
+ const runningCommit = await this.agentStore.update(agentId, (existing) => {
5420
6429
  if (isCancelCleanupHold(existing)) {
5421
6430
  cancelHoldWon = true;
5422
6431
  return AGENT_STORE_NOOP;
5423
6432
  }
6433
+ // 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).
6434
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
6435
+ || !this.deletionGateOpen(agentId, genAtEntry)) {
6436
+ deletedDuringDispatch = true;
6437
+ return AGENT_STORE_NOOP;
6438
+ }
5424
6439
  return {
6440
+ ...existing,
5425
6441
  id: agentId,
5426
6442
  projectId: agent.projectId,
5427
6443
  paneId,
@@ -5431,7 +6447,6 @@ export class AgentManager {
5431
6447
  startedAt: now,
5432
6448
  bootstrappingTaskId: taskId,
5433
6449
  updatedAt: now,
5434
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5435
6450
  };
5436
6451
  });
5437
6452
  if (cancelHoldWon) {
@@ -5440,8 +6455,39 @@ export class AgentManager {
5440
6455
  await cleanupCheckoutOrHold();
5441
6456
  return false;
5442
6457
  }
6458
+ if (deletedDuringDispatch || runningCommit !== 'committed') {
6459
+ console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} binding vanished or was deleted/recreated ` +
6460
+ `during dispatch; not marking running (no orphan state)`);
6461
+ return false;
6462
+ }
5443
6463
  agentMarkedRunning = true;
5444
- await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
6464
+ // armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
6465
+ // push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
6466
+ // 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
6467
+ // cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
6468
+ const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
6469
+ const passStillArmed = opts.dispatchPassToken !== undefined
6470
+ ? async () => {
6471
+ const freshTask = await this.taskStore.get(taskId);
6472
+ if (!freshTask || freshTask.signalToken !== opts.dispatchPassToken)
6473
+ return false;
6474
+ return passDeliverableStatuses.includes(freshTask.status);
6475
+ }
6476
+ : undefined;
6477
+ if (passStillArmed && !(await passStillArmed())) {
6478
+ console.warn(`[AgentManager] startSession[${phase}]: review pass superseded before injection for task ${taskId}; aborting paste`);
6479
+ return false;
6480
+ }
6481
+ const injectResult = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, passStillArmed);
6482
+ if (injectResult.aborted === true) {
6483
+ console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
6484
+ return false;
6485
+ }
6486
+ // prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
6487
+ // 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
6488
+ if (opts.dispatchPassToken !== undefined) {
6489
+ this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
6490
+ }
5445
6491
  try {
5446
6492
  await this.clearBootstrapMarker(agentId, taskId);
5447
6493
  }
@@ -5465,6 +6511,11 @@ export class AgentManager {
5465
6511
  return true;
5466
6512
  }
5467
6513
  catch (err) {
6514
+ // 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
6515
+ // 在途回合还在使用该工作树,park/release 会拽走它
6516
+ const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
6517
+ if (injectBusyPend)
6518
+ throw injectBusyPend;
5468
6519
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
5469
6520
  if (!isAckUnknown) {
5470
6521
  await cleanupCheckoutOrHold();
@@ -5507,7 +6558,7 @@ export class AgentManager {
5507
6558
  throw err;
5508
6559
  }
5509
6560
  }
5510
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
6561
+ async injectAndAwaitAck(tmux, pane, prompt, agentId, runtime, guardBeforePaste) {
5511
6562
  const before = await this.agentStore.get(agentId);
5512
6563
  const beforeLockToken = before?.taskId
5513
6564
  ? await this.resolveTaskLockToken(before, before.taskId)
@@ -5559,21 +6610,22 @@ export class AgentManager {
5559
6610
  if (guardBeforePaste && !(await guardBeforePaste())) {
5560
6611
  return { acked: false, composerDelivered: false, aborted: true };
5561
6612
  }
5562
- return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
6613
+ return await this.injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste);
5563
6614
  }
5564
6615
  finally {
5565
6616
  this.compactInFlight.delete(agentId);
5566
6617
  }
5567
6618
  }
5568
- async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6619
+ async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6620
+ const paneId = pane.paneId;
5569
6621
  // 静态忙信号(文本忙样式/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 });
6622
+ const preTitle = await tmux.readPaneTitle(pane);
6623
+ const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
5572
6624
  const staticBusy = hasOscTitleWorking(preTitle)
5573
6625
  ? (runtimeBusyCheck(preFrame, runtime) || !hasRuntimeReadyView(preFrame, runtime))
5574
6626
  : 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`);
6627
+ if (staticBusy && await this.paneHasLiveTurn(tmux, pane, runtime)) {
6628
+ throw new ReplNotReadyError(paneId, runtime, preFrame, `pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
5577
6629
  }
5578
6630
  await revalidate?.();
5579
6631
  if (guardBeforePaste) {
@@ -5588,7 +6640,7 @@ export class AgentManager {
5588
6640
  pasted = await this.withTaskLock(async () => {
5589
6641
  if (!(await guardBeforePaste()))
5590
6642
  return false;
5591
- await tmux.clearComposerDraft(paneId);
6643
+ await tmux.clearComposerDraft(pane);
5592
6644
  await tmux.pasteStagedBuffer(paneId, staged.buf);
5593
6645
  return true;
5594
6646
  });
@@ -5605,7 +6657,7 @@ export class AgentManager {
5605
6657
  }
5606
6658
  if (bufferConsumed) {
5607
6659
  try {
5608
- await tmux.clearComposerDraft(paneId);
6660
+ await tmux.clearComposerDraft(pane);
5609
6661
  }
5610
6662
  catch (clearErr) {
5611
6663
  console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
@@ -5624,26 +6676,26 @@ export class AgentManager {
5624
6676
  }
5625
6677
  }
5626
6678
  else {
5627
- await tmux.clearComposerDraft(paneId);
6679
+ await tmux.clearComposerDraft(pane);
5628
6680
  await revalidate?.();
5629
- await tmux.injectPrompt(paneId, prompt, agentId);
6681
+ await tmux.injectPrompt(pane, prompt, agentId);
5630
6682
  }
5631
6683
  let baseline;
5632
6684
  let baselineTitle = '';
5633
6685
  try {
5634
- baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
5635
- baselineTitle = await tmux.readPaneTitle(paneId);
6686
+ baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
6687
+ baselineTitle = await tmux.readPaneTitle(pane);
5636
6688
  if (guardBeforePaste) {
5637
6689
  // The Enter is the actual submission; it takes the same fenced queue slot.
5638
6690
  const submitted = await this.withTaskLock(async () => {
5639
6691
  if (!(await guardBeforePaste()))
5640
6692
  return false;
5641
- await tmux.sendEnter(paneId);
6693
+ await tmux.sendEnter(pane);
5642
6694
  return true;
5643
6695
  });
5644
6696
  if (!submitted) {
5645
6697
  try {
5646
- await tmux.clearComposerDraft(paneId);
6698
+ await tmux.clearComposerDraft(pane);
5647
6699
  }
5648
6700
  catch (err) {
5649
6701
  // Fail closed: a submit-ready stale prompt could not be confirmed gone.
@@ -5654,18 +6706,18 @@ export class AgentManager {
5654
6706
  }
5655
6707
  }
5656
6708
  else {
5657
- await tmux.sendEnter(paneId);
6709
+ await tmux.sendEnter(pane);
5658
6710
  }
5659
6711
  }
5660
6712
  catch (preAckErr) {
5661
- if (await this.clearComposerForReuse(tmux, paneId, agentId))
6713
+ if (await this.clearComposerForReuse(tmux, pane, agentId))
5662
6714
  throw preAckErr;
5663
6715
  const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
5664
6716
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
5665
6717
  }
5666
6718
  let staleDuringResend = false;
5667
6719
  try {
5668
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
6720
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
5669
6721
  timeoutMs: this.dispatchAckTimeoutMs,
5670
6722
  baselineTitle,
5671
6723
  resend: guardBeforePaste
@@ -5674,13 +6726,13 @@ export class AgentManager {
5674
6726
  const resent = await this.withTaskLock(async () => {
5675
6727
  if (!(await guardBeforePaste()))
5676
6728
  return false;
5677
- await tmux.sendEnter(paneId);
6729
+ await tmux.sendEnter(pane);
5678
6730
  return true;
5679
6731
  });
5680
6732
  if (!resent)
5681
6733
  staleDuringResend = true;
5682
6734
  }
5683
- : () => tmux.sendEnter(paneId),
6735
+ : () => tmux.sendEnter(pane),
5684
6736
  resendIntervalMs: this.dispatchAckResendIntervalMs,
5685
6737
  });
5686
6738
  return { acked: true, composerDelivered: true };
@@ -5692,7 +6744,7 @@ export class AgentManager {
5692
6744
  }
5693
6745
  if (staleDuringResend) {
5694
6746
  try {
5695
- await tmux.clearComposerDraft(paneId);
6747
+ await tmux.clearComposerDraft(pane);
5696
6748
  }
5697
6749
  catch (scrubErr) {
5698
6750
  const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
@@ -5714,9 +6766,10 @@ export class AgentManager {
5714
6766
  return { acked: false, composerDelivered };
5715
6767
  }
5716
6768
  }
5717
- async clearComposerForReuse(tmux, paneId, agentId) {
6769
+ async clearComposerForReuse(tmux, pane, agentId) {
6770
+ const paneId = pane.paneId;
5718
6771
  try {
5719
- await tmux.clearComposerDraft(paneId);
6772
+ await tmux.clearComposerDraft(pane);
5720
6773
  return true;
5721
6774
  }
5722
6775
  catch (err) {
@@ -5793,7 +6846,7 @@ export class AgentManager {
5793
6846
  }
5794
6847
  throw err;
5795
6848
  }
5796
- const { paneId } = ensure;
6849
+ const { paneId, pane } = ensure;
5797
6850
  const runner = this.createRunnerFor(agent);
5798
6851
  const tmux = new TmuxManager(runner);
5799
6852
  const stateAfterEnsure = await this.agentStore.get(agentId);
@@ -5877,13 +6930,16 @@ export class AgentManager {
5877
6930
  ...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
5878
6931
  });
5879
6932
  }
6933
+ const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
6934
+ const platformCli = this.platformCliContextOf(taskForPrompt);
5880
6935
  prompt = buildPromptInline({
5881
- task,
6936
+ task: taskForPrompt,
5882
6937
  phase,
5883
6938
  agent,
5884
6939
  workdir: verifiedWorkdir,
5885
6940
  skillRegistry: this.skillRegistry,
5886
6941
  hasQaPartner: task.qaAgentId !== undefined,
6942
+ ...(platformCli ? { platformCli } : {}),
5887
6943
  ...(signalToken ? { signalToken } : {}),
5888
6944
  ...(useIncrementalNudge
5889
6945
  ? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
@@ -5969,10 +7025,11 @@ export class AgentManager {
5969
7025
  || latest.workdir !== verifiedWorkdir) {
5970
7026
  return AGENT_STORE_NOOP;
5971
7027
  }
5972
- // The continuation prompt supersedes any question asked under the previous one.
5973
- const { needInputAt: _needInputAt, ...rest } = latest;
7028
+ // The continuation prompt supersedes the previous question, but the arm-time
7029
+ // epoch bump owns that transition — stripping here would race an arm that
7030
+ // already re-established the watermark (post-approve arms before injecting).
5974
7031
  return {
5975
- ...rest,
7032
+ ...latest,
5976
7033
  paneId,
5977
7034
  workdir: verifiedWorkdir,
5978
7035
  lockToken,
@@ -5996,7 +7053,7 @@ export class AgentManager {
5996
7053
  console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
5997
7054
  return false;
5998
7055
  }
5999
- const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
7056
+ const delivery = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, guardBeforeInject);
6000
7057
  if (delivery.aborted) {
6001
7058
  console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
6002
7059
  return false;
@@ -6188,7 +7245,7 @@ export class AgentManager {
6188
7245
  continue;
6189
7246
  if (state.creationToken && !state.taskId) {
6190
7247
  const ct = state.creationToken;
6191
- const pane = result.paneId;
7248
+ const pane = result.pane;
6192
7249
  const cfg = agentConfig;
6193
7250
  const agentId = state.id;
6194
7251
  void (async () => {
@@ -6197,7 +7254,7 @@ export class AgentManager {
6197
7254
  if (!latest || latest.creationToken !== ct)
6198
7255
  return AGENT_STORE_NOOP;
6199
7256
  const { creationToken: _ct, status: _s, awaitingPhase: _ap, awaitingReason: _ar, awaitingSince: _as, ...ready } = latest;
6200
- return { ...ready, paneId: pane, updatedAt: new Date().toISOString() };
7257
+ return { ...ready, paneId: pane.paneId, updatedAt: new Date().toISOString() };
6201
7258
  });
6202
7259
  }
6203
7260
  else {
@@ -6275,6 +7332,7 @@ export class AgentManager {
6275
7332
  ...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
6276
7333
  ...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
6277
7334
  ...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
7335
+ ...(latest.needInput !== undefined ? { needInput: latest.needInput } : {}),
6278
7336
  };
6279
7337
  if (!preserveHeld)
6280
7338
  return withBinding;
@@ -6615,19 +7673,22 @@ export class AgentManager {
6615
7673
  async dispatchReviewToQa(taskId, opts = {}) {
6616
7674
  const claim = await this.withTaskLock(async () => {
6617
7675
  if (this.manualReviewInFlight.has(taskId)) {
6618
- throw new ApiError(409, `Manual review already in progress for task ${taskId}`);
7676
+ throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
6619
7677
  }
6620
7678
  if (this.markCompleteInFlight.has(taskId)) {
6621
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
7679
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
6622
7680
  }
6623
7681
  const task = await this.taskStore.get(taskId);
6624
7682
  if (!task)
6625
7683
  throw new ApiError(404, `Task ${taskId} not found`);
6626
7684
  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('/')}`);
7685
+ throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
6628
7686
  }
6629
7687
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
6630
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
7688
+ throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
7689
+ }
7690
+ if (opts.expectReviewDispatchPending === true && task.reviewDispatchPending !== true) {
7691
+ throw new ApiError(409, `Task ${taskId} review dispatch pending was already consumed; aborting stale sweep`, 'dispatch-superseded');
6631
7692
  }
6632
7693
  if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
6633
7694
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
@@ -6668,17 +7729,33 @@ export class AgentManager {
6668
7729
  throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
6669
7730
  }
6670
7731
  this.manualReviewInFlight.add(taskId);
6671
- return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
7732
+ return {
7733
+ mode: 'github',
7734
+ qaId,
7735
+ devAgentId: task.agentId,
7736
+ taskStatusAtClaim: task.status,
7737
+ signalTokenAtClaim: task.signalToken,
7738
+ reviewRoundAtClaim: task.reviewRound,
7739
+ };
6672
7740
  });
6673
7741
  if (claim.mode === 'server') {
6674
7742
  return this.runManualServerReview(taskId, claim);
6675
7743
  }
6676
7744
  try {
6677
- const { qaId, devAgentId, taskStatusAtClaim } = claim;
7745
+ const { qaId, devAgentId, taskStatusAtClaim, signalTokenAtClaim, reviewRoundAtClaim } = claim;
6678
7746
  const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
6679
- const qaPhase = taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress'
6680
- ? 'review'
6681
- : 'recheck';
7747
+ // 相位恢复归标准入口自己(手工 review API 与各补派方一视同仁):同代 pending 登记的
7748
+ // 原始相位最权威;重启丢登记时以持久化 round=0 兜底——轮次送达才入账,故 0 即首评从未送达
7749
+ const pendingEntry = this.getPendingDispatchRetry(taskId);
7750
+ const queuedPhase = pendingEntry?.kind === 'qa-recheck'
7751
+ && pendingEntry.signalToken === signalTokenAtClaim
7752
+ ? pendingEntry.qaPhase
7753
+ : undefined;
7754
+ const qaPhase = opts.qaPhase
7755
+ ?? queuedPhase
7756
+ ?? (taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress' || reviewRoundAtClaim === 0
7757
+ ? 'review'
7758
+ : 'recheck');
6682
7759
  const prevQa = await this.agentStore.get(qaId);
6683
7760
  const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
6684
7761
  if (prevQa?.taskId === taskId) {
@@ -6703,12 +7780,13 @@ export class AgentManager {
6703
7780
  throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
6704
7781
  }
6705
7782
  }
6706
- const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
7783
+ let acquiredLockToken;
7784
+ const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
7785
+ onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
7786
+ });
6707
7787
  if (!acquired) {
6708
7788
  throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
6709
7789
  }
6710
- const acquiredClaim = await this.lockManager.claimOf(qaId);
6711
- const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
6712
7790
  const releaseOwnAcquire = acquiredLockToken !== undefined
6713
7791
  ? { expectedLockToken: acquiredLockToken }
6714
7792
  : {};
@@ -6735,18 +7813,21 @@ export class AgentManager {
6735
7813
  signalToken: pre?.signalToken,
6736
7814
  reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
6737
7815
  reviewDispatchedAt: pre?.reviewDispatchedAt,
7816
+ reviewRoundPending: pre?.reviewRoundPending,
6738
7817
  passToken: pre?.passToken,
6739
7818
  failToken: pre?.failToken,
6740
7819
  };
6741
7820
  const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
6742
- // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
6743
- const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
7821
+ // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮。
7822
+ // 显式 bumpRound:true 是补派方读到 reviewRoundPending 后的权威意图(该轮从未计数),越过恢复期抑制
7823
+ const bumpRound = opts.bumpRound === true
7824
+ || (opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review'));
6744
7825
  // 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
6745
7826
  // 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
6746
7827
  const abortDispatch = async (dispatchToken) => {
6747
- const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, bumpRound, dispatchToken);
7828
+ const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, dispatchToken);
6748
7829
  if (!takenOver)
6749
- await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
7830
+ await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
6750
7831
  if (devParked)
6751
7832
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
6752
7833
  };
@@ -6775,7 +7856,7 @@ export class AgentManager {
6775
7856
  if (isGitDispatch && reviewAnchor === undefined) {
6776
7857
  // 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
6777
7858
  // 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
6778
- const released = await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => false);
7859
+ const released = await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => false);
6779
7860
  if (!released) {
6780
7861
  // 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
6781
7862
  const blockedTask = await this.taskStore.get(taskId);
@@ -6798,6 +7879,9 @@ export class AgentManager {
6798
7879
  reviewHeadAnchorSha: reviewAnchor,
6799
7880
  reviewDispatchedAt: new Date().toISOString(),
6800
7881
  signalToken: createSignalToken(),
7882
+ // 未计轮 intent 与进入 review 同一原子写:两写之间崩溃会留下「有派发可重放、却无轮次可补计」
7883
+ // 的状态,恢复后首评仍停在旧轮
7884
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
6801
7885
  // pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
6802
7886
  ...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
6803
7887
  });
@@ -6806,7 +7890,7 @@ export class AgentManager {
6806
7890
  await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
6807
7891
  if (devParked)
6808
7892
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
6809
- throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
7893
+ throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`, 'dispatch-superseded');
6810
7894
  }
6811
7895
  transitionToken = preDispatched.task.signalToken;
6812
7896
  }
@@ -6827,7 +7911,9 @@ export class AgentManager {
6827
7911
  }
6828
7912
  await this.taskStore.set({
6829
7913
  ...fresh,
6830
- reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
7914
+ // 计轮送达时才落账:claim 段只持久化「未计轮」intent(与 push 直派链路同构),
7915
+ // busy/hold/崩溃等一切未送达出口都保留 intent,任何补派方(含重启后)恰好补计一次
7916
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
6831
7917
  signalToken: armedToken,
6832
7918
  ...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
6833
7919
  updatedAt: new Date().toISOString(),
@@ -6837,10 +7923,14 @@ export class AgentManager {
6837
7923
  if (!claim2.ok) {
6838
7924
  // 未写入 armedToken:task 仍持 transitionToken,或已被接管方 rotate/漂移 → 用 transitionToken 判 takeover
6839
7925
  await abortDispatch(transitionToken);
6840
- throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`);
7926
+ throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`, 'dispatch-superseded');
6841
7927
  }
7928
+ opts.onPassArmed?.(armedToken);
6842
7929
  const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
6843
- const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
7930
+ const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, {
7931
+ needInputMode: 'fresh',
7932
+ ...(dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
7933
+ });
6844
7934
  if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
6845
7935
  await this.rearmGitReconciliationWatcher(taskId);
6846
7936
  }
@@ -6853,7 +7943,7 @@ export class AgentManager {
6853
7943
  const preStart = await this.taskStore.get(taskId);
6854
7944
  if (!preStart || !opts.fromStatus.includes(preStart.status) || preStart.signalToken !== armedToken) {
6855
7945
  await abortDispatch(armedToken);
6856
- throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`);
7946
+ throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`, 'dispatch-superseded');
6857
7947
  }
6858
7948
  }
6859
7949
  let started = false;
@@ -6861,6 +7951,8 @@ export class AgentManager {
6861
7951
  started = await this.startSession(taskId, qaId, qaPhase, {
6862
7952
  bypassTaskStatusGate: true,
6863
7953
  dialogFailFromStatuses: [isTerminalAtClaim ? taskStatusAtClaim : 'review'],
7954
+ dispatchPassToken: armedToken,
7955
+ ...(opts.pendingBudget !== undefined ? { dispatchPendingBudget: opts.pendingBudget } : {}),
6864
7956
  });
6865
7957
  }
6866
7958
  catch (err) {
@@ -6874,6 +7966,13 @@ export class AgentManager {
6874
7966
  }
6875
7967
  throw err;
6876
7968
  }
7969
+ if (err instanceof EnsureSessionError && err.partial.busyPending) {
7970
+ // 遇忙不算失败:pass 保持 armed、QA 保持绑定、未计轮 intent 原样在库(送达时才消费),
7971
+ // 由对账按 pending 登记补派
7972
+ console.warn(`[AgentManager] dispatchReviewToQa: QA busy for ${taskId}; dispatch queued (${err.message})`);
7973
+ const pending = await this.taskStore.get(taskId);
7974
+ return pending;
7975
+ }
6877
7976
  if (!(err instanceof EnsureSessionError && err.partial.handled)) {
6878
7977
  await abortDispatch(armedToken);
6879
7978
  }
@@ -6883,6 +7982,7 @@ export class AgentManager {
6883
7982
  await abortDispatch(armedToken);
6884
7983
  throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
6885
7984
  }
7985
+ await this.consumeReviewRoundIntent(taskId, armedToken);
6886
7986
  await this.clearReviewDispatchPending(taskId, armedToken);
6887
7987
  const final = await this.taskStore.get(taskId);
6888
7988
  return final;
@@ -7023,7 +8123,7 @@ export class AgentManager {
7023
8123
  // 返回 qaTakenOverInReview:仍在 review 但 token 已换代 = 另一 dispatcher 已 release+acquire 同一 QA
7024
8124
  // (同 task 同 agent,releaseAgentForTask 的 mismatch 保护不设防),此时调用方不得再 release QA。
7025
8125
  // 判定在回滚同一锁段内基于 fresh 完成,是那一刻的权威结论——不受本函数随后恢复 snapshot token 的影响。
7026
- async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, roundBumped, dispatchToken) {
8126
+ async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, dispatchToken) {
7027
8127
  let qaTakenOverInReview = false;
7028
8128
  await this.withTaskLock(async () => {
7029
8129
  const fresh = await this.taskStore.get(taskId);
@@ -7033,27 +8133,25 @@ export class AgentManager {
7033
8133
  if (!isTerminalAtClaim && TERMINAL_STATUSES.includes(fresh.status))
7034
8134
  return;
7035
8135
  // transition 后的中间态恒为 review 且持有本次 dispatch 的 token;status 漂移(如 REQUEST_CHANGES→fixing)
7036
- // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有,只撤本次 dispatch 的轮次残留
8136
+ // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有。
8137
+ // 计轮送达时才落账,claim 段无轮次残留可撤——drift 后不覆盖接管方的任何写入
7037
8138
  const passTakenOver = dispatchToken !== undefined && fresh.signalToken !== dispatchToken;
7038
8139
  qaTakenOverInReview = !isTerminalAtClaim && fresh.status === 'review' && passTakenOver;
7039
8140
  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) {
8141
+ if (drifted)
8142
+ return;
8143
+ const next = {
8144
+ ...fresh,
8145
+ qaAgentId: snapshot.qaAgentId,
8146
+ signalToken: snapshot.signalToken,
8147
+ reviewDispatchedAt: snapshot.reviewDispatchedAt,
8148
+ reviewRoundPending: snapshot.reviewRoundPending,
8149
+ // 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
8150
+ passToken: snapshot.passToken,
8151
+ failToken: snapshot.failToken,
8152
+ updatedAt: new Date().toISOString(),
8153
+ };
8154
+ if (!isTerminalAtClaim) {
7057
8155
  next.status = originalStatus;
7058
8156
  next.reviewHeadAnchorSha = snapshot.reviewHeadAnchorSha;
7059
8157
  }
@@ -7451,6 +8549,9 @@ export class AgentManager {
7451
8549
  const agent = this.getAgentConfig(agentId);
7452
8550
  if (!agent)
7453
8551
  return;
8552
+ // Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
8553
+ // agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
8554
+ const genAtEntry = this.deletionGenerationOf(agentId);
7454
8555
  const state = await this.agentStore.get(agentId);
7455
8556
  if (!state)
7456
8557
  return;
@@ -7465,12 +8566,16 @@ export class AgentManager {
7465
8566
  return;
7466
8567
  }
7467
8568
  if (!state.taskId) {
8569
+ if (this.isDeletionInFlight(agentId))
8570
+ return;
7468
8571
  const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
7469
8572
  if (!lockToken)
7470
8573
  return;
7471
- await this.agentStore.update(agentId, (existing) => {
8574
+ const commit = await this.agentStore.update(agentId, (existing) => {
7472
8575
  if (!existing)
7473
8576
  return AGENT_STORE_NOOP;
8577
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8578
+ return AGENT_STORE_NOOP;
7474
8579
  if (existing.taskId && existing.taskId !== ctx.taskId)
7475
8580
  return AGENT_STORE_NOOP;
7476
8581
  if (existing.creationToken)
@@ -7485,17 +8590,35 @@ export class AgentManager {
7485
8590
  lockToken,
7486
8591
  updatedAt: new Date().toISOString(),
7487
8592
  };
8593
+ }).catch(async (err) => {
8594
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken).catch((releaseErr) => {
8595
+ console.warn(`[AgentManager] postMergeCleanup(${agentId}): lock release after commit failure also failed:`, releaseErr);
8596
+ });
8597
+ throw err;
7488
8598
  });
8599
+ if (commit !== 'committed') {
8600
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
8601
+ return;
8602
+ }
7489
8603
  const fresh = await this.agentStore.get(agentId);
7490
8604
  if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
7491
8605
  await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
7492
8606
  return;
7493
8607
  }
7494
8608
  }
8609
+ // A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
8610
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8611
+ return;
7495
8612
  const runner = this.createRunnerFor(agent);
7496
8613
  const tmux = new TmuxManager(runner);
7497
8614
  const runtime = agentRuntimeKindFor(agent);
7498
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
8615
+ const expectedPaneId = state.paneId;
8616
+ void (async () => {
8617
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8618
+ return;
8619
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
8620
+ await this.runPostMergeCompaction(tmux, pane, agentId, ctx.taskId, runtime);
8621
+ })().catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
7499
8622
  }
7500
8623
  async releasePostMergeAgent(agentId, taskId) {
7501
8624
  const state = await this.agentStore.get(agentId);
@@ -7508,10 +8631,10 @@ export class AgentManager {
7508
8631
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
7509
8632
  }
7510
8633
  }
7511
- async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
8634
+ async runPostMergeCompaction(tmux, pane, agentId, originalTaskId, runtime) {
7512
8635
  await this.acquireCompactGuard(agentId);
7513
8636
  try {
7514
- await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime);
8637
+ await this.runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime);
7515
8638
  }
7516
8639
  finally {
7517
8640
  this.compactInFlight.delete(agentId);
@@ -7537,7 +8660,8 @@ export class AgentManager {
7537
8660
  this.compactInFlight.add(agentId);
7538
8661
  return true;
7539
8662
  }
7540
- async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
8663
+ async runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime) {
8664
+ const paneId = pane.paneId;
7541
8665
  const initial = await this.agentStore.get(agentId);
7542
8666
  if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
7543
8667
  return;
@@ -7557,13 +8681,13 @@ export class AgentManager {
7557
8681
  let cleared = false;
7558
8682
  let clearError;
7559
8683
  try {
7560
- cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
8684
+ cleared = await this.sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs);
7561
8685
  }
7562
8686
  catch (err) {
7563
8687
  clearError = err;
7564
8688
  }
7565
8689
  if (!cleared) {
7566
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
8690
+ if (await this.recoverPostMergeExitedRuntime(tmux, pane, agentId, originalTaskId, lockToken, runtime)) {
7567
8691
  cleared = true;
7568
8692
  }
7569
8693
  else if (!await bindingStillOurs()) {
@@ -7572,7 +8696,7 @@ export class AgentManager {
7572
8696
  else if (clearError instanceof Error
7573
8697
  && clearError.message.includes('runtime rejected /clear')) {
7574
8698
  try {
7575
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8699
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7576
8700
  }
7577
8701
  catch (err) {
7578
8702
  clearError = err;
@@ -7590,10 +8714,11 @@ export class AgentManager {
7590
8714
  }
7591
8715
  await this.releasePostMergeAgent(agentId, originalTaskId);
7592
8716
  }
7593
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
8717
+ async recoverPostMergeExitedRuntime(tmux, pane, agentId, taskId, lockToken, runtime) {
8718
+ const paneId = pane.paneId;
7594
8719
  let paneExists = true;
7595
8720
  try {
7596
- if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
8721
+ if (hasReplProcTitle(await tmux.displayMessage(pane, '#{pane_current_command}'), runtime))
7597
8722
  return false;
7598
8723
  }
7599
8724
  catch (err) {
@@ -7612,7 +8737,7 @@ export class AgentManager {
7612
8737
  return false;
7613
8738
  try {
7614
8739
  if (paneExists)
7615
- await tmux.sendKeysToPane(paneId, 'C-c');
8740
+ await tmux.sendKeysToPane(pane, 'C-c');
7616
8741
  const ensure = await this.ensureSession(agentId, 'runtime');
7617
8742
  if (!ensure.freshRuntime)
7618
8743
  return false;
@@ -7641,22 +8766,22 @@ export class AgentManager {
7641
8766
  return false;
7642
8767
  }
7643
8768
  }
7644
- async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs) {
8769
+ async sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs) {
7645
8770
  let rejection;
7646
8771
  for (let attempt = 1; attempt <= 2; attempt++) {
7647
8772
  if (!await bindingStillOurs())
7648
8773
  return false;
7649
- await tmux.clearComposerDraft(paneId);
7650
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8774
+ await tmux.clearComposerDraft(pane);
8775
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7651
8776
  if (!await bindingStillOurs())
7652
8777
  return false;
7653
- await tmux.sendKeysLiteral(paneId, '/clear');
7654
- await tmux.sendEnter(paneId);
8778
+ await tmux.sendKeysLiteral(pane, '/clear');
8779
+ await tmux.sendEnter(pane);
7655
8780
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7656
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8781
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
7657
8782
  if (!await bindingStillOurs())
7658
8783
  return false;
7659
- if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear'))
8784
+ if (!await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear'))
7660
8785
  return true;
7661
8786
  rejection = new Error('runtime rejected /clear because a task is still in progress');
7662
8787
  if (attempt < 2) {
@@ -7666,29 +8791,33 @@ export class AgentManager {
7666
8791
  }
7667
8792
  throw rejection ?? new Error('runtime rejected /clear');
7668
8793
  }
7669
- async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
7670
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8794
+ async hasRuntimeSlashCommandRejection(tmux, pane, command) {
8795
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
7671
8796
  return this.runtimeSlashCommandRejectedPattern(command).test(cap);
7672
8797
  }
7673
8798
  runtimeSlashCommandRejectedPattern(command) {
7674
8799
  return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
7675
8800
  }
7676
- async waitForReplPromptReady(tmux, paneId, runtime, timeoutMs) {
8801
+ async waitForReplPromptReady(tmux, pane, runtime, timeoutMs, opts = {}) {
8802
+ if (opts.stableIdle) {
8803
+ return this.waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs);
8804
+ }
8805
+ const paneId = pane.paneId;
7677
8806
  const deadline = Date.now() + timeoutMs;
7678
- await tmux.waitReplReady(paneId, runtime, {
8807
+ await tmux.waitReplReady(pane, runtime, {
7679
8808
  timeoutMs,
7680
8809
  intervalMs: this.compactIdlePollMs,
7681
8810
  titleIdleFastPath: true,
7682
8811
  });
7683
8812
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7684
8813
  while (true) {
7685
- const current = await tmux.displayMessage(paneId, '#{pane_current_command}');
8814
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
7686
8815
  if (!hasReplProcTitle(current, runtime)) {
7687
8816
  throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
7688
8817
  }
7689
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8818
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
7690
8819
  const ready = hasRuntimeReadyView(cap, runtime)
7691
- || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(paneId), runtime));
8820
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
7692
8821
  if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
7693
8822
  throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
7694
8823
  }
@@ -7699,11 +8828,41 @@ export class AgentManager {
7699
8828
  return;
7700
8829
  }
7701
8830
  if (Date.now() >= deadline) {
7702
- throw new Error(`waitForReplPromptReady: pane ${paneId} stayed busy past ${timeoutMs}ms`);
8831
+ // 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
8832
+ // 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
8833
+ throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
7703
8834
  }
7704
8835
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
7705
8836
  }
7706
8837
  }
8838
+ // 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
8839
+ // 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
8840
+ async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
8841
+ const paneId = pane.paneId;
8842
+ const samples = Math.max(1, this.readyStableSamples);
8843
+ const spacing = this.readyStableSpacingMs;
8844
+ const deadline = Date.now() + timeoutMs + (samples - 1) * spacing;
8845
+ let streak = 0;
8846
+ while (true) {
8847
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
8848
+ if (!hasReplProcTitle(current, runtime)) {
8849
+ throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
8850
+ }
8851
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
8852
+ const ready = hasRuntimeReadyView(cap, runtime)
8853
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
8854
+ if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
8855
+ throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
8856
+ }
8857
+ streak = ready && !runtimeBusyCheck(cap, runtime) ? streak + 1 : 0;
8858
+ if (streak >= samples)
8859
+ return;
8860
+ if (Date.now() >= deadline) {
8861
+ throw new ReplNotReadyError(paneId, runtime, cap, `stable idle not confirmed within ${timeoutMs}ms (+${(samples - 1) * spacing}ms stability window)`);
8862
+ }
8863
+ await new Promise(r => setTimeout(r, spacing));
8864
+ }
8865
+ }
7707
8866
  stopPhaseSignalWatcher(taskId) {
7708
8867
  this.phaseSignalWatcher?.stop(taskId);
7709
8868
  }
@@ -7733,6 +8892,8 @@ export class AgentManager {
7733
8892
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
7734
8893
  ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
7735
8894
  ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
8895
+ ...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
8896
+ ...(opts.needInputPaneCutover ? { needInputPaneCutover: true } : {}),
7736
8897
  });
7737
8898
  }
7738
8899
  catch (err) {
@@ -7743,6 +8904,10 @@ export class AgentManager {
7743
8904
  async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
7744
8905
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
7745
8906
  skipSnapshot,
8907
+ // Every post-dispatch arm follows a freshly injected prompt: the previous
8908
+ // question is superseded, its watermark must not carry over (fix/publish
8909
+ // continuations included).
8910
+ needInputMode: 'fresh',
7746
8911
  ...(onReadFile ? { onReadFile } : {}),
7747
8912
  });
7748
8913
  if (!armed)
@@ -7783,7 +8948,10 @@ export class AgentManager {
7783
8948
  // pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
7784
8949
  // 这里只轮换 signal token 并布防
7785
8950
  await this.updateTask(taskId, { signalToken: newToken });
7786
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
8951
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
8952
+ needInputMode: 'fresh',
8953
+ ...(isGit ? { replaceScope: 'agent' } : {}),
8954
+ });
7787
8955
  if (armed && isGit && kinds.includes('pr-approved'))
7788
8956
  await this.rearmGitReconciliationWatcher(taskId);
7789
8957
  return { token: newToken, armed };
@@ -7805,6 +8973,7 @@ export class AgentManager {
7805
8973
  signalToken: restore.signalToken,
7806
8974
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
7807
8975
  reviewDispatchedAt: restore.reviewDispatchedAt,
8976
+ reviewRoundPending: restore.reviewRoundPending,
7808
8977
  ...(restore.restorePair === true
7809
8978
  ? { passToken: restore.passToken, failToken: restore.failToken }
7810
8979
  : {}),
@@ -8192,7 +9361,7 @@ export class AgentManager {
8192
9361
  signalToken,
8193
9362
  specDocuments,
8194
9363
  ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
8195
- armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
9364
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
8196
9365
  };
8197
9366
  return this.isResearchHandoff(task, devAgentId)
8198
9367
  ? this.startSession(task.id, devAgentId, 'code', {
@@ -8456,6 +9625,7 @@ export class AgentManager {
8456
9625
  dispatchedCheckoutMode = ctx.serverReviewCheckout;
8457
9626
  const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
8458
9627
  return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
9628
+ needInputMode: 'fresh',
8459
9629
  ...(allowReadFile
8460
9630
  ? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
8461
9631
  : {}),
@@ -8677,7 +9847,7 @@ export class AgentManager {
8677
9847
  // Callers without a baseSha (publish) run long after the review diff was
8678
9848
  // read; refresh origin/HEAD first or upstream commits merged since then
8679
9849
  // 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`);
9850
+ const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
8681
9851
  if (fetch.exitCode !== 0) {
8682
9852
  throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
8683
9853
  }
@@ -8808,6 +9978,8 @@ export class AgentManager {
8808
9978
  const cfg = this.getAgentConfig(agentId);
8809
9979
  if (!cfg)
8810
9980
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
9981
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
9982
+ const genAtEntry = this.deletionGenerationOf(agentId);
8811
9983
  await this.acquireCompactGuard(agentId);
8812
9984
  try {
8813
9985
  const state = await this.agentStore.get(agentId);
@@ -8842,8 +10014,18 @@ export class AgentManager {
8842
10014
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
8843
10015
  }
8844
10016
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
8845
- await tmux.injectPrompt(paneId, text, agentId);
8846
- await tmux.sendEnter(paneId);
10017
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
10018
+ // resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
10019
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
10020
+ throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
10021
+ }
10022
+ const afterResolve = await this.agentStore.get(agentId);
10023
+ if (!afterResolve || afterResolve.paneId !== paneId || afterResolve.taskId !== taskId
10024
+ || (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
10025
+ throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
10026
+ }
10027
+ await tmux.injectPrompt(pane, text, agentId);
10028
+ await tmux.sendEnter(pane);
8847
10029
  }
8848
10030
  finally {
8849
10031
  this.compactInFlight.delete(agentId);
@@ -9000,14 +10182,17 @@ export class AgentManager {
9000
10182
  }
9001
10183
  startCompactionThenRelease(agentId, state, taskId) {
9002
10184
  // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
9003
- if (!state.paneId || state.status === 'awaiting_human' || state.creationToken)
10185
+ const expectedPaneId = state.paneId;
10186
+ if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
9004
10187
  return false;
9005
10188
  const cfg = this.getAgentConfig(agentId);
9006
10189
  if (!cfg)
9007
10190
  return false;
9008
10191
  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));
10192
+ void (async () => {
10193
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
10194
+ await this.runPostMergeCompaction(tmux, pane, agentId, taskId, agentRuntimeKindFor(cfg));
10195
+ })().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
9011
10196
  return true;
9012
10197
  }
9013
10198
  repoMergeQueue = new Map();
@@ -9023,13 +10208,15 @@ export class AgentManager {
9023
10208
  const runner = this.createRunnerFor(dev);
9024
10209
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
9025
10210
  const run = prev.then(async () => {
10211
+ // 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.
10212
+ const guard = `${canonicalSelfGuard(workdir)} && `;
9026
10213
  const cd = `cd ${shellQuote(workdir)} && `;
9027
10214
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
9028
10215
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
9029
10216
  if (db.exitCode !== 0 || defaultBranch === '') {
9030
10217
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
9031
10218
  }
9032
- const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
10219
+ const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
9033
10220
  if (fetch.exitCode !== 0) {
9034
10221
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
9035
10222
  }
@@ -9043,7 +10230,7 @@ export class AgentManager {
9043
10230
  else {
9044
10231
  throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
9045
10232
  }
9046
- const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
10233
+ const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
9047
10234
  if (push.exitCode !== 0) {
9048
10235
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
9049
10236
  }