baxian 1.2.58 → 1.2.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) 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 +204 -8
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +1982 -419
  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 +6 -1
  21. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  22. package/dist/agent/phase-signal-watcher.js +51 -27
  23. package/dist/agent/phase-signal-watcher.js.map +1 -1
  24. package/dist/agent/phase-signal.d.ts +3 -1
  25. package/dist/agent/phase-signal.d.ts.map +1 -1
  26. package/dist/agent/phase-signal.js +27 -4
  27. package/dist/agent/phase-signal.js.map +1 -1
  28. package/dist/agent/preflight.d.ts.map +1 -1
  29. package/dist/agent/preflight.js +18 -8
  30. package/dist/agent/preflight.js.map +1 -1
  31. package/dist/agent/repo-store.d.ts +3 -0
  32. package/dist/agent/repo-store.d.ts.map +1 -1
  33. package/dist/agent/repo-store.js +192 -67
  34. package/dist/agent/repo-store.js.map +1 -1
  35. package/dist/agent/review-transport.d.ts.map +1 -1
  36. package/dist/agent/review-transport.js +4 -2
  37. package/dist/agent/review-transport.js.map +1 -1
  38. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  39. package/dist/agent/tmux-probe-poller.js +29 -20
  40. package/dist/agent/tmux-probe-poller.js.map +1 -1
  41. package/dist/agent/tmux.d.ts +79 -32
  42. package/dist/agent/tmux.d.ts.map +1 -1
  43. package/dist/agent/tmux.js +435 -225
  44. package/dist/agent/tmux.js.map +1 -1
  45. package/dist/api/config.d.ts.map +1 -1
  46. package/dist/api/config.js +26 -4
  47. package/dist/api/config.js.map +1 -1
  48. package/dist/api/platform-guard.d.ts +7 -0
  49. package/dist/api/platform-guard.d.ts.map +1 -0
  50. package/dist/api/platform-guard.js +32 -0
  51. package/dist/api/platform-guard.js.map +1 -0
  52. package/dist/api/projects.d.ts.map +1 -1
  53. package/dist/api/projects.js +202 -97
  54. package/dist/api/projects.js.map +1 -1
  55. package/dist/api/tasks.d.ts.map +1 -1
  56. package/dist/api/tasks.js +5 -1
  57. package/dist/api/tasks.js.map +1 -1
  58. package/dist/app.d.ts +2 -0
  59. package/dist/app.d.ts.map +1 -1
  60. package/dist/app.js +1 -0
  61. package/dist/app.js.map +1 -1
  62. package/dist/cli.d.ts.map +1 -1
  63. package/dist/cli.js +11 -3
  64. package/dist/cli.js.map +1 -1
  65. package/dist/config/hot-reload.d.ts.map +1 -1
  66. package/dist/config/hot-reload.js +1 -0
  67. package/dist/config/hot-reload.js.map +1 -1
  68. package/dist/config/loader.d.ts.map +1 -1
  69. package/dist/config/loader.js +48 -3
  70. package/dist/config/loader.js.map +1 -1
  71. package/dist/config/validator.js +3 -0
  72. package/dist/config/validator.js.map +1 -1
  73. package/dist/errors.d.ts +2 -1
  74. package/dist/errors.d.ts.map +1 -1
  75. package/dist/errors.js +3 -1
  76. package/dist/errors.js.map +1 -1
  77. package/dist/event/handlers.d.ts +1 -0
  78. package/dist/event/handlers.d.ts.map +1 -1
  79. package/dist/event/handlers.js +641 -82
  80. package/dist/event/handlers.js.map +1 -1
  81. package/dist/github/mapper.d.ts +1 -6
  82. package/dist/github/mapper.d.ts.map +1 -1
  83. package/dist/github/mapper.js.map +1 -1
  84. package/dist/github/poller.d.ts +3 -3
  85. package/dist/github/poller.d.ts.map +1 -1
  86. package/dist/github/poller.js +2 -11
  87. package/dist/github/poller.js.map +1 -1
  88. package/dist/github/pr-conversation-cache.d.ts +41 -0
  89. package/dist/github/pr-conversation-cache.d.ts.map +1 -0
  90. package/dist/github/pr-conversation-cache.js +105 -0
  91. package/dist/github/pr-conversation-cache.js.map +1 -0
  92. package/dist/github/resolver.js +1 -1
  93. package/dist/github/resolver.js.map +1 -1
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +40 -2
  96. package/dist/index.js.map +1 -1
  97. package/dist/platform/driver-host.d.ts +7 -0
  98. package/dist/platform/driver-host.d.ts.map +1 -0
  99. package/dist/platform/driver-host.js +18 -0
  100. package/dist/platform/driver-host.js.map +1 -0
  101. package/dist/platform/feedback.d.ts +31 -0
  102. package/dist/platform/feedback.d.ts.map +1 -0
  103. package/dist/platform/feedback.js +85 -0
  104. package/dist/platform/feedback.js.map +1 -0
  105. package/dist/platform/platform-poller.d.ts +3 -2
  106. package/dist/platform/platform-poller.d.ts.map +1 -1
  107. package/dist/platform/platform-poller.js +32 -59
  108. package/dist/platform/platform-poller.js.map +1 -1
  109. package/dist/platform/poller-health.d.ts +3 -0
  110. package/dist/platform/poller-health.d.ts.map +1 -0
  111. package/dist/platform/poller-health.js +12 -0
  112. package/dist/platform/poller-health.js.map +1 -0
  113. package/dist/platform/pr-binding.d.ts +22 -0
  114. package/dist/platform/pr-binding.d.ts.map +1 -0
  115. package/dist/platform/pr-binding.js +28 -0
  116. package/dist/platform/pr-binding.js.map +1 -0
  117. package/dist/platform/types.d.ts +6 -0
  118. package/dist/platform/types.d.ts.map +1 -1
  119. package/dist/platform/types.js.map +1 -1
  120. package/dist/platform/verdict-engine.d.ts +7 -0
  121. package/dist/platform/verdict-engine.d.ts.map +1 -1
  122. package/dist/platform/verdict-engine.js +33 -24
  123. package/dist/platform/verdict-engine.js.map +1 -1
  124. package/dist/shared/constants.d.ts +6 -0
  125. package/dist/shared/constants.d.ts.map +1 -1
  126. package/dist/shared/constants.js +6 -0
  127. package/dist/shared/constants.js.map +1 -1
  128. package/dist/shared/types.d.ts +39 -0
  129. package/dist/shared/types.d.ts.map +1 -1
  130. package/dist/shared/types.js.map +1 -1
  131. package/dist/state/agent-store.d.ts +5 -1
  132. package/dist/state/agent-store.d.ts.map +1 -1
  133. package/dist/state/agent-store.js +29 -15
  134. package/dist/state/agent-store.js.map +1 -1
  135. package/dist/state/lock.d.ts +9 -0
  136. package/dist/state/lock.d.ts.map +1 -1
  137. package/dist/state/lock.js +24 -1
  138. package/dist/state/lock.js.map +1 -1
  139. package/dist/state/task-store.d.ts.map +1 -1
  140. package/dist/state/task-store.js +73 -4
  141. package/dist/state/task-store.js.map +1 -1
  142. package/dist/terminal/attach.d.ts +10 -1
  143. package/dist/terminal/attach.d.ts.map +1 -1
  144. package/dist/terminal/attach.js +40 -20
  145. package/dist/terminal/attach.js.map +1 -1
  146. package/dist/terminal/stream-ws.d.ts.map +1 -1
  147. package/dist/terminal/stream-ws.js +27 -0
  148. package/dist/terminal/stream-ws.js.map +1 -1
  149. package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
  150. package/dist/web/assets/index-DtnDvjOU.js +16 -0
  151. package/dist/web/index.html +2 -2
  152. package/package.json +1 -1
  153. package/dist/web/assets/index-DskVWe7e.js +0 -13
@@ -4,7 +4,12 @@ import { join, normalize } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { createSignalToken } from './phase-signal.js';
6
6
  import { tmuxInstallHint } from './preflight.js';
7
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
7
+ 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
+ import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
+ import { DriverOpError } from '../platform/git-driver.js';
10
+ import { checkOpenPrBinding } from '../platform/pr-binding.js';
11
+ import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
+ import { recheckPassProvenance } from '../platform/verdict-engine.js';
8
13
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
9
14
  import { PostApproveStore } from '../state/post-approve-store.js';
10
15
  import { SkillRegistry } from '../skill/registry.js';
@@ -12,14 +17,16 @@ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey,
12
17
  import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
13
18
  import { findForeignTaskTip } from './lineage.js';
14
19
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
15
- import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
16
- import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
17
- import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
20
+ import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
21
+ import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
22
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
18
23
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
19
24
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
20
25
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
21
26
  import { ApiError } from '../errors.js';
22
27
  import { prepareConfig } from '../config/loader.js';
