audit-tools 0.33.3 → 0.33.6

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 (110) hide show
  1. package/dist/audit/cli/args.d.ts +1 -1
  2. package/dist/audit/cli/args.d.ts.map +1 -1
  3. package/dist/audit/cli/args.js +1 -4
  4. package/dist/audit/cli/args.js.map +1 -1
  5. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts +7 -0
  6. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts.map +1 -1
  7. package/dist/audit/cli/dispatch/hostFanoutGate.js +18 -4
  8. package/dist/audit/cli/dispatch/hostFanoutGate.js.map +1 -1
  9. package/dist/audit/cli/dispatch/quotaPool.d.ts +12 -0
  10. package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
  11. package/dist/audit/cli/dispatch/quotaPool.js +59 -31
  12. package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
  13. package/dist/audit/cli/dispatch/types.d.ts +7 -0
  14. package/dist/audit/cli/dispatch/types.d.ts.map +1 -1
  15. package/dist/audit/cli/dispatch.d.ts +19 -0
  16. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  17. package/dist/audit/cli/dispatch.js +46 -8
  18. package/dist/audit/cli/dispatch.js.map +1 -1
  19. package/dist/audit/cli/hybridDispatch.d.ts +16 -3
  20. package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
  21. package/dist/audit/cli/hybridDispatch.js +16 -23
  22. package/dist/audit/cli/hybridDispatch.js.map +1 -1
  23. package/dist/audit/cli/nextStepCommand.d.ts +1 -1
  24. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  25. package/dist/audit/cli/nextStepCommand.js +32 -18
  26. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  27. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  28. package/dist/audit/cli/nextStepHelpers.js +74 -51
  29. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  30. package/dist/audit/cli/reviewRun.d.ts +1 -0
  31. package/dist/audit/cli/reviewRun.d.ts.map +1 -1
  32. package/dist/audit/cli/reviewRun.js +5 -1
  33. package/dist/audit/cli/reviewRun.js.map +1 -1
  34. package/dist/audit/cli/rollingAuditDispatch.d.ts +12 -30
  35. package/dist/audit/cli/rollingAuditDispatch.d.ts.map +1 -1
  36. package/dist/audit/cli/rollingAuditDispatch.js +11 -60
  37. package/dist/audit/cli/rollingAuditDispatch.js.map +1 -1
  38. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  39. package/dist/audit/cli/semanticReviewStep.js +30 -7
  40. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  41. package/dist/audit/quota/index.d.ts +2 -668
  42. package/dist/audit/quota/index.d.ts.map +1 -1
  43. package/dist/audit/quota/index.js +5 -30
  44. package/dist/audit/quota/index.js.map +1 -1
  45. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
  46. package/dist/remediate/steps/dispatch/marshal.js +8 -1
  47. package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
  48. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +9 -2
  49. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
  50. package/dist/remediate/steps/dispatch/waveScheduling.js +48 -42
  51. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
  52. package/dist/remediate/steps/nextStep.d.ts +79 -65
  53. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  54. package/dist/remediate/steps/nextStep.js +379 -380
  55. package/dist/remediate/steps/nextStep.js.map +1 -1
  56. package/dist/remediate/steps/types.d.ts +7 -27
  57. package/dist/remediate/steps/types.d.ts.map +1 -1
  58. package/dist/remediate/steps/types.js +0 -1
  59. package/dist/remediate/steps/types.js.map +1 -1
  60. package/dist/shared/dispatch/admissionLoop.d.ts +66 -8
  61. package/dist/shared/dispatch/admissionLoop.d.ts.map +1 -1
  62. package/dist/shared/dispatch/admissionLoop.js +168 -3
  63. package/dist/shared/dispatch/admissionLoop.js.map +1 -1
  64. package/dist/shared/dispatch/dispatchQuotaContract.d.ts +720 -0
  65. package/dist/shared/dispatch/dispatchQuotaContract.d.ts.map +1 -0
  66. package/dist/shared/dispatch/dispatchQuotaContract.js +76 -0
  67. package/dist/shared/dispatch/dispatchQuotaContract.js.map +1 -0
  68. package/dist/shared/dispatch/hostDispatchWall.d.ts +39 -0
  69. package/dist/shared/dispatch/hostDispatchWall.d.ts.map +1 -1
  70. package/dist/shared/dispatch/hostDispatchWall.js +22 -1
  71. package/dist/shared/dispatch/hostDispatchWall.js.map +1 -1
  72. package/dist/shared/dispatch/rollingDispatch.d.ts +18 -1
  73. package/dist/shared/dispatch/rollingDispatch.d.ts.map +1 -1
  74. package/dist/shared/dispatch/rollingDispatch.js +35 -9
  75. package/dist/shared/dispatch/rollingDispatch.js.map +1 -1
  76. package/dist/shared/dispatch/settledPools.d.ts +32 -0
  77. package/dist/shared/dispatch/settledPools.d.ts.map +1 -1
  78. package/dist/shared/dispatch/settledPools.js +37 -0
  79. package/dist/shared/dispatch/settledPools.js.map +1 -1
  80. package/dist/shared/index.d.ts +9 -6
  81. package/dist/shared/index.d.ts.map +1 -1
  82. package/dist/shared/index.js +7 -5
  83. package/dist/shared/index.js.map +1 -1
  84. package/dist/shared/providers/inProcessWorkers.d.ts +42 -0
  85. package/dist/shared/providers/inProcessWorkers.d.ts.map +1 -0
  86. package/dist/shared/providers/inProcessWorkers.js +64 -0
  87. package/dist/shared/providers/inProcessWorkers.js.map +1 -0
  88. package/dist/shared/providers/providerPathGuard.d.ts +33 -4
  89. package/dist/shared/providers/providerPathGuard.d.ts.map +1 -1
  90. package/dist/shared/providers/providerPathGuard.js +33 -4
  91. package/dist/shared/providers/providerPathGuard.js.map +1 -1
  92. package/dist/shared/providers/proxyCatalog.d.ts.map +1 -1
  93. package/dist/shared/providers/proxyCatalog.js +21 -4
  94. package/dist/shared/providers/proxyCatalog.js.map +1 -1
  95. package/dist/shared/quota/apiPool.d.ts +94 -44
  96. package/dist/shared/quota/apiPool.d.ts.map +1 -1
  97. package/dist/shared/quota/apiPool.js +173 -75
  98. package/dist/shared/quota/apiPool.js.map +1 -1
  99. package/dist/shared/quota/capacity.d.ts +8 -1
  100. package/dist/shared/quota/capacity.d.ts.map +1 -1
  101. package/dist/shared/quota/capacity.js +2 -1
  102. package/dist/shared/quota/capacity.js.map +1 -1
  103. package/dist/shared/types/auditorDescriptor.d.ts +0 -8
  104. package/dist/shared/types/auditorDescriptor.d.ts.map +1 -1
  105. package/dist/shared/types/auditorDescriptor.js.map +1 -1
  106. package/dist/shared/validation/sessionConfig.d.ts.map +1 -1
  107. package/dist/shared/validation/sessionConfig.js +10 -0
  108. package/dist/shared/validation/sessionConfig.js.map +1 -1
  109. package/opencode.json +2 -2
  110. package/package.json +1 -1