28
+ import { resolveProjectTool } from '../config/validator.js';
29
+ import { SHA_HEX_SOURCE } from '../platform/types.js';
23
30
  export class EnsureSessionError extends Error {
24
31
  partial;
25
32
  constructor(partial, message) {
@@ -140,7 +147,28 @@ export function skillSubdirFor(runtime) {
140
147
  }
141
148
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
142
149
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
150
+ // DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
151
+ // probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
152
+ const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
143
153
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
154
+ const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
155
+ // 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
156
+ export class PlatformCloseError extends Error {
157
+ step;
158
+ constructor(step, cause) {
159
+ super(`platform ${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
160
+ this.step = step;
161
+ this.name = 'PlatformCloseError';
162
+ }
163
+ }
164
+ export class PlatformMergeRecheckError extends Error {
165
+ reason;
166
+ constructor(reason) {
167
+ super(`merge blocked: ${reason}`);
168
+ this.reason = reason;
169
+ this.name = 'PlatformMergeRecheckError';
170
+ }
171
+ }
144
172
  const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
145
173
  const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
146
174
  const RUNTIME_LIVENESS_SAMPLES = 3;
@@ -204,6 +232,8 @@ export class AgentManager {
204
232
  repoCache;
205
233
  paneStreamerManager;
206
234
  postApproveStore;
235
+ pluginRegistry;
236
+ platformDriverExec;
207
237
  phaseSignalWatcher;
208
238
  errorRecordStore;
209
239
  reviewStore;
@@ -225,6 +255,9 @@ export class AgentManager {
225
255
  manualCompactWaitMs = 5_000;
226
256
  runtimeLivenessProbeMs = 700;
227
257
  cleanComposerWaitMs = 5_000;
258
+ readyStableSamples = 3;
259
+ readyStableSpacingMs = 1_000;
260
+ pendingDispatchRetryByTask = new Map();
228
261
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
229
262
  manualReviewInFlight = new Set();
230
263
  markCompleteInFlight = new Set();
@@ -233,6 +266,9 @@ export class AgentManager {
233
266
  // 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
234
267
  cancelCleanupInFlight = new Map();
235
268
  deletionInFlight = new Set();
269
+ // Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
270
+ divergedAgents = new Set();
271
+ deletionGeneration = new Map();
236
272
  compactInFlight = new Set();
237
273
  constructor(deps) {
238
274
  const config = prepareConfig(deps.config);
@@ -246,6 +282,7 @@ export class AgentManager {
246
282
  this.repoStoreFactory = deps.repoStoreFactory;
247
283
  this.paneStreamerManager = deps.paneStreamerManager;
248
284
  this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
285
+ this.pluginRegistry = deps.pluginRegistry;
249
286
  this.errorRecordStore = deps.errorRecordStore;
250
287
  this.phaseSignalWatcher = deps.phaseSignalWatcher
251
288
  ?? (deps.paneStreamerManager
@@ -261,6 +298,7 @@ export class AgentManager {
261
298
  this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
262
299
  this.agentIndex = buildAgentIndex(config);
263
300
  this.platformRunner = deps.platformRunner ?? new LocalRunner();
301
+ this.platformDriverExec = makeDriverExec(this.platformRunner);
264
302
  this.imageStagingRoot = deps.imageStagingRoot ?? join(tmpdir(), 'baxian-task-images');
265
303
  this.repoCache = createRepoStoreCache();
266
304
  this.bootstrapTimeoutsMs = {
@@ -426,14 +464,62 @@ export class AgentManager {
426
464
  this.deletionInFlight.add(id);
427
465
  return null;
428
466
  }
467
+ // Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
468
+ async scanActiveThenClaimDeletion(targets) {
469
+ return this.withTaskLock(async () => {
470
+ for (const id of targets) {
471
+ const state = await this.agentStore.get(id);
472
+ const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
473
+ if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
474
+ return { ok: false, code: 'active', agentId: id, taskId: task.id };
475
+ }
476
+ if (state?.status === 'awaiting_human')
477
+ continue;
478
+ if (state?.creationToken)
479
+ return { ok: false, code: 'bootstrapping', agentId: id };
480
+ }
481
+ const targetSet = new Set(targets);
482
+ const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
483
+ && ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
484
+ || (!!t.agentId && targetSet.has(t.agentId))
485
+ || (!!t.devAgentId && targetSet.has(t.devAgentId))
486
+ || (!!t.qaAgentId && targetSet.has(t.qaAgentId))
487
+ || (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
488
+ if (referencing)
489
+ return { ok: false, code: 'referencing', taskId: referencing.id };
490
+ const conflict = this.tryClaimDeletion(targets);
491
+ if (conflict)
492
+ return { ok: false, code: 'already-deleting', agentId: conflict };
493
+ return { ok: true };
494
+ });
495
+ }
429
496
  releaseDeletionClaim(agentIds) {
430
497
  for (const id of agentIds)
431
498
  this.deletionInFlight.delete(id);
432
499
  }
500
+ // A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
501
+ bumpDeletionGeneration(agentId) {
502
+ this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
503
+ }
504
+ deletionGenerationOf(agentId) {
505
+ return this.deletionGeneration.get(agentId) ?? 0;
506
+ }
507
+ // Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
508
+ deletionGateOpen(agentId, genAtEntry) {
509
+ return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
510
+ }
433
511
  isDeletionInFlight(agentId) {
434
- return this.deletionInFlight.has(agentId);
512
+ return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
513
+ }
514
+ // replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
515
+ retainTombstoneForDivergence(agentId) {
516
+ this.divergedAgents.add(agentId);
435
517
  }
436
- async ensureSession(agentId, mode) {
518
+ async ensureSession(agentId, mode, opts = {}) {
519
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
520
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
521
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
522
+ }
437
523
  const agent = this.getAgentConfig(agentId);
438
524
  if (!agent) {
439
525
  throw new EnsureSessionError({ createdSession: false, agentId }, `Unknown agent: ${agentId}`);
@@ -444,48 +530,67 @@ export class AgentManager {
444
530
  }
445
531
  const runner = this.createRunnerFor(agent);
446
532
  const tmux = new TmuxManager(runner);
533
+ // Side-effects run inside the lifecycle section (serialized with DELETE cleanup) behind a re-checked generation gate; the entry gate is only a fast-fail.
534
+ return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
535
+ }
536
+ async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
537
+ const assertGenerationOpen = () => {
538
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
539
+ throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
540
+ }
541
+ };
542
+ assertGenerationOpen();
447
543
  let workdir;
448
544
  try {
449
545
  const resolved = await this.ensureWorkdir(agent, project, runner);
450
546
  workdir = resolved.workdir;
451
- await this.agentStore.update(agentId, (existing) => {
452
- if (!existing)
453
- return AGENT_STORE_NOOP;
454
- return {
455
- ...existing,
456
- workdir,
457
- updatedAt: new Date().toISOString(),
458
- };
459
- });
460
547
  }
461
548
  catch (err) {
462
549
  throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
463
550
  }
551
+ // Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
552
+ assertGenerationOpen();
553
+ await this.agentStore.update(agentId, (existing) => {
554
+ if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
555
+ return AGENT_STORE_NOOP;
556
+ return { ...existing, workdir, updatedAt: new Date().toISOString() };
557
+ });
558
+ assertGenerationOpen();
464
559
  try {
465
560
  await this.provisionRepoSkills(runner, agent, workdir);
466
561
  }
467
562
  catch (err) {
468
563
  throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
469
564
  }
470
- let alive;
565
+ assertGenerationOpen();
566
+ let snapshot;
471
567
  try {
472
- alive = await tmux.hasSession(agentId);
568
+ snapshot = await tmux.getSessionSnapshot(agentId);
473
569
  }
474
570
  catch (err) {
475
571
  throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
476
572
  `if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
477
573
  }
478
- if (mode === 'create' && alive) {
479
- const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
480
- if (!reclaimed) {
481
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
482
- `it creates itself — kill it manually or pick a different agent id`);
574
+ // Re-gate after the probe await: a DELETE may have tombstoned mid-probe, and build/adopt would else create a session cleanup can only reap later.
575
+ assertGenerationOpen();
576
+ if (!snapshot) {
577
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
578
+ }
579
+ if (snapshot.claim === null) {
580
+ if (await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
581
+ return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
483
582
  }
583
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
584
+ `it creates itself — kill it manually or pick a different agent id`);
484
585
  }
485
- if (alive && (mode === 'runtime' || mode === 'recover')) {
486
- return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
586
+ if (mode === 'create' || snapshot.claim !== agentId) {
587
+ throw new EnsureSessionError({ createdSession: false, agentId }, mode === 'create'
588
+ ? `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
589
+ `it creates itself — kill it manually or pick a different agent id`
590
+ : `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
591
+ `(got "${snapshot.claim}"); baxian will not adopt foreign session — operator must intervene`);
487
592
  }
488
- return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
593
+ return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
489
594
  }
490
595
  async provisionRepoSkills(runner, agent, workdir) {
491
596
  if (this.skillRegistry.names().length === 0)
@@ -554,9 +659,10 @@ export class AgentManager {
554
659
  return;
555
660
  }
556
661
  const runner = this.createRunnerFor(cfg);
557
- const outcome = await new TmuxManager(runner).killSessionRef(ref);
558
- if (outcome === 'stale-server') {
559
- console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
662
+ // Rollback may run before or after the claim write, so accept empty-or-ours.
663
+ const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
664
+ if (outcome === 'refused') {
665
+ console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
560
666
  }
561
667
  else if (outcome === 'killed') {
562
668
  console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
@@ -603,7 +709,9 @@ export class AgentManager {
603
709
  async ensureSkillDirSafe(runner, workdir, subdir) {
604
710
  const top = subdir.split('/')[0];
605
711
  const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
606
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
712
+ const inner =
713
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
714
+ `${canonicalSelfGuard(workdir)} && ` +
607
715
  `cd ${shellQuote(workdir)} && ` +
608
716
  `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
609
717
  `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
@@ -624,14 +732,16 @@ export class AgentManager {
624
732
  ['@baxian-skills-version', this.skillRegistry.contentHash()],
625
733
  ]);
626
734
  }
627
- async replSkillsStale(tmux, agentId) {
735
+ async replSkillsStale(tmux, agentId, ref) {
628
736
  if (this.skillRegistry.names().length === 0)
629
737
  return false;
630
- const tagged = await tmux.getOption(agentId, '@baxian-skills-version');
738
+ const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
631
739
  return tagged !== this.skillRegistry.contentHash();
632
740
  }
633
741
  async excludeInjectedSkills(runner, workdir, subdir) {
634
- const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
742
+ const inner =
743
+ // canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
744
+ `${canonicalSelfGuard(workdir)} && ` +
635
745
  `cd ${shellQuote(workdir)} && ` +
636
746
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
637
747
  `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
@@ -664,12 +774,26 @@ export class AgentManager {
664
774
  }
665
775
  // A name (or even a bare $id) can rebind to a same-name successor mid-flight;
666
776
  // every option write goes through the identity-checked by-ref batch.
667
- async setSessionOptions(tmux, agentId, ref, entries) {
668
- const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
777
+ async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
778
+ const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
669
779
  if (outcome === 'gone') {
670
780
  throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
671
781
  }
672
782
  }
783
+ // Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
784
+ async resolveClaimedPane(tmux, agentId, expectedPaneId) {
785
+ const snapshot = await tmux.getSessionSnapshot(agentId);
786
+ if (!snapshot)
787
+ throw new PaneGoneError(agentId, 'no tmux session');
788
+ if (snapshot.claim !== agentId) {
789
+ throw new PaneGoneError(agentId, `session claim mismatch (got "${snapshot.claim ?? 'null'}")`);
790
+ }
791
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
792
+ if (expectedPaneId !== undefined && pane.paneId !== expectedPaneId) {
793
+ throw new PaneGoneError(agentId, `pane id changed (state ${expectedPaneId} != live ${pane.paneId})`);
794
+ }
795
+ return pane;
796
+ }
673
797
  async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
674
798
  return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
675
799
  }
@@ -681,6 +805,7 @@ export class AgentManager {
681
805
  try {
682
806
  createdRef = await tmux.createSession(agentId, sessionDir);
683
807
  createdSession = true;
808
+ // First batch writes the claim itself, so it must prove the session is still unclaimed.
684
809
  await this.setSessionOptions(tmux, agentId, createdRef, [
685
810
  ['@baxian-agent-id', agentId],
686
811
  ['@baxian-runtime', agent.runtime],
@@ -690,20 +815,21 @@ export class AgentManager {
690
815
  ['window-size', 'latest'],
691
816
  ...this.runtimePinEntries(),
692
817
  ['status-right', ''],
693
- ]);
818
+ ], '');
694
819
  await tmux.setServerOption('extended-keys', 'on');
695
820
  await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
696
- const paneId = await tmux.getSinglePaneIdByRef(createdRef);
697
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
698
- await tmux.handleTrustDialog(paneId, runtime, {
821
+ const pane = await tmux.getSinglePaneByRef(createdRef, agentId);
822
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
823
+ await tmux.sendEnter(pane);
824
+ await tmux.handleTrustDialog(pane, runtime, {
699
825
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
700
826
  });
701
- await tmux.waitReplReady(paneId, runtime, {
827
+ await tmux.waitReplReady(pane, runtime, {
702
828
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
703
829
  scrollback: 0,
704
830
  });
705
831
  await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
706
- return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
832
+ return { ok: true, createdSession: true, freshRuntime: true, paneId: pane.paneId, pane, workdir, sessionRef: createdRef };
707
833
  }
708
834
  catch (err) {
709
835
  const partial = { createdSession, agentId, genAtCreate };
@@ -726,7 +852,7 @@ export class AgentManager {
726
852
  }
727
853
  try {
728
854
  const result = await this.ensureSession(agentId, 'create');
729
- if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
855
+ if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.pane))) {
730
856
  const current = await this.agentStore.get(agentId);
731
857
  if (current && current.creationToken !== creationToken) {
732
858
  console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
@@ -787,7 +913,7 @@ export class AgentManager {
787
913
  await this.markBootstrapFailed(agentId, creationToken, message);
788
914
  }
789
915
  }
790
- async runGreetingHandshake(agentId, agent, paneId) {
916
+ async runGreetingHandshake(agentId, agent, pane) {
791
917
  const watcher = this.phaseSignalWatcher;
792
918
  if (!watcher)
793
919
  return true;
@@ -795,7 +921,7 @@ export class AgentManager {
795
921
  for (let attempt = 1; attempt <= this.greetingMaxAttempts; attempt++) {
796
922
  const token = createSignalToken();
797
923
  try {
798
- await this.injectAndAwaitAckSteps(tmux, paneId, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
924
+ await this.injectAndAwaitAckSteps(tmux, pane, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
799
925
  }
800
926
  catch (err) {
801
927
  console.warn(`[bootstrap] greeting inject failed for ${agentId} (attempt ${attempt}):`, err);
@@ -814,7 +940,7 @@ export class AgentManager {
814
940
  console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
815
941
  if (outcome === 'no-agent')
816
942
  break;
817
- if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, paneId, agentId))) {
943
+ if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
818
944
  break;
819
945
  }
820
946
  }
@@ -856,21 +982,25 @@ export class AgentManager {
856
982
  const agent = this.getAgentConfig(agentId);
857
983
  if (!agent)
858
984
  return false;
985
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
986
+ const genAtEntry = this.deletionGenerationOf(agentId);
859
987
  const state = await this.agentStore.get(agentId);
860
988
  if (state?.awaitingPhase !== 'greeting_failed')
861
989
  return false;
862
990
  const guardSince = state.awaitingSince;
863
- let paneId = state.paneId;
864
- if (!paneId) {
865
- try {
866
- paneId = await new TmuxManager(this.createRunnerFor(agent)).getSinglePaneId(agentId);
867
- }
868
- catch (err) {
869
- console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
870
- return true;
871
- }
991
+ if (!this.deletionGateOpen(agentId, genAtEntry))
992
+ return false;
993
+ let pane;
994
+ try {
995
+ pane = await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(agent)), agentId, state.paneId);
996
+ }
997
+ catch (err) {
998
+ console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
999
+ return true;
872
1000
  }
873
- if (!(await this.runGreetingHandshake(agentId, agent, paneId))) {
1001
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1002
+ return false;
1003
+ if (!(await this.runGreetingHandshake(agentId, agent, pane))) {
874
1004
  return true;
875
1005
  }
876
1006
  await this.agentStore.update(agentId, (fresh) => {
@@ -907,8 +1037,7 @@ export class AgentManager {
907
1037
  let paneId = existing.paneId;
908
1038
  if (cfg && !paneId) {
909
1039
  try {
910
- const runner = this.createRunnerFor(cfg);
911
- paneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1040
+ paneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
912
1041
  }
913
1042
  catch {
914
1043
  }
@@ -985,8 +1114,10 @@ export class AgentManager {
985
1114
  if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
986
1115
  return false;
987
1116
  }
1117
+ // The error belongs to the caller's incarnation; gate every side-effect so a DELETE→recreate reusing %0 can't write the stale dialog/fail onto the new agent.
1118
+ const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
988
1119
  let state = await this.agentStore.get(agentId);
989
- if (!state)
1120
+ if (!state || !gateOpen())
990
1121
  return false;
991
1122
  if (state.paneId === undefined && err.partial.createdSession) {
992
1123
  const cfg = this.getAgentConfig(agentId);
@@ -994,8 +1125,7 @@ export class AgentManager {
994
1125
  return false;
995
1126
  let discoveredPaneId;
996
1127
  try {
997
- const runner = this.createRunnerFor(cfg);
998
- discoveredPaneId = await new TmuxManager(runner).getSinglePaneId(agentId);
1128
+ discoveredPaneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
999
1129
  }
1000
1130
  catch (probeErr) {
1001
1131
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: tmux probe paneId failed for ${agentId}:`, probeErr);
@@ -1005,7 +1135,7 @@ export class AgentManager {
1005
1135
  return false;
1006
1136
  const probeNow = new Date().toISOString();
1007
1137
  await this.agentStore.update(agentId, (fresh) => {
1008
- if (!fresh)
1138
+ if (!fresh || !gateOpen())
1009
1139
  return AGENT_STORE_NOOP;
1010
1140
  if (fresh.paneId !== undefined)
1011
1141
  return AGENT_STORE_NOOP;
@@ -1019,6 +1149,8 @@ export class AgentManager {
1019
1149
  console.warn(`[AgentManager] handleDialogPendingFromRuntime: ${agentId} has no paneId/taskId snapshot (no generation guard); refusing — caller should rollback`);
1020
1150
  return false;
1021
1151
  }
1152
+ if (!gateOpen())
1153
+ return false;
1022
1154
  await this.markDialogPending(agentId, undefined, {
1023
1155
  runtimePath: true,
1024
1156
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
@@ -1088,15 +1220,16 @@ export class AgentManager {
1088
1220
  return;
1089
1221
  if (generationMismatch(state))
1090
1222
  return;
1091
- let paneId;
1223
+ let pane;
1092
1224
  try {
1093
- paneId = await tmux.getSinglePaneId(agentId);
1225
+ pane = await this.resolveClaimedPane(tmux, agentId);
1094
1226
  }
1095
1227
  catch {
1096
1228
  continue;
1097
1229
  }
1230
+ const paneId = pane.paneId;
1098
1231
  try {
1099
- await tmux.waitReplReady(paneId, runtime, {
1232
+ await tmux.waitReplReady(pane, runtime, {
1100
1233
  timeoutMs: 1_000,
1101
1234
  intervalMs: 200,
1102
1235
  scrollback: 0,
@@ -1114,7 +1247,7 @@ export class AgentManager {
1114
1247
  let projectIdForEmit = '';
1115
1248
  let wrote = false;
1116
1249
  const isBootstrapPath = creationToken !== undefined;
1117
- if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, paneId))) {
1250
+ if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
1118
1251
  await this.markGreetingFailed(agentId, creationToken);
1119
1252
  return;
1120
1253
  }
@@ -1209,7 +1342,8 @@ export class AgentManager {
1209
1342
  continue;
1210
1343
  let stripped;
1211
1344
  try {
1212
- stripped = await tmux.capturePaneById(state.paneId, { ansi: false, scrollback: 0 });
1345
+ const pane = await this.resolveClaimedPane(tmux, agentId, state.paneId);
1346
+ stripped = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
1213
1347
  }
1214
1348
  catch {
1215
1349
  continue;
@@ -1250,29 +1384,9 @@ export class AgentManager {
1250
1384
  signal.addEventListener('abort', onAbort, { once: true });
1251
1385
  });
1252
1386
  }
1253
- // Claim snapshot and adoption declaration share one critical section with rollbacks.
1254
- async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
1255
- return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
1256
- }
1257
- async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
1258
- let snapshot;
1259
- try {
1260
- snapshot = await tmux.getSessionSnapshot(agentId);
1261
- }
1262
- catch (err) {
1263
- throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1264
- }
1265
- if (!snapshot) {
1266
- return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1267
- }
1387
+ // Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
1388
+ async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
1268
1389
  const sessionRef = snapshot.ref;
1269
- if (snapshot.claim !== agentId) {
1270
- if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
1271
- return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
1272
- }
1273
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
1274
- `(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
1275
- }
1276
1390
  this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
1277
1391
  try {
1278
1392
  await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
@@ -1280,24 +1394,25 @@ export class AgentManager {
1280
1394
  catch (err) {
1281
1395
  throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
1282
1396
  }
1283
- let paneId;
1397
+ let pane;
1284
1398
  try {
1285
- paneId = await tmux.getSinglePaneIdByRef(sessionRef);
1399
+ pane = await tmux.getSinglePaneByRef(sessionRef, agentId);
1286
1400
  }
1287
1401
  catch (err) {
1288
1402
  throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
1289
1403
  }
1404
+ const paneId = pane.paneId;
1290
1405
  const runtime = agentRuntimeKindFor(agent);
1291
1406
  let state;
1292
1407
  try {
1293
- state = await tmux.classifyPaneForAdopt(paneId, runtime);
1408
+ state = await tmux.classifyPaneForAdopt(pane, runtime);
1294
1409
  }
1295
1410
  catch (err) {
1296
1411
  throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
1297
1412
  }
1298
1413
  let currentPath;
1299
1414
  try {
1300
- currentPath = await tmux.getPaneCurrentPath(paneId);
1415
+ currentPath = await tmux.getPaneCurrentPath(pane);
1301
1416
  }
1302
1417
  catch (err) {
1303
1418
  throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1312,7 +1427,7 @@ export class AgentManager {
1312
1427
  if (!pathMatches) {
1313
1428
  if (state.kind === 'live-runtime') {
1314
1429
  try {
1315
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
1430
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs);
1316
1431
  }
1317
1432
  catch (err) {
1318
1433
  throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
@@ -1329,7 +1444,7 @@ export class AgentManager {
1329
1444
  case 'live-runtime': {
1330
1445
  let stale;
1331
1446
  try {
1332
- stale = await this.replSkillsStale(tmux, agentId);
1447
+ stale = await this.replSkillsStale(tmux, agentId, sessionRef);
1333
1448
  }
1334
1449
  catch (err) {
1335
1450
  throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1341,11 +1456,12 @@ export class AgentManager {
1341
1456
  freshRuntime: false,
1342
1457
  skillsStale: true,
1343
1458
  paneId,
1459
+ pane,
1344
1460
  workdir,
1345
1461
  sessionRef,
1346
1462
  };
1347
1463
  }
1348
- return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
1464
+ return { ok: true, createdSession: false, freshRuntime: false, paneId, pane, workdir, sessionRef };
1349
1465
  }
1350
1466
  case 'startup-dialog':
1351
1467
  throw new EnsureSessionError({
@@ -1360,15 +1476,15 @@ export class AgentManager {
1360
1476
  `must reset the pane manually`);
1361
1477
  case 'trust-dialog':
1362
1478
  try {
1363
- await tmux.handleTrustDialog(paneId, runtime, {
1479
+ await tmux.handleTrustDialog(pane, runtime, {
1364
1480
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1365
1481
  });
1366
- await tmux.waitReplReady(paneId, runtime, {
1482
+ await tmux.waitReplReady(pane, runtime, {
1367
1483
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1368
1484
  scrollback: 0,
1369
1485
  });
1370
1486
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1371
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1487
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1372
1488
  }
1373
1489
  catch (trustErr) {
1374
1490
  if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
@@ -1386,16 +1502,17 @@ export class AgentManager {
1386
1502
  break;
1387
1503
  }
1388
1504
  try {
1389
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
1390
- await tmux.handleTrustDialog(paneId, runtime, {
1505
+ await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
1506
+ await tmux.sendEnter(pane);
1507
+ await tmux.handleTrustDialog(pane, runtime, {
1391
1508
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
1392
1509
  });
1393
- await tmux.waitReplReady(paneId, runtime, {
1510
+ await tmux.waitReplReady(pane, runtime, {
1394
1511
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
1395
1512
  scrollback: 0,
1396
1513
  });
1397
1514
  await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
1398
- return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
1515
+ return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
1399
1516
  }
1400
1517
  catch (relErr) {
1401
1518
  if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
@@ -1435,12 +1552,12 @@ export class AgentManager {
1435
1552
  let outcome;
1436
1553
  try {
1437
1554
  // claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
1438
- outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
1555
+ outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
1439
1556
  }
1440
1557
  catch (err) {
1441
1558
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
1442
1559
  }
1443
- if (outcome === 'refused' || outcome === 'stale-server') {
1560
+ if (outcome === 'refused') {
1444
1561
  throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
1445
1562
  }
1446
1563
  if (outcome === 'killed') {
@@ -1454,13 +1571,13 @@ export class AgentManager {
1454
1571
  async killAdoptTarget(tmux, agentId, sessionRef, reason) {
1455
1572
  let outcome;
1456
1573
  try {
1457
- outcome = await tmux.killSessionRef(sessionRef);
1574
+ outcome = await tmux.killSessionRef(sessionRef, { kind: 'equals', claim: agentId });
1458
1575
  }
1459
1576
  catch (err) {
1460
1577
  throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
1461
1578
  }
1462
- if (outcome === 'stale-server') {
1463
- throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
1579
+ if (outcome === 'refused') {
1580
+ throw new EnsureSessionError({ createdSession: false, agentId }, `tmux generation/claim changed while replacing session (${reason}); retry to observe the new state`);
1464
1581
  }
1465
1582
  if (outcome === 'killed') {
1466
1583
  console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
@@ -1469,7 +1586,17 @@ export class AgentManager {
1469
1586
  console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
1470
1587
  }
1471
1588
  }
1472
- async acquireAgentForTask(agentId, taskId, phase) {
1589
+ // Create-on-null only under this predicate (evaluated inside the store's serialized commit): closes the delete→recreate ABA yet keeps lazy first-allocation for config-only agents.
1590
+ bindingInitPredicate(agentId, genAtEntry) {
1591
+ return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
1592
+ }
1593
+ async acquireAgentForTask(agentId, taskId, phase,
1594
+ // 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
1595
+ // release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
1596
+ opts = {}) {
1597
+ const genAtEntry = this.deletionGenerationOf(agentId);
1598
+ if (this.isDeletionInFlight(agentId))
1599
+ return false;
1473
1600
  const cfg = this.getAgentConfig(agentId);
1474
1601
  if (!cfg)
1475
1602
  throw new Error(`Unknown agent: ${agentId}`);
@@ -1493,16 +1620,45 @@ export class AgentManager {
1493
1620
  const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
1494
1621
  if (!token)
1495
1622
  return false;
1623
+ const releaseAcquired = async (originalErr) => {
1624
+ if (reuseLock)
1625
+ return;
1626
+ try {
1627
+ await this.lockManager.releaseIfOwner(agentId, taskId, token);
1628
+ }
1629
+ catch (releaseErr) {
1630
+ console.warn(`[AgentManager] acquireAgentForTask(${agentId}, ${taskId}): lock release after ` +
1631
+ `${originalErr === undefined ? 'refused commit' : 'commit failure'} itself failed:`, releaseErr);
1632
+ }
1633
+ };
1496
1634
  const now = new Date().toISOString();
1497
- await this.agentStore.update(agentId, (existing) => ({
1498
- ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1499
- id: agentId,
1500
- projectId: cfg.projectId,
1501
- taskId,
1502
- lockToken: token,
1503
- ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1504
- updatedAt: now,
1505
- }));
1635
+ let commit;
1636
+ try {
1637
+ commit = await this.agentStore.update(agentId, (existing) => {
1638
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
1639
+ return AGENT_STORE_NOOP;
1640
+ if (!this.deletionGateOpen(agentId, genAtEntry))
1641
+ return AGENT_STORE_NOOP;
1642
+ return {
1643
+ ...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
1644
+ id: agentId,
1645
+ projectId: cfg.projectId,
1646
+ taskId,
1647
+ lockToken: token,
1648
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1649
+ updatedAt: now,
1650
+ };
1651
+ });
1652
+ }
1653
+ catch (err) {
1654
+ await releaseAcquired(err);
1655
+ throw err;
1656
+ }
1657
+ if (commit !== 'committed') {
1658
+ await releaseAcquired();
1659
+ return false;
1660
+ }
1661
+ opts.onAcquired?.(token);
1506
1662
  return true;
1507
1663
  }
1508
1664
  async releaseAgentForTask(agentId, expectedTaskId, mode, opts = {}) {
@@ -1662,7 +1818,7 @@ export class AgentManager {
1662
1818
  return hold(runtime.reason);
1663
1819
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1664
1820
  if (runtime.kind === 'pane') {
1665
- if (state.paneId !== runtime.paneId) {
1821
+ if (state.paneId !== runtime.pane.paneId) {
1666
1822
  await this.agentStore.update(agentId, (latest) => {
1667
1823
  if (!latest
1668
1824
  || latest.taskId !== expectedTaskId
@@ -1670,14 +1826,14 @@ export class AgentManager {
1670
1826
  || latest.workdir !== releaseWorkdir) {
1671
1827
  return AGENT_STORE_NOOP;
1672
1828
  }
1673
- return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1829
+ return { ...latest, paneId: runtime.pane.paneId, updatedAt: new Date().toISOString() };
1674
1830
  });
1675
1831
  const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1676
- if (refreshed.paneId !== runtime.paneId) {
1832
+ if (refreshed.paneId !== runtime.pane.paneId) {
1677
1833
  throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1678
1834
  }
1679
1835
  }
1680
- await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1836
+ await this.waitForReplPromptReady(tmux, runtime.pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1681
1837
  }
1682
1838
  else {
1683
1839
  sessionConfirmedAbsent = true;
@@ -1755,6 +1911,14 @@ export class AgentManager {
1755
1911
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1756
1912
  }
1757
1913
  catch (err) {
1914
+ // REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
1915
+ // branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
1916
+ // 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
1917
+ if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
1918
+ console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
1919
+ `(the pass can be re-queued): ${err.message}`);
1920
+ return false;
1921
+ }
1758
1922
  const reason = err instanceof DirtyWorkdirError
1759
1923
  ? err.message
1760
1924
  : `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -1880,6 +2044,8 @@ export class AgentManager {
1880
2044
  if (candidateTasks.length === 0)
1881
2045
  continue;
1882
2046
  const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
2047
+ // Pin the incarnation before the first state/workdir/refs snapshot so a DELETE→same-id recreate (possibly a different workdir) in the read→gate window is rejected.
2048
+ const genAtEntry = this.deletionGenerationOf(cfg.id);
1883
2049
  const state = await this.agentStore.get(cfg.id);
1884
2050
  if (!state || !canDispatchWithBinding(state))
1885
2051
  continue;
@@ -1899,11 +2065,15 @@ export class AgentManager {
1899
2065
  }
1900
2066
  if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
1901
2067
  continue;
2068
+ if (this.isDeletionInFlight(cfg.id))
2069
+ continue;
1902
2070
  const token = await this.lockManager.acquire(cfg.id, owner);
1903
2071
  if (!token)
1904
2072
  continue;
1905
2073
  const cleanupResults = [];
1906
2074
  try {
2075
+ if (!this.deletionGateOpen(cfg.id, genAtEntry))
2076
+ continue;
1907
2077
  const lockedState = await this.agentStore.get(cfg.id);
1908
2078
  if (!lockedState || !canDispatchWithBinding(lockedState))
1909
2079
  continue;
@@ -1915,12 +2085,8 @@ export class AgentManager {
1915
2085
  continue;
1916
2086
  const tmux = new TmuxManager(runner);
1917
2087
  if (await tmux.hasSession(cfg.id)) {
1918
- const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
1919
- if (claim !== cfg.id) {
1920
- throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
1921
- }
1922
- const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
1923
- await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
2088
+ const pane = await this.resolveClaimedPane(tmux, cfg.id, lockedState.paneId ?? undefined);
2089
+ await this.waitForReplPromptReady(tmux, pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1924
2090
  }
1925
2091
  if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
1926
2092
  continue;
@@ -2369,10 +2535,18 @@ export class AgentManager {
2369
2535
  }
2370
2536
  if (result.redispatchReviewTaskId) {
2371
2537
  try {
2538
+ const reviewTask = await this.taskStore.get(result.redispatchReviewTaskId);
2539
+ const resumeEntry = this.getPendingDispatchRetry(result.redispatchReviewTaskId);
2540
+ const resumeQaPhase = resumeEntry?.kind === 'qa-recheck'
2541
+ && resumeEntry.signalToken === reviewTask?.signalToken
2542
+ && resumeEntry.qaPhase !== undefined
2543
+ ? resumeEntry.qaPhase
2544
+ : (reviewTask?.reviewRound === 0 ? 'review' : undefined);
2372
2545
  await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
2373
- bumpRound: false,
2546
+ bumpRound: reviewTask?.reviewRoundPending === true,
2374
2547
  fromStatus: ['review'],
2375
2548
  ...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
2549
+ ...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
2376
2550
  });
2377
2551
  }
2378
2552
  catch (err) {
@@ -2467,14 +2641,12 @@ export class AgentManager {
2467
2641
  ...(result.reason ? { reason: result.reason } : {}),
2468
2642
  };
2469
2643
  }
2470
- async resolvePaneId(state, cfg) {
2471
- if (state.paneId)
2472
- return state.paneId;
2644
+ async resolveClaimedPaneOrNull(state, cfg) {
2473
2645
  try {
2474
- return await new TmuxManager(this.createRunnerFor(cfg)).getSinglePaneId(cfg.id);
2646
+ return await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), cfg.id, state.paneId);
2475
2647
  }
2476
2648
  catch (err) {
2477
- console.warn(`[AgentManager] resolvePaneId: getSinglePaneId failed for ${cfg.id}:`, err);
2649
+ console.warn(`[AgentManager] resolveClaimedPane failed for ${cfg.id}:`, err);
2478
2650
  return null;
2479
2651
  }
2480
2652
  }
@@ -2492,27 +2664,16 @@ export class AgentManager {
2492
2664
  }
2493
2665
  if (!sessionAlive)
2494
2666
  return { kind: 'absent' };
2495
- let claim;
2496
- try {
2497
- claim = await tmux.getOption(agentId, '@baxian-agent-id');
2498
- }
2499
- catch (err) {
2500
- return {
2501
- kind: 'hold',
2502
- reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
2503
- `${err instanceof Error ? err.message : String(err)}`,
2504
- };
2505
- }
2506
- if (claim !== agentId) {
2507
- return {
2508
- kind: 'hold',
2509
- reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
2510
- };
2511
- }
2512
2667
  try {
2513
- return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
2668
+ return { kind: 'pane', pane: await this.resolveClaimedPane(tmux, agentId) };
2514
2669
  }
2515
2670
  catch (err) {
2671
+ if (err instanceof PaneGoneError) {
2672
+ return {
2673
+ kind: 'hold',
2674
+ reason: `Runtime session claim probe/mismatch; refusing checkout cleanup: ${err.message}`,
2675
+ };
2676
+ }
2516
2677
  return {
2517
2678
  kind: 'hold',
2518
2679
  reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
@@ -2521,9 +2682,10 @@ export class AgentManager {
2521
2682
  }
2522
2683
  }
2523
2684
  async interruptPaneAndWaitReady(state, cfg) {
2524
- const paneId = await this.resolvePaneId(state, cfg);
2525
- if (!paneId)
2685
+ const pane = await this.resolveClaimedPaneOrNull(state, cfg);
2686
+ if (!pane)
2526
2687
  return false;
2688
+ const paneId = pane.paneId;
2527
2689
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
2528
2690
  const runtime = agentRuntimeKindFor(cfg);
2529
2691
  if (!(await this.acquireCompactGuardWithin(cfg.id, this.cancelInterruptGuardWaitMs))) {
@@ -2534,43 +2696,44 @@ export class AgentManager {
2534
2696
  }
2535
2697
  try {
2536
2698
  try {
2537
- await tmux.sendKeysToPane(paneId, 'Escape');
2699
+ await tmux.sendKeysToPane(pane, 'Escape');
2538
2700
  await new Promise(r => setTimeout(r, 200));
2539
2701
  }
2540
2702
  catch (err) {
2541
2703
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
2542
2704
  return false;
2543
2705
  }
2544
- if (await this.paneReachedReplReady(tmux, paneId, runtime, 10_000))
2706
+ if (await this.paneReachedReplReady(tmux, pane, runtime, 10_000))
2545
2707
  return true;
2546
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
2708
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2547
2709
  return false;
2548
- if (await this.paneHasLiveTurn(tmux, paneId, runtime)) {
2710
+ if (await this.paneHasLiveTurn(tmux, pane, runtime)) {
2549
2711
  console.warn(`[AgentManager] interruptPaneAndWaitReady: pane ${paneId} still running a turn after ESC; holding`);
2550
2712
  return false;
2551
2713
  }
2552
- if (!(await this.paneRunsRuntime(tmux, paneId, runtime)))
2714
+ if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
2553
2715
  return false;
2554
2716
  try {
2555
- await tmux.sendKeysToPane(paneId, 'C-c');
2717
+ await tmux.sendKeysToPane(pane, 'C-c');
2556
2718
  await new Promise(r => setTimeout(r, 200));
2557
2719
  }
2558
2720
  catch (err) {
2559
2721
  console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c (composer clear) failed for pane ${paneId}:`, err);
2560
2722
  return false;
2561
2723
  }
2562
- return this.paneReachedReplReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
2724
+ return this.paneReachedReplReady(tmux, pane, runtime, this.cleanComposerWaitMs);
2563
2725
  }
2564
2726
  finally {
2565
2727
  this.compactInFlight.delete(cfg.id);
2566
2728
  }
2567
2729
  }
2568
- async paneHasLiveTurn(tmux, paneId, runtime) {
2730
+ async paneHasLiveTurn(tmux, pane, runtime) {
2731
+ const paneId = pane.paneId;
2569
2732
  let first;
2570
2733
  let firstTitle;
2571
2734
  try {
2572
- first = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2573
- firstTitle = await tmux.readPaneTitle(paneId);
2735
+ first = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
2736
+ firstTitle = await tmux.readPaneTitle(pane);
2574
2737
  }
2575
2738
  catch (err) {
2576
2739
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness capture failed for pane ${paneId}:`, err);
@@ -2581,8 +2744,8 @@ export class AgentManager {
2581
2744
  let frame;
2582
2745
  let title;
2583
2746
  try {
2584
- frame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
2585
- title = await tmux.readPaneTitle(paneId);
2747
+ frame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
2748
+ title = await tmux.readPaneTitle(pane);
2586
2749
  }
2587
2750
  catch (err) {
2588
2751
  console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness re-capture failed for pane ${paneId}:`, err);
@@ -2597,10 +2760,11 @@ export class AgentManager {
2597
2760
  }
2598
2761
  return false;
2599
2762
  }
2600
- async paneRunsRuntime(tmux, paneId, runtime) {
2763
+ async paneRunsRuntime(tmux, pane, runtime) {
2764
+ const paneId = pane.paneId;
2601
2765
  let proc;
2602
2766
  try {
2603
- proc = await tmux.displayMessage(paneId, '#{pane_current_command}');
2767
+ proc = await tmux.displayMessage(pane, '#{pane_current_command}');
2604
2768
  }
2605
2769
  catch (err) {
2606
2770
  console.warn(`[AgentManager] interruptPaneAndWaitReady: proc-title read failed for pane ${paneId}:`, err);
@@ -2612,9 +2776,10 @@ export class AgentManager {
2612
2776
  }
2613
2777
  return true;
2614
2778
  }
2615
- async paneReachedReplReady(tmux, paneId, runtime, timeoutMs) {
2779
+ async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
2780
+ const paneId = pane.paneId;
2616
2781
  try {
2617
- await tmux.waitReplReady(paneId, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
2782
+ await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
2618
2783
  return true;
2619
2784
  }
2620
2785
  catch (err) {
@@ -2745,20 +2910,25 @@ export class AgentManager {
2745
2910
  }
2746
2911
  }
2747
2912
  }
2748
- async pollPaneCommandStable(tmux, paneId, opts) {
2913
+ async pollPaneCommandStable(tmux, pane, opts) {
2749
2914
  const deadline = Date.now() + opts.timeoutMs;
2750
2915
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2751
2916
  let last = '';
2752
2917
  while (Date.now() < deadline) {
2753
2918
  await new Promise(r => setTimeout(r, 100));
2754
- const raw = await tmux.displayMessage(paneId, '#{pane_current_command}');
2919
+ const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
2755
2920
  last = raw.trim();
2756
2921
  if (opts.expectShell ? SHELL.test(last) : last !== '')
2757
2922
  return last;
2758
2923
  }
2759
2924
  return last;
2760
2925
  }
2761
- async restartReplOnly(agentId) {
2926
+ async restartReplOnly(agentId, opts = {}) {
2927
+ // Re-verify the entry generation so a request that slept across a DELETE→recreate can't restart the NEW incarnation's REPL (claim check alone can't tell).
2928
+ const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
2929
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2930
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2931
+ }
2762
2932
  const cfg = this.getAgentConfig(agentId);
2763
2933
  if (!cfg)
2764
2934
  throw new Error(`Unknown agent: ${agentId}`);
@@ -2771,14 +2941,17 @@ export class AgentManager {
2771
2941
  if (snapshot.claim !== agentId) {
2772
2942
  throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
2773
2943
  }
2774
- const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
2775
- await tmux.sendKeysToPane(paneId, 'C-c');
2776
- const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
2944
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2945
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2946
+ }
2947
+ const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
2948
+ await tmux.sendKeysToPane(pane, 'C-c');
2949
+ const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
2777
2950
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
2778
2951
  const SHELL = /^(?:zsh|bash|sh|fish)$/;
2779
2952
  if (RUNTIME.test(cmd)) {
2780
- await tmux.sendKeysToPane(paneId, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
2781
- await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000, expectShell: true });
2953
+ await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
2954
+ await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
2782
2955
  }
2783
2956
  else if (!SHELL.test(cmd)) {
2784
2957
  throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
@@ -2786,33 +2959,42 @@ export class AgentManager {
2786
2959
  const project = this.getProjectConfig(cfg.projectId);
2787
2960
  if (!project)
2788
2961
  throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
2962
+ // Re-verify before Workdir/skill/session-option side-effects: pane writes fail-closed on a stale PaneRef, but ensureWorkdir/skills would write old-cfg files under a recreated id.
2963
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2964
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2965
+ }
2789
2966
  const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
2790
- const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
2967
+ const paneWorkdir = await tmux.getPaneCurrentPath(pane);
2791
2968
  if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
2792
2969
  throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
2793
2970
  'use retry to rebuild the session safely');
2794
2971
  }
2972
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
2973
+ throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
2974
+ }
2795
2975
  await this.provisionRepoSkills(runner, cfg, workdir);
2796
2976
  await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
2797
2977
  const runtime = agentRuntimeKindFor(cfg);
2798
2978
  const relaunch = async () => {
2799
- await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
2800
- await tmux.handleTrustDialog(paneId, runtime, {
2979
+ await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
2980
+ await tmux.sendEnter(pane);
2981
+ await tmux.handleTrustDialog(pane, runtime, {
2801
2982
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
2802
2983
  });
2803
- await tmux.waitReplReady(paneId, runtime, {
2984
+ await tmux.waitReplReady(pane, runtime, {
2804
2985
  timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
2805
2986
  scrollback: 0,
2806
2987
  });
2807
2988
  await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
2808
2989
  };
2809
2990
  await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
2991
+ // Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
2810
2992
  await this.agentStore.update(agentId, (state) => {
2811
- if (!state)
2993
+ if (!state || !this.deletionGateOpen(agentId, genAtEntry))
2812
2994
  return AGENT_STORE_NOOP;
2813
2995
  return {
2814
2996
  ...state,
2815
- paneId,
2997
+ paneId: pane.paneId,
2816
2998
  updatedAt: new Date().toISOString(),
2817
2999
  };
2818
3000
  });
@@ -2891,6 +3073,9 @@ export class AgentManager {
2891
3073
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
2892
3074
  skipSnapshot: true,
2893
3075
  onlyReplaceOwnToken: true,
3076
+ // git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
3077
+ // sibling(reconciliation)的不同 token 否决本应合法的重布防
3078
+ ...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
2894
3079
  });
2895
3080
  if (!armed) {
2896
3081
  if (!(await stillCurrent()))
@@ -2907,6 +3092,8 @@ export class AgentManager {
2907
3092
  if (task.status === 'approved' && agent.role === 'dev') {
2908
3093
  return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
2909
3094
  }
3095
+ // reconciliation 条目不是 replay 目标:它没有对应的可重放 prompt,误选会以 QA 轮换 token
3096
+ // 重注 dev 的 publish 提示(spec §5.3 ④ 只允许一次 actor 归位)
2910
3097
  const mapped = this.mapTaskStateToExpectedWatcher(task);
2911
3098
  if (!mapped || mapped.agentId !== agentId)
2912
3099
  return false;
@@ -3106,6 +3293,9 @@ export class AgentManager {
3106
3293
  await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3107
3294
  throw err;
3108
3295
  }
3296
+ if (resumed && task.reviewMode === 'git') {
3297
+ await this.confirmPostApprovePromptDelivered(taskId, completion.token);
3298
+ }
3109
3299
  if (!resumed && completion.rebuilt) {
3110
3300
  await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3111
3301
  const fresh = await this.taskStore.get(taskId);
@@ -3142,6 +3332,19 @@ export class AgentManager {
3142
3332
  || fresh.postApproveHeadSha !== approvedHeadSha) {
3143
3333
  return false;
3144
3334
  }
3335
+ if (fresh.reviewMode === 'git') {
3336
+ if (fresh.postApproveToken !== undefined)
3337
+ return false;
3338
+ // rebuild 与常规安装同一 installed→delivered 协议:写后崩溃由 sweep 按 pending 补派
3339
+ await this.taskStore.set({
3340
+ ...fresh,
3341
+ postApproveToken: token,
3342
+ postApprovePhase: 'installed',
3343
+ pendingRedispatch: true,
3344
+ updatedAt: new Date().toISOString(),
3345
+ });
3346
+ return true;
3347
+ }
3145
3348
  if (await this.postApproveStore.get(task.id))
3146
3349
  return false;
3147
3350
  await this.postApproveStore.set(task.id, { token, approvedHeadSha });
@@ -3171,21 +3374,7 @@ export class AgentManager {
3171
3374
  continue;
3172
3375
  const runner = this.createRunnerFor(cfg);
3173
3376
  const tmux = new TmuxManager(runner);
3174
- const boundState = await this.agentStore.get(id);
3175
- if (boundState?.taskId) {
3176
- try {
3177
- const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
3178
- allowAwaitingHuman: true,
3179
- });
3180
- if (!released) {
3181
- throw new Error(`could not safely release task ${boundState.taskId}`);
3182
- }
3183
- }
3184
- catch (err) {
3185
- failures.push({ agentId: id, step: 'binding.release', error: err });
3186
- continue;
3187
- }
3188
- }
3377
+ // No releaseAgentForTask: the binding dies with the state file in phase 3, and deletability was already enforced by the phase-1 API gates — so cleanup only stops watchers and kills the session.
3189
3378
  this.stopRuntimeMenuWatch(id);
3190
3379
  if (this.paneStreamerManager) {
3191
3380
  try {
@@ -3197,21 +3386,45 @@ export class AgentManager {
3197
3386
  }
3198
3387
  try {
3199
3388
  await this.runUnderSessionLifecycle(id, async () => {
3200
- const snapshot = await tmux.getSessionSnapshot(id);
3389
+ const cleanupOpts = { timeout: DELETE_CLEANUP_TIMEOUT_MS };
3390
+ const snapshot = await tmux.getSessionSnapshot(id, cleanupOpts);
3201
3391
  if (!snapshot)
3202
3392
  return;
3203
- if (snapshot.claim !== id) {
3204
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3205
- `(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
3393
+ if (snapshot.claim === id) {
3394
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'equals', claim: id }, cleanupOpts);
3395
+ if (outcome === 'refused') {
3396
+ throw new Error(`tmux generation/claim changed for ${id} before kill; retry DELETE to observe new state`);
3397
+ }
3398
+ if (outcome === 'killed') {
3399
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
3400
+ }
3206
3401
  return;
3207
3402
  }
3208
- const outcome = await tmux.killSessionRef(snapshot.ref);
3209
- if (outcome === 'stale-server') {
3210
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
3211
- }
3212
- else if (outcome === 'killed') {
3213
- console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
3403
+ if (snapshot.claim === null) {
3404
+ // A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
3405
+ let nonceOwned;
3406
+ try {
3407
+ nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
3408
+ }
3409
+ catch (err) {
3410
+ throw new Error(`creation-nonce probe failed for ${id}; retry DELETE: ${err instanceof Error ? err.message : String(err)}`);
3411
+ }
3412
+ if (!nonceOwned) {
3413
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping ${id} ` +
3414
+ `(unclaimed, no baxian creation nonce; foreign session)`);
3415
+ return;
3416
+ }
3417
+ const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' }, cleanupOpts);
3418
+ if (outcome === 'refused') {
3419
+ throw new Error(`half-created session for ${id} was claimed or its server changed before kill; retry DELETE`);
3420
+ }
3421
+ if (outcome === 'killed') {
3422
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed half-created leftover ${snapshot.ref.sessionId} for removed agent ${id}`);
3423
+ }
3424
+ return;
3214
3425
  }
3426
+ console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
3427
+ `(claim=${snapshot.claim}; not baxian-managed)`);
3215
3428
  });
3216
3429
  }
3217
3430
  catch (err) {
@@ -3292,6 +3505,17 @@ export class AgentManager {
3292
3505
  getProjectByRepo(repo) {
3293
3506
  return this.config.project.find(p => repoSlug(p.repo) === repo);
3294
3507
  }
3508
+ getProjectByRepoIdentity(repoKey) {
3509
+ return this.config.project.find(p => repoIdentityKey(p.repo) === repoKey);
3510
+ }
3511
+ platformDriverFor(projectId) {
3512
+ if (!this.pluginRegistry)
3513
+ return undefined;
3514
+ const project = this.getProjectConfig(projectId);
3515
+ if (!project)
3516
+ return undefined;
3517
+ return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
3518
+ }
3295
3519
  findAgentGroup(anchorAgentId) {
3296
3520
  for (const project of this.config.project) {
3297
3521
  for (const group of project.agent) {
@@ -3327,8 +3551,28 @@ export class AgentManager {
3327
3551
  return this.agentStore.get(agentId);
3328
3552
  }
3329
3553
  async getPostApproveCompletion(taskId) {
3554
+ const task = await this.taskStore.get(taskId);
3555
+ if (task?.reviewMode === 'git')
3556
+ return this.gitCompletionOf(task);
3330
3557
  return this.postApproveStore.get(taskId);
3331
3558
  }
3559
+ // 'git' 的 completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与
3560
+ // token CAS 必须同一次持久化(spec §6 双写协议);legacy github 沿 PostApproveStore 至 M3c。
3561
+ gitCompletionOf(task) {
3562
+ if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined)
3563
+ return null;
3564
+ return {
3565
+ token: task.postApproveToken,
3566
+ approvedHeadSha: task.postApproveHeadSha,
3567
+ updatedAt: task.updatedAt,
3568
+ ...(task.redispatchCount !== undefined ? { redispatchCount: task.redispatchCount } : {}),
3569
+ ...(task.pendingRedispatch !== undefined ? { pendingRedispatch: task.pendingRedispatch } : {}),
3570
+ };
3571
+ }
3572
+ stripGitCompletionFields(task) {
3573
+ const { postApproveToken: _t, pendingRedispatch: _p, redispatchCount: _r, postApprovePhase: _ph, ...rest } = task;
3574
+ return rest;
3575
+ }
3332
3576
  // Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
3333
3577
  async setPostApproveCompletion(taskId, value, opts = {}) {
3334
3578
  return this.withTaskLock(async () => {
@@ -3341,37 +3585,111 @@ export class AgentManager {
3341
3585
  if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
3342
3586
  return false;
3343
3587
  }
3344
- await this.postApproveStore.set(taskId, value);
3588
+ if (task?.reviewMode === 'git') {
3589
+ const { postApproveRevoked: _revoked, ...rest } = task;
3590
+ // 全量替换语义与 PostApproveStore.set 一致:未携带的 pending/redispatch 字段清空
3591
+ await this.taskStore.set({
3592
+ ...this.stripGitCompletionFields(opts.clearRevocation ? rest : task),
3593
+ postApproveToken: value.token,
3594
+ postApprovePhase: 'installed',
3595
+ ...(value.redispatchCount !== undefined ? { redispatchCount: value.redispatchCount } : {}),
3596
+ ...(value.pendingRedispatch !== undefined ? { pendingRedispatch: value.pendingRedispatch } : {}),
3597
+ updatedAt: new Date().toISOString(),
3598
+ });
3599
+ }
3600
+ else {
3601
+ await this.postApproveStore.set(taskId, value);
3602
+ }
3345
3603
  if (!task)
3346
3604
  return true;
3347
- if (task.postApproveRevoked) {
3605
+ if (task.postApproveRevoked && task.reviewMode !== 'git') {
3348
3606
  const { postApproveRevoked: _revoked, ...rest } = task;
3349
3607
  await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3350
3608
  }
3351
3609
  if (!this.phaseSignalWatcher)
3352
3610
  return true;
3353
- await this.startPhaseSignalWatch({
3611
+ const armed = await this.startPhaseSignalWatch({
3354
3612
  taskId,
3355
3613
  projectId: task.projectId,
3356
3614
  agentId: task.agentId,
3357
3615
  expectedKinds: 'pr-merge-ready',
3358
3616
  token: value.token,
3359
3617
  });
3618
+ if (!armed) {
3619
+ // completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
3620
+ if (task.reviewMode === 'git') {
3621
+ await this.taskStore.set({ ...task, updatedAt: new Date().toISOString() });
3622
+ }
3623
+ else {
3624
+ await this.postApproveStore.clear(taskId);
3625
+ }
3626
+ return false;
3627
+ }
3360
3628
  return true;
3361
3629
  });
3362
3630
  }
3631
+ // post-approve prompt 的至少一次投递(spec §6):pendingRedispatch 随 completion 安装保持 true,
3632
+ // 仅在 continueSession 确认送达后清除——安装与投递之间的崩溃由恢复扫描按 pending 补派。
3633
+ async confirmPostApprovePromptDelivered(taskId, token) {
3634
+ await this.withTaskLock(async () => {
3635
+ const task = await this.taskStore.get(taskId);
3636
+ if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
3637
+ return;
3638
+ await this.taskStore.set({
3639
+ ...task,
3640
+ pendingRedispatch: false,
3641
+ postApprovePhase: 'delivered',
3642
+ updatedAt: new Date().toISOString(),
3643
+ });
3644
+ });
3645
+ }
3646
+ async markPostApproveSignalReceived(taskId, token) {
3647
+ await this.withTaskLock(async () => {
3648
+ const task = await this.taskStore.get(taskId);
3649
+ if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
3650
+ return;
3651
+ if (task.postApprovePhase === 'signaled')
3652
+ return;
3653
+ await this.taskStore.set({ ...task, postApprovePhase: 'signaled', updatedAt: new Date().toISOString() });
3654
+ });
3655
+ }
3363
3656
  async clearPostApproveCompletion(taskId) {
3364
3657
  await this.withTaskLock(async () => {
3365
- await this.postApproveStore.clear(taskId);
3658
+ const task = await this.taskStore.get(taskId);
3659
+ if (task?.reviewMode === 'git') {
3660
+ if (task.postApproveToken !== undefined || task.pendingRedispatch !== undefined
3661
+ || task.redispatchCount !== undefined) {
3662
+ await this.taskStore.set({
3663
+ ...this.stripGitCompletionFields(task),
3664
+ updatedAt: new Date().toISOString(),
3665
+ });
3666
+ }
3667
+ }
3668
+ else {
3669
+ await this.postApproveStore.clear(taskId);
3670
+ }
3366
3671
  this.phaseSignalWatcher?.stop(taskId);
3367
3672
  });
3368
3673
  }
3369
3674
  // CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
3370
- async updatePostApproveCompletionIfToken(taskId, expectedToken, patch) {
3675
+ async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
3371
3676
  return this.withTaskLock(async () => {
3372
3677
  const task = await this.taskStore.get(taskId);
3373
3678
  if (task?.postApproveRevoked)
3374
3679
  return false;
3680
+ if (task?.reviewMode === 'git') {
3681
+ if (task.postApproveToken !== expectedToken)
3682
+ return false;
3683
+ await this.taskStore.set({
3684
+ ...task,
3685
+ ...patch,
3686
+ ...(opts.consumeRevision
3687
+ ? { consumedFeedback: { ...task.consumedFeedback, [opts.consumeRevision.key]: opts.consumeRevision.versionTime } }
3688
+ : {}),
3689
+ updatedAt: new Date().toISOString(),
3690
+ });
3691
+ return true;
3692
+ }
3375
3693
  const current = await this.postApproveStore.get(taskId);
3376
3694
  if (!current || current.token !== expectedToken)
3377
3695
  return false;
@@ -3391,9 +3709,24 @@ export class AgentManager {
3391
3709
  && task.postApproveHeadSha !== opts.expectedHeadSha)
3392
3710
  return false;
3393
3711
  if (opts.expectedToken !== undefined) {
3394
- const current = await this.postApproveStore.get(taskId);
3395
- if (current?.token !== opts.expectedToken)
3396
- return false;
3712
+ if (task.reviewMode === 'git') {
3713
+ if (task.postApproveToken !== opts.expectedToken)
3714
+ return false;
3715
+ }
3716
+ else {
3717
+ const current = await this.postApproveStore.get(taskId);
3718
+ if (current?.token !== opts.expectedToken)
3719
+ return false;
3720
+ }
3721
+ }
3722
+ if (task.reviewMode === 'git') {
3723
+ await this.taskStore.set({
3724
+ ...this.stripGitCompletionFields(task),
3725
+ postApproveRevoked: { reason, at: new Date().toISOString() },
3726
+ updatedAt: new Date().toISOString(),
3727
+ });
3728
+ this.phaseSignalWatcher?.stop(taskId);
3729
+ return true;
3397
3730
  }
3398
3731
  // Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
3399
3732
  await this.taskStore.set({
@@ -3408,6 +3741,17 @@ export class AgentManager {
3408
3741
  }
3409
3742
  async clearPostApproveCompletionIfMatches(taskId, token) {
3410
3743
  return this.withTaskLock(async () => {
3744
+ const task = await this.taskStore.get(taskId);
3745
+ if (task?.reviewMode === 'git') {
3746
+ if (task.postApproveToken !== token)
3747
+ return false;
3748
+ await this.taskStore.set({
3749
+ ...this.stripGitCompletionFields(task),
3750
+ updatedAt: new Date().toISOString(),
3751
+ });
3752
+ this.phaseSignalWatcher?.stop(taskId);
3753
+ return true;
3754
+ }
3411
3755
  const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
3412
3756
  if (cleared)
3413
3757
  this.phaseSignalWatcher?.stop(taskId);
@@ -3421,7 +3765,9 @@ export class AgentManager {
3421
3765
  if (!task || task.status !== 'approved' || task.postApproveRevoked) {
3422
3766
  return { refused: 'stale' };
3423
3767
  }
3424
- const completion = await this.postApproveStore.get(taskId);
3768
+ const completion = task.reviewMode === 'git'
3769
+ ? this.gitCompletionOf(task)
3770
+ : await this.postApproveStore.get(taskId);
3425
3771
  if (!completion
3426
3772
  || completion.token !== expectedToken
3427
3773
  || task.postApproveHeadSha === undefined
@@ -3430,16 +3776,339 @@ export class AgentManager {
3430
3776
  }
3431
3777
  if (completion.pendingRedispatch)
3432
3778
  return { refused: 'pending' };
3433
- delete task.postApproveHeadSha;
3434
- Object.assign(task, {
3779
+ const next = task.reviewMode === 'git' ? this.stripGitCompletionFields(task) : task;
3780
+ delete next.postApproveHeadSha;
3781
+ Object.assign(next, {
3435
3782
  status: 'merge-ready',
3436
3783
  latestHeadSha: completion.approvedHeadSha,
3437
3784
  updatedAt: new Date().toISOString(),
3438
3785
  });
3439
- await this.taskStore.set(task);
3440
- await this.postApproveStore.clear(taskId);
3786
+ await this.taskStore.set(next);
3787
+ if (task.reviewMode !== 'git')
3788
+ await this.postApproveStore.clear(taskId);
3441
3789
  this.phaseSignalWatcher?.stop(taskId);
3442
- return { task };
3790
+ return { task: next };
3791
+ });
3792
+ }
3793
+ // 修剪顺序单向(spec §6):仅在对应源 cursor durable 落盘后调用;水位停滞的源不修剪,
3794
+ // 有界性由水位前进保证,禁止按容量淘汰未覆盖键。
3795
+ async pruneConsumedFeedback(taskId, sourceKey, watermarkTime) {
3796
+ await this.withTaskLock(async () => {
3797
+ const task = await this.taskStore.get(taskId);
3798
+ if (!task?.consumedFeedback)
3799
+ return;
3800
+ const prefix = `${sourceKey}:`;
3801
+ const kept = Object.entries(task.consumedFeedback)
3802
+ .filter(([key, time]) => !key.startsWith(prefix) || time >= watermarkTime);
3803
+ if (kept.length === Object.keys(task.consumedFeedback).length)
3804
+ return;
3805
+ await this.taskStore.set({
3806
+ ...task,
3807
+ consumedFeedback: Object.fromEntries(kept),
3808
+ updatedAt: new Date().toISOString(),
3809
+ });
3810
+ });
3811
+ }
3812
+ // TaskState 效果+outbox 先 durable、投递成功再清(spec §6 双写协议):崩溃于任一侧,
3813
+ // 重启从未清 outbox 重投,事件携确定性 key 供展示端去重——效果恰一次、通知至少一次。
3814
+ async recordClosedUnmergedAnchor(taskId, prNumber) {
3815
+ return this.withTaskLock(async () => {
3816
+ const task = await this.taskStore.get(taskId);
3817
+ if (!task || task.reviewMode !== 'git' || TERMINAL_STATUSES.includes(task.status))
3818
+ return false;
3819
+ const anchor = task.closedUnmergedAnchor;
3820
+ if (anchor?.prNumber === prNumber && anchor.cleared !== true)
3821
+ return false;
3822
+ const generation = (anchor?.generation ?? 0) + 1;
3823
+ const eventKey = `${taskId}:${prNumber}:mr-closed-unmerged:${generation}`;
3824
+ await this.taskStore.set({
3825
+ ...task,
3826
+ closedUnmergedAnchor: { prNumber, generation },
3827
+ outbox: [...(task.outbox ?? []), {
3828
+ key: eventKey,
3829
+ type: 'human.intervention',
3830
+ data: { phase: 'mr-closed-unmerged', prNumber, eventKey },
3831
+ }],
3832
+ updatedAt: new Date().toISOString(),
3833
+ });
3834
+ return true;
3835
+ });
3836
+ }
3837
+ async clearClosedUnmergedAnchor(taskId, prNumber) {
3838
+ return this.withTaskLock(async () => {
3839
+ const task = await this.taskStore.get(taskId);
3840
+ const anchor = task?.closedUnmergedAnchor;
3841
+ if (!task || anchor?.prNumber !== prNumber || anchor.cleared === true)
3842
+ return false;
3843
+ const generation = anchor.generation;
3844
+ const eventKey = `${taskId}:${prNumber}:mr-reopened:${generation}`;
3845
+ await this.taskStore.set({
3846
+ ...task,
3847
+ closedUnmergedAnchor: { ...anchor, cleared: true },
3848
+ outbox: [...(task.outbox ?? []), {
3849
+ key: eventKey,
3850
+ type: 'human.intervention',
3851
+ data: { phase: 'mr-reopened', prNumber, eventKey },
3852
+ }],
3853
+ updatedAt: new Date().toISOString(),
3854
+ });
3855
+ return true;
3856
+ });
3857
+ }
3858
+ async deliverTaskOutbox(taskId) {
3859
+ const task = await this.taskStore.get(taskId);
3860
+ if (!task?.outbox?.length)
3861
+ return;
3862
+ for (const entry of task.outbox) {
3863
+ try {
3864
+ await this.eventBus.emit({
3865
+ // 确定性 id(spec §6 outbox):重投/并发 flush 的重复条目同 id,消费/展示端可去重
3866
+ id: `outbox:${entry.key}`,
3867
+ type: entry.type,
3868
+ timestamp: new Date().toISOString(),
3869
+ projectId: task.projectId,
3870
+ agentId: task.agentId,
3871
+ taskId: task.id,
3872
+ data: entry.data,
3873
+ });
3874
+ }
3875
+ catch (err) {
3876
+ console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
3877
+ continue;
3878
+ }
3879
+ await this.withTaskLock(async () => {
3880
+ const fresh = await this.taskStore.get(taskId);
3881
+ if (!fresh?.outbox?.some(e => e.key === entry.key))
3882
+ return;
3883
+ const remaining = fresh.outbox.filter(e => e.key !== entry.key);
3884
+ const { outbox: _outbox, ...rest } = fresh;
3885
+ await this.taskStore.set({
3886
+ ...rest,
3887
+ ...(remaining.length > 0 ? { outbox: remaining } : {}),
3888
+ updatedAt: new Date().toISOString(),
3889
+ });
3890
+ });
3891
+ }
3892
+ }
3893
+ // merge-ready 信号是单发消费:git 分支在消费点引入网络复核,失败时必须重 arm(skipSnapshot
3894
+ // false 让仍在面板上的信号立即重放),否则一次瞬态失败吞掉整轮完成信号。
3895
+ async rearmPostApproveSignal(taskId, opts = {}) {
3896
+ const task = await this.taskStore.get(taskId);
3897
+ if (!task || task.status !== 'approved')
3898
+ return false;
3899
+ const completion = task.reviewMode === 'git'
3900
+ ? this.gitCompletionOf(task)
3901
+ : await this.postApproveStore.get(taskId);
3902
+ if (!completion)
3903
+ return false;
3904
+ if (!this.phaseSignalWatcher)
3905
+ return true;
3906
+ return this.startPhaseSignalWatch({
3907
+ taskId,
3908
+ projectId: task.projectId,
3909
+ agentId: task.agentId,
3910
+ expectedKinds: 'pr-merge-ready',
3911
+ token: completion.token,
3912
+ // 刚消费的信号仍在滚动区:扫描失败路径必须 live-only,否则与持续失败叠加成无退避热循环
3913
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
3914
+ });
3915
+ }
3916
+ // 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
3917
+ // createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
3918
+ async guardGitConfigCommit(current, next, scan, commit) {
3919
+ return this.withTaskLock(async () => {
3920
+ const blockers = await scan(this, current, next);
3921
+ if (blockers.length > 0)
3922
+ return { ok: false, blockers };
3923
+ await commit();
3924
+ return { ok: true };
3925
+ });
3926
+ }
3927
+ async flushGitOutbox() {
3928
+ const tasks = await this.taskStore.list();
3929
+ for (const task of tasks) {
3930
+ if (task.reviewMode !== 'git' || !task.outbox?.length)
3931
+ continue;
3932
+ try {
3933
+ await this.deliverTaskOutbox(task.id);
3934
+ }
3935
+ catch (err) {
3936
+ // 单任务失败不得饿死其后的 durable outbox(至少一次的重启保证)
3937
+ console.warn(`[AgentManager] flushGitOutbox: task=${task.id} failed, continuing:`, err);
3938
+ }
3939
+ }
3940
+ }
3941
+ // cancel 清理的部分成功可重试(spec §6 close 条 + 破坏性清理不静默失败):close 已成而
3942
+ // deleteBranch 瞬态失败时任务已终态,重启在守卫成立的前提下幂等补删(REF_NOT_FOUND 即成功)。
3943
+ // 只清本代 pending:A 的 start 晚于 B 的失败返回时,无条件清除会吞掉 B 留下的重试锚
3944
+ async clearReviewDispatchPending(taskId, expectedToken) {
3945
+ await this.withTaskLock(async () => {
3946
+ const task = await this.taskStore.get(taskId);
3947
+ if (!task || task.reviewDispatchPending !== true)
3948
+ return;
3949
+ if (task.status !== 'review' || task.signalToken !== expectedToken)
3950
+ return;
3951
+ const { reviewDispatchPending: _p, ...rest } = task;
3952
+ await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3953
+ });
3954
+ }
3955
+ // 评审派发的至少一次(sweep 消费):transition 落 pending、QA session 启动成功才清——
3956
+ // QA 缺失/忙/arm/start 失败的每条早退路径都由此在 60s 内自愈,无需依赖事件重放。
3957
+ async retryPendingGitReviewDispatches() {
3958
+ const tasks = await this.listActiveGitTasks();
3959
+ for (const task of tasks) {
3960
+ if (task.reviewDispatchPending !== true)
3961
+ continue;
3962
+ // arm/start 失败回滚会退到 in_progress/fixing 且无第二触发源(poller 已记 pushSha、
3963
+ // watcher 已消费):pending 在场即重新拉入 review
3964
+ if (!['review', 'in_progress', 'fixing'].includes(task.status))
3965
+ continue;
3966
+ try {
3967
+ // 列表是旧快照:消费谓词按实时值复核(pending 仍在 + 状态允许),并把代际
3968
+ // CAS(expectSignalToken + expectReviewDispatchPending)压进 dispatch 锁内
3969
+ const live = await this.taskStore.get(task.id);
3970
+ if (!live || live.reviewDispatchPending !== true)
3971
+ continue;
3972
+ if (!['review', 'in_progress', 'fixing'].includes(live.status))
3973
+ continue;
3974
+ const entry = this.getPendingDispatchRetry(live.id);
3975
+ if (entry?.kind === 'qa-recheck' && entry.signalToken === live.signalToken) {
3976
+ // 同代 busy pending 在场:QA 已知忙碌,重派只会空转 release/acquire 并重置预算;
3977
+ // 交给对账的观测门在空闲后消费
3978
+ continue;
3979
+ }
3980
+ const qaPhase = live.reviewRound === 0 ? 'review' : undefined;
3981
+ await this.dispatchReviewToQa(live.id, {
3982
+ bumpRound: live.reviewRoundPending === true,
3983
+ ...(qaPhase !== undefined ? { qaPhase } : {}),
3984
+ ...(live.signalToken !== undefined ? { expectSignalToken: live.signalToken } : {}),
3985
+ expectReviewDispatchPending: true,
3986
+ fromStatus: ['review', 'in_progress', 'fixing'],
3987
+ });
3988
+ }
3989
+ catch (err) {
3990
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
3991
+ }
3992
+ }
3993
+ }
3994
+ async retryGitRemoteBranchCleanup() {
3995
+ const tasks = await this.taskStore.list({ status: 'cancelled' });
3996
+ for (const task of tasks) {
3997
+ const reason = task.branchCleanupPending?.reason;
3998
+ if (task.reviewMode !== 'git'
3999
+ || (reason !== 'remote-delete-failed' && reason !== 'remote-close-failed'))
4000
+ continue;
4001
+ try {
4002
+ const driver = this.platformDriverFor(task.projectId);
4003
+ const branch = task.branch;
4004
+ if (!driver || branch === undefined)
4005
+ continue;
4006
+ // close 先补(幂等:closed PR 再 PATCH state=closed 为 no-op),再按守卫补删分支
4007
+ if (reason === 'remote-close-failed' && task.prNumber !== undefined) {
4008
+ await driver.runOp('close', { prNumber: task.prNumber });
4009
+ }
4010
+ if (!isAutoDeletableTaskBranch({
4011
+ taskId: task.id,
4012
+ taskBranch: branch,
4013
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
4014
+ actualRef: `refs/heads/${branch}`,
4015
+ })) {
4016
+ if (reason !== 'remote-close-failed')
4017
+ continue;
4018
+ }
4019
+ else {
4020
+ await driver.runOp('deleteBranch', { branch });
4021
+ }
4022
+ await this.withTaskLock(async () => {
4023
+ const fresh = await this.taskStore.get(task.id);
4024
+ const freshReason = fresh?.branchCleanupPending?.reason;
4025
+ if (freshReason !== 'remote-delete-failed' && freshReason !== 'remote-close-failed')
4026
+ return;
4027
+ const { branchCleanupPending: _pending, ...rest } = fresh;
4028
+ await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
4029
+ });
4030
+ }
4031
+ catch (err) {
4032
+ console.warn(`[AgentManager] retryGitRemoteBranchCleanup: task=${task.id} still failing:`, err);
4033
+ }
4034
+ }
4035
+ }
4036
+ // 反馈消费的唯一状态机(spec §6 双写协议 + merge 条):锁内按实时状态决定效果,效果字段
4037
+ // 与消费键同一次持久化——入口快照与提交之间的并发状态迁移不再能让 revision 落空。
4038
+ async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
4039
+ return this.withTaskLock(async () => {
4040
+ const task = await this.taskStore.get(taskId);
4041
+ if (!task || task.reviewMode !== 'git')
4042
+ return { kind: 'gone' };
4043
+ if (task.consumedFeedback?.[consume.key] !== undefined)
4044
+ return { kind: 'duplicate' };
4045
+ const consumed = { ...task.consumedFeedback, [consume.key]: consume.versionTime };
4046
+ const stamp = { updatedAt: new Date().toISOString() };
4047
+ if (TERMINAL_STATUSES.includes(task.status)) {
4048
+ await this.taskStore.set({ ...task, consumedFeedback: consumed, ...stamp });
4049
+ return { kind: 'consumed' };
4050
+ }
4051
+ if (task.status === 'merge-ready') {
4052
+ const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
4053
+ ? task.latestHeadSha
4054
+ : undefined;
4055
+ if (approvedHead !== undefined) {
4056
+ const next = {
4057
+ ...task,
4058
+ status: 'approved',
4059
+ postApproveHeadSha: approvedHead,
4060
+ pendingRedispatch: true,
4061
+ consumedFeedback: consumed,
4062
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
4063
+ ...stamp,
4064
+ };
4065
+ await this.taskStore.set(next);
4066
+ return { kind: 'returned', task: next };
4067
+ }
4068
+ }
4069
+ if (task.status === 'approved' && task.postApproveRevoked === undefined) {
4070
+ const next = {
4071
+ ...task,
4072
+ pendingRedispatch: true,
4073
+ consumedFeedback: consumed,
4074
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
4075
+ ...stamp,
4076
+ };
4077
+ await this.taskStore.set(next);
4078
+ return { kind: 'pending', task: next };
4079
+ }
4080
+ await this.taskStore.set({
4081
+ ...task,
4082
+ consumedFeedback: consumed,
4083
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
4084
+ ...stamp,
4085
+ });
4086
+ return { kind: 'consumed' };
4087
+ });
4088
+ }
4089
+ // merge-ready 不是评论面的冻结点(spec §6 merge 条):新反馈单写退回 approved + 消费键,
4090
+ // 与 push 使 merge-ready 失效的既有语义对称。
4091
+ async returnMergeReadyToApproved(taskId, consume) {
4092
+ return this.withTaskLock(async () => {
4093
+ const task = await this.taskStore.get(taskId);
4094
+ if (!task || task.reviewMode !== 'git' || task.status !== 'merge-ready')
4095
+ return null;
4096
+ const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
4097
+ ? task.latestHeadSha
4098
+ : undefined;
4099
+ if (!approvedHead)
4100
+ return null;
4101
+ // pendingRedispatch 与状态翻转/消费键同写:崩溃于派发前,恢复扫描凭它补派(spec §6 至少一次)
4102
+ const next = {
4103
+ ...task,
4104
+ status: 'approved',
4105
+ postApproveHeadSha: approvedHead,
4106
+ pendingRedispatch: true,
4107
+ consumedFeedback: { ...task.consumedFeedback, [consume.key]: consume.versionTime },
4108
+ updatedAt: new Date().toISOString(),
4109
+ };
4110
+ await this.taskStore.set(next);
4111
+ return next;
3443
4112
  });
3444
4113
  }
3445
4114
  async setupRecoveredPostApproveSignals() {
@@ -3448,14 +4117,26 @@ export class AgentManager {
3448
4117
  if (task.postApproveRevoked) {
3449
4118
  // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
3450
4119
  try {
3451
- await this.postApproveStore.clear(task.id);
4120
+ if (task.reviewMode === 'git') {
4121
+ if (task.postApproveToken !== undefined) {
4122
+ await this.taskStore.set({
4123
+ ...this.stripGitCompletionFields(task),
4124
+ updatedAt: new Date().toISOString(),
4125
+ });
4126
+ }
4127
+ }
4128
+ else {
4129
+ await this.postApproveStore.clear(task.id);
4130
+ }
3452
4131
  }
3453
4132
  catch (err) {
3454
4133
  console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
3455
4134
  }
3456
4135
  continue;
3457
4136
  }
3458
- const completion = await this.postApproveStore.get(task.id);
4137
+ const completion = task.reviewMode === 'git'
4138
+ ? this.gitCompletionOf(task)
4139
+ : await this.postApproveStore.get(task.id);
3459
4140
  if (!completion)
3460
4141
  continue;
3461
4142
  if (task.postApproveHeadSha === undefined) {
@@ -3465,7 +4146,14 @@ export class AgentManager {
3465
4146
  }
3466
4147
  if (completion.approvedHeadSha !== task.postApproveHeadSha) {
3467
4148
  try {
3468
- await this.postApproveStore.clear(task.id);
4149
+ if (task.reviewMode === 'git') {
4150
+ await this.taskStore.set({
4151
+ ...this.stripGitCompletionFields(task),
4152
+ updatedAt: new Date().toISOString(),
4153
+ });
4154
+ }
4155
+ else
4156
+ await this.postApproveStore.clear(task.id);
3469
4157
  console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
3470
4158
  }
3471
4159
  catch (err) {
@@ -3498,9 +4186,16 @@ export class AgentManager {
3498
4186
  for (const task of tasks) {
3499
4187
  if (!task.signalToken)
3500
4188
  continue;
3501
- const mapped = this.mapTaskStateToExpectedWatcher(task);
3502
- if (!mapped)
4189
+ const mappings = this.mapTaskStateToExpectedWatchers(task);
4190
+ if (mappings.length === 0)
3503
4191
  continue;
4192
+ for (const mapped of mappings) {
4193
+ await this.setupRecoveredWatcherForMapping(task, mapped.token ?? task.signalToken, mapped, mappings.length > 1);
4194
+ }
4195
+ }
4196
+ }
4197
+ async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
4198
+ {
3504
4199
  const { expectedKinds, agentId } = mapped;
3505
4200
  const specStage = isSpecStagePhase(task.phase);
3506
4201
  const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
@@ -3522,9 +4217,10 @@ export class AgentManager {
3522
4217
  projectId: task.projectId,
3523
4218
  agentId,
3524
4219
  expectedKinds,
3525
- token: task.signalToken,
4220
+ token: signalToken,
3526
4221
  skipSnapshot: !scanSnapshotOnRecover,
3527
4222
  recovered: true,
4223
+ ...(hasSiblings ? { replaceScope: 'agent' } : {}),
3528
4224
  ...(allowRecoveredReadFile
3529
4225
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
3530
4226
  : {}),
@@ -3618,16 +4314,259 @@ export class AgentManager {
3618
4314
  return undefined;
3619
4315
  throw new Error(`gh pr view exited ${result.exitCode} for PR #${prNumber}: ${stderr.slice(0, 200)}`);
3620
4316
  }
3621
- const parts = result.stdout.trim().split('\t');
3622
- const [headRefName, headSha, state, isCross] = parts;
3623
- const body = parts.slice(4).join('\t');
3624
- if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
3625
- return undefined;
3626
- if (state !== 'OPEN')
3627
- return undefined;
3628
- if (isCross === 'true')
3629
- return undefined;
3630
- return { headRefName, headSha, body };
4317
+ const parts = result.stdout.trim().split('\t');
4318
+ const [headRefName, headSha, state, isCross] = parts;
4319
+ const body = parts.slice(4).join('\t');
4320
+ if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
4321
+ return undefined;
4322
+ if (state !== 'OPEN')
4323
+ return undefined;
4324
+ if (isCross === 'true')
4325
+ return undefined;
4326
+ return { headRefName, headSha, body };
4327
+ }
4328
+ platformBindingFields(projectId) {
4329
+ if (this.effectiveReviewMode(projectId) !== 'git')
4330
+ return {};
4331
+ const project = this.getProjectConfig(projectId);
4332
+ if (!project)
4333
+ return {};
4334
+ const tool = resolveProjectTool(project);
4335
+ if (tool === undefined)
4336
+ return {};
4337
+ return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
4338
+ }
4339
+ // 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
4340
+ assertPlatformBinding(task) {
4341
+ const binding = task.platformBinding;
4342
+ if (!binding) {
4343
+ if (task.reviewMode !== 'git')
4344
+ return;
4345
+ // D11 无兼容负担:不存在合法的无快照 git 任务,缺失即损坏/手工恢复,按失配同级拒绝
4346
+ void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
4347
+ phase: 'platform-binding-mismatch',
4348
+ reason: 'missing-binding-snapshot',
4349
+ });
4350
+ throw new Error(`platform binding missing for git task ${task.id}; refusing platform operations`);
4351
+ }
4352
+ const project = this.getProjectConfig(task.projectId);
4353
+ const live = project === undefined ? undefined : {
4354
+ mode: this.effectiveReviewMode(task.projectId),
4355
+ repoKey: repoIdentityKey(project.repo),
4356
+ tool: resolveProjectTool(project),
4357
+ };
4358
+ if (live !== undefined && live.mode === binding.mode
4359
+ && live.repoKey === binding.repoKey && live.tool === binding.tool)
4360
+ return;
4361
+ void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
4362
+ phase: 'platform-binding-mismatch',
4363
+ binding,
4364
+ ...(live !== undefined ? { live: { mode: live.mode, repoKey: live.repoKey, tool: live.tool ?? '' } } : {}),
4365
+ });
4366
+ throw new Error(`platform binding mismatch for task ${task.id}: snapshot ${binding.mode}/${binding.repoKey}/${binding.tool} no longer matches the live config`);
4367
+ }
4368
+ async platformTaskAndDriver(taskId) {
4369
+ const task = await this.taskStore.get(taskId);
4370
+ if (!task)
4371
+ throw new Error(`task ${taskId} not found`);
4372
+ this.assertPlatformBinding(task);
4373
+ const driver = this.platformDriverFor(task.projectId);
4374
+ if (!driver)
4375
+ throw new Error(`no git driver resolvable for project ${task.projectId}`);
4376
+ return { task, driver };
4377
+ }
4378
+ async listActiveGitTasks(projectId) {
4379
+ const tasks = await this.taskStore.list(projectId !== undefined ? { projectId } : undefined);
4380
+ return tasks.filter(t => t.reviewMode === 'git' && !TERMINAL_STATUSES.includes(t.status));
4381
+ }
4382
+ async platformFetchPrView(taskId) {
4383
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4384
+ if (task.prNumber === undefined)
4385
+ throw new Error(`task ${taskId} has no bound PR`);
4386
+ const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
4387
+ return row;
4388
+ }
4389
+ async platformVerifyPrBinding(taskId, prNumber, opts = {}) {
4390
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4391
+ const [row] = await driver.runOp('prView', { prNumber });
4392
+ let expectedBase = task.baseBranch;
4393
+ if (expectedBase === undefined) {
4394
+ try {
4395
+ const [project] = await driver.runOp('projectView');
4396
+ if (typeof project?.defaultBranch === 'string')
4397
+ expectedBase = project.defaultBranch;
4398
+ }
4399
+ catch {
4400
+ // 快照与实时默认分支都拿不到 → unverifiable:核验失败不是核验通过(fail closed)
4401
+ }
4402
+ }
4403
+ const check = checkOpenPrBinding(row, { branch: opts.branchOverride ?? task.branch, expectedBase });
4404
+ if (!check.ok) {
4405
+ return {
4406
+ ...check,
4407
+ ...(check.reason === 'branch' && typeof row.branch === 'string' ? { prBranch: row.branch } : {}),
4408
+ };
4409
+ }
4410
+ return {
4411
+ ok: true,
4412
+ headSha: String(row.headSha),
4413
+ branch: String(row.branch),
4414
+ targetBranch: String(row.targetBranch),
4415
+ };
4416
+ }
4417
+ stopPhaseSignalWatcherAgent(taskId, agentId) {
4418
+ this.phaseSignalWatcher?.stopAgent(taskId, agentId);
4419
+ }
4420
+ async platformClosePr(taskId, opts) {
4421
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4422
+ if (task.prNumber === undefined)
4423
+ throw new Error(`task ${taskId} has no bound PR`);
4424
+ try {
4425
+ await driver.runOp('close', { prNumber: task.prNumber });
4426
+ }
4427
+ catch (err) {
4428
+ throw new PlatformCloseError('close', err);
4429
+ }
4430
+ const branch = task.branch;
4431
+ const deletable = opts.deleteBranch && branch !== undefined && isAutoDeletableTaskBranch({
4432
+ taskId: task.id,
4433
+ taskBranch: branch,
4434
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
4435
+ actualRef: `refs/heads/${branch}`,
4436
+ });
4437
+ if (deletable) {
4438
+ try {
4439
+ await driver.runOp('deleteBranch', { branch });
4440
+ }
4441
+ catch (err) {
4442
+ throw new PlatformCloseError('deleteBranch', err);
4443
+ }
4444
+ }
4445
+ }
4446
+ async platformScanComments(driver, prNumber) {
4447
+ return scanCommentSourcesOnce(driver, prNumber, () => Date.now());
4448
+ }
4449
+ // merge-ready 迁移与 merge confirm 的 provenance/ack 双复核共用一次全源扫描(spec §6/§10)
4450
+ async platformVerifyAcceptedPass(taskId) {
4451
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4452
+ if (task.prNumber === undefined)
4453
+ return { ok: false, reason: 'no bound PR' };
4454
+ const provenance = task.passProvenance;
4455
+ if (!provenance)
4456
+ return { ok: false, reason: 'no accepted pass provenance' };
4457
+ const scans = await this.platformScanComments(driver, task.prNumber);
4458
+ const recheck = recheckPassProvenance({
4459
+ token: provenance.token,
4460
+ failToken: provenance.failToken,
4461
+ anchorSha: provenance.anchorSha,
4462
+ carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
4463
+ }, scans);
4464
+ if (!recheck.ok)
4465
+ return { ok: false, reason: `provenance ${recheck.reason}` };
4466
+ const pending = collectPendingFeedback(scans, {
4467
+ replyActorId: task.replyActorId,
4468
+ replyActorStatus: task.replyActorStatus,
4469
+ });
4470
+ if (!pending.allSourcesOk)
4471
+ return { ok: false, reason: 'source scan incomplete' };
4472
+ return { ok: true, pendingCount: pending.pending.size };
4473
+ }
4474
+ async platformPendingFeedback(taskId) {
4475
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4476
+ if (task.prNumber === undefined)
4477
+ throw new Error(`task ${taskId} has no bound PR`);
4478
+ const scans = await this.platformScanComments(driver, task.prNumber);
4479
+ const failed = scans.filter(s => !s.ok).map(s => s.key);
4480
+ if (failed.length > 0) {
4481
+ throw new Error(`pending-feedback scan incomplete; failed sources: ${failed.join(', ')}`);
4482
+ }
4483
+ return collectPendingFeedback(scans, {
4484
+ replyActorId: task.replyActorId,
4485
+ replyActorStatus: task.replyActorStatus,
4486
+ });
4487
+ }
4488
+ async platformConfirmMerge(taskId, opts) {
4489
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4490
+ if (task.prNumber === undefined)
4491
+ throw new Error(`task ${taskId} has no bound PR`);
4492
+ const prNumber = task.prNumber;
4493
+ const [row] = await driver.runOp('prView', { prNumber });
4494
+ const binding = checkOpenPrBinding(row, { branch: task.branch, expectedBase: task.baseBranch });
4495
+ if (!binding.ok)
4496
+ throw new Error(`merge blocked: binding ${binding.reason}`);
4497
+ if (String(row.headSha) !== opts.expectedHeadSha) {
4498
+ throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
4499
+ }
4500
+ // 人工兜底(max_rounds Mark complete)绕过 QA 授权复核(该路径本就无 pass),
4501
+ // binding/head/REST sha 的安全检查保留(spec §7 威胁模型:merge 有人工 confirm)
4502
+ if (opts.humanOverride !== true) {
4503
+ const provenance = task.passProvenance;
4504
+ if (!provenance)
4505
+ throw new PlatformMergeRecheckError('no accepted pass provenance on task');
4506
+ if (provenance.anchorSha.toLowerCase() !== opts.expectedHeadSha.toLowerCase()) {
4507
+ throw new PlatformMergeRecheckError(`pass provenance anchors ${provenance.anchorSha}, not the merge head ${opts.expectedHeadSha}`);
4508
+ }
4509
+ const scans = await this.platformScanComments(driver, prNumber);
4510
+ const recheck = recheckPassProvenance({
4511
+ token: provenance.token,
4512
+ failToken: provenance.failToken,
4513
+ anchorSha: provenance.anchorSha,
4514
+ carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
4515
+ }, scans);
4516
+ if (!recheck.ok) {
4517
+ // 瞬态扫描不完整留在 gate 可重试;只有确定性失效(撤销/编辑/缺失)才退回 approved
4518
+ if (recheck.reason === 'source-scan-incomplete') {
4519
+ throw new Error('merge blocked: provenance source-scan-incomplete');
4520
+ }
4521
+ throw new PlatformMergeRecheckError(`provenance ${recheck.reason}`);
4522
+ }
4523
+ const pending = collectPendingFeedback(scans, {
4524
+ replyActorId: task.replyActorId,
4525
+ replyActorStatus: task.replyActorStatus,
4526
+ });
4527
+ if (!pending.allSourcesOk)
4528
+ throw new Error('merge blocked: pending-feedback scan incomplete');
4529
+ if (pending.pending.size > 0) {
4530
+ throw new PlatformMergeRecheckError(`${pending.pending.size} pending feedback revision(s) without ack`);
4531
+ }
4532
+ }
4533
+ // 临门 CAS:扫描期间 durable 退回(approved/pendingRedispatch/fixing)必须使本次 Confirm 失效;
4534
+ // REST sha 只保护 source head,保护不了评论面与 TaskState(残余网络窗口按 spec §6 如实声明)
4535
+ const fresh = await this.taskStore.get(taskId);
4536
+ if (!fresh
4537
+ || (opts.humanOverride === true
4538
+ // override 只绕过 provenance/ack,不绕过本次认领的 gate 代际:并发 RC 转 fixing 即失效
4539
+ ? fresh.status !== 'merge-ready'
4540
+ : (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
4541
+ || fresh.latestHeadSha !== opts.expectedHeadSha
4542
+ || fresh.pendingRedispatch === true) {
4543
+ throw new Error(`merge blocked: task left its merge gate (status=${fresh?.status ?? 'gone'})`);
4544
+ }
4545
+ try {
4546
+ await driver.runOp('merge', { prNumber, expectedHeadSha: opts.expectedHeadSha });
4547
+ }
4548
+ catch (err) {
4549
+ if (err instanceof DriverOpError && err.info.errorClass === 'MERGE_BLOCKED') {
4550
+ let mergeStatus;
4551
+ try {
4552
+ const [fresh] = await driver.runOp('prView', { prNumber });
4553
+ if (typeof fresh?.detailedMergeStatus === 'string')
4554
+ mergeStatus = fresh.detailedMergeStatus;
4555
+ }
4556
+ catch {
4557
+ // 归因查询失败不遮蔽原始错误
4558
+ }
4559
+ throw new Error(`merge blocked by platform${mergeStatus ? ` (${mergeStatus})` : ''}: ${err.message}`);
4560
+ }
4561
+ throw err;
4562
+ }
4563
+ }
4564
+ mintReviewTokenPair() {
4565
+ const passToken = createSignalToken();
4566
+ let failToken = createSignalToken();
4567
+ while (failToken === passToken)
4568
+ failToken = createSignalToken();
4569
+ return { passToken, failToken };
3631
4570
  }
3632
4571
  async listTasksByProject(projectId) {
3633
4572
  return this.taskStore.list({ projectId });
@@ -3684,6 +4623,7 @@ export class AgentManager {
3684
4623
  return null;
3685
4624
  if (!guard.fromStatus.includes(previousStatus))
3686
4625
  return null;
4626
+ // 代际 CAS(spec §7):校验后提交前的并发换代(push/重派轮换 token+pair)使旧 verdict 失效
3687
4627
  if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
3688
4628
  return null;
3689
4629
  if (patch?.branch && patch.branch !== task.branch) {
@@ -3704,16 +4644,21 @@ export class AgentManager {
3704
4644
  return { task, previousStatus };
3705
4645
  });
3706
4646
  }
3707
- async bumpReviewRoundIfStillAt(taskId, expectedRound) {
4647
+ // 送达即计轮:flag 是唯一入账凭据,与 verdict 公式(round + flag)按 token CAS 互斥消费,
4648
+ // successor 换代或 verdict 先行都自然跳过,恰好一次
4649
+ // 不设终态守卫:只写轮次不写 status(无复活风险),而终态任务的 recheck 派发是受支持的能力,
4650
+ // 其送达的轮次同样要入账
4651
+ async consumeReviewRoundIntent(taskId, expectedToken) {
3708
4652
  await this.withTaskLock(async () => {
3709
- const task = await this.taskStore.get(taskId);
3710
- if (!task || TERMINAL_STATUSES.includes(task.status))
3711
- return;
3712
- if ((task.reviewRound ?? 0) !== expectedRound)
4653
+ const fresh = await this.taskStore.get(taskId);
4654
+ if (!fresh || fresh.signalToken !== expectedToken || fresh.reviewRoundPending !== true)
3713
4655
  return;
3714
- task.reviewRound = expectedRound + 1;
3715
- task.updatedAt = new Date().toISOString();
3716
- await this.taskStore.set(task);
4656
+ await this.taskStore.set({
4657
+ ...fresh,
4658
+ reviewRound: fresh.reviewRound + 1,
4659
+ reviewRoundPending: undefined,
4660
+ updatedAt: new Date().toISOString(),
4661
+ });
3717
4662
  });
3718
4663
  }
3719
4664
  createRunnerFor(agent) {
@@ -3743,6 +4688,9 @@ export class AgentManager {
3743
4688
  return this.repoCache;
3744
4689
  }
3745
4690
  async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
4691
+ // Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
4692
+ // window must not let the final state write-back resurrect the deleted agent.
4693
+ const genAtEntry = this.deletionGenerationOf(agentId);
3746
4694
  const existing = await this.agentStore.get(agentId);
3747
4695
  if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
3748
4696
  console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
@@ -3789,12 +4737,16 @@ export class AgentManager {
3789
4737
  else {
3790
4738
  const now = new Date().toISOString();
3791
4739
  await this.agentStore.update(agentId, (latest) => {
3792
- if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
4740
+ // Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
4741
+ // since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
4742
+ if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
3793
4743
  return AGENT_STORE_NOOP;
3794
- if (latest?.taskId !== undefined && latest.taskId !== taskId)
4744
+ if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
4745
+ return AGENT_STORE_NOOP;
4746
+ if (latest.taskId !== undefined && latest.taskId !== taskId)
3795
4747
  return AGENT_STORE_NOOP;
3796
4748
  return {
3797
- ...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
4749
+ ...latest,
3798
4750
  id: agentId,
3799
4751
  projectId,
3800
4752
  taskId: undefined,
@@ -3871,6 +4823,7 @@ export class AgentManager {
3871
4823
  branch: taskBranch,
3872
4824
  branchCreatedByBaxian,
3873
4825
  reviewMode: this.effectiveReviewMode(projectId),
4826
+ ...this.platformBindingFields(projectId),
3874
4827
  createdAt: now,
3875
4828
  updatedAt: now,
3876
4829
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -3884,6 +4837,8 @@ export class AgentManager {
3884
4837
  };
3885
4838
  return this.persistQueuedTask(unassigned, 'unassigned');
3886
4839
  }
4840
+ // Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
4841
+ const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
3887
4842
  const target = await this.pickAgent(projectId, input.preferredAgentId);
3888
4843
  const targetConfig = this.getAgentConfig(input.preferredAgentId);
3889
4844
  const group = this.findAgentGroup(input.preferredAgentId);
@@ -3895,6 +4850,12 @@ export class AgentManager {
3895
4850
  const researchFields = research
3896
4851
  ? { researchAgentId: research.id, phase: 'research' }
3897
4852
  : {};
4853
+ // Pin EVERY participant's generation: a member (e.g. QA) deleted→reintroduced after the snapshot leaves isDeletionInFlight false and the preferred generation unchanged, yet the task would still carry its stale id.
4854
+ const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
4855
+ .map(id => [id, this.deletionGenerationOf(id)]));
4856
+ const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
4857
+ && !this.isDeletionInFlight(input.preferredAgentId)
4858
+ && [...participantGens].every(([id, gen]) => this.deletionGenerationOf(id) === gen && !this.isDeletionInFlight(id));
3898
4859
  const queued = {
3899
4860
  ...taskBase,
3900
4861
  agentId: '',
@@ -3903,12 +4864,81 @@ export class AgentManager {
3903
4864
  status: 'pending',
3904
4865
  ...(qa ? { qaAgentId: qa.id } : {}),
3905
4866
  };
4867
+ // A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
4868
+ // pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
4869
+ const persistQueuedOrReject = (reason) => {
4870
+ if (!participantsFresh()) {
4871
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
4872
+ }
4873
+ return this.persistQueuedTask(queued, reason, input.preferredAgentId);
4874
+ };
3906
4875
  if (!target) {
3907
- return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
4876
+ return persistQueuedOrReject('preferred_agent_busy');
4877
+ }
4878
+ if (this.isDeletionInFlight(target.id)) {
4879
+ return persistQueuedOrReject('agent_locked');
3908
4880
  }
3909
4881
  const lockToken = await this.lockManager.acquire(target.id, taskId);
3910
4882
  if (!lockToken) {
3911
- return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
4883
+ return persistQueuedOrReject('agent_locked');
4884
+ }
4885
+ // Three-step transaction (queued task → binding → in_progress): any failure leaves at worst a queued task and/or a stale binding to it, both reclaimable — never an active task with no binding.
4886
+ const releaseLock = async () => {
4887
+ try {
4888
+ await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
4889
+ }
4890
+ catch (releaseErr) {
4891
+ console.warn(`[AgentManager] createTask(${taskId}): lock release after failed assignment also failed:`, releaseErr);
4892
+ }
4893
+ };
4894
+ try {
4895
+ await this.taskStore.set(queued);
4896
+ }
4897
+ catch (err) {
4898
+ await releaseLock();
4899
+ throw err;
4900
+ }
4901
+ let commit;
4902
+ try {
4903
+ commit = await this.agentStore.update(target.id, (existing) => {
4904
+ if (existing === null && !this.bindingInitPredicate(target.id, genAtEntry))
4905
+ return AGENT_STORE_NOOP;
4906
+ if (!this.deletionGateOpen(target.id, genAtEntry))
4907
+ return AGENT_STORE_NOOP;
4908
+ return {
4909
+ id: target.id,
4910
+ projectId,
4911
+ taskId,
4912
+ lockToken,
4913
+ bootstrappingTaskId: taskId,
4914
+ updatedAt: now,
4915
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
4916
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
4917
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
4918
+ };
4919
+ });
4920
+ }
4921
+ catch (err) {
4922
+ await releaseLock();
4923
+ throw err;
4924
+ }
4925
+ if (commit !== 'committed') {
4926
+ await releaseLock();
4927
+ return (await this.taskStore.get(taskId)) ?? queued;
4928
+ }
4929
+ // Binding-before-active: a DELETE slipping in after the binding commit sees a still-'pending' task and can rotate the lock; re-verify lock ownership + preferred/participant generations before the active write, else roll back.
4930
+ if (this.isDeletionInFlight(target.id)
4931
+ || !this.deletionGateOpen(target.id, genAtEntry)
4932
+ || !participantsFresh()
4933
+ || !(await this.lockManager.isOwner(target.id, taskId, lockToken))) {
4934
+ await this.agentStore.update(target.id, (existing) => {
4935
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
4936
+ return AGENT_STORE_NOOP;
4937
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
4938
+ return { ...rest, updatedAt: new Date().toISOString() };
4939
+ });
4940
+ await releaseLock();
4941
+ return (await this.taskStore.get(taskId)) ?? queued;
3912
4942
  }
3913
4943
  const task = {
3914
4944
  ...taskBase,
@@ -3918,18 +4948,24 @@ export class AgentManager {
3918
4948
  ...researchFields,
3919
4949
  status: 'in_progress',
3920
4950
  };
3921
- await this.taskStore.set(task);
3922
- await this.agentStore.update(target.id, (existing) => ({
3923
- id: target.id,
3924
- projectId,
3925
- taskId,
3926
- lockToken,
3927
- bootstrappingTaskId: taskId,
3928
- updatedAt: now,
3929
- ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
3930
- ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
3931
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
3932
- }));
4951
+ try {
4952
+ await this.taskStore.set(task);
4953
+ }
4954
+ catch (err) {
4955
+ try {
4956
+ await this.agentStore.update(target.id, (existing) => {
4957
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
4958
+ return AGENT_STORE_NOOP;
4959
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
4960
+ return { ...rest, updatedAt: new Date().toISOString() };
4961
+ });
4962
+ }
4963
+ catch (rollbackErr) {
4964
+ console.warn(`[AgentManager] createTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
4965
+ }
4966
+ await releaseLock();
4967
+ throw err;
4968
+ }
3933
4969
  await this.safeEmit({
3934
4970
  id: '',
3935
4971
  type: 'task.assigned',
@@ -3946,7 +4982,7 @@ export class AgentManager {
3946
4982
  const task = await this.createTask(projectId, input);
3947
4983
  if (task.status === 'in_progress' && task.agentId) {
3948
4984
  const signalToken = createSignalToken();
3949
- await this.updateTask(task.id, { signalToken });
4985
+ await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
3950
4986
  const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
3951
4987
  const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
3952
4988
  if (opts.background) {
@@ -4037,6 +5073,9 @@ export class AgentManager {
4037
5073
  const cfg = this.getAgentConfig(agentId);
4038
5074
  if (!cfg)
4039
5075
  throw new ApiError(404, `Unknown agent: ${agentId}`);
5076
+ // Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
5077
+ // DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
5078
+ const genAtEntry = this.deletionGenerationOf(agentId);
4040
5079
  const state = await this.agentStore.get(agentId);
4041
5080
  const paneId = state?.paneId;
4042
5081
  if (!paneId)
@@ -4046,6 +5085,9 @@ export class AgentManager {
4046
5085
  }
4047
5086
  try {
4048
5087
  const assertUploadStillValid = async () => {
5088
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
5089
+ throw new ApiError(409, `Agent ${agentId} was deleted or recreated while uploading; image paste aborted`);
5090
+ }
4049
5091
  const held = await this.agentStore.get(agentId);
4050
5092
  if (!held || held.paneId !== paneId) {
4051
5093
  throw new ApiError(409, `Agent ${agentId} session changed while uploading; image paste aborted`);
@@ -4065,7 +5107,10 @@ export class AgentManager {
4065
5107
  await writeImageToHost(runner, path, bytes);
4066
5108
  await assertUploadStillValid();
4067
5109
  const tmux = new TmuxManager(runner);
4068
- await tmux.injectPrompt(paneId, `${path} `, agentId);
5110
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
5111
+ // resolveClaimedPane resolved by the CURRENT same-name session; re-verify once more before injecting so a DELETE→recreate in that gap can't take the paste.
5112
+ await assertUploadStillValid();
5113
+ await tmux.injectPrompt(pane, `${path} `, agentId);
4069
5114
  return { path };
4070
5115
  }
4071
5116
  finally {
@@ -4097,9 +5142,10 @@ export class AgentManager {
4097
5142
  }
4098
5143
  };
4099
5144
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
5145
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
4100
5146
  const waitReady = async () => {
4101
5147
  try {
4102
- await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
5148
+ await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
4103
5149
  }
4104
5150
  catch (err) {
4105
5151
  const detail = err instanceof Error ? err.message : String(err);
@@ -4108,13 +5154,13 @@ export class AgentManager {
4108
5154
  };
4109
5155
  await waitReady();
4110
5156
  await assertSessionUnchanged();
4111
- await tmux.clearComposerDraft(paneId);
5157
+ await tmux.clearComposerDraft(pane);
4112
5158
  await waitReady();
4113
5159
  await assertSessionUnchanged();
4114
- await tmux.sendKeysLiteral(paneId, command);
4115
- await tmux.sendEnter(paneId);
5160
+ await tmux.sendKeysLiteral(pane, command);
5161
+ await tmux.sendEnter(pane);
4116
5162
  guardHandedOff = true;
4117
- void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
5163
+ void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
4118
5164
  .catch(err => {
4119
5165
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
4120
5166
  })
@@ -4133,25 +5179,98 @@ export class AgentManager {
4133
5179
  async clearAgent(agentId) {
4134
5180
  return this.sendSlashCommand(agentId, '/clear');
4135
5181
  }
4136
- async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
5182
+ registerPendingDispatchRetry(taskId, entry, budgetOverride) {
5183
+ const prev = this.pendingDispatchRetryByTask.get(taskId);
5184
+ // 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
5185
+ // budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
5186
+ // fence 注入,外部 successor 的登记结构上拿不到
5187
+ const sameGeneration = prev?.kind === entry.kind
5188
+ && prev.agentId === entry.agentId
5189
+ && prev.signalToken === entry.signalToken;
5190
+ const since = sameGeneration ? prev.since : (budgetOverride?.since ?? Date.now());
5191
+ const budgetAlerted = sameGeneration
5192
+ ? prev.budgetAlerted === true
5193
+ : budgetOverride?.budgetAlerted === true;
5194
+ this.pendingDispatchRetryByTask.set(taskId, {
5195
+ ...entry,
5196
+ since,
5197
+ ...(budgetAlerted ? { budgetAlerted: true } : {}),
5198
+ });
5199
+ }
5200
+ getPendingDispatchRetry(taskId) {
5201
+ return this.pendingDispatchRetryByTask.get(taskId);
5202
+ }
5203
+ listPendingDispatchRetries() {
5204
+ return this.pendingDispatchRetryByTask;
5205
+ }
5206
+ clearPendingDispatchRetry(taskId) {
5207
+ this.pendingDispatchRetryByTask.delete(taskId);
5208
+ }
5209
+ clearPendingDispatchRetryIfMatches(taskId, expected) {
5210
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5211
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5212
+ this.pendingDispatchRetryByTask.delete(taskId);
5213
+ }
5214
+ }
5215
+ markPendingDispatchRetryBudgetAlerted(taskId, expected) {
5216
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
5217
+ if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
5218
+ this.pendingDispatchRetryByTask.set(taskId, { ...entry, budgetAlerted: true });
5219
+ }
5220
+ }
5221
+ async queueQaBusyPendingRetry(taskId, agentId, phase, createdSession, err, dispatch) {
5222
+ if (!(err instanceof ReplNotReadyError))
5223
+ return null;
5224
+ if (phase !== 'review' && phase !== 'recheck')
5225
+ return null;
5226
+ // 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
5227
+ // 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
5228
+ const passToken = dispatch.passToken;
5229
+ if (!passToken)
5230
+ return null;
5231
+ let current;
5232
+ try {
5233
+ current = await this.taskStore.get(taskId);
5234
+ }
5235
+ catch (readErr) {
5236
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but pass token could not be verified; ` +
5237
+ `falling back to the non-pending failure path:`, readErr);
5238
+ return null;
5239
+ }
5240
+ if (current?.signalToken !== passToken) {
5241
+ console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but the review pass was superseded ` +
5242
+ `(expected ${passToken}, now ${current?.signalToken ?? 'gone'}); not queueing a pending retry`);
5243
+ return null;
5244
+ }
5245
+ this.registerPendingDispatchRetry(taskId, {
5246
+ kind: 'qa-recheck',
5247
+ agentId,
5248
+ signalToken: passToken,
5249
+ qaPhase: phase,
5250
+ }, dispatch.pendingBudget);
5251
+ console.warn(`[AgentManager] startSession[${phase}]: QA pane busy for task ${taskId}; ` +
5252
+ `${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
5253
+ return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
5254
+ }
5255
+ async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
4137
5256
  await this.acquireCompactGuard(agentId);
4138
5257
  try {
4139
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
5258
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
4140
5259
  await revalidate();
4141
- await tmux.clearComposerDraft(paneId);
4142
- const baseline = await tmux.captureSettledSnapshot(paneId, {
5260
+ await tmux.clearComposerDraft(pane);
5261
+ const baseline = await tmux.captureSettledSnapshot(pane, {
4143
5262
  timeoutMs: this.dispatchSettleTimeoutMs,
4144
5263
  });
4145
- const baselineTitle = await tmux.readPaneTitle(paneId);
4146
- await tmux.sendKeysLiteral(paneId, '/clear');
4147
- await tmux.sendEnter(paneId);
4148
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
5264
+ const baselineTitle = await tmux.readPaneTitle(pane);
5265
+ await tmux.sendKeysLiteral(pane, '/clear');
5266
+ await tmux.sendEnter(pane);
5267
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
4149
5268
  timeoutMs: this.dispatchAckTimeoutMs,
4150
5269
  baselineTitle,
4151
5270
  acceptComposerChange: true,
4152
5271
  });
4153
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
4154
- if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
5272
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
5273
+ if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
4155
5274
  throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
4156
5275
  }
4157
5276
  await revalidate();
@@ -4228,6 +5347,8 @@ export class AgentManager {
4228
5347
  error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
4229
5348
  };
4230
5349
  }
5350
+ // Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
5351
+ const genAtEntry = this.deletionGenerationOf(agentId);
4231
5352
  const cfg = this.getAgentConfig(agentId);
4232
5353
  if (!cfg)
4233
5354
  return { task: fresh, errorCode: 400, error: `Unknown agent: ${agentId}` };
@@ -4248,6 +5369,9 @@ export class AgentManager {
4248
5369
  if (!canDispatchWithBinding(state)) {
4249
5370
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
4250
5371
  }
5372
+ if (this.isDeletionInFlight(agentId)) {
5373
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5374
+ }
4251
5375
  const lockToken = await this.lockManager.acquire(agentId, taskId);
4252
5376
  if (!lockToken) {
4253
5377
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
@@ -4266,6 +5390,10 @@ export class AgentManager {
4266
5390
  ? group?.find(agent => agent.role === 'qa')?.id
4267
5391
  : fresh.qaAgentId;
4268
5392
  const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
5393
+ // Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
5394
+ const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
5395
+ .map(pid => [pid, this.deletionGenerationOf(pid)]));
5396
+ const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
4269
5397
  const claimedTask = {
4270
5398
  ...fresh,
4271
5399
  preferredAgentId: agentId,
@@ -4277,18 +5405,76 @@ export class AgentManager {
4277
5405
  status: 'in_progress',
4278
5406
  updatedAt: now,
4279
5407
  };
4280
- await this.taskStore.set(claimedTask);
4281
- await this.agentStore.update(agentId, (existing) => ({
4282
- id: agentId,
4283
- projectId: cfg.projectId,
4284
- taskId,
4285
- lockToken,
4286
- bootstrappingTaskId: taskId,
4287
- updatedAt: now,
4288
- ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
4289
- ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
4290
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
4291
- }));
5408
+ // Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
5409
+ // step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
5410
+ const releaseLock = async () => {
5411
+ try {
5412
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
5413
+ }
5414
+ catch (releaseErr) {
5415
+ console.warn(`[AgentManager] claimTask(${taskId}): lock release after failed claim also failed:`, releaseErr);
5416
+ }
5417
+ };
5418
+ let commit;
5419
+ try {
5420
+ commit = await this.agentStore.update(agentId, (existing) => {
5421
+ if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
5422
+ return AGENT_STORE_NOOP;
5423
+ if (!this.deletionGateOpen(agentId, genAtEntry))
5424
+ return AGENT_STORE_NOOP;
5425
+ return {
5426
+ id: agentId,
5427
+ projectId: cfg.projectId,
5428
+ taskId,
5429
+ lockToken,
5430
+ bootstrappingTaskId: taskId,
5431
+ updatedAt: now,
5432
+ ...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
5433
+ ...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
5434
+ ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
5435
+ };
5436
+ });
5437
+ }
5438
+ catch (err) {
5439
+ await releaseLock();
5440
+ throw err;
5441
+ }
5442
+ if (commit !== 'committed') {
5443
+ await releaseLock();
5444
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5445
+ }
5446
+ // Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
5447
+ if (this.isDeletionInFlight(agentId)
5448
+ || !this.deletionGateOpen(agentId, genAtEntry)
5449
+ || !participantsFresh()
5450
+ || !(await this.lockManager.isOwner(agentId, taskId, lockToken))) {
5451
+ await this.agentStore.update(agentId, (existing) => {
5452
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5453
+ return AGENT_STORE_NOOP;
5454
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5455
+ return { ...rest, updatedAt: new Date().toISOString() };
5456
+ });
5457
+ await releaseLock();
5458
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
5459
+ }
5460
+ try {
5461
+ await this.taskStore.set(claimedTask);
5462
+ }
5463
+ catch (err) {
5464
+ try {
5465
+ await this.agentStore.update(agentId, (existing) => {
5466
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
5467
+ return AGENT_STORE_NOOP;
5468
+ const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
5469
+ return { ...rest, updatedAt: new Date().toISOString() };
5470
+ });
5471
+ }
5472
+ catch (rollbackErr) {
5473
+ console.warn(`[AgentManager] claimTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
5474
+ }
5475
+ await releaseLock();
5476
+ throw err;
5477
+ }
4292
5478
  await this.safeEmit({
4293
5479
  id: '',
4294
5480
  type: 'task.assigned',
@@ -4306,7 +5492,7 @@ export class AgentManager {
4306
5492
  return claim;
4307
5493
  const claimed = claim.task;
4308
5494
  const signalToken = createSignalToken();
4309
- await this.updateTask(claimed.id, { signalToken });
5495
+ await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
4310
5496
  let started = false;
4311
5497
  let dispatchErr = null;
4312
5498
  const initialDispatch = this.resolveInitialDispatch(claimed);
@@ -4388,6 +5574,8 @@ export class AgentManager {
4388
5574
  const agent = this.getAgentConfig(agentId);
4389
5575
  if (!agent)
4390
5576
  throw new Error(`Unknown agent: ${agentId}`);
5577
+ // Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
5578
+ const genAtEntry = this.deletionGenerationOf(agentId);
4391
5579
  const task = await this.taskStore.get(taskId);
4392
5580
  if (!task)
4393
5581
  throw new Error(`Unknown task: ${taskId}`);
@@ -4433,6 +5621,11 @@ export class AgentManager {
4433
5621
  return false;
4434
5622
  }
4435
5623
  const assertOwner = async () => {
5624
+ // Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
5625
+ // reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
5626
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
5627
+ throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
5628
+ }
4436
5629
  const stateNow = await this.agentStore.get(agentId);
4437
5630
  if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
4438
5631
  throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
@@ -4443,10 +5636,10 @@ export class AgentManager {
4443
5636
  const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
4444
5637
  let ensure;
4445
5638
  try {
4446
- ensure = await this.ensureSession(agentId, 'runtime');
5639
+ ensure = await this.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
4447
5640
  }
4448
5641
  catch (err) {
4449
- if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
5642
+ if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses, expectedGeneration: genAtEntry })) {
4450
5643
  throw err;
4451
5644
  }
4452
5645
  if (err instanceof EnsureSessionError && err.partial.createdSession) {
@@ -4464,7 +5657,7 @@ export class AgentManager {
4464
5657
  }
4465
5658
  throw err;
4466
5659
  }
4467
- const { paneId, workdir } = ensure;
5660
+ const { paneId, pane, workdir } = ensure;
4468
5661
  const runner = this.createRunnerFor(agent);
4469
5662
  const tmux = new TmuxManager(runner);
4470
5663
  const branchManager = new BranchManager(runner);
@@ -4472,7 +5665,15 @@ export class AgentManager {
4472
5665
  if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
4473
5666
  return false;
4474
5667
  await assertOwner();
4475
- await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
5668
+ try {
5669
+ await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
5670
+ }
5671
+ catch (err) {
5672
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5673
+ if (busyPend)
5674
+ throw busyPend;
5675
+ throw err;
5676
+ }
4476
5677
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
4477
5678
  const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
4478
5679
  let reviewCheckout;
@@ -4508,12 +5709,15 @@ export class AgentManager {
4508
5709
  }
4509
5710
  await assertOwner();
4510
5711
  if (!ensure.freshRuntime) {
4511
- await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
5712
+ await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
4512
5713
  if (ensure.skillsStale)
4513
5714
  await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
4514
5715
  }
4515
5716
  }
4516
5717
  catch (err) {
5718
+ const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5719
+ if (busyPend)
5720
+ throw busyPend;
4517
5721
  const message = err instanceof Error ? err.message : String(err);
4518
5722
  const holdPhase = err instanceof DirtyWorkdirError
4519
5723
  ? 'dirty-workdir'
@@ -4658,12 +5862,20 @@ export class AgentManager {
4658
5862
  let agentMarkedRunning = false;
4659
5863
  try {
4660
5864
  let cancelHoldWon = false;
4661
- await this.agentStore.update(agentId, (existing) => {
5865
+ let deletedDuringDispatch = false;
5866
+ const runningCommit = await this.agentStore.update(agentId, (existing) => {
4662
5867
  if (isCancelCleanupHold(existing)) {
4663
5868
  cancelHoldWon = true;
4664
5869
  return AGENT_STORE_NOOP;
4665
5870
  }
5871
+ // Binding must already exist and still hold our lock; never re-create a deleted agent's state nor write under a bumped deletion generation (ABA).
5872
+ if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
5873
+ || !this.deletionGateOpen(agentId, genAtEntry)) {
5874
+ deletedDuringDispatch = true;
5875
+ return AGENT_STORE_NOOP;
5876
+ }
4666
5877
  return {
5878
+ ...existing,
4667
5879
  id: agentId,
4668
5880
  projectId: agent.projectId,
4669
5881
  paneId,
@@ -4673,7 +5885,6 @@ export class AgentManager {
4673
5885
  startedAt: now,
4674
5886
  bootstrappingTaskId: taskId,
4675
5887
  updatedAt: now,
4676
- ...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
4677
5888
  };
4678
5889
  });
4679
5890
  if (cancelHoldWon) {
@@ -4682,8 +5893,39 @@ export class AgentManager {
4682
5893
  await cleanupCheckoutOrHold();
4683
5894
  return false;
4684
5895
  }
5896
+ if (deletedDuringDispatch || runningCommit !== 'committed') {
5897
+ console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} binding vanished or was deleted/recreated ` +
5898
+ `during dispatch; not marking running (no orphan state)`);
5899
+ return false;
5900
+ }
4685
5901
  agentMarkedRunning = true;
4686
- await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
5902
+ // armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
5903
+ // push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
5904
+ // 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
5905
+ // cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
5906
+ const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
5907
+ const passStillArmed = opts.dispatchPassToken !== undefined
5908
+ ? async () => {
5909
+ const freshTask = await this.taskStore.get(taskId);
5910
+ if (!freshTask || freshTask.signalToken !== opts.dispatchPassToken)
5911
+ return false;
5912
+ return passDeliverableStatuses.includes(freshTask.status);
5913
+ }
5914
+ : undefined;
5915
+ if (passStillArmed && !(await passStillArmed())) {
5916
+ console.warn(`[AgentManager] startSession[${phase}]: review pass superseded before injection for task ${taskId}; aborting paste`);
5917
+ return false;
5918
+ }
5919
+ const injectResult = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, passStillArmed);
5920
+ if (injectResult.aborted === true) {
5921
+ console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
5922
+ return false;
5923
+ }
5924
+ // prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
5925
+ // 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
5926
+ if (opts.dispatchPassToken !== undefined) {
5927
+ this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
5928
+ }
4687
5929
  try {
4688
5930
  await this.clearBootstrapMarker(agentId, taskId);
4689
5931
  }
@@ -4707,6 +5949,11 @@ export class AgentManager {
4707
5949
  return true;
4708
5950
  }
4709
5951
  catch (err) {
5952
+ // 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
5953
+ // 在途回合还在使用该工作树,park/release 会拽走它
5954
+ const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
5955
+ if (injectBusyPend)
5956
+ throw injectBusyPend;
4710
5957
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
4711
5958
  if (!isAckUnknown) {
4712
5959
  await cleanupCheckoutOrHold();
@@ -4749,7 +5996,7 @@ export class AgentManager {
4749
5996
  throw err;
4750
5997
  }
4751
5998
  }
4752
- async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
5999
+ async injectAndAwaitAck(tmux, pane, prompt, agentId, runtime, guardBeforePaste) {
4753
6000
  const before = await this.agentStore.get(agentId);
4754
6001
  const beforeLockToken = before?.taskId
4755
6002
  ? await this.resolveTaskLockToken(before, before.taskId)
@@ -4801,21 +6048,22 @@ export class AgentManager {
4801
6048
  if (guardBeforePaste && !(await guardBeforePaste())) {
4802
6049
  return { acked: false, composerDelivered: false, aborted: true };
4803
6050
  }
4804
- return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
6051
+ return await this.injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste);
4805
6052
  }
4806
6053
  finally {
4807
6054
  this.compactInFlight.delete(agentId);
4808
6055
  }
4809
6056
  }
4810
- async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6057
+ async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
6058
+ const paneId = pane.paneId;
4811
6059
  // 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
4812
- const preTitle = await tmux.readPaneTitle(paneId);
4813
- const preFrame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
6060
+ const preTitle = await tmux.readPaneTitle(pane);
6061
+ const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
4814
6062
  const staticBusy = hasOscTitleWorking(preTitle)
4815
6063
  ? (runtimeBusyCheck(preFrame, runtime) || !hasRuntimeReadyView(preFrame, runtime))
4816
6064
  : runtimeBusyCheck(preFrame, runtime);
4817
- if (staticBusy && await this.paneHasLiveTurn(tmux, paneId, runtime)) {
4818
- throw new Error(`pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
6065
+ if (staticBusy && await this.paneHasLiveTurn(tmux, pane, runtime)) {
6066
+ throw new ReplNotReadyError(paneId, runtime, preFrame, `pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
4819
6067
  }
4820
6068
  await revalidate?.();
4821
6069
  if (guardBeforePaste) {
@@ -4830,7 +6078,7 @@ export class AgentManager {
4830
6078
  pasted = await this.withTaskLock(async () => {
4831
6079
  if (!(await guardBeforePaste()))
4832
6080
  return false;
4833
- await tmux.clearComposerDraft(paneId);
6081
+ await tmux.clearComposerDraft(pane);
4834
6082
  await tmux.pasteStagedBuffer(paneId, staged.buf);
4835
6083
  return true;
4836
6084
  });
@@ -4847,7 +6095,7 @@ export class AgentManager {
4847
6095
  }
4848
6096
  if (bufferConsumed) {
4849
6097
  try {
4850
- await tmux.clearComposerDraft(paneId);
6098
+ await tmux.clearComposerDraft(pane);
4851
6099
  }
4852
6100
  catch (clearErr) {
4853
6101
  console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
@@ -4866,26 +6114,26 @@ export class AgentManager {
4866
6114
  }
4867
6115
  }
4868
6116
  else {
4869
- await tmux.clearComposerDraft(paneId);
6117
+ await tmux.clearComposerDraft(pane);
4870
6118
  await revalidate?.();
4871
- await tmux.injectPrompt(paneId, prompt, agentId);
6119
+ await tmux.injectPrompt(pane, prompt, agentId);
4872
6120
  }
4873
6121
  let baseline;
4874
6122
  let baselineTitle = '';
4875
6123
  try {
4876
- baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
4877
- baselineTitle = await tmux.readPaneTitle(paneId);
6124
+ baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
6125
+ baselineTitle = await tmux.readPaneTitle(pane);
4878
6126
  if (guardBeforePaste) {
4879
6127
  // The Enter is the actual submission; it takes the same fenced queue slot.
4880
6128
  const submitted = await this.withTaskLock(async () => {
4881
6129
  if (!(await guardBeforePaste()))
4882
6130
  return false;
4883
- await tmux.sendEnter(paneId);
6131
+ await tmux.sendEnter(pane);
4884
6132
  return true;
4885
6133
  });
4886
6134
  if (!submitted) {
4887
6135
  try {
4888
- await tmux.clearComposerDraft(paneId);
6136
+ await tmux.clearComposerDraft(pane);
4889
6137
  }
4890
6138
  catch (err) {
4891
6139
  // Fail closed: a submit-ready stale prompt could not be confirmed gone.
@@ -4896,18 +6144,18 @@ export class AgentManager {
4896
6144
  }
4897
6145
  }
4898
6146
  else {
4899
- await tmux.sendEnter(paneId);
6147
+ await tmux.sendEnter(pane);
4900
6148
  }
4901
6149
  }
4902
6150
  catch (preAckErr) {
4903
- if (await this.clearComposerForReuse(tmux, paneId, agentId))
6151
+ if (await this.clearComposerForReuse(tmux, pane, agentId))
4904
6152
  throw preAckErr;
4905
6153
  const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
4906
6154
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
4907
6155
  }
4908
6156
  let staleDuringResend = false;
4909
6157
  try {
4910
- await tmux.waitSubmitAck(paneId, baseline, runtime, {
6158
+ await tmux.waitSubmitAck(pane, baseline, runtime, {
4911
6159
  timeoutMs: this.dispatchAckTimeoutMs,
4912
6160
  baselineTitle,
4913
6161
  resend: guardBeforePaste
@@ -4916,13 +6164,13 @@ export class AgentManager {
4916
6164
  const resent = await this.withTaskLock(async () => {
4917
6165
  if (!(await guardBeforePaste()))
4918
6166
  return false;
4919
- await tmux.sendEnter(paneId);
6167
+ await tmux.sendEnter(pane);
4920
6168
  return true;
4921
6169
  });
4922
6170
  if (!resent)
4923
6171
  staleDuringResend = true;
4924
6172
  }
4925
- : () => tmux.sendEnter(paneId),
6173
+ : () => tmux.sendEnter(pane),
4926
6174
  resendIntervalMs: this.dispatchAckResendIntervalMs,
4927
6175
  });
4928
6176
  return { acked: true, composerDelivered: true };
@@ -4934,7 +6182,7 @@ export class AgentManager {
4934
6182
  }
4935
6183
  if (staleDuringResend) {
4936
6184
  try {
4937
- await tmux.clearComposerDraft(paneId);
6185
+ await tmux.clearComposerDraft(pane);
4938
6186
  }
4939
6187
  catch (scrubErr) {
4940
6188
  const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
@@ -4956,9 +6204,10 @@ export class AgentManager {
4956
6204
  return { acked: false, composerDelivered };
4957
6205
  }
4958
6206
  }
4959
- async clearComposerForReuse(tmux, paneId, agentId) {
6207
+ async clearComposerForReuse(tmux, pane, agentId) {
6208
+ const paneId = pane.paneId;
4960
6209
  try {
4961
- await tmux.clearComposerDraft(paneId);
6210
+ await tmux.clearComposerDraft(pane);
4962
6211
  return true;
4963
6212
  }
4964
6213
  catch (err) {
@@ -5035,7 +6284,7 @@ export class AgentManager {
5035
6284
  }
5036
6285
  throw err;
5037
6286
  }
5038
- const { paneId } = ensure;
6287
+ const { paneId, pane } = ensure;
5039
6288
  const runner = this.createRunnerFor(agent);
5040
6289
  const tmux = new TmuxManager(runner);
5041
6290
  const stateAfterEnsure = await this.agentStore.get(agentId);
@@ -5238,7 +6487,7 @@ export class AgentManager {
5238
6487
  console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
5239
6488
  return false;
5240
6489
  }
5241
- const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
6490
+ const delivery = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, guardBeforeInject);
5242
6491
  if (delivery.aborted) {
5243
6492
  console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
5244
6493
  return false;
@@ -5385,6 +6634,13 @@ export class AgentManager {
5385
6634
  }
5386
6635
  }
5387
6636
  async recover() {
6637
+ // 'git' outbox 重投先行:崩溃于「效果已写、通知未达」的窗口由此闭合(spec §6),legacy 任务零涉
6638
+ await this.flushGitOutbox().catch(err => {
6639
+ console.warn('[AgentManager] recover: git outbox flush failed:', err);
6640
+ });
6641
+ await this.retryGitRemoteBranchCleanup().catch(err => {
6642
+ console.warn('[AgentManager] recover: git remote branch cleanup retry failed:', err);
6643
+ });
5388
6644
  const states = await this.agentStore.list();
5389
6645
  await this.releaseOrphanedLocks(states);
5390
6646
  const deferredCleanups = [];
@@ -5423,7 +6679,7 @@ export class AgentManager {
5423
6679
  continue;
5424
6680
  if (state.creationToken && !state.taskId) {
5425
6681
  const ct = state.creationToken;
5426
- const pane = result.paneId;
6682
+ const pane = result.pane;
5427
6683
  const cfg = agentConfig;
5428
6684
  const agentId = state.id;
5429
6685
  void (async () => {
@@ -5432,7 +6688,7 @@ export class AgentManager {
5432
6688
  if (!latest || latest.creationToken !== ct)
5433
6689
  return AGENT_STORE_NOOP;
5434
6690
  const { creationToken: _ct, status: _s, awaitingPhase: _ap, awaitingReason: _ar, awaitingSince: _as, ...ready } = latest;
5435
- return { ...ready, paneId: pane, updatedAt: new Date().toISOString() };
6691
+ return { ...ready, paneId: pane.paneId, updatedAt: new Date().toISOString() };
5436
6692
  });
5437
6693
  }
5438
6694
  else {
@@ -5775,7 +7031,11 @@ export class AgentManager {
5775
7031
  }
5776
7032
  const project = this.getProjectConfig(result.projectId);
5777
7033
  try {
5778
- if (project) {
7034
+ if (project && result.reviewMode === 'git') {
7035
+ // close 不附评论(spec §10 有意差异);deleteBranch 由 isAutoDeletableTaskBranch 守卫
7036
+ await this.platformClosePr(taskId, { deleteBranch: true });
7037
+ }
7038
+ else if (project) {
5779
7039
  const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
5780
7040
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
5781
7041
  if (close.exitCode !== 0)
@@ -5784,6 +7044,26 @@ export class AgentManager {
5784
7044
  }
5785
7045
  catch (err) {
5786
7046
  console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
7047
+ if (result.reviewMode === 'git') {
7048
+ await this.withTaskLock(async () => {
7049
+ const fresh = await this.taskStore.get(taskId);
7050
+ if (!fresh)
7051
+ return;
7052
+ await this.taskStore.set({
7053
+ ...fresh,
7054
+ branchCleanupPending: {
7055
+ agentId: fresh.devAgentId,
7056
+ reason: err instanceof PlatformCloseError && err.step === 'close'
7057
+ ? 'remote-close-failed'
7058
+ : 'remote-delete-failed',
7059
+ updatedAt: new Date().toISOString(),
7060
+ },
7061
+ updatedAt: new Date().toISOString(),
7062
+ });
7063
+ }).catch(persistErr => {
7064
+ console.warn(`[AgentManager] cancelTask cleanup marker persist failed for ${taskId}:`, persistErr);
7065
+ });
7066
+ }
5787
7067
  await this.emitIntervention(result.projectId, undefined, taskId, {
5788
7068
  phase: 'cancel-published-artifact-cleanup-failed',
5789
7069
  afterDone: publishedCleanup.afterDone,
@@ -5826,19 +7106,22 @@ export class AgentManager {
5826
7106
  async dispatchReviewToQa(taskId, opts = {}) {
5827
7107
  const claim = await this.withTaskLock(async () => {
5828
7108
  if (this.manualReviewInFlight.has(taskId)) {
5829
- throw new ApiError(409, `Manual review already in progress for task ${taskId}`);
7109
+ throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
5830
7110
  }
5831
7111
  if (this.markCompleteInFlight.has(taskId)) {
5832
- throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
7112
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
5833
7113
  }
5834
7114
  const task = await this.taskStore.get(taskId);
5835
7115
  if (!task)
5836
7116
  throw new ApiError(404, `Task ${taskId} not found`);
5837
7117
  if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
5838
- throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`);
7118
+ throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
5839
7119
  }
5840
7120
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
5841
- throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
7121
+ throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
7122
+ }
7123
+ if (opts.expectReviewDispatchPending === true && task.reviewDispatchPending !== true) {
7124
+ throw new ApiError(409, `Task ${taskId} review dispatch pending was already consumed; aborting stale sweep`, 'dispatch-superseded');
5842
7125
  }
5843
7126
  if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
5844
7127
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
@@ -5879,17 +7162,33 @@ export class AgentManager {
5879
7162
  throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
5880
7163
  }
5881
7164
  this.manualReviewInFlight.add(taskId);
5882
- return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
7165
+ return {
7166
+ mode: 'github',
7167
+ qaId,
7168
+ devAgentId: task.agentId,
7169
+ taskStatusAtClaim: task.status,
7170
+ signalTokenAtClaim: task.signalToken,
7171
+ reviewRoundAtClaim: task.reviewRound,
7172
+ };
5883
7173
  });
5884
7174
  if (claim.mode === 'server') {
5885
7175
  return this.runManualServerReview(taskId, claim);
5886
7176
  }
5887
7177
  try {
5888
- const { qaId, devAgentId, taskStatusAtClaim } = claim;
7178
+ const { qaId, devAgentId, taskStatusAtClaim, signalTokenAtClaim, reviewRoundAtClaim } = claim;
5889
7179
  const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
5890
- const qaPhase = taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress'
5891
- ? 'review'
5892
- : 'recheck';
7180
+ // 相位恢复归标准入口自己(手工 review API 与各补派方一视同仁):同代 pending 登记的
7181
+ // 原始相位最权威;重启丢登记时以持久化 round=0 兜底——轮次送达才入账,故 0 即首评从未送达
7182
+ const pendingEntry = this.getPendingDispatchRetry(taskId);
7183
+ const queuedPhase = pendingEntry?.kind === 'qa-recheck'
7184
+ && pendingEntry.signalToken === signalTokenAtClaim
7185
+ ? pendingEntry.qaPhase
7186
+ : undefined;
7187
+ const qaPhase = opts.qaPhase
7188
+ ?? queuedPhase
7189
+ ?? (taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress' || reviewRoundAtClaim === 0
7190
+ ? 'review'
7191
+ : 'recheck');
5893
7192
  const prevQa = await this.agentStore.get(qaId);
5894
7193
  const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
5895
7194
  if (prevQa?.taskId === taskId) {
@@ -5914,12 +7213,13 @@ export class AgentManager {
5914
7213
  throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
5915
7214
  }
5916
7215
  }
5917
- const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
7216
+ let acquiredLockToken;
7217
+ const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
7218
+ onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
7219
+ });
5918
7220
  if (!acquired) {
5919
7221
  throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
5920
7222
  }
5921
- const acquiredClaim = await this.lockManager.claimOf(qaId);
5922
- const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
5923
7223
  const releaseOwnAcquire = acquiredLockToken !== undefined
5924
7224
  ? { expectedLockToken: acquiredLockToken }
5925
7225
  : {};
@@ -5946,22 +7246,64 @@ export class AgentManager {
5946
7246
  signalToken: pre?.signalToken,
5947
7247
  reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
5948
7248
  reviewDispatchedAt: pre?.reviewDispatchedAt,
7249
+ reviewRoundPending: pre?.reviewRoundPending,
7250
+ passToken: pre?.passToken,
7251
+ failToken: pre?.failToken,
5949
7252
  };
5950
7253
  const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
5951
- // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
5952
- const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
7254
+ // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮。
7255
+ // 显式 bumpRound:true 是补派方读到 reviewRoundPending 后的权威意图(该轮从未计数),越过恢复期抑制
7256
+ const bumpRound = opts.bumpRound === true
7257
+ || (opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review'));
5953
7258
  // 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
5954
7259
  // 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
5955
7260
  const abortDispatch = async (dispatchToken) => {
5956
- const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, bumpRound, dispatchToken);
7261
+ const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, dispatchToken);
5957
7262
  if (!takenOver)
5958
- await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
7263
+ await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
5959
7264
  if (devParked)
5960
7265
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
5961
7266
  };
5962
7267
  let transitionToken;
7268
+ let reviewAnchor;
5963
7269
  if (!isTerminalAtClaim) {
5964
- const reviewAnchor = await this.fetchPrHeadSha(taskId).catch(() => undefined);
7270
+ let gitDispatchBlocked;
7271
+ const anchorViaDriver = async () => {
7272
+ const preTask = await this.taskStore.get(taskId);
7273
+ if (preTask?.prNumber === undefined) {
7274
+ gitDispatchBlocked = 'no bound PR';
7275
+ return undefined;
7276
+ }
7277
+ // 与收编/pr-fixed/merge 同一完整谓词:closed/draft/retarget 的 PR 派出去只会造出
7278
+ // 不可裁决的评审轮(spec §6 统一谓词)
7279
+ const verify = await this.platformVerifyPrBinding(taskId, preTask.prNumber);
7280
+ if (!verify.ok) {
7281
+ gitDispatchBlocked = `binding ${verify.reason}`;
7282
+ return undefined;
7283
+ }
7284
+ return PLATFORM_HEAD_SHA_RE.test(verify.headSha) ? verify.headSha : undefined;
7285
+ };
7286
+ const isGitDispatch = (await this.taskStore.get(taskId))?.reviewMode === 'git';
7287
+ reviewAnchor = await (isGitDispatch ? anchorViaDriver() : this.fetchPrHeadSha(taskId))
7288
+ .catch(() => undefined);
7289
+ if (isGitDispatch && reviewAnchor === undefined) {
7290
+ // 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
7291
+ // 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
7292
+ const released = await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => false);
7293
+ if (!released) {
7294
+ // 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
7295
+ const blockedTask = await this.taskStore.get(taskId);
7296
+ await this.emitIntervention(blockedTask?.projectId ?? '', qaId, taskId, {
7297
+ phase: 'qa-release-failed-after-precheck',
7298
+ qaAgentId: qaId,
7299
+ }).catch(() => undefined);
7300
+ }
7301
+ if (devParked)
7302
+ await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
7303
+ throw gitDispatchBlocked !== undefined
7304
+ ? new ApiError(409, `Cannot dispatch git review for ${taskId}: ${gitDispatchBlocked}`)
7305
+ : new ApiError(502, `Cannot anchor the review round for ${taskId}: platform prView failed`);
7306
+ }
5965
7307
  const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
5966
7308
  fromStatus: [taskStatusAtClaim],
5967
7309
  // 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
@@ -5970,13 +7312,18 @@ export class AgentManager {
5970
7312
  reviewHeadAnchorSha: reviewAnchor,
5971
7313
  reviewDispatchedAt: new Date().toISOString(),
5972
7314
  signalToken: createSignalToken(),
7315
+ // 未计轮 intent 与进入 review 同一原子写:两写之间崩溃会留下「有派发可重放、却无轮次可补计」
7316
+ // 的状态,恢复后首评仍停在旧轮
7317
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
7318
+ // pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
7319
+ ...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
5973
7320
  });
5974
7321
  if (!preDispatched) {
5975
7322
  // fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
5976
7323
  await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
5977
7324
  if (devParked)
5978
7325
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
5979
- throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
7326
+ throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`, 'dispatch-superseded');
5980
7327
  }
5981
7328
  transitionToken = preDispatched.task.signalToken;
5982
7329
  }
@@ -5990,10 +7337,18 @@ export class AgentManager {
5990
7337
  if (!isTerminalAtClaim && (fresh.status !== 'review' || fresh.signalToken !== transitionToken)) {
5991
7338
  return { ok: false, reason: fresh.status !== 'review' ? `status=${fresh.status}` : 'pass superseded' };
5992
7339
  }
7340
+ // 并发 push 在 prView 读取与本提交之间前进 head 时,本次手动锚点已陈旧(TOCTOU)
7341
+ if (fresh.reviewMode === 'git' && fresh.latestHeadSha !== undefined
7342
+ && reviewAnchor !== undefined && fresh.latestHeadSha !== reviewAnchor) {
7343
+ return { ok: false, reason: 'head advanced past the manual anchor' };
7344
+ }
5993
7345
  await this.taskStore.set({
5994
7346
  ...fresh,
5995
- reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
7347
+ // 计轮送达时才落账:claim 段只持久化「未计轮」intent(与 push 直派链路同构),
7348
+ // busy/hold/崩溃等一切未送达出口都保留 intent,任何补派方(含重启后)恰好补计一次
7349
+ ...(bumpRound ? { reviewRoundPending: true } : {}),
5996
7350
  signalToken: armedToken,
7351
+ ...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
5997
7352
  updatedAt: new Date().toISOString(),
5998
7353
  });
5999
7354
  return { ok: true };
@@ -6001,9 +7356,14 @@ export class AgentManager {
6001
7356
  if (!claim2.ok) {
6002
7357
  // 未写入 armedToken:task 仍持 transitionToken,或已被接管方 rotate/漂移 → 用 transitionToken 判 takeover
6003
7358
  await abortDispatch(transitionToken);
6004
- throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`);
7359
+ throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`, 'dispatch-superseded');
7360
+ }
7361
+ opts.onPassArmed?.(armedToken);
7362
+ const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
7363
+ const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
7364
+ if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
7365
+ await this.rearmGitReconciliationWatcher(taskId);
6005
7366
  }
6006
- const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken);
6007
7367
  if (!armed) {
6008
7368
  await abortDispatch(armedToken);
6009
7369
  throw new ApiError(500, `Failed to arm review verdict watcher for ${taskId}`);
@@ -6013,7 +7373,7 @@ export class AgentManager {
6013
7373
  const preStart = await this.taskStore.get(taskId);
6014
7374
  if (!preStart || !opts.fromStatus.includes(preStart.status) || preStart.signalToken !== armedToken) {
6015
7375
  await abortDispatch(armedToken);
6016
- throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`);
7376
+ throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`, 'dispatch-superseded');
6017
7377
  }
6018
7378
  }
6019
7379
  let started = false;
@@ -6021,6 +7381,8 @@ export class AgentManager {
6021
7381
  started = await this.startSession(taskId, qaId, qaPhase, {
6022
7382
  bypassTaskStatusGate: true,
6023
7383
  dialogFailFromStatuses: [isTerminalAtClaim ? taskStatusAtClaim : 'review'],
7384
+ dispatchPassToken: armedToken,
7385
+ ...(opts.pendingBudget !== undefined ? { dispatchPendingBudget: opts.pendingBudget } : {}),
6024
7386
  });
6025
7387
  }
6026
7388
  catch (err) {
@@ -6034,6 +7396,13 @@ export class AgentManager {
6034
7396
  }
6035
7397
  throw err;
6036
7398
  }
7399
+ if (err instanceof EnsureSessionError && err.partial.busyPending) {
7400
+ // 遇忙不算失败:pass 保持 armed、QA 保持绑定、未计轮 intent 原样在库(送达时才消费),
7401
+ // 由对账按 pending 登记补派
7402
+ console.warn(`[AgentManager] dispatchReviewToQa: QA busy for ${taskId}; dispatch queued (${err.message})`);
7403
+ const pending = await this.taskStore.get(taskId);
7404
+ return pending;
7405
+ }
6037
7406
  if (!(err instanceof EnsureSessionError && err.partial.handled)) {
6038
7407
  await abortDispatch(armedToken);
6039
7408
  }
@@ -6043,6 +7412,8 @@ export class AgentManager {
6043
7412
  await abortDispatch(armedToken);
6044
7413
  throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
6045
7414
  }
7415
+ await this.consumeReviewRoundIntent(taskId, armedToken);
7416
+ await this.clearReviewDispatchPending(taskId, armedToken);
6046
7417
  const final = await this.taskStore.get(taskId);
6047
7418
  return final;
6048
7419
  }
@@ -6182,7 +7553,7 @@ export class AgentManager {
6182
7553
  // 返回 qaTakenOverInReview:仍在 review 但 token 已换代 = 另一 dispatcher 已 release+acquire 同一 QA
6183
7554
  // (同 task 同 agent,releaseAgentForTask 的 mismatch 保护不设防),此时调用方不得再 release QA。
6184
7555
  // 判定在回滚同一锁段内基于 fresh 完成,是那一刻的权威结论——不受本函数随后恢复 snapshot token 的影响。
6185
- async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, roundBumped, dispatchToken) {
7556
+ async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, dispatchToken) {
6186
7557
  let qaTakenOverInReview = false;
6187
7558
  await this.withTaskLock(async () => {
6188
7559
  const fresh = await this.taskStore.get(taskId);
@@ -6192,24 +7563,25 @@ export class AgentManager {
6192
7563
  if (!isTerminalAtClaim && TERMINAL_STATUSES.includes(fresh.status))
6193
7564
  return;
6194
7565
  // transition 后的中间态恒为 review 且持有本次 dispatch 的 token;status 漂移(如 REQUEST_CHANGES→fixing)
6195
- // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有,只撤本次 dispatch 的轮次残留
7566
+ // 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有。
7567
+ // 计轮送达时才落账,claim 段无轮次残留可撤——drift 后不覆盖接管方的任何写入
6196
7568
  const passTakenOver = dispatchToken !== undefined && fresh.signalToken !== dispatchToken;
6197
7569
  qaTakenOverInReview = !isTerminalAtClaim && fresh.status === 'review' && passTakenOver;
6198
7570
  const drifted = !isTerminalAtClaim && (fresh.status !== 'review' || passTakenOver);
6199
- // 只在本次 dispatch 仍拥有该 pass(未 drift)且确实 bump 过时才撤回 +1;
6200
- // drift 后 reviewRound 归接管方所有(它可能基于此再 bump),一律不减
6201
- const rolledRound = (!drifted && roundBumped) ? Math.max(0, fresh.reviewRound - 1) : fresh.reviewRound;
6202
- const next = drifted
6203
- ? { ...fresh, reviewRound: rolledRound, updatedAt: new Date().toISOString() }
6204
- : {
6205
- ...fresh,
6206
- reviewRound: rolledRound,
6207
- qaAgentId: snapshot.qaAgentId,
6208
- signalToken: snapshot.signalToken,
6209
- reviewDispatchedAt: snapshot.reviewDispatchedAt,
6210
- updatedAt: new Date().toISOString(),
6211
- };
6212
- if (!isTerminalAtClaim && !drifted) {
7571
+ if (drifted)
7572
+ return;
7573
+ const next = {
7574
+ ...fresh,
7575
+ qaAgentId: snapshot.qaAgentId,
7576
+ signalToken: snapshot.signalToken,
7577
+ reviewDispatchedAt: snapshot.reviewDispatchedAt,
7578
+ reviewRoundPending: snapshot.reviewRoundPending,
7579
+ // 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
7580
+ passToken: snapshot.passToken,
7581
+ failToken: snapshot.failToken,
7582
+ updatedAt: new Date().toISOString(),
7583
+ };
7584
+ if (!isTerminalAtClaim) {
6213
7585
  next.status = originalStatus;
6214
7586
  next.reviewHeadAnchorSha = snapshot.reviewHeadAnchorSha;
6215
7587
  }
@@ -6224,8 +7596,10 @@ export class AgentManager {
6224
7596
  return qaTakenOverInReview;
6225
7597
  }
6226
7598
  if (afterRollback?.status === 'approved') {
6227
- const completion = await this.postApproveStore.get(taskId);
6228
- const task = await this.taskStore.get(taskId);
7599
+ const completion = afterRollback.reviewMode === 'git'
7600
+ ? this.gitCompletionOf(afterRollback)
7601
+ : await this.postApproveStore.get(taskId);
7602
+ const task = afterRollback;
6229
7603
  if (completion && task) {
6230
7604
  try {
6231
7605
  await this.startPhaseSignalWatch({
@@ -6246,21 +7620,30 @@ export class AgentManager {
6246
7620
  const restored = await this.taskStore.get(taskId);
6247
7621
  if (!restored || !restored.signalToken)
6248
7622
  return qaTakenOverInReview;
6249
- const mapped = this.mapTaskStateToExpectedWatcher(restored);
6250
- if (!mapped)
7623
+ const mappings = this.mapTaskStateToExpectedWatchers(restored);
7624
+ if (mappings.length === 0)
6251
7625
  return qaTakenOverInReview;
7626
+ for (const mapped of mappings) {
7627
+ try {
7628
+ await this.startPhaseSignalWatch({
7629
+ taskId,
7630
+ projectId: restored.projectId,
7631
+ agentId: mapped.agentId,
7632
+ expectedKinds: mapped.expectedKinds,
7633
+ token: mapped.token ?? restored.signalToken,
7634
+ ...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
7635
+ });
7636
+ }
7637
+ catch (err) {
7638
+ console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
7639
+ }
7640
+ }
7641
+ // 回滚路径不得因 tearDown 探测失败上抛:那会顶替调用方本应抛出的 ApiError 并跳过 QA 释放
6252
7642
  try {
6253
- await this.startPhaseSignalWatch({
6254
- taskId,
6255
- projectId: restored.projectId,
6256
- agentId: mapped.agentId,
6257
- expectedKinds: mapped.expectedKinds,
6258
- token: restored.signalToken,
6259
- });
6260
7643
  await this.tearDownWatcherIfTaskTerminal(taskId);
6261
7644
  }
6262
7645
  catch (err) {
6263
- console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
7646
+ console.warn(`[AgentManager] rollback: terminal watcher teardown failed for task=${taskId}:`, err);
6264
7647
  }
6265
7648
  return qaTakenOverInReview;
6266
7649
  }
@@ -6282,6 +7665,22 @@ export class AgentManager {
6282
7665
  return { phase: 'research', kinds: ['spec-done'] };
6283
7666
  return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
6284
7667
  }
7668
+ mapTaskStateToExpectedWatchers(task) {
7669
+ const single = this.mapTaskStateToExpectedWatcher(task);
7670
+ const mappings = single ? [single] : [];
7671
+ // review 窗口内 dev/QA 双条目并存:review 之外 dev 侧另有 pr-fixed/pr-merge-ready 同 agent
7672
+ // 期望,(taskId, agentId) 基数无法同 agent 双 token 并存——错过窗口按 fail-closed 交人工(spec §5.3 ④)
7673
+ if (task.reviewMode === 'git' && task.status === 'review'
7674
+ && task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
7675
+ && task.devAgentId && task.prNumber !== undefined) {
7676
+ mappings.push({
7677
+ expectedKinds: ['pr-created'],
7678
+ agentId: task.devAgentId,
7679
+ token: task.pendingPrSignalToken,
7680
+ });
7681
+ }
7682
+ return mappings;
7683
+ }
6285
7684
  mapTaskStateToExpectedWatcher(task) {
6286
7685
  if (task.reviewMode === 'server')
6287
7686
  return this.mapServerTaskToExpectedWatcher(task);
@@ -6334,8 +7733,9 @@ export class AgentManager {
6334
7733
  const task = await this.taskStore.get(taskId);
6335
7734
  if (!task?.signalToken)
6336
7735
  return false;
6337
- return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, {
7736
+ return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, opts.tokenOverride ?? task.signalToken, {
6338
7737
  skipSnapshot: opts.skipSnapshot ?? false,
7738
+ ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
6339
7739
  });
6340
7740
  }
6341
7741
  async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
@@ -6443,6 +7843,17 @@ export class AgentManager {
6443
7843
  if (!project) {
6444
7844
  throw new Error(`mergePr: unknown project ${task.projectId}`);
6445
7845
  }
7846
+ if (task.reviewMode === 'git') {
7847
+ const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
7848
+ if (!expectedHeadSha)
7849
+ throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
7850
+ // max_rounds 的 Mark complete 在调用前已被转成 merge-ready:override 必须显式随调用链传入
7851
+ await this.platformConfirmMerge(taskId, {
7852
+ expectedHeadSha,
7853
+ ...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
7854
+ });
7855
+ return;
7856
+ }
6446
7857
  const matchHead = opts.matchHeadSha
6447
7858
  ? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
6448
7859
  : '';
@@ -6517,7 +7928,8 @@ export class AgentManager {
6517
7928
  throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
6518
7929
  }
6519
7930
  try {
6520
- await this.mergePr(taskId);
7931
+ // 从 max_rounds 认领而来:人工兜底 override 必须显式随调用链传入(此刻 status 已是 merge-ready)
7932
+ await this.mergePr(taskId, { humanOverride: true });
6521
7933
  }
6522
7934
  catch (err) {
6523
7935
  await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['merge-ready'] })
@@ -6567,6 +7979,9 @@ export class AgentManager {
6567
7979
  const agent = this.getAgentConfig(agentId);
6568
7980
  if (!agent)
6569
7981
  return;
7982
+ // Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
7983
+ // agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
7984
+ const genAtEntry = this.deletionGenerationOf(agentId);
6570
7985
  const state = await this.agentStore.get(agentId);
6571
7986
  if (!state)
6572
7987
  return;
@@ -6581,12 +7996,16 @@ export class AgentManager {
6581
7996
  return;
6582
7997
  }
6583
7998
  if (!state.taskId) {
7999
+ if (this.isDeletionInFlight(agentId))
8000
+ return;
6584
8001
  const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
6585
8002
  if (!lockToken)
6586
8003
  return;
6587
- await this.agentStore.update(agentId, (existing) => {
8004
+ const commit = await this.agentStore.update(agentId, (existing) => {
6588
8005
  if (!existing)
6589
8006
  return AGENT_STORE_NOOP;
8007
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8008
+ return AGENT_STORE_NOOP;
6590
8009
  if (existing.taskId && existing.taskId !== ctx.taskId)
6591
8010
  return AGENT_STORE_NOOP;
6592
8011
  if (existing.creationToken)
@@ -6601,17 +8020,35 @@ export class AgentManager {
6601
8020
  lockToken,
6602
8021
  updatedAt: new Date().toISOString(),
6603
8022
  };
8023
+ }).catch(async (err) => {
8024
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken).catch((releaseErr) => {
8025
+ console.warn(`[AgentManager] postMergeCleanup(${agentId}): lock release after commit failure also failed:`, releaseErr);
8026
+ });
8027
+ throw err;
6604
8028
  });
8029
+ if (commit !== 'committed') {
8030
+ await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
8031
+ return;
8032
+ }
6605
8033
  const fresh = await this.agentStore.get(agentId);
6606
8034
  if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
6607
8035
  await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
6608
8036
  return;
6609
8037
  }
6610
8038
  }
8039
+ // A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
8040
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8041
+ return;
6611
8042
  const runner = this.createRunnerFor(agent);
6612
8043
  const tmux = new TmuxManager(runner);
6613
8044
  const runtime = agentRuntimeKindFor(agent);
6614
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
8045
+ const expectedPaneId = state.paneId;
8046
+ void (async () => {
8047
+ if (!this.deletionGateOpen(agentId, genAtEntry))
8048
+ return;
8049
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
8050
+ await this.runPostMergeCompaction(tmux, pane, agentId, ctx.taskId, runtime);
8051
+ })().catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
6615
8052
  }
6616
8053
  async releasePostMergeAgent(agentId, taskId) {
6617
8054
  const state = await this.agentStore.get(agentId);
@@ -6624,10 +8061,10 @@ export class AgentManager {
6624
8061
  console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
6625
8062
  }
6626
8063
  }
6627
- async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
8064
+ async runPostMergeCompaction(tmux, pane, agentId, originalTaskId, runtime) {
6628
8065
  await this.acquireCompactGuard(agentId);
6629
8066
  try {
6630
- await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime);
8067
+ await this.runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime);
6631
8068
  }
6632
8069
  finally {
6633
8070
  this.compactInFlight.delete(agentId);
@@ -6653,7 +8090,8 @@ export class AgentManager {
6653
8090
  this.compactInFlight.add(agentId);
6654
8091
  return true;
6655
8092
  }
6656
- async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
8093
+ async runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime) {
8094
+ const paneId = pane.paneId;
6657
8095
  const initial = await this.agentStore.get(agentId);
6658
8096
  if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
6659
8097
  return;
@@ -6673,13 +8111,13 @@ export class AgentManager {
6673
8111
  let cleared = false;
6674
8112
  let clearError;
6675
8113
  try {
6676
- cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
8114
+ cleared = await this.sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs);
6677
8115
  }
6678
8116
  catch (err) {
6679
8117
  clearError = err;
6680
8118
  }
6681
8119
  if (!cleared) {
6682
- if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
8120
+ if (await this.recoverPostMergeExitedRuntime(tmux, pane, agentId, originalTaskId, lockToken, runtime)) {
6683
8121
  cleared = true;
6684
8122
  }
6685
8123
  else if (!await bindingStillOurs()) {
@@ -6688,7 +8126,7 @@ export class AgentManager {
6688
8126
  else if (clearError instanceof Error
6689
8127
  && clearError.message.includes('runtime rejected /clear')) {
6690
8128
  try {
6691
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8129
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
6692
8130
  }
6693
8131
  catch (err) {
6694
8132
  clearError = err;
@@ -6706,10 +8144,11 @@ export class AgentManager {
6706
8144
  }
6707
8145
  await this.releasePostMergeAgent(agentId, originalTaskId);
6708
8146
  }
6709
- async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
8147
+ async recoverPostMergeExitedRuntime(tmux, pane, agentId, taskId, lockToken, runtime) {
8148
+ const paneId = pane.paneId;
6710
8149
  let paneExists = true;
6711
8150
  try {
6712
- if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
8151
+ if (hasReplProcTitle(await tmux.displayMessage(pane, '#{pane_current_command}'), runtime))
6713
8152
  return false;
6714
8153
  }
6715
8154
  catch (err) {
@@ -6728,7 +8167,7 @@ export class AgentManager {
6728
8167
  return false;
6729
8168
  try {
6730
8169
  if (paneExists)
6731
- await tmux.sendKeysToPane(paneId, 'C-c');
8170
+ await tmux.sendKeysToPane(pane, 'C-c');
6732
8171
  const ensure = await this.ensureSession(agentId, 'runtime');
6733
8172
  if (!ensure.freshRuntime)
6734
8173
  return false;
@@ -6757,22 +8196,22 @@ export class AgentManager {
6757
8196
  return false;
6758
8197
  }
6759
8198
  }
6760
- async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs) {
8199
+ async sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs) {
6761
8200
  let rejection;
6762
8201
  for (let attempt = 1; attempt <= 2; attempt++) {
6763
8202
  if (!await bindingStillOurs())
6764
8203
  return false;
6765
- await tmux.clearComposerDraft(paneId);
6766
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8204
+ await tmux.clearComposerDraft(pane);
8205
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
6767
8206
  if (!await bindingStillOurs())
6768
8207
  return false;
6769
- await tmux.sendKeysLiteral(paneId, '/clear');
6770
- await tmux.sendEnter(paneId);
8208
+ await tmux.sendKeysLiteral(pane, '/clear');
8209
+ await tmux.sendEnter(pane);
6771
8210
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
6772
- await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
8211
+ await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
6773
8212
  if (!await bindingStillOurs())
6774
8213
  return false;
6775
- if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear'))
8214
+ if (!await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear'))
6776
8215
  return true;
6777
8216
  rejection = new Error('runtime rejected /clear because a task is still in progress');
6778
8217
  if (attempt < 2) {
@@ -6782,29 +8221,33 @@ export class AgentManager {
6782
8221
  }
6783
8222
  throw rejection ?? new Error('runtime rejected /clear');
6784
8223
  }
6785
- async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
6786
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8224
+ async hasRuntimeSlashCommandRejection(tmux, pane, command) {
8225
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
6787
8226
  return this.runtimeSlashCommandRejectedPattern(command).test(cap);
6788
8227
  }
6789
8228
  runtimeSlashCommandRejectedPattern(command) {
6790
8229
  return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
6791
8230
  }
6792
- async waitForReplPromptReady(tmux, paneId, runtime, timeoutMs) {
8231
+ async waitForReplPromptReady(tmux, pane, runtime, timeoutMs, opts = {}) {
8232
+ if (opts.stableIdle) {
8233
+ return this.waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs);
8234
+ }
8235
+ const paneId = pane.paneId;
6793
8236
  const deadline = Date.now() + timeoutMs;
6794
- await tmux.waitReplReady(paneId, runtime, {
8237
+ await tmux.waitReplReady(pane, runtime, {
6795
8238
  timeoutMs,
6796
8239
  intervalMs: this.compactIdlePollMs,
6797
8240
  titleIdleFastPath: true,
6798
8241
  });
6799
8242
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
6800
8243
  while (true) {
6801
- const current = await tmux.displayMessage(paneId, '#{pane_current_command}');
8244
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
6802
8245
  if (!hasReplProcTitle(current, runtime)) {
6803
8246
  throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
6804
8247
  }
6805
- const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
8248
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
6806
8249
  const ready = hasRuntimeReadyView(cap, runtime)
6807
- || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(paneId), runtime));
8250
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
6808
8251
  if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
6809
8252
  throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
6810
8253
  }
@@ -6815,11 +8258,41 @@ export class AgentManager {
6815
8258
  return;
6816
8259
  }
6817
8260
  if (Date.now() >= deadline) {
6818
- throw new Error(`waitForReplPromptReady: pane ${paneId} stayed busy past ${timeoutMs}ms`);
8261
+ // 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
8262
+ // 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
8263
+ throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
6819
8264
  }
6820
8265
  await new Promise(r => setTimeout(r, this.compactIdlePollMs));
6821
8266
  }
6822
8267
  }
8268
+ // 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
8269
+ // 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
8270
+ async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
8271
+ const paneId = pane.paneId;
8272
+ const samples = Math.max(1, this.readyStableSamples);
8273
+ const spacing = this.readyStableSpacingMs;
8274
+ const deadline = Date.now() + timeoutMs + (samples - 1) * spacing;
8275
+ let streak = 0;
8276
+ while (true) {
8277
+ const current = await tmux.displayMessage(pane, '#{pane_current_command}');
8278
+ if (!hasReplProcTitle(current, runtime)) {
8279
+ throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
8280
+ }
8281
+ const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
8282
+ const ready = hasRuntimeReadyView(cap, runtime)
8283
+ || (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
8284
+ if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
8285
+ throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
8286
+ }
8287
+ streak = ready && !runtimeBusyCheck(cap, runtime) ? streak + 1 : 0;
8288
+ if (streak >= samples)
8289
+ return;
8290
+ if (Date.now() >= deadline) {
8291
+ throw new ReplNotReadyError(paneId, runtime, cap, `stable idle not confirmed within ${timeoutMs}ms (+${(samples - 1) * spacing}ms stability window)`);
8292
+ }
8293
+ await new Promise(r => setTimeout(r, spacing));
8294
+ }
8295
+ }
6823
8296
  stopPhaseSignalWatcher(taskId) {
6824
8297
  this.phaseSignalWatcher?.stop(taskId);
6825
8298
  }
@@ -6847,6 +8320,7 @@ export class AgentManager {
6847
8320
  token,
6848
8321
  skipSnapshot: opts.skipSnapshot ?? false,
6849
8322
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
8323
+ ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
6850
8324
  ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
6851
8325
  });
6852
8326
  }
@@ -6867,10 +8341,40 @@ export class AgentManager {
6867
8341
  const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
6868
8342
  await this.markAwaitingHuman(agentId, `signal-arm-failed:${label}`, 'Pane-signal watcher failed to arm after dispatch; the prompt expects a signal with no consumer. Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
6869
8343
  }
8344
+ // git 任务的初始 dev 派发把 pr-created 期望 token 独立持久化:评审轮轮换不得使 late
8345
+ // publish 信号失去 reconciliation 凭据(spec §5.3 ④)。
8346
+ dispatchTokenFields(task, signalToken) {
8347
+ const wantsPrCreated = task.reviewMode === 'git'
8348
+ && this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
8349
+ return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
8350
+ }
8351
+ // review 窗口内 QA verdict arm 之后重建 dev reconciliation 条目(spec §5.3 ④):approval 路径的
8352
+ // stop(taskId) 与 fix 轮的同键替换都会拆掉它,重入 review 时在此恢复;skipSnapshot=false 让仍在
8353
+ // 面板滚动区的迟到信号立即重放。
8354
+ async rearmGitReconciliationWatcher(taskId, opts = {}) {
8355
+ const task = await this.taskStore.get(taskId);
8356
+ if (!task || task.reviewMode !== 'git' || task.status !== 'review')
8357
+ return;
8358
+ if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
8359
+ return;
8360
+ if (!task.devAgentId || task.prNumber === undefined)
8361
+ return;
8362
+ await this.setupPhaseSignalWatcher(taskId, task.devAgentId, ['pr-created'], task.pendingPrSignalToken, {
8363
+ replaceScope: 'agent',
8364
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
8365
+ }).catch(() => false);
8366
+ }
6870
8367
  async rotateAndSetupPhaseSignal(taskId, agentId, expectedKinds) {
6871
8368
  const newToken = createSignalToken();
8369
+ const task = await this.taskStore.get(taskId);
8370
+ const kinds = typeof expectedKinds === 'string' ? [expectedKinds] : [...expectedKinds];
8371
+ const isGit = task?.reviewMode === 'git';
8372
+ // pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
8373
+ // 这里只轮换 signal token 并布防
6872
8374
  await this.updateTask(taskId, { signalToken: newToken });
6873
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken);
8375
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
8376
+ if (armed && isGit && kinds.includes('pr-approved'))
8377
+ await this.rearmGitReconciliationWatcher(taskId);
6874
8378
  return { token: newToken, armed };
6875
8379
  }
6876
8380
  async rollbackVerdictArmFailure(taskId, restore, opts = {}) {
@@ -6890,6 +8394,10 @@ export class AgentManager {
6890
8394
  signalToken: restore.signalToken,
6891
8395
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
6892
8396
  reviewDispatchedAt: restore.reviewDispatchedAt,
8397
+ reviewRoundPending: restore.reviewRoundPending,
8398
+ ...(restore.restorePair === true
8399
+ ? { passToken: restore.passToken, failToken: restore.failToken }
8400
+ : {}),
6893
8401
  updatedAt: new Date().toISOString(),
6894
8402
  });
6895
8403
  return true;
@@ -6907,10 +8415,16 @@ export class AgentManager {
6907
8415
  return;
6908
8416
  if (TERMINAL_STATUSES.includes(task.status))
6909
8417
  return;
6910
- const mapped = this.mapTaskStateToExpectedWatcher(task);
6911
- if (!mapped)
8418
+ const mappings = this.mapTaskStateToExpectedWatchers(task);
8419
+ if (mappings.length === 0)
6912
8420
  return;
6913
- await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, opts.skipSnapshot ? { skipSnapshot: true } : undefined);
8421
+ for (const mapped of mappings) {
8422
+ await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, {
8423
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
8424
+ ...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
8425
+ ...(mapped.token !== undefined ? { tokenOverride: mapped.token } : {}),
8426
+ });
8427
+ }
6914
8428
  await this.tearDownWatcherIfTaskTerminal(taskId);
6915
8429
  }
6916
8430
  async parkTaskAtSpecReady(taskId, opts = {}) {
@@ -7328,7 +8842,7 @@ export class AgentManager {
7328
8842
  const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
7329
8843
  const newToken = createSignalToken();
7330
8844
  // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
7331
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken, maxRoundsContinues: 0 });
8845
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
7332
8846
  if (!transition) {
7333
8847
  await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
7334
8848
  .catch(() => undefined);
@@ -7753,7 +9267,7 @@ export class AgentManager {
7753
9267
  // Callers without a baseSha (publish) run long after the review diff was
7754
9268
  // read; refresh origin/HEAD first or upstream commits merged since then
7755
9269
  // would linger in base..HEAD and flag tasks that leak nothing.
7756
- const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
9270
+ const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
7757
9271
  if (fetch.exitCode !== 0) {
7758
9272
  throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
7759
9273
  }
@@ -7884,6 +9398,8 @@ export class AgentManager {
7884
9398
  const cfg = this.getAgentConfig(agentId);
7885
9399
  if (!cfg)
7886
9400
  throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
9401
+ // Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
9402
+ const genAtEntry = this.deletionGenerationOf(agentId);
7887
9403
  await this.acquireCompactGuard(agentId);
7888
9404
  try {
7889
9405
  const state = await this.agentStore.get(agentId);
@@ -7918,8 +9434,18 @@ export class AgentManager {
7918
9434
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
7919
9435
  }
7920
9436
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
7921
- await tmux.injectPrompt(paneId, text, agentId);
7922
- await tmux.sendEnter(paneId);
9437
+ const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
9438
+ // resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
9439
+ if (!this.deletionGateOpen(agentId, genAtEntry)) {
9440
+ throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
9441
+ }
9442
+ const afterResolve = await this.agentStore.get(agentId);
9443
+ if (!afterResolve || afterResolve.paneId !== paneId || afterResolve.taskId !== taskId
9444
+ || (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
9445
+ throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
9446
+ }
9447
+ await tmux.injectPrompt(pane, text, agentId);
9448
+ await tmux.sendEnter(pane);
7923
9449
  }
7924
9450
  finally {
7925
9451
  this.compactInFlight.delete(agentId);
@@ -8007,6 +9533,38 @@ export class AgentManager {
8007
9533
  }
8008
9534
  catch (err) {
8009
9535
  const message = err instanceof Error ? err.message : String(err);
9536
+ if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
9537
+ // 复核类失效(provenance 撤销/未 ack 反馈)没有「再 Confirm」的出路:退回 approved 置
9538
+ // durable pending 重入 post-approve 闭环(计划 Task 8),把失效授权留在 gate 只剩 Cancel
9539
+ const retreated = await this.withTaskLock(async () => {
9540
+ const fresh = await this.taskStore.get(task.id);
9541
+ if (!fresh || fresh.status !== 'merge-ready')
9542
+ return false;
9543
+ const approvedHead = typeof fresh.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(fresh.latestHeadSha)
9544
+ ? fresh.latestHeadSha
9545
+ : undefined;
9546
+ if (approvedHead === undefined)
9547
+ return false;
9548
+ await this.taskStore.set({
9549
+ ...fresh,
9550
+ status: 'approved',
9551
+ postApproveHeadSha: approvedHead,
9552
+ pendingRedispatch: true,
9553
+ updatedAt: new Date().toISOString(),
9554
+ });
9555
+ return true;
9556
+ });
9557
+ await this.emitIntervention(task.projectId, undefined, task.id, {
9558
+ phase: 'confirm-merge-recheck-failed',
9559
+ gate: task.status,
9560
+ error: message,
9561
+ retreated,
9562
+ note: retreated
9563
+ ? 'Accepted pass no longer verifies; task returned to approved for the post-approve loop to re-settle.'
9564
+ : 'Accepted pass no longer verifies and the task could not be returned automatically; inspect and re-dispatch.',
9565
+ });
9566
+ throw new ApiError(409, `Merge blocked for task ${task.id}: ${message}`);
9567
+ }
8010
9568
  await this.emitIntervention(task.projectId, undefined, task.id, {
8011
9569
  phase: 'confirm-merge-failed',
8012
9570
  gate: task.status,
@@ -8044,14 +9602,17 @@ export class AgentManager {
8044
9602
  }
8045
9603
  startCompactionThenRelease(agentId, state, taskId) {
8046
9604
  // 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
8047
- if (!state.paneId || state.status === 'awaiting_human' || state.creationToken)
9605
+ const expectedPaneId = state.paneId;
9606
+ if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
8048
9607
  return false;
8049
9608
  const cfg = this.getAgentConfig(agentId);
8050
9609
  if (!cfg)
8051
9610
  return false;
8052
9611
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
8053
- void this.runPostMergeCompaction(tmux, state.paneId, agentId, taskId, agentRuntimeKindFor(cfg))
8054
- .catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
9612
+ void (async () => {
9613
+ const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
9614
+ await this.runPostMergeCompaction(tmux, pane, agentId, taskId, agentRuntimeKindFor(cfg));
9615
+ })().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
8055
9616
  return true;
8056
9617
  }
8057
9618
  repoMergeQueue = new Map();
@@ -8067,13 +9628,15 @@ export class AgentManager {
8067
9628
  const runner = this.createRunnerFor(dev);
8068
9629
  const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
8069
9630
  const run = prev.then(async () => {
9631
+ // fetch/push reach a remote derived from THIS Workdir; a rebound Workdir would redirect the push to a foreign repo, so each mutation carries the canonical self-guard in its own command.
9632
+ const guard = `${canonicalSelfGuard(workdir)} && `;
8070
9633
  const cd = `cd ${shellQuote(workdir)} && `;
8071
9634
  const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
8072
9635
  const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
8073
9636
  if (db.exitCode !== 0 || defaultBranch === '') {
8074
9637
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
8075
9638
  }
8076
- const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
9639
+ const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
8077
9640
  if (fetch.exitCode !== 0) {
8078
9641
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
8079
9642
  }
@@ -8087,7 +9650,7 @@ export class AgentManager {
8087
9650
  else {
8088
9651
  throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
8089
9652
  }
8090
- const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
9653
+ const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
8091
9654
  if (push.exitCode !== 0) {
8092
9655
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
8093
9656
  }