@@ -3,14 +3,14 @@ import { existsSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, rename } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { StateStore } from "../state/store.js";
6
- import { readConfirmedDispatchPolicy, resolveDispatchExclusion, readOptionalJsonFile, readValidatedRepoSessionIntent, stagedAndUntracked, writeJsonFile, writeTextFile, buildAuditDeliverablePair, formatValidationIssues, isRecord, withFsRetry, RunLogger, DISPATCH_PROMPT_HANDOFF_NOTE, renderHostScratchNote, hostScratchDir, renderQuotaCoverageNudge, renderTokenBudgetView, coerceJsonObjectArg, driveRolling, resolveLedgerBudgets, setQuotaStateDir, detectHostDispatchWall, admissionBlockedOnBudget, reconcileAdmissionLeasesFromQuotaFile, buildQuotaPausedTerminal, interpretFreeFormIntent, advance, decideFrictionTriage, buildFrictionTriageBlock, planHybridDispatch, readSettledPools, addSettledPool, sourceByPoolId, classifyProvider, selectDispatchDriver, renderDispatchDriverInstruction, HostSessionQuotaSource, buildProviderModelKey, captureStepBoundaryFriction, captureCostDriftFriction, captureCreditExhaustionFriction, captureQuotaUnclassifiedFriction, captureModelUnavailableFriction, capturePacketTooLargeFriction, LENSES, SEVERITIES, resolveHostProviderName, resolveConversationHostProvider, resolveHostDispatchCapability as sharedResolveHostDispatchCapability, resolveAutonomousMode, resolveRollingEngineFlag, shouldDemotePrimaryInProcess, DEFAULT_CONTEXT_TOKENS } from "audit-tools/shared";
6
+ import { readConfirmedDispatchPolicy, resolveDispatchExclusion, readOptionalJsonFile, readValidatedRepoSessionIntent, stagedAndUntracked, writeJsonFile, writeTextFile, buildAuditDeliverablePair, formatValidationIssues, isRecord, withFsRetry, RunLogger, DISPATCH_PROMPT_HANDOFF_NOTE, renderHostScratchNote, hostScratchDir, renderQuotaCoverageNudge, renderTokenBudgetView, coerceJsonObjectArg, driveRolling, resolveLedgerBudgets, setQuotaStateDir, detectHostDispatchWall, admissionBlockedOnBudget, reconcileAdmissionLeasesFromQuotaFile, buildQuotaPausedTerminal, interpretFreeFormIntent, advance, decideFrictionTriage, buildFrictionTriageBlock, planHybridDispatch, readSettledPools, addSettledPool, isPoolSettlingOutcome, isInProcessWorkerProvider, sourceByPoolId, classifyProvider, selectDispatchDriver, renderDispatchDriverInstruction, HostSessionQuotaSource, buildProviderModelKey, captureStepBoundaryFriction, captureCostDriftFriction, captureCreditExhaustionFriction, captureQuotaUnclassifiedFriction, captureModelUnavailableFriction, capturePacketTooLargeFriction, LENSES, SEVERITIES, resolveHostProviderName, resolveHostDispatchProviderName, resolveHostDispatchCapability as sharedResolveHostDispatchCapability, resolveAutonomousMode, resolveRollingEngineFlag, DEFAULT_CONTEXT_TOKENS } from "audit-tools/shared";
7
7
  import { readRemediationAccessMemory, computeBlockContinuityScores } from "../state/accessMemory.js";
8
8
  import { applyPlanPipeline, buildCoverageLedger } from "../phases/plan.js";
9
9
  import { groundExtractedFindings } from "../phases/grounding.js";
10
10
  import { runTriagePhase } from "../phases/triage.js";
11
11
  import { runClosePhase } from "../phases/close.js";
12
12
  import { validateRemediationPlan } from "../validation/remediationState.js";
13
- import { mergeImplementResults, prepareImplementDispatch, readExtractedPlanIfPresent, buildConfirmedPools, executeNodeInWorktree, blockScopesFromPlan, declaredPathsFromPlan, targetedCommandsForBlock, } from "./dispatch.js";
13
+ import { mergeImplementResults, prepareImplementDispatch, readExtractedPlanIfPresent, buildConfirmedPools, executeNodeInWorktree, blockScopesFromPlan, targetedCommandsForBlock, } from "./dispatch.js";
14
14
  import { makeProviderNodeDispatcher } from "./providerNodeDispatch.js";
15
15
  import { prepareHostRollingDispatch, nodeClaimRegistry, nodeSettledPoolsPath } from "./rollingSession.js";
16
16
  import { ClaimRegistry } from "../../shared/quota/claimRegistry.js";
@@ -509,12 +509,16 @@ export async function driveRollingDispatch(levels, options) {
509
509
  ...(continuity > 0 ? { continuity } : {}),
510
510
  };
511
511
  },
512
- toPacket: (b) => ({
513
- id: b.block_id,
514
- payload: { block_id: b.block_id },
515
- estimatedTokens: estimateTokens(b),
516
- complexity: 0.5,
517
- }),
512
+ toPacket: (b) => {
513
+ const requiredTier = options.tierForBlock?.(b);
514
+ return {
515
+ id: b.block_id,
516
+ payload: { block_id: b.block_id },
517
+ estimatedTokens: estimateTokens(b),
518
+ complexity: 0.5,
519
+ ...(requiredTier ? { requiredTier } : {}),
520
+ };
521
+ },
518
522
  dispatchPacket: async (packet, slot) => options.dispatchNode(blockById.get(packet.payload.block_id), slot),
519
523
  ...(options.root !== undefined ? { root: options.root } : {}),
520
524
  ...(options.onCostDrift ? { onCostDrift: options.onCostDrift } : {}),
@@ -538,44 +542,16 @@ export async function driveRollingDispatch(levels, options) {
538
542
  levels: run.levels.map((l) => ({ blockIds: l.nodeIds, results: l.results })),
539
543
  rebuilds: run.rebuilds,
540
544
  ...(run.terminal ? { terminal: run.terminal } : {}),
545
+ exhaustedPoolIds: run.exhaustedPoolIds,
541
546
  };
542
547
  }
543
- /**
544
- * Backends the orchestrator can drive IN-PROCESS as the per-node implement worker
545
- * via `driveRollingImplementDispatch` (it resolves + launches the provider with each
546
- * node's worktree-rooted prompt, cwd-confined to that worktree). The conversation
547
- * host (claude-code) and IDE-bound providers (vscode-task / antigravity) are
548
- * excluded: claude-code self-blocks inside a session, and the IDE providers have no
549
- * headless invocation. "auto" is intentionally absent — auto-resolution stays on the
550
- * conversation host-subagent default, so the in-process driver is opt-in via an
551
- * EXPLICIT backend provider in session config. When one is set, it takes precedence
552
- * over the host-subagent driver: an operator who configured a backend (e.g. a NIM
553
- * pool for headless autonomy) wants it to do the implement work, not the host.
554
- */
555
- const IN_PROCESS_DISPATCH_PROVIDERS = new Set([
556
- "openai-compatible",
557
- "codex",
558
- "opencode",
559
- "subprocess-template",
560
- "worker-command",
561
- "agy",
562
- // The proxied isolated Claude-harness worker (commit 3b/3c) — headless in-process
563
- // launch from its claude-worker source (mirrors audit's IN_PROCESS_AUDIT_PROVIDERS;
564
- // both draws must classify the new worker kind or its pools confirm but never route).
565
- "claude-worker",
566
- ]);
567
- function resolvesToInProcessDispatchProvider(sessionConfig) {
568
- const provider = sessionConfig?.provider;
569
- return provider !== undefined && IN_PROCESS_DISPATCH_PROVIDERS.has(provider);
570
- }
571
548
  /**
572
549
  * Whether a confirmed pool is one the orchestrator launches IN-PROCESS this cycle
573
- * (vs. the conversation host's subagent pool). The remediate classification for the
574
- * shared `planHybridDispatch` split — single-sourced off the same provider set
575
- * `resolvesToInProcessDispatchProvider` uses.
550
+ * (vs. the conversation host's subagent pool) — the shared
551
+ * `isInProcessWorkerProvider` predicate (H3), same remediate policy.
576
552
  */
577
553
  function isInProcessPool(pool) {
578
- return IN_PROCESS_DISPATCH_PROVIDERS.has(pool.providerName);
554
+ return isInProcessWorkerProvider(pool.providerName, { commandWorkers: true });
579
555
  }
580
556
  /**
581
557
  * Release a node's shared claim on a terminal accept (token-checked through the
@@ -607,21 +583,23 @@ export async function driveRollingImplementDispatch(options) {
607
583
  const { root, artifactsDir, runId } = options;
608
584
  // Prepare the dispatch plan (eligible verified-complete frontier) with the SAME
609
585
  // quota-derived sizing the wave path uses. This writes per-node prompts +
610
- // dispatch-plan.json + dispatch-quota.json.
611
- const plan = await prepareImplementDispatch({ root, artifactsDir }, runId, undefined, {
612
- hostMaxConcurrent: options.waveOptions?.hostMaxConcurrent,
613
- sessionConfig: options.sessionConfig,
614
- hostContextTokens: options.waveOptions?.hostContextTokens,
615
- hostOutputTokens: options.waveOptions?.hostOutputTokens,
616
- hostModels: options.waveOptions?.hostModels,
617
- hostModelId: options.waveOptions?.hostModelId,
618
- // Each node runs in its own worktree, so its prompt is rooted there.
619
- worktreeRootedPrompts: true,
620
- // The in-process rolling engine admits + leases per-packet itself, so the
621
- // dispatch-quota grant here must NOT lease (a host grant lease would
622
- // double-count the same work against the shared account budget).
623
- grantLeases: false,
624
- });
586
+ // dispatch-plan.json + dispatch-quota.json. A partition-scoped hybrid caller
587
+ // supplies its already-prepared plan instead (planOverride — see its docblock).
588
+ const plan = options.planOverride ??
589
+ (await prepareImplementDispatch({ root, artifactsDir }, runId, undefined, {
590
+ hostMaxConcurrent: options.waveOptions?.hostMaxConcurrent,
591
+ sessionConfig: options.sessionConfig,
592
+ hostContextTokens: options.waveOptions?.hostContextTokens,
593
+ hostOutputTokens: options.waveOptions?.hostOutputTokens,
594
+ hostModels: options.waveOptions?.hostModels,
595
+ hostModelId: options.waveOptions?.hostModelId,
596
+ // Each node runs in its own worktree, so its prompt is rooted there.
597
+ worktreeRootedPrompts: true,
598
+ // The in-process rolling engine admits + leases per-packet itself, so the
599
+ // dispatch-quota grant here must NOT lease (a host grant lease would
600
+ // double-count the same work against the shared account budget).
601
+ grantLeases: false,
602
+ }));
625
603
  if (plan.items.length === 0) {
626
604
  return null;
627
605
  }
@@ -644,6 +622,12 @@ export async function driveRollingImplementDispatch(options) {
644
622
  const referencedFilesByBlock = new Map(plan.items
645
623
  .filter((i) => typeof i.block_id === "string")
646
624
  .map((i) => [i.block_id, i.access?.read_paths ?? []]));
625
+ // Per-block capability floor from the plan's model hints (F4) — the same tier
626
+ // the contract's admission packets carry, so the engine's packet→pool
627
+ // selection enforces what the contract displays.
628
+ const tierByBlock = new Map(plan.items
629
+ .filter((i) => typeof i.block_id === "string")
630
+ .flatMap((i) => (i.model_hint ? [[i.block_id, i.model_hint.tier]] : [])));
647
631
  // The RETAINED host-session source: threaded through pool sizing AND the
648
632
  // dispatcher's escalation hooks so the bounded re-limit chain (recordLimit →
649
633
  // escalate → strand → quota_escalation friction) is fed end-to-end. Its
@@ -669,20 +653,22 @@ export async function driveRollingImplementDispatch(options) {
669
653
  },
670
654
  });
671
655
  // Confirmed pools: quota-derived concurrency, never the raw host flag (INV-QD-11).
672
- const confirmedPools = await buildConfirmedPools({
673
- sessionConfig: options.sessionConfig,
674
- hostMaxConcurrent: options.waveOptions?.hostMaxConcurrent,
675
- hostContextTokens: options.waveOptions?.hostContextTokens,
676
- hostOutputTokens: options.waveOptions?.hostOutputTokens,
677
- hostModels: options.waveOptions?.hostModels,
678
- hostModelId: options.waveOptions?.hostModelId,
679
- hostSession,
680
- // The operator's Gate-0 exclusions, applied as a set-difference over freshly
681
- // gathered reach. Read here because this layer owns `root`; self-spawn-blocked is
682
- // recomputed against THIS process's env rather than inherited from the auditor
683
- // that wrote the confirmation.
684
- excludedBackends: resolveDispatchExclusion(await readConfirmedDispatchPolicy(options.root)),
685
- });
656
+ // A partition-scoped caller supplies its coordinator's already-built set instead.
657
+ const confirmedPools = options.poolsOverride ??
658
+ (await buildConfirmedPools({
659
+ sessionConfig: options.sessionConfig,
660
+ hostMaxConcurrent: options.waveOptions?.hostMaxConcurrent,
661
+ hostContextTokens: options.waveOptions?.hostContextTokens,
662
+ hostOutputTokens: options.waveOptions?.hostOutputTokens,
663
+ hostModels: options.waveOptions?.hostModels,
664
+ hostModelId: options.waveOptions?.hostModelId,
665
+ hostSession,
666
+ // The operator's Gate-0 exclusions, applied as a set-difference over freshly
667
+ // gathered reach. Read here because this layer owns `root`; self-spawn-blocked is
668
+ // recomputed against THIS process's env rather than inherited from the auditor
669
+ // that wrote the confirmation.
670
+ excludedBackends: resolveDispatchExclusion(await readConfirmedDispatchPolicy(options.root)),
671
+ }));
686
672
  // The live per-node worker: the configured provider, launched with the node's
687
673
  // worktree-rooted prompt and cwd = its worktree. Tests inject `options.dispatchNode`
688
674
  // to exercise the engine without spawning a real worker. A node on a source-backed
@@ -702,11 +688,22 @@ export async function driveRollingImplementDispatch(options) {
702
688
  if (!state)
703
689
  return null;
704
690
  const plannedBlockIds = new Set(resultPathByBlock.keys());
691
+ // Partition scope (H2): when the caller assigned this driver a coordinator
692
+ // partition, only those planned blocks are driven — everything else stays
693
+ // pending for the caller's other pools (the host share included).
694
+ const partitionIds = options.blocksOverride !== undefined ? new Set(options.blocksOverride) : null;
705
695
  const allLevels = rollingDependencyLevels(state);
706
696
  // Keep only the blocks that were actually planned this dispatch (eligible now).
707
697
  const levels = allLevels
708
- .map((level) => level.filter((b) => plannedBlockIds.has(b.block_id)))
698
+ .map((level) => level.filter((b) => plannedBlockIds.has(b.block_id) && (partitionIds === null || partitionIds.has(b.block_id))))
709
699
  .filter((level) => level.length > 0);
700
+ // Empty partition → an empty-but-SHAPED result, never null: `null` means "no
701
+ // eligible work, run the merge" to existing callers, and a partition caller
702
+ // following that recipe against the just-written full-frontier plan would
703
+ // terminal-block every undriven block (review h2c2 F1).
704
+ if (partitionIds !== null && levels.length === 0) {
705
+ return { nodes: [], rebuilds: 0, state_status: state.status, exhausted_pool_ids: [] };
706
+ }
710
707
  const nodeOutcomes = [];
711
708
  // The SAME file-backed claim registry the host-subagent driver claims through
712
709
  // (`nodeClaimRegistry`, keyed only to run + artifacts dir). Claiming a node here
@@ -716,7 +713,9 @@ export async function driveRollingImplementDispatch(options) {
716
713
  // life of this run: a `rate_limited` re-queue re-enters for the same block, so a
717
714
  // node already claimed by THIS driver reuses its token rather than self-colliding.
718
715
  const registry = nodeClaimRegistry(artifactsDir, runId);
719
- const claimTokens = new Map();
716
+ // Seeded with any coordinator-held claims the caller hands off (hybrid partition
717
+ // drive) — an adopted node skips self-claiming and releases with the adopted token.
718
+ const claimTokens = new Map(options.claimOwnerTokens ?? []);
720
719
  // Per-node worktree dispatch + verify-before-accept, wrapped so the rolling
721
720
  // engine's dispatchNode callback always RESOLVES (never rejects).
722
721
  const dispatchNodeWithWorktree = async (block, slot) => {
@@ -729,7 +728,7 @@ export async function driveRollingImplementDispatch(options) {
729
728
  if (!claimTokens.has(block.block_id)) {
730
729
  const claim = await registry.claim(block.block_id, "in-process");
731
730
  if (!claim.acquired) {
732
- nodeOutcomes.push({ block_id: block.block_id, outcome: "success", verify_passed: false, merged: false });
731
+ nodeOutcomes.push({ block_id: block.block_id, outcome: "success", verify_passed: false, merged: false, pool_id: slot.poolId });
733
732
  return {
734
733
  packet: { id: block.block_id, payload: { block_id: block.block_id }, estimatedTokens: 0, complexity: 0.5 },
735
734
  outcome: "success",
@@ -762,6 +761,9 @@ export async function driveRollingImplementDispatch(options) {
762
761
  outcome: accept.outcome,
763
762
  verify_passed: accept.verifyPassed,
764
763
  merged: accept.merged,
764
+ // Real per-node pool attribution (H2 plan D2) — the slot the engine bound,
765
+ // so a partition caller can settle the RIGHT pool from a node's outcome.
766
+ pool_id: slot.poolId,
765
767
  });
766
768
  // Release the claim ONLY on a terminal accept. A `rate_limited`,
767
769
  // `credit_exhausted`, or `quota_unclassified` worker re-queues (still owned
@@ -794,6 +796,7 @@ export async function driveRollingImplementDispatch(options) {
794
796
  hostSession,
795
797
  continuityScores,
796
798
  scopeForBlock: (block) => writePathsByBlock.get(block.block_id) ?? block.touched_files ?? [],
799
+ tierForBlock: (block) => tierByBlock.get(block.block_id),
797
800
  // Reactive cost verification: a declared-free source pool observed charging has
798
801
  // been demoted by the engine; surface it as reviewable friction so the operator
799
802
  // reconciles the stale `cost_per_mtok:0` (single step-boundary chokepoint).
@@ -827,6 +830,22 @@ export async function driveRollingImplementDispatch(options) {
827
830
  capturePacketTooLargeFriction(artifactsDir, runId, info, "remediate-code");
828
831
  },
829
832
  });
833
+ // Partition-scoped drive (H2): run-level lifecycle belongs to the CALLER — the
834
+ // engine terminal is surfaced on the result but never persisted (a backend-only
835
+ // wall must not pause the whole run while the host share proceeds), and the
836
+ // deterministic merge is the caller's, run once over ALL partitions.
837
+ if (partitionIds !== null) {
838
+ // Freshly persisted status; falls back to the pre-drive snapshot only if
839
+ // state.json vanished mid-drive (review h2c2 F4 — documented, not silent).
840
+ const current = await new StateStore(artifactsDir).loadState();
841
+ return {
842
+ nodes: nodeOutcomes,
843
+ rebuilds: Math.max(0, levels.length - 1),
844
+ state_status: (current ?? state).status,
845
+ exhausted_pool_ids: driven.exhaustedPoolIds,
846
+ ...(driven.terminal ? { terminal: driven.terminal } : {}),
847
+ };
848
+ }
830
849
  // Piece D — persist the rolling engine's partial-completion terminal onto state
831
850
  // BEFORE the merge, so the merge can SKIP-block the quota_paused stranded nodes
832
851
  // (their worker rate-limited → no result file, but they must stay PENDING for a
@@ -848,6 +867,8 @@ export async function driveRollingImplementDispatch(options) {
848
867
  nodes: nodeOutcomes,
849
868
  rebuilds: Math.max(0, levels.length - 1),
850
869
  state_status: merged.status,
870
+ exhausted_pool_ids: driven.exhaustedPoolIds,
871
+ ...(driven.terminal ? { terminal: driven.terminal } : {}),
851
872
  };
852
873
  }
853
874
  /**
@@ -857,85 +878,6 @@ export async function driveRollingImplementDispatch(options) {
857
878
  * uses (`driveRollingDispatch`'s `() => 2000`), keeping the two paths consistent.
858
879
  */
859
880
  const HYBRID_NODE_TOKEN_ESTIMATE = 2000;
860
- /**
861
- * Run the A-8 coordinator's IN-PROCESS partition this cycle. Each node was already
862
- * claimed by the coordinator and assigned to a backend pool (NIM / codex / …), so it
863
- * is launched on THAT pool's provider — binding the slot's providerName to the
864
- * per-node assignment is what routes a node to its assigned backend — cwd-confined to
865
- * its worktree, through the shared `executeNodeInWorktree` lifecycle (commit → verify
866
- * → write-scope → merge); then the coordinator's claim is released.
867
- *
868
- * Nodes run concurrently: the coordinator's proactive split already bounded the
869
- * partition to the backend pools' capacity, so the partition size IS the safe
870
- * concurrency. The host partition runs in parallel via the host-subagent driver;
871
- * both write accept-outcome sidecars the run-level `mergeImplementResults` reconciles.
872
- *
873
- * Run-once (no in-pass re-queue): a node whose worker rate-limits or errors is not
874
- * merged (its worktree drops) and is routed to triage by the deterministic merge —
875
- * bounded, never a livelock. (Cross-cycle settled-pool re-balancing is a follow-up.)
876
- */
877
- export async function executeInProcessPartition(params) {
878
- const { root, artifactsDir, runId, sessionConfig, partition, plan, coordinator, registry } = params;
879
- if (partition.length === 0)
880
- return { nodes: [] };
881
- const allBlockScopes = blockScopesFromPlan(plan);
882
- const withBlockId = plan.items.filter((i) => typeof i.block_id === "string");
883
- const promptPathByBlock = new Map(withBlockId.map((i) => [i.block_id, i.prompt_path]));
884
- const referencedFilesByBlock = new Map(withBlockId.map((i) => [i.block_id, i.access?.read_paths ?? []]));
885
- const resultPathByBlock = new Map(withBlockId.map((i) => [i.block_id, i.result_path]));
886
- const state = await new StateStore(artifactsDir).loadState();
887
- const blockById = new Map((state?.plan?.blocks ?? []).map((b) => [b.block_id, b]));
888
- const dispatchNode = params.dispatchNode ??
889
- makeProviderNodeDispatcher({
890
- root,
891
- artifactsDir,
892
- runId,
893
- sessionConfig,
894
- promptPathByBlock,
895
- referencedFilesByBlock,
896
- sourceByPoolId: params.sourceByPoolId,
897
- });
898
- const nodes = await Promise.all(partition.map(async (a) => {
899
- // `a` IS the coordinator's NodeAssignment — release it directly on terminal.
900
- const resultPath = resultPathByBlock.get(a.nodeId);
901
- if (!resultPath) {
902
- // No prepared prompt/result for this node — release + mark error (the merge
903
- // routes it to triage); never silently drop a claimed node.
904
- await coordinator.release(a);
905
- return { block_id: a.nodeId, outcome: "error", verify_passed: false, merged: false };
906
- }
907
- const block = blockById.get(a.nodeId) ?? { block_id: a.nodeId };
908
- const slot = {
909
- providerName: a.providerName,
910
- hostModel: a.hostModel,
911
- poolId: a.poolId,
912
- };
913
- const { accept } = await executeNodeInWorktree({
914
- block,
915
- slot,
916
- root,
917
- artifactsDir,
918
- runId,
919
- resultPath,
920
- seedPaths: declaredPathsFromPlan(plan, a.nodeId),
921
- allBlockScopes,
922
- additionalVerifyCommands: state ? targetedCommandsForBlock(state, a.nodeId) : [],
923
- dispatchNode,
924
- // Merge-time ownership gate (OD3 layer 2): the SAME lease the coordinator
925
- // claimed `a` under. Omitted when the caller didn't supply a registry.
926
- ...(registry ? { ownership: { registry, nodeId: a.nodeId, ownerToken: a.ownerToken } } : {}),
927
- });
928
- // Run-once → terminal; free the coordinator claim (token-checked).
929
- await coordinator.release(a);
930
- return {
931
- block_id: a.nodeId,
932
- outcome: accept.outcome,
933
- verify_passed: accept.verifyPassed,
934
- merged: accept.merged,
935
- };
936
- }));
937
- return { nodes };
938
- }
939
881
  // ---------------------------------------------------------------------------
940
882
  // Tool-owned final completion gate (INV-RS-10) + coarse re-block (INV-RS-09)
941
883
  // ---------------------------------------------------------------------------
@@ -1244,80 +1186,23 @@ async function buildImplementDispatchStep(ctx) {
1244
1186
  hostModels: resolvedHostModels,
1245
1187
  hostModelId: resolvedHostModelId,
1246
1188
  };
1247
- // A8 in-process provider driver: when the rolling engine is enabled, the run is
1248
- // HEADLESS (no attended host that can dispatch subagents), AND the operator
1249
- // EXPLICITLY configured a programmatic backend provider (openai-compatible / codex /
1250
- // opencode / …), the orchestrator drives the FULL rolling implement dispatch ITSELF —
1251
- // the configured provider is the per-node worker, cwd-confined to each node's
1252
- // worktree, sharing the same `acceptNodeWorktree` core (commit → verify → merge,
1253
- // verify-fail → triage) as the host-subagent driver. Defect-1: gated on `!canDispatchImpl`
1254
- // so an ATTENDED host demotes the backend to a source pool (the hybrid branch below,
1255
- // host + backend + NIM concurrent) rather than the backend monopolizing the frontier;
1256
- // only a truly headless run (e.g. a NIM pool for headless autonomy, host_can_dispatch:false)
1257
- // lets the backend self-drive.
1258
- // Defect-1: DEMOTE (attended concurrent fan-out) applies only to the demotable
1259
- // backends (codex/opencode/openai-compatible). A non-demotable in-process provider
1260
- // (subprocess-template/worker-command — no standalone source pool) keeps
1261
- // self-driving regardless of attendance, so the monopoly branch still fires for it.
1262
- // B1 same-agent guard: suppress the demote when the conversation host IS the
1263
- // primary backend provider (one account ⇒ host self-drives as a single pool),
1264
- // else host + demoted-source pools double-book that one meter.
1265
- const demoteBackendToSource = shouldDemotePrimaryInProcess({
1266
- sessionConfig: sessionConfigImpl,
1267
- hostCanDispatch: canDispatchImpl,
1268
- });
1269
- if (rollingEngineEnabled &&
1270
- !demoteBackendToSource &&
1271
- resolvesToInProcessDispatchProvider(sessionConfigImpl)) {
1272
- const driven = await driveRollingImplementDispatch({
1273
- root,
1274
- artifactsDir,
1275
- runId,
1276
- sessionConfig: sessionConfigImpl ?? null,
1277
- // Per-node verify (targeted_commands) owns each node's build/test; an
1278
- // inter-level "shared surface" rebuild is a monorepo-self-remediation concern
1279
- // the host-driven paths handle, not a generic target-repo step → no-op here.
1280
- rebuildSharedBetweenLevels: async () => { },
1281
- waveOptions: {
1282
- hostMaxConcurrent: resolvedHostMaxConcurrent,
1283
- hostContextTokens: resolvedHostContextTokens,
1284
- hostOutputTokens: resolvedHostOutputTokens,
1285
- hostModels: resolvedHostModels,
1286
- hostModelId: resolvedHostModelId,
1287
- },
1189
+ // H2+H4 collapse: ONE fan-out over the eligible pool set. `buildConfirmedPools`
1190
+ // folds the configured primary in-process backend in as a source pool
1191
+ // UNCONDITIONALLY (no demote flag; command-shaped primaries included under
1192
+ // remediate's policy — plan D3) and includes the conversation host as a member
1193
+ // pool iff it can dispatch subagents. Headless is the degenerate "no host pool
1194
+ // in the set" case: the engine drives the whole frontier itself. The same-agent
1195
+ // case (conversation host IS the primary backend) is the shared cross-class
1196
+ // dedup's D1 collision rule — the engine/source pool survives, so one account is
1197
+ // never double-booked across a host pool and a folded source.
1198
+ if (rollingEngineEnabled) {
1199
+ // D5: the host-session quota key follows the DRIVER identity — an in-process
1200
+ // worker primary keys to the conversation host; an explicit IDE/host provider
1201
+ // passes through verbatim (never re-keyed to the literal claude-code, the
1202
+ // founding-bug misattribution class [[capability-is-per-auditor-not-per-audit]]).
1203
+ const hybridProviderName = resolveHostDispatchProviderName(sessionConfigImpl, {
1204
+ commandWorkers: true,
1288
1205
  });
1289
- // null = no eligible pending work this pass; the engine merges internally once
1290
- // it has run, so only the empty-frontier case needs a merge here. Either way the
1291
- // implement frontier is resolved — transition on the freshly-merged state so the
1292
- // engine re-scans (triage / closing) without recursion.
1293
- if (driven === null) {
1294
- const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1295
- return { kind: "transition", state: merged };
1296
- }
1297
- return { kind: "transition", state: await store.loadState() };
1298
- }
1299
- if (rollingEngineEnabled && canDispatchImpl) {
1300
- // A-8 hybrid spill: when an in-process backend pool is ALSO confirmed (a
1301
- // configured NIM/openai-compatible endpoint alongside the conversation host),
1302
- // split the eligible frontier across BOTH pool classes via the shared
1303
- // HybridSpillCoordinator (single claimant, proactive capacity split) — the
1304
- // orchestrator runs the in-process partition THIS cycle while the host spawns
1305
- // subagents for its partition. Pure host-subagent dispatch falls out when no
1306
- // backend pool is confirmed (the coordinator has nothing to split against).
1307
- // Retained host-session source for the hybrid pool-sizing pre-wall throttle
1308
- // (this branch previously sized pools with no account-wall awareness at all,
1309
- // unlike the primary path above). This branch has its own already-working,
1310
- // bounded rate-limited/settle mechanism below (DC-4) rather than routing
1311
- // through HostSessionQuotaSource.recordLimit/isEscalated, so onEscalation is
1312
- // unused here — the source only feeds buildConfirmedPools' sizing.
1313
- // Defect-1: the host-session quota key must follow the CONVERSATION HOST, not a
1314
- // demoted backend — key it to the auto-detected conversation host (B1: codex
1315
- // when inside a Codex session, else claude-code; --host-provider overrides)
1316
- // when the configured primary is a demotable backend that this attended run is
1317
- // fanning out onto as a source.
1318
- const hybridProviderName = demoteBackendToSource
1319
- ? resolveConversationHostProvider({ sessionConfig: sessionConfigImpl })
1320
- : resolveHostProviderName(sessionConfigImpl);
1321
1206
  const hybridHostSessionModelKey = buildProviderModelKey(hybridProviderName, sessionConfigImpl
1322
1207
  ?.block_quota?.host_model ??
1323
1208
  resolvedHostModelId ??
@@ -1333,166 +1218,248 @@ async function buildImplementDispatchStep(ctx) {
1333
1218
  hostModels: resolvedHostModels,
1334
1219
  hostModelId: resolvedHostModelId,
1335
1220
  hostSession: hybridHostSession,
1336
- // Defect-1: reached here because the headless in-process branch above was
1337
- // skipped (attended host) — demote the configured primary backend into the
1338
- // source-pool set so the split fans across host + backend + NIM.
1339
- demotePrimaryInProcess: demoteBackendToSource,
1221
+ // Attendance IS pool-set membership: headless ⇒ no host pool in the set.
1222
+ hostCanDispatch: canDispatchImpl,
1340
1223
  // The operator's Gate-0 exclusions, applied as a set-difference over freshly
1341
1224
  // gathered reach; self-spawn-blocked recomputed against THIS process's env.
1342
1225
  excludedBackends: resolveDispatchExclusion(await readConfirmedDispatchPolicy(root)),
1343
1226
  });
1344
1227
  const backendPools = confirmedPools.filter(isInProcessPool);
1345
- let rolling;
1346
- if (backendPools.length > 0) {
1347
- // Prepare the frontier ONCE (worktree-rooted prompts) so the coordinator
1348
- // split, the in-process executor, and the host driver all read the same plan.
1349
- const plan = await prepareImplementDispatch({ root, artifactsDir }, runId, undefined, {
1350
- ...waveOptsImpl,
1351
- worktreeRootedPrompts: true,
1352
- });
1353
- const frontier = plan.items
1354
- .filter((i) => typeof i.block_id === "string")
1355
- .map((i) => ({ id: i.block_id, estimatedTokens: HYBRID_NODE_TOKEN_ESTIMATE }));
1356
- if (frontier.length === 0) {
1357
- const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1358
- return { kind: "transition", state: merged };
1228
+ if (!canDispatchImpl) {
1229
+ // Headless: no host pool in the eligible set — when any dispatchable
1230
+ // backend pool is confirmed, the engine drives the FULL rolling implement
1231
+ // dispatch itself (no blocksOverride: the driver's full path owns terminal
1232
+ // persistence + the final merge), cwd-confined to each node's worktree,
1233
+ // sharing the same `acceptNodeWorktree` core as the host-subagent driver.
1234
+ // No pool at all ⇒ fall through to the sequential host step below.
1235
+ if (backendPools.length > 0) {
1236
+ const driven = await driveRollingImplementDispatch({
1237
+ root,
1238
+ artifactsDir,
1239
+ runId,
1240
+ sessionConfig: sessionConfigImpl ?? null,
1241
+ // Per-node verify (targeted_commands) owns each node's build/test; an
1242
+ // inter-level "shared surface" rebuild is a monorepo-self-remediation concern
1243
+ // the host-driven paths handle, not a generic target-repo step → no-op here.
1244
+ rebuildSharedBetweenLevels: async () => { },
1245
+ waveOptions: {
1246
+ hostMaxConcurrent: resolvedHostMaxConcurrent,
1247
+ hostContextTokens: resolvedHostContextTokens,
1248
+ hostOutputTokens: resolvedHostOutputTokens,
1249
+ hostModels: resolvedHostModels,
1250
+ hostModelId: resolvedHostModelId,
1251
+ },
1252
+ // The eligible pool set built above (source pools only — headless),
1253
+ // so the drive routes across every confirmed backend pool.
1254
+ poolsOverride: backendPools,
1255
+ });
1256
+ // null = no eligible pending work this pass; the engine merges internally once
1257
+ // it has run, so only the empty-frontier case needs a merge here. Either way the
1258
+ // implement frontier is resolved — transition on the freshly-merged state so the
1259
+ // engine re-scans (triage / closing) without recursion.
1260
+ if (driven === null) {
1261
+ const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1262
+ return { kind: "transition", state: merged };
1263
+ }
1264
+ return { kind: "transition", state: await store.loadState() };
1359
1265
  }
1360
- // One coordinator over the shared claim registry splits + claims each node to
1361
- // exactly one pool. DC-4: the settled set is cross-cycle (persisted) — a backend
1362
- // pool that exhausted on a prior cycle is excluded here, so its work falls to the
1363
- // host-subagent pool instead of re-looping on a dead backend.
1364
- const settledPath = nodeSettledPoolsPath(artifactsDir, runId);
1365
- const settled = await readSettledPools(settledPath);
1366
- // Hoisted so the coordinator's claim registry and the ownership gate's
1367
- // heartbeat probe are the SAME file-backed instance — never independently
1368
- // re-derived by path (the coordinator's own `claimRegistry` field is private).
1369
- const hybridClaimRegistry = nodeClaimRegistry(artifactsDir, runId);
1370
- const partition = await planHybridDispatch({
1371
- frontier,
1372
- pools: confirmedPools,
1373
- sessionConfig: sessionConfigImpl ?? {},
1374
- claimRegistry: hybridClaimRegistry,
1375
- readSettled: () => settled,
1376
- onSettle: async (id) => {
1377
- settled.add(id);
1378
- await addSettledPool(settledPath, id);
1379
- },
1380
- isInProcess: isInProcessPool,
1381
- });
1382
- // Run the in-process partition now (each node on its assigned backend pool,
1383
- // launched FROM that pool's source config — A-8 generic dispatchable sources).
1384
- const inProcessOutcome = await executeInProcessPartition({
1385
- root,
1386
- artifactsDir,
1387
- runId,
1388
- sessionConfig: sessionConfigImpl ?? null,
1389
- partition: partition.inProcess,
1390
- plan,
1391
- coordinator: partition.coordinator,
1392
- registry: hybridClaimRegistry,
1393
- sourceByPoolId: sourceByPoolId(confirmedPools),
1394
- });
1395
- // DC-4: a backend pool whose node rate-limited, credit-exhausted, OR
1396
- // returned a quota_unclassified death → settle it (cross-cycle) so the next
1397
- // cycle routes its share to the host pool. This partition spans multiple
1398
- // cycles, each with its own fresh in-process dispatcher (a fresh in-memory
1399
- // exhaustedPoolIds AND pausedPoolResetAt), so the rolling engine's own
1400
- // reversible pause evaporates at the cycle boundary — without settling here,
1401
- // credit_exhausted (no reset timer) and a chronically-quota_unclassified pool
1402
- // alike would be RE-OFFERED next cycle and re-die on the same pool. Settling
1403
- // in the hybrid path is the cross-cycle analog of the rolling path's pause.
1404
- // NOTE: the verbatim-message harvest (captureQuotaUnclassifiedFriction /
1405
- // captureCreditExhaustionFriction) rides the rolling engine's hooks, which
1406
- // this direct-Promise.all partition does not invoke; here every settled node
1407
- // still surfaces as a quota_escalation friction (below), but without the
1408
- // verbatim text. Threading verbatim capture into executeInProcessPartition is
1409
- // a documented follow-up (affects credit_exhausted identically — not new to A2b).
1410
- const settledOutcomeBlocks = new Set(inProcessOutcome.nodes
1411
- .filter((n) => n.outcome === "rate_limited" ||
1412
- n.outcome === "credit_exhausted" ||
1413
- n.outcome === "model_unavailable" ||
1414
- n.outcome === "packet_too_large" ||
1415
- n.outcome === "quota_unclassified")
1416
- .map((n) => n.block_id));
1417
- const exhaustedPools = new Set(partition.inProcess.filter((a) => settledOutcomeBlocks.has(a.nodeId)).map((a) => a.poolId));
1418
- for (const poolId of exhaustedPools) {
1419
- await partition.coordinator.settlePool(poolId);
1266
+ }
1267
+ if (canDispatchImpl) {
1268
+ // A-8 hybrid spill: when an in-process backend pool is ALSO confirmed (a
1269
+ // configured NIM/openai-compatible endpoint alongside the conversation host),
1270
+ // split the eligible frontier across BOTH pool classes via the shared
1271
+ // HybridSpillCoordinator (single claimant, proactive capacity split) — the
1272
+ // orchestrator runs the in-process partition THIS cycle while the host spawns
1273
+ // subagents for its partition. Pure host-subagent dispatch falls out when no
1274
+ // backend pool is confirmed (the coordinator has nothing to split against).
1275
+ // The hybrid host-session source above fed `buildConfirmedPools`' pool-sizing
1276
+ // pre-wall throttle; this branch has its own bounded rate-limited/settle
1277
+ // mechanism below (DC-4) rather than routing through
1278
+ // HostSessionQuotaSource.recordLimit/isEscalated.
1279
+ let rolling;
1280
+ if (backendPools.length > 0) {
1281
+ // Prepare the frontier ONCE (worktree-rooted prompts) so the coordinator
1282
+ // split, the partition drive (via planOverride), and the host driver all
1283
+ // read the same plan — the partition drive deliberately does NOT re-prepare
1284
+ // (see DriveRollingImplementDispatchOptions.planOverride for the lease
1285
+ // overlap this shape carries and why a re-prepare would clobber the
1286
+ // host-share dispatch-quota).
1287
+ const plan = await prepareImplementDispatch({ root, artifactsDir }, runId, undefined, {
1288
+ ...waveOptsImpl,
1289
+ worktreeRootedPrompts: true,
1290
+ });
1291
+ const frontier = plan.items
1292
+ .filter((i) => typeof i.block_id === "string")
1293
+ .map((i) => ({ id: i.block_id, estimatedTokens: HYBRID_NODE_TOKEN_ESTIMATE }));
1294
+ if (frontier.length === 0) {
1295
+ const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1296
+ return { kind: "transition", state: merged };
1297
+ }
1298
+ // One coordinator over the shared claim registry splits + claims each node to
1299
+ // exactly one pool. DC-4: the settled set is cross-cycle (persisted) — a backend
1300
+ // pool that exhausted on a prior cycle is excluded here, so its work falls to the
1301
+ // host-subagent pool instead of re-looping on a dead backend.
1302
+ const settledPath = nodeSettledPoolsPath(artifactsDir, runId);
1303
+ const settled = await readSettledPools(settledPath);
1304
+ // Hoisted so the coordinator's claim registry and the ownership gate's
1305
+ // heartbeat probe are the SAME file-backed instance — never independently
1306
+ // re-derived by path (the coordinator's own `claimRegistry` field is private).
1307
+ const hybridClaimRegistry = nodeClaimRegistry(artifactsDir, runId);
1308
+ const partition = await planHybridDispatch({
1309
+ frontier,
1310
+ pools: confirmedPools,
1311
+ sessionConfig: sessionConfigImpl ?? {},
1312
+ claimRegistry: hybridClaimRegistry,
1313
+ readSettled: () => settled,
1314
+ onSettle: async (id) => {
1315
+ settled.add(id);
1316
+ await addSettledPool(settledPath, id);
1317
+ },
1318
+ isInProcess: isInProcessPool,
1319
+ });
1320
+ // Drive the in-process partition through the ROLLING ENGINE (H2 plan D2 —
1321
+ // `executeInProcessPartition`'s direct Promise.all executor is deleted; one
1322
+ // core, one driver): each node runs on a coordinator-claimed backend pool,
1323
+ // launched FROM that pool's source config, with the engine's full hook set
1324
+ // live (413 → packet_too_large re-queue + friction, verbatim quota harvest,
1325
+ // cost-drift / credit-exhaustion / model-unavailable capture) instead of the
1326
+ // hand-replicated friction blocks this replaces. The coordinator's claims
1327
+ // are ADOPTED (claimOwnerTokens) — same registry, no self-collision — and
1328
+ // run-level lifecycle stays HERE: the drive is partition-scoped
1329
+ // (blocksOverride), so a backend-only wall never persists a run terminal and
1330
+ // the final merge remains this caller's.
1331
+ if (partition.inProcess.length > 0) {
1332
+ // DC-4 (review h2c3 F1): the engine's per-packet selection binds freely
1333
+ // across `poolsOverride`, so a pool settled on a PRIOR cycle must be
1334
+ // filtered out here — the coordinator's claim walk already excluded it,
1335
+ // and re-offering it would re-die on the same dead pool every cycle.
1336
+ const liveBackendPools = backendPools.filter((p) => !settled.has(p.id));
1337
+ const driven = await driveRollingImplementDispatch({
1338
+ root,
1339
+ artifactsDir,
1340
+ runId,
1341
+ sessionConfig: sessionConfigImpl ?? null,
1342
+ rebuildSharedBetweenLevels: async () => { },
1343
+ waveOptions: {
1344
+ hostMaxConcurrent: resolvedHostMaxConcurrent,
1345
+ hostContextTokens: resolvedHostContextTokens,
1346
+ hostOutputTokens: resolvedHostOutputTokens,
1347
+ hostModels: resolvedHostModels,
1348
+ hostModelId: resolvedHostModelId,
1349
+ },
1350
+ blocksOverride: partition.inProcess.map((a) => a.nodeId),
1351
+ poolsOverride: liveBackendPools,
1352
+ planOverride: plan,
1353
+ claimOwnerTokens: new Map(partition.inProcess.map((a) => [a.nodeId, a.ownerToken])),
1354
+ });
1355
+ // Free any coordinator claim still held (a non-terminal outcome keeps its
1356
+ // claim through the drive); terminal accepts already released in-driver,
1357
+ // so this is a token-checked no-op for them.
1358
+ for (const a of partition.inProcess) {
1359
+ await partition.coordinator.release(a);
1360
+ }
1361
+ // DC-4 cross-cycle settle (D2 iii — PRESERVED across the engine
1362
+ // migration): a backend pool whose node rate-limited, credit-exhausted,
1363
+ // went model-unavailable, or died quota-unclassified → settle it
1364
+ // (cross-cycle) so the next cycle routes its share to the host pool. This
1365
+ // partition spans multiple cycles, each with a fresh in-process dispatcher
1366
+ // (fresh in-memory exhaustedPoolIds AND pausedPoolResetAt), so the
1367
+ // engine's reversible pause evaporates at the cycle boundary — the BROAD
1368
+ // `isPoolSettlingOutcome` predicate (incl. reset-bearing 429 +
1369
+ // quota_unclassified; see settledPools.ts for the divergence rationale)
1370
+ // therefore applies over the drive's real per-node pool attribution, plus
1371
+ // the engine's own terminal-exhaustion set. `packet_too_large` is
1372
+ // deliberately NOT a settle trigger (step D): a 413 is a per-(node,pool)
1373
+ // sizing fact, surfaced via the engine's onPacketTooLarge friction hook.
1374
+ // `driven` is null only when the plan/state vanished mid-cycle (nothing was
1375
+ // dispatched) — no outcomes ⇒ nothing to settle.
1376
+ const settlingPoolIds = new Set((driven?.nodes ?? [])
1377
+ .filter((n) => isPoolSettlingOutcome(n.outcome))
1378
+ .map((n) => n.pool_id));
1379
+ for (const poolId of driven?.exhausted_pool_ids ?? []) {
1380
+ settlingPoolIds.add(poolId);
1381
+ }
1382
+ const liveBackendPoolIds = new Set(liveBackendPools.map((p) => p.id));
1383
+ for (const poolId of settlingPoolIds) {
1384
+ if (liveBackendPoolIds.has(poolId)) {
1385
+ await partition.coordinator.settlePool(poolId);
1386
+ // The settle FACT itself is reviewable friction (review h2c3 F2):
1387
+ // the engine hooks capture the per-death evidence (verbatim 429
1388
+ // text, credit exhaustion, 404), but reset-bearing rate limits have
1389
+ // no engine hook, and no hook records "this pool is now settled for
1390
+ // the run". One record per (run, pool) via the dedupe chokepoint.
1391
+ void captureStepBoundaryFriction(artifactsDir, runId, {
1392
+ eventType: "quota_escalation",
1393
+ discriminator: `pool-settled:${poolId}`,
1394
+ note: "Hybrid in-process backend pool settled for this run (rate-limited / credit-exhausted / model-unavailable / quota-unclassified); its remaining share routes to the host on later cycles.",
1395
+ severity: "high",
1396
+ category: "trap",
1397
+ area: "dispatch/quota",
1398
+ }, "remediate-code");
1399
+ }
1400
+ }
1401
+ }
1402
+ // The backend carried the whole batch (or every host node was contested by a
1403
+ // peer driver) → nothing for the host this cycle; merge what landed + transition.
1404
+ if (partition.host.length === 0) {
1405
+ const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1406
+ return { kind: "transition", state: merged };
1407
+ }
1408
+ // Hand the host partition (pre-claimed) to the host-subagent driver.
1409
+ rolling = await prepareHostRollingDispatch({ root, artifactsDir }, runId, waveOptsImpl, {
1410
+ plan,
1411
+ partition: partition.host.map((a) => ({ block_id: a.nodeId, ownerToken: a.ownerToken })),
1412
+ });
1420
1413
  }
1421
- // Surface each settled node as reviewable friction (not just the settle
1422
- // side-effect above). The shared step-boundary chokepoint dedupes on
1423
- // {eventType, runId, discriminator}, so a chronically-exhausted backend pool
1424
- // re-hitting the same block_id across cycles collapses to one record.
1425
- for (const blockId of settledOutcomeBlocks) {
1426
- void captureStepBoundaryFriction(artifactsDir, runId, {
1427
- eventType: "quota_escalation",
1428
- discriminator: blockId,
1429
- note: "A-8 hybrid in-process node rate-limited / credit-exhausted / model-unavailable / packet-too-large / quota-unclassified; its backend pool was settled for this run.",
1430
- severity: "high",
1431
- category: "trap",
1432
- area: "dispatch/quota",
1433
- }, "remediate-code");
1414
+ else {
1415
+ rolling = await prepareHostRollingDispatch({ root, artifactsDir }, runId, waveOptsImpl);
1434
1416
  }
1435
- // The backend carried the whole batch (or every host node was contested by a
1436
- // peer driver) → nothing for the host this cycle; merge what landed + transition.
1437
- if (partition.host.length === 0) {
1438
- const merged = await mergeImplementResults({ root, artifactsDir }, runId);
1439
- return { kind: "transition", state: merged };
1417
+ // Increment B residual (a): the hybrid host-subagent driver hit the cooldown wall
1418
+ // (admission over-granted the throttled set). Reconcile the reserved leases (the
1419
+ // pause skips the merge that would) and set the resumable `quota_paused` terminal
1420
+ // so the `partial_terminal` obligation emits it this same advance; the ungranted
1421
+ // nodes stay PENDING and re-dispatch on resume. The in-process partition (above)
1422
+ // already ran on its own cooldown-safe pool, so its work still lands.
1423
+ if (rolling.wall) {
1424
+ await reconcileAdmissionLeasesFromQuotaFile(rolling.quotaPath);
1425
+ const paused = await store.loadState();
1426
+ if (paused) {
1427
+ paused.partial_completion_terminal = buildQuotaPausedTerminal(rolling.wall.strandedBlockIds, rolling.wall.detected.earliestResetAt);
1428
+ await store.saveState(paused);
1429
+ }
1430
+ return { kind: "transition", state: paused };
1440
1431
  }
1441
- // Hand the host partition (pre-claimed) to the host-subagent driver.
1442
- rolling = await prepareHostRollingDispatch({ root, artifactsDir }, runId, waveOptsImpl, {
1443
- plan,
1444
- partition: partition.host.map((a) => ({ block_id: a.nodeId, ownerToken: a.ownerToken })),
1445
- });
1446
- }
1447
- else {
1448
- rolling = await prepareHostRollingDispatch({ root, artifactsDir }, runId, waveOptsImpl);
1449
- }
1450
- // Increment B residual (a): the hybrid host-subagent driver hit the cooldown wall
1451
- // (admission over-granted the throttled set). Reconcile the reserved leases (the
1452
- // pause skips the merge that would) and set the resumable `quota_paused` terminal
1453
- // so the `partial_terminal` obligation emits it this same advance; the ungranted
1454
- // nodes stay PENDING and re-dispatch on resume. The in-process partition (above)
1455
- // already ran on its own cooldown-safe pool, so its work still lands.
1456
- if (rolling.wall) {
1457
- await reconcileAdmissionLeasesFromQuotaFile(rolling.quotaPath);
1458
- const paused = await store.loadState();
1459
- if (paused) {
1460
- paused.partial_completion_terminal = buildQuotaPausedTerminal(rolling.wall.strandedBlockIds, rolling.wall.detected.earliestResetAt);
1461
- await store.saveState(paused);
1432
+ // Everything eligible may already be done/skipped — fold straight to merge
1433
+ // rather than emitting a dispatch step with zero nodes.
1434
+ if (rolling.session.frontier.length === 0) {
1435
+ await mergeImplementResults({ root, artifactsDir }, runId);
1436
+ return { kind: "transition", state: await store.loadState() };
1462
1437
  }
1463
- return { kind: "transition", state: paused };
1464
- }
1465
- // Everything eligible may already be done/skipped — fold straight to merge
1466
- // rather than emitting a dispatch step with zero nodes.
1467
- if (rolling.session.frontier.length === 0) {
1468
- await mergeImplementResults({ root, artifactsDir }, runId);
1469
- return { kind: "transition", state: await store.loadState() };
1470
- }
1471
- // S-BROKER-WIRING: pick the dispatch DRIVER (delegate the rolling loop to a
1472
- // dedicated dispatcher subagent vs. drive it from the top host) off the
1473
- // single classification + the live frontier/slot count — not host prose.
1474
- const hostProvider = resolveHostProviderName(sessionConfigImpl);
1475
- const driverSelection = selectDispatchDriver({
1476
- classification: classifyProvider(hostProvider),
1477
- eligibleItemCount: rolling.session.frontier.length,
1478
- // The granted set's size IS the instantaneous admission width — there is no
1479
- // separate concurrency number. The whole granted set runs at once, so the
1480
- // driver-selection "slots" is the granted-set size.
1481
- slots: rolling.session.frontier.length,
1482
- });
1483
- const rollMerge = loaderCommand(`merge-implement-results --run-id ${runId}`);
1484
- const rollNext = loaderCommand("next-step");
1485
- const acceptCmd = loaderCommand(`accept-node --id <BLOCK_ID> --run-id ${runId}`);
1486
- const nodeLines = rolling.initial
1487
- .map((n) => `- \`${n.block_id}\` — prompt: \`${n.prompt_path}\` — worktree (subagent cwd): \`${n.worktree_root}\``)
1488
- .join("\n");
1489
- return { kind: "emit", step: await writeCurrentStep({
1490
- stepKind: "dispatch_implement_rolling",
1491
- status: "ready",
1492
- runId,
1493
- repoRoot: root,
1494
- artifactsDir,
1495
- prompt: `
1438
+ // S-BROKER-WIRING: pick the dispatch DRIVER (delegate the rolling loop to a
1439
+ // dedicated dispatcher subagent vs. drive it from the top host) off the
1440
+ // single classification + the live frontier/slot count — not host prose.
1441
+ const hostProvider = resolveHostProviderName(sessionConfigImpl);
1442
+ const driverSelection = selectDispatchDriver({
1443
+ classification: classifyProvider(hostProvider),
1444
+ eligibleItemCount: rolling.session.frontier.length,
1445
+ // The granted set's size IS the instantaneous admission width — there is no
1446
+ // separate concurrency number. The whole granted set runs at once, so the
1447
+ // driver-selection "slots" is the granted-set size.
1448
+ slots: rolling.session.frontier.length,
1449
+ });
1450
+ const rollMerge = loaderCommand(`merge-implement-results --run-id ${runId}`);
1451
+ const rollNext = loaderCommand("next-step");
1452
+ const acceptCmd = loaderCommand(`accept-node --id <BLOCK_ID> --run-id ${runId}`);
1453
+ const nodeLines = rolling.initial
1454
+ .map((n) => `- \`${n.block_id}\` — prompt: \`${n.prompt_path}\` — worktree (subagent cwd): \`${n.worktree_root}\``)
1455
+ .join("\n");
1456
+ return { kind: "emit", step: await writeCurrentStep({
1457
+ stepKind: "dispatch_implement_rolling",
1458
+ status: "ready",
1459
+ runId,
1460
+ repoRoot: root,
1461
+ artifactsDir,
1462
+ prompt: `
1496
1463
  # Dispatch Implementation Work (host-subagent rolling, worktree-isolated)
1497
1464
 
1498
1465
  Each granted node runs in its OWN git worktree (hard isolation between nodes). The
@@ -1537,10 +1504,11 @@ ${DISPATCH_PROMPT_HANDOFF_NOTE}
1537
1504
 
1538
1505
  ${renderHostScratchNote(hostScratchDir(artifactsDir, runId))}
1539
1506
  `,
1540
- allowedCommands: [acceptCmd, rollMerge, rollNext],
1541
- stopCondition: "Stop after every node has been accepted (accept-node returns done), results merged, and next-step has been run.",
1542
- artifactPaths: { dispatch_plan: rolling.planPath, dispatch_quota: rolling.quotaPath },
1543
- }) };
1507
+ allowedCommands: [acceptCmd, rollMerge, rollNext],
1508
+ stopCondition: "Stop after every node has been accepted (accept-node returns done), results merged, and next-step has been run.",
1509
+ artifactPaths: { dispatch_plan: rolling.planPath, dispatch_quota: rolling.quotaPath },
1510
+ }) };
1511
+ }
1544
1512
  }
1545
1513
  // Rolling per-node dispatch: prepare EVERY currently-eligible node (deps all
1546
1514
  // verified-complete), never a single artificially-serialized block. There is
@@ -1627,6 +1595,9 @@ Then run:
1627
1595
  grantedCount: implQuota?.admission?.granted_packet_ids?.length ?? 0,
1628
1596
  cooldownUntil: implQuota?.cooldown_until ?? null,
1629
1597
  bindingWindow: implBindingWindow,
1598
+ // Step E parity: classify the zero-grant cause so the quota_paused terminal's
1599
+ // reset semantics stay honest (a no_capable_pool wall has no reset to wait for).
1600
+ explains: implQuota?.admission?.explains ?? [],
1630
1601
  now: Date.now(),
1631
1602
  });
1632
1603
  if (implWall.atWall) {
@@ -1638,7 +1609,7 @@ Then run:
1638
1609
  .filter((id) => typeof id === "string");
1639
1610
  const paused = await store.loadState();
1640
1611
  if (paused) {
1641
- paused.partial_completion_terminal = buildQuotaPausedTerminal(strandedIds, implWall.earliestResetAt);
1612
+ paused.partial_completion_terminal = buildQuotaPausedTerminal(strandedIds, implWall.earliestResetAt, implWall.emptyGrantCause);
1642
1613
  await store.saveState(paused);
1643
1614
  }
1644
1615
  return { kind: "transition", state: paused };
@@ -1701,8 +1672,34 @@ Then run:
1701
1672
  * clean. Emitted (not blocked) so the run is resumable, never a failure.
1702
1673
  */
1703
1674
  async function buildQuotaPausedStep(params) {
1704
- const { root, artifactsDir, runId, strandedIds, resetAt } = params;
1675
+ const { root, artifactsDir, runId, strandedIds, resetAt, emptyGrantCause } = params;
1705
1676
  const nextCommand = loaderCommand("next-step");
1677
+ // Honest pause (step E): a no_capable_pool zero-grant is a structural fit
1678
+ // mismatch — telling the operator to wait for a reset that will never clear it
1679
+ // would strand the run indefinitely (D+E review F2).
1680
+ if (emptyGrantCause === "no_capable_pool") {
1681
+ return writeCurrentStep({
1682
+ stepKind: "quota_paused",
1683
+ status: "ready",
1684
+ runId,
1685
+ repoRoot: root,
1686
+ artifactsDir,
1687
+ prompt: `
1688
+ # Remediation paused — no available pool fits the work
1689
+
1690
+ ${strandedIds.length} node(s) could not be granted because they exceed the context
1691
+ window (or capability) of every pool currently available — a fit mismatch, NOT a
1692
+ quota wall, so waiting for a reset will not clear it. The nodes remain PENDING.
1693
+
1694
+ Options: free a larger pool (un-exclude one at the provider gate, or declare one),
1695
+ or shrink the oversized nodes' scope; then run:
1696
+
1697
+ \`${nextCommand}\`
1698
+ `,
1699
+ allowedCommands: [nextCommand],
1700
+ stopCondition: "No available pool fits the stranded nodes — free a larger pool or shrink the work, then re-run next-step.",
1701
+ });
1702
+ }
1706
1703
  const resetLine = resetAt
1707
1704
  ? `The earliest provider reset is \`${resetAt}\`. Wait until then, then run:`
1708
1705
  : `Wait for the provider session limit to reset, then run:`;
@@ -3692,6 +3689,7 @@ function buildMainObligations(ctx) {
3692
3689
  // the resuming step starts fresh; the pending nodes are the durable signal.
3693
3690
  if (terminal.reason === "quota_paused") {
3694
3691
  const resetAt = terminal.earliest_reset_at ?? null;
3692
+ const emptyGrantCause = terminal.empty_grant_cause ?? null;
3695
3693
  delete s.partial_completion_terminal;
3696
3694
  await store.saveState(s);
3697
3695
  return {
@@ -3702,6 +3700,7 @@ function buildMainObligations(ctx) {
3702
3700
  runId: stateRunId(s),
3703
3701
  strandedIds: terminal.stranded_ids ?? [],
3704
3702
  resetAt,
3703
+ emptyGrantCause,
3705
3704
  }),
3706
3705
  };
3707
3706
  }