@claudexor/orchestrator 3.1.2 → 3.2.0

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 (125) hide show
  1. package/dist/attemptFinalize.d.ts +14 -11
  2. package/dist/attemptFinalize.d.ts.map +1 -1
  3. package/dist/attemptFinalize.js +41 -14
  4. package/dist/attemptFinalize.js.map +1 -1
  5. package/dist/attemptTelemetry.d.ts +7 -0
  6. package/dist/attemptTelemetry.d.ts.map +1 -1
  7. package/dist/attemptTelemetry.js +2 -1
  8. package/dist/attemptTelemetry.js.map +1 -1
  9. package/dist/attemptUsageCost.d.ts +17 -1
  10. package/dist/attemptUsageCost.d.ts.map +1 -1
  11. package/dist/attemptUsageCost.js +11 -1
  12. package/dist/attemptUsageCost.js.map +1 -1
  13. package/dist/budgetFailure.js.map +1 -1
  14. package/dist/credential-profile-rotation.d.ts +145 -0
  15. package/dist/credential-profile-rotation.d.ts.map +1 -0
  16. package/dist/credential-profile-rotation.js +315 -0
  17. package/dist/credential-profile-rotation.js.map +1 -0
  18. package/dist/credential-profiles.d.ts +35 -134
  19. package/dist/credential-profiles.d.ts.map +1 -1
  20. package/dist/credential-profiles.js +73 -295
  21. package/dist/credential-profiles.js.map +1 -1
  22. package/dist/decisionBudget.d.ts +2 -2
  23. package/dist/decisionBudget.d.ts.map +1 -1
  24. package/dist/deepScanReducer.d.ts +14 -44
  25. package/dist/deepScanReducer.d.ts.map +1 -1
  26. package/dist/deepScanReducer.js +240 -128
  27. package/dist/deepScanReducer.js.map +1 -1
  28. package/dist/delegationBudgetAuthority.d.ts.map +1 -1
  29. package/dist/delegationBudgetAuthority.js.map +1 -1
  30. package/dist/delegationFailure.d.ts.map +1 -1
  31. package/dist/git-precondition.d.ts +11 -0
  32. package/dist/git-precondition.d.ts.map +1 -0
  33. package/dist/git-precondition.js +69 -0
  34. package/dist/git-precondition.js.map +1 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/interaction.d.ts +14 -6
  40. package/dist/interaction.d.ts.map +1 -1
  41. package/dist/interaction.js +106 -19
  42. package/dist/interaction.js.map +1 -1
  43. package/dist/orchestrator.d.ts +39 -83
  44. package/dist/orchestrator.d.ts.map +1 -1
  45. package/dist/orchestrator.js +480 -676
  46. package/dist/orchestrator.js.map +1 -1
  47. package/dist/planBrief.d.ts +27 -0
  48. package/dist/planBrief.d.ts.map +1 -0
  49. package/dist/planBrief.js +42 -0
  50. package/dist/planBrief.js.map +1 -0
  51. package/dist/planRun.d.ts +4 -3
  52. package/dist/planRun.d.ts.map +1 -1
  53. package/dist/planRun.js +33 -33
  54. package/dist/planRun.js.map +1 -1
  55. package/dist/plannerAttempt.d.ts +64 -0
  56. package/dist/plannerAttempt.d.ts.map +1 -0
  57. package/dist/plannerAttempt.js +235 -0
  58. package/dist/plannerAttempt.js.map +1 -0
  59. package/dist/policyFindings.js.map +1 -1
  60. package/dist/requestRequirements.d.ts +28 -1
  61. package/dist/requestRequirements.d.ts.map +1 -1
  62. package/dist/requestRequirements.js +89 -10
  63. package/dist/requestRequirements.js.map +1 -1
  64. package/dist/reviewerPanel.js.map +1 -1
  65. package/dist/root-ledger.d.ts.map +1 -1
  66. package/dist/root-ledger.js +2 -0
  67. package/dist/root-ledger.js.map +1 -1
  68. package/dist/run-bounded.d.ts.map +1 -1
  69. package/dist/run-bounded.js +7 -1
  70. package/dist/run-bounded.js.map +1 -1
  71. package/dist/run-input-resolution.d.ts.map +1 -1
  72. package/dist/run-input-resolution.js +26 -8
  73. package/dist/run-input-resolution.js.map +1 -1
  74. package/dist/runEventLog.d.ts +48 -0
  75. package/dist/runEventLog.d.ts.map +1 -0
  76. package/dist/runEventLog.js +63 -0
  77. package/dist/runEventLog.js.map +1 -0
  78. package/dist/runFacts.d.ts +23 -0
  79. package/dist/runFacts.d.ts.map +1 -0
  80. package/dist/runFacts.js +179 -0
  81. package/dist/runFacts.js.map +1 -0
  82. package/dist/runFactsBuilder.d.ts +5 -0
  83. package/dist/runFactsBuilder.d.ts.map +1 -0
  84. package/dist/runFactsBuilder.js +412 -0
  85. package/dist/runFactsBuilder.js.map +1 -0
  86. package/dist/runFactsDeliverable.d.ts +13 -0
  87. package/dist/runFactsDeliverable.d.ts.map +1 -0
  88. package/dist/runFactsDeliverable.js +94 -0
  89. package/dist/runFactsDeliverable.js.map +1 -0
  90. package/dist/runFactsPresentation.d.ts +7 -0
  91. package/dist/runFactsPresentation.d.ts.map +1 -0
  92. package/dist/runFactsPresentation.js +113 -0
  93. package/dist/runFactsPresentation.js.map +1 -0
  94. package/dist/runFactsReview.d.ts +7 -0
  95. package/dist/runFactsReview.d.ts.map +1 -0
  96. package/dist/runFactsReview.js +63 -0
  97. package/dist/runFactsReview.js.map +1 -0
  98. package/dist/runSupport.d.ts +17 -15
  99. package/dist/runSupport.d.ts.map +1 -1
  100. package/dist/runSupport.js +16 -0
  101. package/dist/runSupport.js.map +1 -1
  102. package/dist/runTerminalContext.d.ts +31 -0
  103. package/dist/runTerminalContext.d.ts.map +1 -0
  104. package/dist/runTerminalContext.js +12 -0
  105. package/dist/runTerminalContext.js.map +1 -0
  106. package/dist/runTerminalResults.d.ts +55 -0
  107. package/dist/runTerminalResults.d.ts.map +1 -0
  108. package/dist/runTerminalResults.js +134 -0
  109. package/dist/runTerminalResults.js.map +1 -0
  110. package/dist/runTerminals.d.ts +4 -80
  111. package/dist/runTerminals.d.ts.map +1 -1
  112. package/dist/runTerminals.js +357 -260
  113. package/dist/runTerminals.js.map +1 -1
  114. package/dist/secretDiff.d.ts +3 -0
  115. package/dist/secretDiff.d.ts.map +1 -1
  116. package/dist/secretDiff.js +7 -0
  117. package/dist/secretDiff.js.map +1 -1
  118. package/dist/structuredOutput.d.ts.map +1 -1
  119. package/dist/structuredOutput.js.map +1 -1
  120. package/dist/task-contract-builder.d.ts +35 -0
  121. package/dist/task-contract-builder.d.ts.map +1 -0
  122. package/dist/task-contract-builder.js +114 -0
  123. package/dist/task-contract-builder.js.map +1 -0
  124. package/dist/transientClassify.js.map +1 -1
  125. package/package.json +17 -17
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
2
+ import { effectiveAuthPreference, observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, probeCredentialProfileStatus, profileHeadroomBreach, profileStatusAdmits, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, staticRotationCandidates, } from "./credential-profiles.js";
3
3
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
4
4
  import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
5
5
  import { processAttemptUsage } from "./attemptUsage.js";
@@ -9,9 +9,10 @@ import { capabilityIntents } from "@claudexor/gateway";
9
9
  import { policyFindings } from "./policyFindings.js";
10
10
  import { join } from "node:path";
11
11
  import { finalizePlanRun, runCouncilPlan, writePlanHarnessFailure, } from "./planRun.js";
12
- import { HarnessRunSpec, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_ACCESS_CODE, FrozenTaskContractArtifact as TaskContractSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
13
- import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
14
- import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
12
+ import { runPlannerAttempt as executePlannerAttempt, } from "./plannerAttempt.js";
13
+ import { HarnessRunSpec, ModeKind as ModeKindSchema, QuotaSnapshot as QuotaSnapshotSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
14
+ import { globalConfigDir, loadConfig } from "@claudexor/config";
15
+ import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
15
16
  import { assertRouteModelsAllowed } from "./modelGovernance.js";
16
17
  import { governRouteEffort } from "./effortGovernance.js";
17
18
  import { isFullAccess, RequestRequirementsResolver } from "./requestRequirements.js";
@@ -20,16 +21,18 @@ import { routingFailureClassification } from "./routing-failure.js";
20
21
  export { routingFailureClassification } from "./routing-failure.js";
21
22
  import { runBounded } from "./run-bounded.js";
22
23
  import { planPrompt } from "./plan-prompt.js";
24
+ import { verifiedPlanBrief, withPlanBrief } from "./planBrief.js";
23
25
  import { resolveRunInputDefaults } from "./run-input-resolution.js";
24
- import { createRootLedger } from "./root-ledger.js";
26
+ import { beginAnnouncedRun } from "./runEventLog.js";
25
27
  import { arbitrationBudgetOptions, decisionBudgetSummary } from "./decisionBudget.js";
26
28
  import { buildRevisePrompt } from "./revisePrompt.js";
27
- import { announcedRunContext, cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
29
+ import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
28
30
  import { budgetFailureRecord, classifyBudgetFailure } from "./budgetFailure.js";
29
31
  import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
30
- import { transientRetryDelayMs, promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
32
+ import { transientRetryDelayMs, promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, winnerNeedsHuman, writeRaceDeliveryDecision, } from "./runSupport.js";
31
33
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
32
34
  import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
35
+ import { ensureWriteModeGitBoundary } from "./git-precondition.js";
33
36
  import { buildContinuation, } from "./continuity.js";
34
37
  import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
35
38
  import { runDiffReview } from "./diffReview.js";
@@ -41,18 +44,18 @@ import { dominantHarnessFailureCategory, harnessFailureNextActions } from "./har
41
44
  import { finalizeAttempt, readOnlyNoSuccessTerminal, resolveWorkReportEnvelope, unrecoveredToolErrorFailure, unwrapWorkReportEnvelope, webEvidenceFailure, } from "./attemptFinalize.js";
42
45
  import { buildContinuationPacket, decideContinuation, synthesizeContinuationRequest, } from "./continuation.js";
43
46
  import { interactionChannelFor } from "./interaction.js";
44
- import { gateSpecsFromContract, renderTestsEvidence, resolveContractGates, } from "./contract-gates.js";
47
+ import { gateSpecsFromContract, renderTestsEvidence } from "./contract-gates.js";
48
+ import { buildTaskContract } from "./task-contract-builder.js";
45
49
  import { ArtifactStore } from "@claudexor/artifact-store";
46
- import { EventLog } from "@claudexor/event-log";
47
50
  import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
48
- import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureClaudeBridge, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
51
+ import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureClaudeBridge, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
49
52
  import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
50
53
  import { HarnessGateway } from "@claudexor/gateway";
51
54
  import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
52
55
  import { arbitrate } from "@claudexor/arbitration";
53
56
  import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
54
57
  import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, explainRanking, loadHarnessMetrics, promptFingerprint, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
55
- import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLikeToken, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
58
+ import { readTextSafe, appendLine, assertNoInlineSecretValues, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
56
59
  import { assertWriteIsolation } from "./write-isolation.js";
57
60
  const LABELS = "ABCDEFGHIJ".split("");
58
61
  const NO_PROJECT_ROOT = noProjectRepoRoot();
@@ -110,10 +113,14 @@ export class Orchestrator {
110
113
  throw new Error(`Delegate is an agent-only strategy (got mode=${mode})`);
111
114
  }
112
115
  const runId = resolved.runId ?? newId("run");
116
+ let delegatedLedgerAttached = false;
113
117
  resolved = {
114
118
  ...resolved,
115
119
  runId,
116
120
  taskId: resolved.taskId ?? newId("task"),
121
+ onDelegatedLedgerAttached: () => {
122
+ delegatedLedgerAttached = true;
123
+ },
117
124
  };
118
125
  if (resolved.delegate === true) {
119
126
  resolved = {
@@ -157,50 +164,63 @@ export class Orchestrator {
157
164
  if (resolved.repoRoot !== NO_PROJECT_ROOT) {
158
165
  assertMandatoryContext(resolved.repoRoot, this.projectConfig(resolved.repoRoot).context.mandatory_files);
159
166
  }
160
- // Reviewer panels are validated only inside the strategies that actually
161
- // review (race/convergence under agent, and plan) — AFTER run-dir
167
+ // Reviewer panels are validated only inside Agent strategies that actually
168
+ // review (race/convergence; Plan Council is the plan critique path) — AFTER run-dir
162
169
  // creation, so a doomed explicit panel yields typed failure ARTIFACTS
163
170
  // (failure.yaml naming the refusal) instead of a bare pre-run throw.
164
- // ask/audit never spawn reviewers, so a panel there never spends doctor/
171
+ // Ask and Plan never spawn code reviewers, so a panel there never spends doctor/
165
172
  // model probes and never fails a run that would not use it.
166
173
  // Whole-strategy terminal net: once a strategy ANNOUNCES its
167
174
  // run, any escaped throw still stamps failure.yaml + summary + run.failed
168
175
  // instead of orphaning events.jsonl.
169
- return guardAnnouncedRun(resolved.signal, (announce) => {
170
- switch (mode) {
171
- case "ask":
172
- // `--deep-scan` widens the answer into the bounded multi-scout
173
- // research sweep with synthesis (the old `audit --swarm`/`explore`).
174
- return resolved.deepScan
175
- ? this.runDeepScan(resolved, announce)
176
- : this.runAsk(resolved, announce);
177
- case "agent":
178
- // Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
179
- // and `--attempts` select the convergence loop; `--n` selects the race
180
- // width; `--create` switches the candidate intent to create_from_scratch.
181
- if (resolved.untilClean)
182
- return this.runConvergence(resolved, mode, null, announce);
183
- if (resolved.attempts !== undefined && resolved.attempts !== null) {
184
- return this.runConvergence(resolved, mode, resolved.attempts, announce);
185
- }
186
- return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
187
- case "plan":
188
- return this.runPlan(resolved, announce);
176
+ const releaseRunState = (settledRunId) => {
177
+ this.routingRationaleByRun.delete(settledRunId);
178
+ this.deps.delegationBudgetAuthority?.releaseRun(settledRunId);
179
+ };
180
+ try {
181
+ return await guardAnnouncedRun(resolved.signal, (announce) => {
182
+ switch (mode) {
183
+ case "ask":
184
+ // `--deep-scan` widens the answer into the bounded multi-scout
185
+ // research sweep with synthesis (the old `audit --swarm`/`explore`).
186
+ return resolved.deepScan
187
+ ? this.runDeepScan(resolved, announce)
188
+ : this.runAsk(resolved, announce);
189
+ case "agent":
190
+ // Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
191
+ // and `--attempts` select the convergence loop; `--n` selects the race
192
+ // width; `--create` switches the candidate intent to create_from_scratch.
193
+ if (resolved.untilClean)
194
+ return this.runConvergence(resolved, mode, null, announce);
195
+ if (resolved.attempts !== undefined && resolved.attempts !== null) {
196
+ return this.runConvergence(resolved, mode, resolved.attempts, announce);
197
+ }
198
+ return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
199
+ case "plan":
200
+ return this.runPlan(resolved, announce);
201
+ }
202
+ }, async ({ runId }) => {
203
+ const authority = this.deps.delegationBudgetAuthority;
204
+ if (!authority?.hasParent(runId))
205
+ return;
206
+ authority.beginParentClose(runId);
207
+ await authority.waitForChildren(runId);
208
+ },
209
+ // Single per-run terminalization hook: release the routing-rationale map
210
+ // entry on EVERY terminal (incl. a run that died before its telemetry
211
+ // writer ran, which is the leak this closes).
212
+ releaseRunState);
213
+ }
214
+ catch (error) {
215
+ // A durable startup sink may refuse `run.created` before the strategy can
216
+ // announce its context. Release only a delegated child ledger acquired by
217
+ // THIS invocation; a caller-supplied run-id collision must never release
218
+ // another live parent/child authority or its routing state.
219
+ if (delegatedLedgerAttached) {
220
+ this.deps.delegationBudgetAuthority?.releaseRun(runId);
189
221
  }
190
- }, async ({ runId }) => {
191
- const authority = this.deps.delegationBudgetAuthority;
192
- if (!authority?.hasParent(runId))
193
- return;
194
- authority.beginParentClose(runId);
195
- await authority.waitForChildren(runId);
196
- },
197
- // Single per-run terminalization hook: release the routing-rationale map
198
- // entry on EVERY terminal (incl. a run that died before its telemetry
199
- // writer ran, which is the leak this closes).
200
- (runId) => {
201
- this.routingRationaleByRun.delete(runId);
202
- this.deps.delegationBudgetAuthority?.releaseRun(runId);
203
- });
222
+ throw error;
223
+ }
204
224
  }
205
225
  async resolveReviewers(cwd, runAuthPreference, onIgnoredSetting) {
206
226
  if (this.deps.reviewers)
@@ -273,11 +293,7 @@ export class Orchestrator {
273
293
  }
274
294
  authPreferenceForHarness(repoRoot, harnessId, runAuthPreference) {
275
295
  const cfg = this.config(repoRoot)?.global;
276
- const explicit = (v) => v && v !== "auto" ? v : undefined;
277
- return (explicit(runAuthPreference) ??
278
- explicit(cfg?.harnesses?.[harnessId]?.auth_preference) ??
279
- explicit(cfg?.routing?.auth_preference) ??
280
- "auto");
296
+ return effectiveAuthPreference(runAuthPreference, cfg?.harnesses?.[harnessId]?.auth_preference, cfg?.routing?.auth_preference);
281
297
  }
282
298
  artifactStore(input) {
283
299
  if (input.mode === "ask" && input.contextMode === "off" && input.repoRoot === NO_PROJECT_ROOT) {
@@ -300,20 +316,23 @@ export class Orchestrator {
300
316
  estimateUsdFloor(repoRoot) {
301
317
  return this.config(repoRoot)?.global.budget.estimate_usd_floor ?? 0.05;
302
318
  }
319
+ /** Delegate children overlap their still-running parent, so child-side paid
320
+ * units use the same bounded floor as later slots in a parallel wave. */
321
+ reservationEstimateUsd(input, parallel = false) {
322
+ return parallel || Boolean(input.delegatedFromRunId)
323
+ ? this.estimateUsdFloor(input.repoRoot)
324
+ : undefined;
325
+ }
303
326
  execRootOf(input) {
304
327
  return input.executionRoot ?? input.repoRoot;
305
328
  }
306
- sessionSpecFields(input, harnessId, log) {
329
+ async sessionSpecFields(input, harnessId, log, defaultRoute = null) {
307
330
  const cfg = this.config(input.repoRoot)?.global;
308
- const profile = this.preflightProfile(input, harnessId, log);
309
- const explicit = (v) => (v && v !== "auto" ? v : undefined);
331
+ const profile = await this.preflightProfile(input, harnessId, log, defaultRoute);
310
332
  return {
311
333
  // "auto" at ANY level falls through (thread turns send the thread default
312
334
  // "auto" as a per-run value; it must not shadow a configured preference).
313
- auth_preference: explicit(input.authPreference) ??
314
- explicit(cfg?.harnesses?.[harnessId]?.auth_preference) ??
315
- explicit(cfg?.routing?.auth_preference) ??
316
- "auto",
335
+ auth_preference: effectiveAuthPreference(input.authPreference, cfg?.harnesses?.[harnessId]?.auth_preference, cfg?.routing?.auth_preference),
317
336
  resume_session_id: resumeSessionForProfile(input.resumeSessions?.[harnessId], profile),
318
337
  credential_profile: profile,
319
338
  };
@@ -388,19 +407,60 @@ export class Orchestrator {
388
407
  const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
389
408
  return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
390
409
  }
391
- preflightProfile(input, harnessId, log) {
410
+ /** Fresh profile readiness for one rotation decision epoch. Accounts uses
411
+ * the same probe wrapper + admission predicate when projecting next_up. */
412
+ async readyProfileIdsForRotation(input, harnessId, current, excluded = new Set()) {
413
+ const profiles = staticRotationCandidates({
414
+ registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
415
+ harnessId,
416
+ policy: this.profilePolicy(input.repoRoot, harnessId),
417
+ current,
418
+ excluded,
419
+ });
420
+ const adapter = this.deps.registry.get(harnessId);
421
+ const entries = await Promise.all(profiles.map(async (profile) => ({
422
+ profile,
423
+ status: await probeCredentialProfileStatus(profile, adapter?.probeCredentialProfile?.bind(adapter)),
424
+ })));
425
+ return new Set(entries
426
+ .filter(({ profile, status }) => profileStatusAdmits(profile, status))
427
+ .map(({ profile }) => profile.profile_id));
428
+ }
429
+ async preflightProfile(input, harnessId, log, defaultRoute) {
392
430
  const profile = this.resolveCredentialProfile(input, harnessId);
393
431
  const policy = this.profilePolicy(input.repoRoot, harnessId);
394
432
  const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
395
433
  const snapshots = this.deps.quotaSnapshots?.() ?? [];
396
434
  const emit = (type, payload) => log?.emit(type, payload);
435
+ const breach = profileHeadroomBreach(snapshots, harnessId, profile?.profile_id ?? null, policy.headroom_threshold);
436
+ const readyProfileIds = policy.limit_action === "rotate" &&
437
+ breach !== null &&
438
+ (profile !== null || defaultRoute === "local_session")
439
+ ? await this.readyProfileIdsForRotation(input, harnessId, profile)
440
+ : new Set();
397
441
  if (!profile) {
398
442
  // Unpinned runs (INV-135 auto-balance): under `rotate`, a fresh
399
443
  // default-subject headroom breach starts on the next eligible
400
444
  // subscription profile instead; `fail`/`ask` change nothing.
401
- return preflightDefaultSubject({ harnessId, policy, registry, snapshots, emit });
445
+ return preflightDefaultSubject({
446
+ harnessId,
447
+ policy,
448
+ registry,
449
+ snapshots,
450
+ readyProfileIds,
451
+ defaultRoute,
452
+ emit,
453
+ });
402
454
  }
403
- return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
455
+ return preflightCredentialProfile({
456
+ profile,
457
+ harnessId,
458
+ policy,
459
+ registry,
460
+ snapshots,
461
+ readyProfileIds,
462
+ emit,
463
+ });
404
464
  }
405
465
  /**
406
466
  * Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
@@ -477,6 +537,27 @@ export class Orchestrator {
477
537
  }
478
538
  }
479
539
  }
540
+ const attachments = input.attachments ?? [];
541
+ // Resolve the complete attachment pool once from manifest truth. If any
542
+ // lane still lacks usable discovery truth, preserve the existing per-lane
543
+ // discovery/doctor precedence and resolve that lane inside the route loop.
544
+ const canResolveAttachmentPool = attachments.length > 0 &&
545
+ ids.every((id) => {
546
+ const status = statusById.get(id);
547
+ return status?.manifest != null && status.status !== "unavailable";
548
+ });
549
+ const attachmentPoolAdmission = canResolveAttachmentPool
550
+ ? this.requestRequirements.resolveAttachmentPool(explicitPool ? "explicit" : "auto", attachments, ids.map((id) => ({
551
+ harnessId: id,
552
+ declarations: statusById.get(id)?.manifest?.capability_profile.attachment_inputs ?? null,
553
+ available: true,
554
+ })))
555
+ : null;
556
+ if (attachmentPoolAdmission?.outcome === "refused") {
557
+ throw new HarnessUnavailableError(attachmentPoolAdmission.message ??
558
+ "no available harness lane can receive the selected attachments");
559
+ }
560
+ const attachmentRejectionById = new Map(attachmentPoolAdmission?.rejected.map((admission) => [admission.harnessId, admission]) ?? []);
480
561
  const policy = input.web ?? input.externalContextPolicy ?? "auto";
481
562
  const pool = [];
482
563
  const dropped = [];
@@ -610,9 +691,10 @@ export class Orchestrator {
610
691
  dropLane(id, "web", why);
611
692
  continue;
612
693
  }
613
- const attachmentRefusal = this.requestRequirements.attachmentRefusal(id, input.attachments ?? [], manifest.capability_profile.attachment_inputs);
614
- if (attachmentRefusal) {
615
- dropLane(id, "attachment", attachmentRefusal);
694
+ const attachmentAdmission = attachmentRejectionById.get(id) ??
695
+ this.requestRequirements.resolveAttachmentLane(id, attachments, manifest.capability_profile.attachment_inputs);
696
+ if (!attachmentAdmission.admitted) {
697
+ dropLane(id, "attachment", attachmentAdmission.message ?? `${id} rejects attachments`);
616
698
  continue;
617
699
  }
618
700
  const reason = status.reasons.length > 0 ? `: ${status.reasons.join("; ")}` : "";
@@ -981,119 +1063,9 @@ export class Orchestrator {
981
1063
  return this.config(repoRoot).project;
982
1064
  }
983
1065
  buildContract(input, taskId, mode) {
984
- const resolvedCfg = this.config(input.repoRoot);
985
- const cfg = resolvedCfg.project;
986
- const readOnlyMode = mode === "ask" || mode === "plan";
987
- const requestedAccess = input.access ?? (readOnlyMode ? "readonly" : resolvedCfg.trust.access_default);
988
- // Effective access is COMPUTED by the engine, never echoed from a client:
989
- // read-only modes clamp to readonly regardless of the request.
990
- const effectiveAccess = readOnlyMode ? "readonly" : requestedAccess;
991
- // TrustConfig is USER-LEVEL only (versioned repo config must never
992
- // self-grant sensitive powers): unsandboxed full access requires an
993
- // explicit allow in ~/.claudexor trust settings — loud error, no downgrade.
994
- // The gate applies to the EFFECTIVE profile: a read-only run clamped to
995
- // readonly never runs unsandboxed and needs no trust allow.
996
- if (effectiveAccess === "full" && !resolvedCfg.trust.allow_full_access) {
997
- // Typed refusal: the `code` rides the daemon job record onto the thread
998
- // turn (TurnEnqueueError.code), so surfaces key remedies on the CODE —
999
- // never on substring-matching this human message.
1000
- throw Object.assign(new Error(`access profile 'full' requires allow_full_access: true in the user-level trust file for this repo ` +
1001
- `(${trustConfigPath(input.repoRoot)}); enable it with \`claudexor trust --allow-full-access\` — refusing to run unsandboxed`),
1002
- // Refusal semantics are born at the throw (W24): the one-time grant is
1003
- // a 403, and the daemon persists this status onto the job record.
1004
- { code: TRUST_FULL_ACCESS_CODE, status: 403 });
1005
- }
1006
- const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
1007
- // Deterministic gate commands come from explicit run input, then versioned
1008
- // project config. Without these, gateSpecs is empty and convergence is
1009
- // review-only; with them, convergence is test-driven.
1010
- const resolvedGates = resolveContractGates({
1011
- repoRoot: input.repoRoot,
1012
- effectiveAccess,
1013
- config: cfg,
1014
- trustGrants: resolvedCfg.trust.test_command_grants,
1015
- operatorCommands: input.tests ?? [],
1016
- projectCommands: cfg.tests?.commands ?? [],
1017
- });
1018
- const commands = resolvedGates.commands;
1019
- const protectedPaths = [...new Set(cfg.constraints.protected_paths)];
1020
- const autoProtectedPaths = resolvedGates.autoProtectedPaths;
1021
- const protectedPathApprovals = [
1022
- ...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
1023
- ];
1024
- return TaskContractSchema.parse({
1025
- schema_version: SCHEMA_VERSION,
1026
- task_id: taskId,
1027
- created_at: nowIso(),
1028
- repo: { root: input.repoRoot, base_ref: input.baseRef ?? "HEAD", dirty_policy: "snapshot" },
1029
- mode: { kind: mode },
1030
- delegation_requested: input.delegate === true,
1031
- run_lineage: {
1032
- parent_run_id: input.parentRunId ?? null,
1033
- delegated_from_run_id: input.delegatedFromRunId ?? null,
1034
- },
1035
- user_intent: { raw: redactSecrets(input.prompt) },
1036
- // Redacted for symmetry with user_intent.raw — a no-op on fenced input
1037
- // (the inline-secret fence already blocked any secret-like value at every
1038
- // ingress incl. this engine boundary), so task-producing lanes read back
1039
- // the real instructions via harnessSpecKnobs().
1040
- instructions: input.instructions === undefined ? undefined : redactSecrets(input.instructions),
1041
- // Already normalized/strictified at the engine boundary (run() refuses
1042
- // unsupported shapes before any run dir exists).
1043
- output_schema: input.outputSchema ?? null,
1044
- auth_preference: input.authPreference ?? "auto",
1045
- credential_profile_id: input.credentialProfileId ?? null,
1046
- max_turns: input.maxTurns ?? null,
1047
- constraints: {
1048
- protected_paths: protectedPaths,
1049
- deny_paths: [...new Set(input.denyPaths ?? [])],
1050
- auto_protected_paths: autoProtectedPaths,
1051
- protected_path_approvals: protectedPathApprovals,
1052
- },
1053
- tests: { commands },
1054
- access: {
1055
- requested_profile: requestedAccess,
1056
- effective_profile: effectiveAccess,
1057
- },
1058
- external_context: {
1059
- policy: externalContextPolicy,
1060
- web_required: externalContextPolicy === "cached" || externalContextPolicy === "live",
1061
- // Per-route upgrades (e.g. claude cached->live) are disclosed in events
1062
- // and telemetry.yaml; the immutable contract records the requested policy.
1063
- effective_mode: externalContextPolicy,
1064
- },
1065
- // Harness-native tool names are adapter knowledge; the neutral contract
1066
- // carries only the policy plus user-configured allow/deny lists (wired
1067
- // from per-harness settings).
1068
- tool_permission_policy: {
1069
- web: externalContextPolicy,
1070
- allow: [],
1071
- deny: [],
1072
- },
1073
- budget: {
1074
- routing_goal: input.routingGoal ?? this.deps.routingGoal ?? cfg?.budget?.routing_goal ?? "auto",
1075
- paid_budget: this.resolvePaidBudget(input.paidBudget, resolvedCfg),
1076
- },
1077
- // The resolved harness-scoped model map (scalar already expanded to the
1078
- // primary by resolveRunInput). The contract is what route spec building
1079
- // reads — there is no run-global model (INV-103).
1080
- routing_models: input.models ?? {},
1081
- // QA-035: freeze the RESOLVED reasoning-effort per known lane so Exact
1082
- // Retry replays it instead of re-resolving current settings. Precedence
1083
- // (specific beats general): the harness-scoped `efforts` map entry, then a
1084
- // per-turn scalar `input.effort`, then the harness settings default — the
1085
- // same map that Exact Retry replays so a NON-PRIMARY lane keeps its own
1086
- // frozen effort (QA-035 completeness). Only known-pool lanes are frozen
1087
- // here (a pure auto pool's lanes resolve later — documented seam).
1088
- routing_efforts: Object.fromEntries([...new Set([...(input.harnesses ?? []), ...Object.keys(input.efforts ?? {})])]
1089
- .map((hid) => [
1090
- hid,
1091
- input.efforts?.[hid] ??
1092
- input.effort ??
1093
- resolvedCfg.global.harnesses?.[hid]?.effort ??
1094
- null,
1095
- ])
1096
- .filter((entry) => entry[1] !== null)),
1066
+ return buildTaskContract(input, taskId, mode, {
1067
+ paidBudget: this.deps.paidBudget,
1068
+ routingGoal: this.deps.routingGoal,
1097
1069
  });
1098
1070
  }
1099
1071
  /**
@@ -1172,9 +1144,9 @@ export class Orchestrator {
1172
1144
  /**
1173
1145
  * D-16: the WorkReport transport envelope for one route. Called at every
1174
1146
  * task-producing spec-build site AFTER harnessSpecKnobs so it OVERRIDES the
1175
- * plain caller-schema transport with the compiled `{work_report, output}`
1176
- * envelope on capable routes. The returned `mode` is retained by the caller
1177
- * and handed to `unwrapWorkReportEnvelope` when the answer is finalized.
1147
+ * plain caller-schema transport with the resolved WorkReport channel on
1148
+ * capable routes. The returned `mode` is retained by the caller and handed
1149
+ * to `unwrapWorkReportEnvelope` when the answer is finalized.
1178
1150
  */
1179
1151
  workReportEnvelopeFor(routed, contract, interactive) {
1180
1152
  return resolveWorkReportEnvelope({
@@ -1188,7 +1160,7 @@ export class Orchestrator {
1188
1160
  /**
1189
1161
  * D-16: apply the resolved WorkReport transport to a built spec — set the
1190
1162
  * envelope output_schema (constrained/side_tool routes) and APPEND the fenced
1191
- * envelope instruction (validated routes, e.g. cursor). Mutates the spec in
1163
+ * metadata instruction (validated routes, e.g. cursor). Mutates the spec in
1192
1164
  * place and returns the mode the answer unwrap consumes. Called at every
1193
1165
  * task-producing spec-build site so the transport is never wired one-off.
1194
1166
  */
@@ -1265,7 +1237,7 @@ export class Orchestrator {
1265
1237
  * Returns null (no packet, no pointer) for native resume, a fresh thread, or
1266
1238
  * a non-thread run. Never throws: continuity failure degrades to no packet.
1267
1239
  */
1268
- async resolveContinuity(runInput, harnessId, resolvedProfileId, nativeResumeAvailable, store, paths, repoRoot, log) {
1240
+ async resolveContinuity(runInput, harnessId, resolvedProfileId, nativeResumeAvailable, sessionFields, store, paths, repoRoot, log) {
1269
1241
  const ctx = runInput.threadContinuity;
1270
1242
  if (!runInput.threadId || !ctx)
1271
1243
  return null;
@@ -1303,7 +1275,6 @@ export class Orchestrator {
1303
1275
  // summary. Same credential route + scoped lane home a real read-only
1304
1276
  // thread turn uses (INV-034/135). Best-effort in its OWN guard — a summary
1305
1277
  // failure keeps the full mechanical packet, never drops it.
1306
- const sessionFields = this.sessionSpecFields(runInput, harnessId);
1307
1278
  req.cachedSummary = await resolveContinuitySummary({
1308
1279
  req,
1309
1280
  threadId: runInput.threadId,
@@ -1371,13 +1342,15 @@ export class Orchestrator {
1371
1342
  // Isolated scoped-home sessions are never retained after disposal.
1372
1343
  const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
1373
1344
  const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
1374
- const sessionFields = runInput ? this.sessionSpecFields(runInput, adapter.id, log) : undefined;
1345
+ const sessionFields = runInput
1346
+ ? await this.sessionSpecFields(runInput, adapter.id, log, routed.authRouteEstimate)
1347
+ : undefined;
1375
1348
  // Continuity (INV-137): once the lane (harness + resolved profile) is known,
1376
1349
  // build the continuation packet, materialize context/THREAD.md, and point
1377
1350
  // the prompt at it — never embed the packet body in the prompt. Replaces the
1378
1351
  // old static session.rebound "not_portable" phrase with a real disclosure.
1379
1352
  const laneContinuity = runInput
1380
- ? await this.resolveContinuity(runInput, adapter.id, sessionFields?.credential_profile?.profile_id ?? runInput.credentialProfileId ?? null, inPlaceEnvelope && !!sessionFields?.resume_session_id, store, paths, envelope.repo_root, log)
1353
+ ? await this.resolveContinuity(runInput, adapter.id, sessionFields?.credential_profile?.profile_id ?? runInput.credentialProfileId ?? null, inPlaceEnvelope && !!sessionFields?.resume_session_id, sessionFields, store, paths, envelope.repo_root, log)
1381
1354
  : null;
1382
1355
  let spec = HarnessRunSpec.parse({
1383
1356
  session_id: newId("ses"),
@@ -1480,13 +1453,16 @@ export class Orchestrator {
1480
1453
  try {
1481
1454
  const watched = withInactivityWatchdog(adapter.run(runSpec), {
1482
1455
  timeoutMs: inactivityMs,
1456
+ countsAsProgress: countsAsAgentProgress,
1483
1457
  onTimeout: () => {
1484
1458
  attemptAbort.abort();
1485
1459
  void adapter.cancel?.(activeSessionId)?.catch(() => { });
1486
1460
  },
1487
- // Waiting on the USER (pending interaction) is legitimate
1488
- // silence the interaction channel enforces its own wait budget.
1461
+ // Waiting on the USER is legitimate silence. The interaction policy
1462
+ // either enforces a finite deadline or waits until answer/cancel/
1463
+ // terminal/restart when the timeout is disabled.
1489
1464
  isSuspended: () => (interaction?.pendingCount?.() ?? 0) > 0,
1465
+ suspensionVersion: () => interaction?.suspensionVersion?.() ?? 0,
1490
1466
  });
1491
1467
  for await (const ev of watched) {
1492
1468
  if (signal?.aborted)
@@ -1616,13 +1592,18 @@ export class Orchestrator {
1616
1592
  // W5.4 failover: a typed-limit hit rebuilds the spec on a NEW vendor
1617
1593
  // session under the next profile with provenance (vendor_limit_rejected).
1618
1594
  if (harnessErrored && runInput && !signal?.aborted) {
1595
+ const rotationPolicy = this.profilePolicy(contract.repo.root, adapter.id);
1596
+ const readyProfileIds = sawTypedLimit && deliverableEmpty && rotationPolicy.limit_action === "rotate"
1597
+ ? await this.readyProfileIdsForRotation(runInput, adapter.id, spec.credential_profile ?? null, triedProfiles)
1598
+ : new Set();
1619
1599
  const rotated = rotateSpecOnTypedLimit({
1620
1600
  spec,
1621
1601
  harnessId: adapter.id,
1622
1602
  attemptId,
1623
- policy: this.profilePolicy(contract.repo.root, adapter.id),
1603
+ policy: rotationPolicy,
1624
1604
  registry: this.config(contract.repo.root)?.global.credential_profiles ?? [],
1625
1605
  snapshots: this.deps.quotaSnapshots?.() ?? [],
1606
+ readyProfileIds,
1626
1607
  triedProfiles,
1627
1608
  sawTypedLimit,
1628
1609
  deliverableEmpty,
@@ -1679,7 +1660,7 @@ export class Orchestrator {
1679
1660
  if (webUnsatisfied(telemetry)) {
1680
1661
  errors.push(webEvidenceFailure(telemetry.web));
1681
1662
  }
1682
- // D-16: un-nest {work_report, output} so answer.md persists the OUTPUT, not the envelope.
1663
+ // D-16: remove the WorkReport transport so answer.md persists only the deliverable.
1683
1664
  const unwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", workReportMode, {
1684
1665
  sideToolReport: telemetry.sideToolWorkReport ?? undefined,
1685
1666
  });
@@ -1814,66 +1795,17 @@ export class Orchestrator {
1814
1795
  // Thin delegate — the channel mechanics live in interaction.ts.
1815
1796
  return interactionChannelFor(input, log, runId, taskId, attemptId, harnessId, supportsInteractive, DEFAULT_INTERACTION_TIMEOUT_MS);
1816
1797
  }
1817
- /**
1818
- * Guarantee a git boundary for write-mode runs. Non-git project folders are
1819
- * initialized in place (`git init`, deterministic baseline commit) without
1820
- * creating or editing `.gitignore`, and the action is announced via a
1821
- * `project.git.initialized` event. Returns the failure message when the
1822
- * boundary cannot be established (the terminal failure events are already
1823
- * emitted); null on success.
1824
- */
1825
- async ensureWriteModeGitBoundary(repoRoot, log, store, paths, runId, mode) {
1826
- if (repoRoot === NO_PROJECT_ROOT)
1827
- return null;
1828
- try {
1829
- const result = await ensureGitRepository(repoRoot);
1830
- if (result.initialized || result.baselineCommitted) {
1831
- log.emit("project.git.initialized", {
1832
- repo_root: repoRoot,
1833
- initialized: result.initialized,
1834
- baseline_committed: result.baselineCommitted,
1835
- gitignore_seeded: result.gitignoreSeeded,
1836
- head_sha: result.headSha,
1837
- });
1838
- }
1839
- return null;
1840
- }
1841
- catch (err) {
1842
- const message = safeErrorMessage(err);
1843
- writeFailure(store, paths, {
1844
- phase: "workspace",
1845
- category: "project",
1846
- safeMessage: message,
1847
- runDir: paths.root,
1848
- nextActions: [
1849
- "Check the project folder permissions",
1850
- "Initialize git manually (git init)",
1851
- "Retry the run",
1852
- ],
1853
- });
1854
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: workspace\n\n${message}\n`);
1855
- log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1856
- log.emit("run.failed", {
1857
- lifecycle: "failed",
1858
- facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1859
- reason: "harness_failed",
1860
- phase: "workspace",
1861
- error: message,
1862
- failure_ref: "final/failure.yaml",
1863
- });
1864
- return message;
1865
- }
1866
- }
1867
1798
  /**
1868
1799
  * D-14 layer 3 (AGENTS.md unification, INV-113): the ONE new live-tree write.
1869
1800
  * When the PROJECT root has `AGENTS.md` and no `CLAUDE.md`, drop a thin
1870
1801
  * `CLAUDE.md` (`@AGENTS.md` import + Claudexor ownership marker) so a Claude
1871
1802
  * Code route reads the same instruction file codex/cursor read natively.
1872
1803
  *
1873
- * Fenced exactly where the automatic git-init boundary is: read-only modes
1874
- * never reach this run-prep stage; `--in-place` stateful targets are left
1875
- * untouched; the write targets the PROJECT root (`repoRoot`), never a worktree
1876
- * envelope. The workspace helper adds exclusive-create + no-follow +
1804
+ * The project-root bridge has its own narrower fence: read-only modes never
1805
+ * reach this run-prep stage and `--in-place` stateful targets are left
1806
+ * untouched. Git admission is independently owned by `runStartRequiresGit`.
1807
+ * The write targets the PROJECT root (`repoRoot`), never a worktree envelope.
1808
+ * The workspace helper adds exclusive-create + no-follow +
1877
1809
  * idempotency, so a hand-written or symlinked `CLAUDE.md` is never overwritten
1878
1810
  * and a concurrent/second prep is a no-op. Announced via a typed
1879
1811
  * `project.claude_bridge.created` event on an actual create only — the git-init
@@ -1898,37 +1830,6 @@ export class Orchestrator {
1898
1830
  });
1899
1831
  }
1900
1832
  }
1901
- /**
1902
- * Freeze-on-implement delivery (D17/D27): verify the frozen plan's hash and
1903
- * materialize it as context/PLAN.md in the run artifact tree — OUTSIDE every
1904
- * worktree, so it can never dirty a diff — then point the prompt at the
1905
- * absolute path. A mismatched or unreadable plan fails LOUDLY before any
1906
- * harness spawns (the tamper fence; retry replays planRef verbatim, so a
1907
- * retried implement can never silently run without its plan).
1908
- */
1909
- withPlanBrief(input, store, paths, log) {
1910
- if (!input.planRef)
1911
- return input;
1912
- const text = readTextSafe(input.planRef.path);
1913
- if (!text || !text.trim()) {
1914
- throw new Error(`implement plan: the frozen plan at ${input.planRef.path} is missing or unreadable`);
1915
- }
1916
- const digest = sha256(text).replace(/^sha256:/, "");
1917
- if (digest !== input.planRef.sha256) {
1918
- throw new Error(`implement plan: plan hash mismatch (expected ${input.planRef.sha256}, got ${digest}) — the plan was modified after freeze; re-run Implement from the plan turn`);
1919
- }
1920
- const briefPath = join(paths.contextDir, "PLAN.md");
1921
- store.writeText(briefPath, text);
1922
- log.emit("plan.brief.materialized", {
1923
- plan_run_id: input.planRef.runId,
1924
- sha256: input.planRef.sha256,
1925
- path: "context/PLAN.md",
1926
- });
1927
- return {
1928
- ...input,
1929
- prompt: `${input.prompt}\n\nThe approved plan is at: ${briefPath} — read it before starting and re-read it as needed.`,
1930
- };
1931
- }
1932
1833
  async runRace(input, mode, announce) {
1933
1834
  const taskId = input.taskId ?? newId("task");
1934
1835
  const runId = input.runId ?? newId("run");
@@ -1936,20 +1837,28 @@ export class Orchestrator {
1936
1837
  // announced: a refused run must fail the request loudly, not 200 a runId
1937
1838
  // and leave an orphaned run dir without a terminal event.
1938
1839
  const contract = this.buildContract(input, taskId, mode);
1939
- const store = this.artifactStore(input);
1940
- const paths = store.createRun(runId);
1941
- const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
1942
- input = this.withPlanBrief(input, store, paths, log);
1943
- // The execution root is the tree the harness mutates: the project itself for
1944
- // in-place threads/ordinary runs, or the thread's persistent worktree for an
1945
- // isolated thread. Config/artifacts/contract stay anchored to repoRoot. Both
1946
- // the WorkspaceManager and the git boundary resolve against this SINGLE root.
1840
+ const planBrief = verifiedPlanBrief(input);
1841
+ const quotaSnapshots = this.quotaSnapshotPreflight();
1842
+ const { store, paths, log, ledger } = beginAnnouncedRun({
1843
+ input,
1844
+ contract,
1845
+ quotaSnapshots,
1846
+ store: this.artifactStore(input),
1847
+ authority: this.deps.delegationBudgetAuthority,
1848
+ runId,
1849
+ taskId,
1850
+ mode,
1851
+ phase: "race",
1852
+ prompt: input.prompt,
1853
+ }, announce);
1854
+ input = withPlanBrief(input, store, paths, log, planBrief);
1855
+ // The execution root is the tree the harness mutates: the project itself
1856
+ // for in-place threads/ordinary runs, or the thread's persistent worktree
1857
+ // for an isolated thread. Config/artifacts/contract stay anchored to
1858
+ // repoRoot. Both the WorkspaceManager and the git boundary resolve against
1859
+ // this SINGLE root.
1947
1860
  const execRoot = this.execRootOf(input);
1948
1861
  const wsm = new WorkspaceManager(execRoot);
1949
- safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
1950
- log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
1951
- const ledger = this.rootLedger(input, contract, log);
1952
- announce?.(announcedRunContext({ log, store, paths, runId, taskId, mode, phase: "race" }, ledger, () => this.deps.delegationBudgetAuthority?.hasParent(runId) === true));
1953
1862
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
1954
1863
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
1955
1864
  // Write modes need a git boundary for worktree isolation and honest diffs.
@@ -1958,17 +1867,17 @@ export class Orchestrator {
1958
1867
  // silent mutation (user-locked decision, comparator: Codex requires git).
1959
1868
  // For an isolated thread the execution root is already a git worktree, so
1960
1869
  // this is a no-op there; for in-place it ensures the live project is git.
1961
- const gitPreconditionError = await this.ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
1870
+ const gitPreconditionError = await ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
1962
1871
  if (gitPreconditionError) {
1963
1872
  return {
1964
1873
  runId,
1965
1874
  taskId,
1966
1875
  mode,
1967
1876
  lifecycle: "failed",
1968
- facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1877
+ facts: makeOutcomeFacts("failed", { reason: gitPreconditionError.reason }),
1969
1878
  winner: null,
1970
1879
  runDir: paths.root,
1971
- summary: gitPreconditionError,
1880
+ summary: gitPreconditionError.message,
1972
1881
  candidates: [],
1973
1882
  };
1974
1883
  }
@@ -2066,7 +1975,7 @@ export class Orchestrator {
2066
1975
  attemptId,
2067
1976
  intent: this.candidateIntent(input),
2068
1977
  harnessId: routed.adapter.id,
2069
- cost: attemptCostEvidence(routed.adapter.id, attemptId, i > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, routed.adapter.id)),
1978
+ cost: attemptCostEvidence(routed.adapter.id, attemptId, this.reservationEstimateUsd(input, i > 0), this.routeBillingKnowledge(input, routed.adapter.id)),
2070
1979
  });
2071
1980
  log.emit("budget.lease.created", {
2072
1981
  granted: lease.granted,
@@ -2232,6 +2141,13 @@ export class Orchestrator {
2232
2141
  continuation_count: candidateContinuationCount,
2233
2142
  packet_turns: packet.continuity.disclosure.packetTurns,
2234
2143
  });
2144
+ log.emit("harness.started", {
2145
+ harness_id: adapter.id,
2146
+ attempt_id: contAttemptId,
2147
+ external_context_policy: knobs.webPolicy,
2148
+ ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
2149
+ continuation_of: run.attemptId,
2150
+ });
2235
2151
  const contLeaseId = contLease.lease?.lease_id ?? "";
2236
2152
  try {
2237
2153
  const contRun = await this.runCandidateInEnvelope(slot.routed, envelope, contAttemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
@@ -2519,7 +2435,7 @@ export class Orchestrator {
2519
2435
  // candidates (a trivial greeting in agent mode no longer burns two reviewers on
2520
2436
  // "(empty diff)"). Candidates still flow through arbitration/gates so the
2521
2437
  // no_op/answer outcome and gate failures are unchanged.
2522
- evidences = await this.reviewRuns(workingRuns, reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
2438
+ evidences = await this.reviewRuns(workingRuns, reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal, this.reservationEstimateUsd(input));
2523
2439
  }
2524
2440
  catch (err) {
2525
2441
  // Review preflight/evidence failures end TERMINALLY with artifacts —
@@ -2544,7 +2460,7 @@ export class Orchestrator {
2544
2460
  attemptId: "synth",
2545
2461
  intent: "synthesize",
2546
2462
  harnessId: synthRouted.adapter.id,
2547
- cost: attemptCostEvidence(synthRouted.adapter.id, "synth", undefined, this.routeBillingKnowledge(input, synthRouted.adapter.id)),
2463
+ cost: attemptCostEvidence(synthRouted.adapter.id, "synth", this.reservationEstimateUsd(input), this.routeBillingKnowledge(input, synthRouted.adapter.id)),
2548
2464
  });
2549
2465
  if (lease.granted) {
2550
2466
  let envelope;
@@ -2580,7 +2496,7 @@ export class Orchestrator {
2580
2496
  runs.push(run);
2581
2497
  try {
2582
2498
  if (isWorkingCandidate(run)) {
2583
- const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
2499
+ const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal, this.reservationEstimateUsd(input));
2584
2500
  evidences.push(...synthEvidence);
2585
2501
  workingRuns.push(run);
2586
2502
  }
@@ -2629,8 +2545,12 @@ export class Orchestrator {
2629
2545
  // Winner can only be a candidate that actually produced work; corpses are
2630
2546
  // excluded from arbitration upstream and from the fallback here.
2631
2547
  const winnerRun = workingRuns.find((r) => r.attemptId === result.decision.winner) ?? workingRuns[0];
2632
- // A reviewer escalation to a human is a BLOCKED terminal, not a silent risk note.
2633
- const needsHuman = evidences.some((e) => e.findings.some((f) => f.severity === "NEEDS_HUMAN" && isBlocking(f)));
2548
+ const winnerEvidence = winnerRun
2549
+ ? evidences.find((e) => e.attemptId === winnerRun.attemptId)
2550
+ : undefined;
2551
+ // D9 winner-only NEEDS_HUMAN gate, fail-closed on a winner with no review
2552
+ // evidence record (see winnerNeedsHuman).
2553
+ const needsHuman = winnerNeedsHuman(winnerRun?.attemptId ?? null, evidences);
2634
2554
  // Run-level review_verified is the WINNER's verification: an
2635
2555
  // empty-diff loser's unverified route must not drag the shipped result's
2636
2556
  // flag false. No winner -> fall back to the all-candidates view.
@@ -2693,12 +2613,11 @@ export class Orchestrator {
2693
2613
  })) {
2694
2614
  log.emit("output.ready", { kind: "artifact", path });
2695
2615
  }
2696
- assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
2616
+ secretDiff.assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
2697
2617
  const patchSha256 = sha256(winnerRun.diff);
2698
2618
  store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
2699
2619
  const wstats = diffStats(winnerRun.diff);
2700
2620
  const hasDiff = winnerRun.diff.trim().length > 0;
2701
- const winnerEvidence = evidences.find((e) => e.attemptId === winnerRun.attemptId);
2702
2621
  const blockers = winnerEvidence
2703
2622
  ? winnerEvidence.findings.filter((f) => isBlocking(f)).length
2704
2623
  : 0;
@@ -3011,7 +2930,7 @@ export class Orchestrator {
3011
2930
  env: reviewHome.env,
3012
2931
  }).finally(() => reviewHome.dispose());
3013
2932
  }
3014
- async reviewRuns(runs, reviewers, reviewVerified, reviewDir, cwd, contract, store, paths, log, ledger, taskId, signal) {
2933
+ async reviewRuns(runs, reviewers, reviewVerified, reviewDir, cwd, contract, store, paths, log, ledger, taskId, signal, reservationEstimateUsd) {
3015
2934
  const evidences = [];
3016
2935
  for (const run of runs) {
3017
2936
  const candidateCwd = run.reviewCwd ?? cwd;
@@ -3030,7 +2949,7 @@ export class Orchestrator {
3030
2949
  attemptId: run.attemptId,
3031
2950
  intent: "review",
3032
2951
  harnessId: "review-panel",
3033
- cost: attemptCostEvidence("review-panel", run.attemptId),
2952
+ cost: attemptCostEvidence("review-panel", run.attemptId, reservationEstimateUsd),
3034
2953
  })
3035
2954
  : undefined;
3036
2955
  const result = hasDiff && reviewers.length > 0 && (reviewLease?.granted ?? true)
@@ -3174,10 +3093,21 @@ export class Orchestrator {
3174
3093
  const runId = input.runId ?? newId("run");
3175
3094
  // Contract validation BEFORE the run is announced (see runRace).
3176
3095
  const contract = this.buildContract(input, taskId, mode);
3177
- const store = this.artifactStore(input);
3178
- const paths = store.createRun(runId);
3179
- const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
3180
- input = this.withPlanBrief(input, store, paths, log);
3096
+ const planBrief = verifiedPlanBrief(input);
3097
+ const quotaSnapshots = this.quotaSnapshotPreflight();
3098
+ const { store, paths, log, ledger } = beginAnnouncedRun({
3099
+ input,
3100
+ contract,
3101
+ quotaSnapshots,
3102
+ store: this.artifactStore(input),
3103
+ authority: this.deps.delegationBudgetAuthority,
3104
+ runId,
3105
+ taskId,
3106
+ mode,
3107
+ phase: "convergence",
3108
+ prompt: input.prompt,
3109
+ }, announce);
3110
+ input = withPlanBrief(input, store, paths, log, planBrief);
3181
3111
  // The execution root is the tree the harness mutates (thread worktree for an
3182
3112
  // isolated thread, else the project). The WorkspaceManager AND the git
3183
3113
  // boundary must resolve against the SAME root — the race path does so via the
@@ -3186,27 +3116,11 @@ export class Orchestrator {
3186
3116
  const execRoot = this.execRootOf(input);
3187
3117
  const wsm = new WorkspaceManager(execRoot);
3188
3118
  const readiness = new ReadinessLedger();
3189
- let ledger;
3190
- try {
3191
- ledger = this.rootLedger(input, contract, log);
3192
- store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3193
- safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
3194
- log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
3195
- announce?.(announcedRunContext({ log, store, paths, runId, taskId, mode, phase: "convergence" }, ledger, () => this.deps.delegationBudgetAuthority?.hasParent(runId) === true));
3196
- }
3197
- catch (error) {
3198
- // A delegated child attaches its scoped financial view before the run is
3199
- // announced. If any fallible artifact/start callback in that narrow gap
3200
- // throws, the terminal net has no run context, so detach here explicitly.
3201
- if (input.delegatedFromRunId) {
3202
- this.deps.delegationBudgetAuthority?.releaseRun(runId);
3203
- }
3204
- throw error;
3205
- }
3119
+ store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3206
3120
  // Live (in-place) isolation deliberately tolerates non-git stateful
3207
3121
  // environments; only envelope isolation needs the git boundary.
3208
3122
  if (!input.inPlace) {
3209
- const gitPreconditionError = await this.ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
3123
+ const gitPreconditionError = await ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
3210
3124
  if (gitPreconditionError) {
3211
3125
  return {
3212
3126
  spendUsd: ledger.spend(),
@@ -3214,10 +3128,10 @@ export class Orchestrator {
3214
3128
  taskId,
3215
3129
  mode,
3216
3130
  lifecycle: "failed",
3217
- facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3131
+ facts: makeOutcomeFacts("failed", { reason: gitPreconditionError.reason }),
3218
3132
  winner: null,
3219
3133
  runDir: paths.root,
3220
- summary: gitPreconditionError,
3134
+ summary: gitPreconditionError.message,
3221
3135
  candidates: [],
3222
3136
  };
3223
3137
  }
@@ -3406,7 +3320,7 @@ export class Orchestrator {
3406
3320
  attemptId,
3407
3321
  intent: "repair",
3408
3322
  harnessId: adapter.id,
3409
- cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
3323
+ cost: attemptCostEvidence(adapter.id, attemptId, this.reservationEstimateUsd(input), this.routeBillingKnowledge(input, adapter.id)),
3410
3324
  });
3411
3325
  if (!lease.granted) {
3412
3326
  exhausted = true;
@@ -3518,7 +3432,7 @@ export class Orchestrator {
3518
3432
  attemptId,
3519
3433
  intent: "review",
3520
3434
  harnessId: "review-panel",
3521
- cost: attemptCostEvidence("review-panel", attemptId),
3435
+ cost: attemptCostEvidence("review-panel", attemptId, this.reservationEstimateUsd(input)),
3522
3436
  })
3523
3437
  : null;
3524
3438
  const reviewResult = reviewers.length > 0 && (reviewLease?.granted ?? false)
@@ -3808,7 +3722,7 @@ export class Orchestrator {
3808
3722
  // work_product (its partial patch.diff stays diagnostic via attempts/);
3809
3723
  // in-place keeps the product so the honest Revert offer survives.
3810
3724
  if (lastRun && (!interrupted || input.inPlace === true)) {
3811
- assertNoSecretLikeTokens("final patch diff", lastRun.diff);
3725
+ secretDiff.assertNoSecretLikeTokens("final patch diff", lastRun.diff);
3812
3726
  const patchSha256 = sha256(lastRun.diff);
3813
3727
  store.writeText(join(paths.finalDir, "patch.diff"), lastRun.diff);
3814
3728
  // Honest apply-state (parity with runRace single-candidate in-place): a
@@ -3978,265 +3892,77 @@ export class Orchestrator {
3978
3892
  reviewVerified: actualReviewVerified,
3979
3893
  };
3980
3894
  }
3981
- /** One read-only planner spawn shared by solo fallback, Council drafts, and merge. */
3982
- async runPlannerAttempt(args) {
3983
- const { input, contract, taskId, runId, log, store, paths, ledger, routed, attemptId } = args;
3984
- const adapter = routed.adapter;
3985
- const lease = ledger.reserve({
3986
- taskId,
3987
- attemptId,
3988
- intent: args.intent,
3989
- harnessId: adapter.id,
3990
- cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
3991
- });
3992
- if (!lease.granted) {
3993
- log.emit("budget.lease.created", {
3994
- granted: false,
3995
- reason: lease.reason,
3996
- denied: lease.denied,
3997
- attempt_id: attemptId,
3998
- harness_id: adapter.id,
3999
- });
4000
- return {
4001
- attemptId,
4002
- harnessId: adapter.id,
4003
- status: "failed",
4004
- outcomeClass: "clean", // never spawned: refused pre-flight by the budget gate
4005
- error: lease.reason ?? "budget lease denied",
4006
- text: null,
4007
- telemetry: null,
4008
- budgetDenied: true,
4009
- budgetDenial: {
4010
- code: lease.denied ?? "hard_cap",
4011
- reason: lease.reason ?? "budget lease denied",
4012
- harnessId: adapter.id,
4013
- attemptId,
4014
- },
4015
- };
4016
- }
4017
- const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
4018
- const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4019
- const planSessionFields = this.sessionSpecFields(input, adapter.id, log);
4020
- // Continuity (INV-137): a thread PLAN turn is a chat turn — hydrate a
4021
- // lane switch/gap with a packet and disclose it.
4022
- const laneContinuity = args.laneRun
4023
- ? await this.resolveContinuity(input, adapter.id, planSessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, planSessionFields.resume_session_id !== null, store, paths, this.execRootOf(input), log)
4024
- : null;
4025
- const spec = HarnessRunSpec.parse({
4026
- session_id: newId("ses"),
4027
- intent: args.intent,
4028
- prompt: laneContinuity?.pointerLine
4029
- ? `${args.promptBody}\n\n${laneContinuity.pointerLine}`
4030
- : args.promptBody,
4031
- cwd: this.execRootOf(input),
4032
- access: "readonly",
4033
- // Planners must SEE any image/file the user attached (e.g. "plan a fix for
4034
- // what's in this screenshot"), not just agent/race runs.
4035
- attachments: input.attachments ?? [],
4036
- ...planSessionFields,
4037
- ...this.harnessSpecKnobs(contract, knobs, args.intent),
4038
- env_inheritance: envInheritance(this.config(input.repoRoot)),
4039
- // A thread plan turn spawns in its DURABLE per-lane home so its native
4040
- // session is reachable for resume next turn (INV-034); a non-thread
4041
- // plan keeps the disposable route-context home.
4042
- env: (args.laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? args.fallbackHome,
4043
- });
4044
- const plannerAbort = new AbortController();
4045
- spec.extra["abortSignal"] = input.signal
4046
- ? AbortSignal.any([input.signal, plannerAbort.signal])
4047
- : plannerAbort.signal;
4048
- const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
4049
- if (planInteraction)
4050
- spec.extra["interactionChannel"] = planInteraction;
4051
- // D-16: compile the WorkReport envelope for the plan lane (require plan text
4052
- // below folds the deliverable; the veto rides work_state).
4053
- const planWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(planInteraction));
4054
- const planWorkMode = this.applyWorkEnvelope(spec, planWorkEnvelope);
4055
- const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
4056
- const answer = new AnswerAssembly();
4057
- const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
4058
- knobs.webPolicy === "cached" ||
4059
- knobs.webPolicy === "live", effectiveWeb, [],
4060
- // Requested-model capture: a plan lane silently downgraded to another
4061
- // model surfaces the mismatch in its route receipt, just like agent.
4062
- knobs.model);
4063
- const onAbort = () => {
4064
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
4065
- };
4066
- if (input.signal) {
4067
- if (input.signal.aborted)
4068
- onAbort();
4069
- else
4070
- input.signal.addEventListener("abort", onAbort, { once: true });
4071
- }
4072
- let cost = 0;
4073
- let costEstimated = false;
4074
- let harnessError = null;
4075
- const budgetSignalState = { quotaPressureDisclosed: false };
4076
- try {
4077
- log.emit("harness.started", {
4078
- harness_id: adapter.id,
4079
- attempt_id: attemptId,
4080
- external_context_policy: knobs.webPolicy,
4081
- ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
4082
- });
4083
- if (!input.signal?.aborted) {
4084
- const watchedPlan = withInactivityWatchdog(adapter.run(spec), {
4085
- timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
4086
- onTimeout: () => {
4087
- plannerAbort.abort();
4088
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
4089
- },
4090
- isSuspended: () => (planInteraction?.pendingCount?.() ?? 0) > 0,
4091
- });
4092
- for await (const ev of watchedPlan) {
4093
- if (input.signal?.aborted)
4094
- break;
4095
- const safeEv = redactHarnessEvent(ev);
4096
- safeInvoke(input.onHarnessEvent, safeEv);
4097
- // A thread PLAN turn IS a chat turn now (INV-034): its native
4098
- // session lives in the DURABLE per-lane home, so record it for the
4099
- // next lane turn's resume. Council members are distinct lanes.
4100
- if (args.laneRun)
4101
- observeNativeSessionEvent(input, adapter.id, safeEv);
4102
- observeAuthSwitch(log, adapter.id, attemptId, safeEv);
4103
- log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
4104
- appendLine(attemptEventsPath, JSON.stringify(safeEv));
4105
- observeAttemptTelemetry(telemetry, safeEv);
4106
- if (safeEv.plan_progress) {
4107
- log.emit("plan.progress", {
4108
- attempt_id: attemptId,
4109
- harness_id: adapter.id,
4110
- items: safeEv.plan_progress.items,
4111
- });
4112
- }
4113
- // read-only routes burn quota too — same single owner as the agent loop.
4114
- observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
4115
- this.deps.quotaEventSink?.(adapter.id, safeEv);
4116
- if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
4117
- cost += safeEv.usage.cost_usd;
4118
- if (safeEv.usage.estimated)
4119
- costEstimated = true;
4120
- log.emit("budget.observation", {
4121
- harness_id: adapter.id,
4122
- attempt_id: attemptId,
4123
- kind: "spend",
4124
- usd: safeEv.usage.cost_usd,
4125
- estimated: safeEv.usage.estimated === true,
4126
- });
4127
- }
4128
- // A TYPED final message wins verbatim over joined narration.
4129
- answer.observe(safeEv);
4130
- if (safeEv.type === "error")
4131
- harnessError = safeEv.error ? redactSecrets(safeEv.error) : "harness emitted an error";
4132
- }
4133
- }
4134
- }
4135
- catch (err) {
4136
- harnessError = safeErrorMessage(err);
4137
- }
4138
- finally {
4139
- input.signal?.removeEventListener("abort", onAbort);
4140
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
4141
- }
4142
- // D-16: unwrap and require PLAN TEXT — a plan with no text is not delivered.
4143
- // The unwrap runs BEFORE the error axes: the deliverable it yields is what
4144
- // decides whether an unrecovered tool error is fatal (explorer parity).
4145
- const planUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", planWorkMode, {
4146
- sideToolReport: telemetry.sideToolWorkReport ?? undefined,
4147
- });
4148
- const planText = redactSecrets(planUnwrapped.deliverable).trim();
4149
- const unrecovered = unrecoveredToolErrors(telemetry);
4150
- const webBlocked = webUnsatisfied(telemetry);
4151
- if (!harnessError && webBlocked) {
4152
- harnessError = webEvidenceFailure(telemetry.web);
4153
- }
4154
- // INV-043/INV-044, explorer parity: a DELIVERED plan keeps an unrecovered
4155
- // non-web tool error as warning evidence instead of discarding the plan (see
4156
- // the helper). Web keeps its hard gate above; the finalizer outranks both.
4157
- harnessError ??= unrecoveredToolErrorFailure(unrecovered, planText.length > 0);
4158
- const planFinalized = finalizeAttempt({
4159
- deliverableEvidence: planText.length > 0,
4160
- harnessErrored: harnessError !== null && !webBlocked,
4161
- workReport: planUnwrapped.workReport,
4162
- workReportSource: planUnwrapped.source,
4163
- workReportViolation: planUnwrapped.contractViolation,
4164
- contextTerminalExhausted: telemetry.contextExhausted,
4165
- });
4166
- // A broken WorkReport contract is a hard failure only when the finalizer
4167
- // ranked it so (a terminal context exhaustion outranks it).
4168
- if (!harnessError && planFinalized.outcomeClass === "contract_failure") {
4169
- harnessError = `work_report contract: ${planUnwrapped.contractViolation}`;
4170
- }
4171
- // D-16 r9: an interrupted (context-exhausted) planner is NEVER a clean
4172
- // plan — partial text must not become final/plan.md as success. A VETO
4173
- // (needs_input/incomplete work_state) is DIFFERENT by the sealed D-16
4174
- // contract (X35, INV-116 canaries): the plan still delivers, lifecycle
4175
- // succeeded, and the work_state veto rides the OUTCOME (non-zero exit) —
4176
- // it must not be laundered into a harness failure either direction.
4177
- if (!harnessError && planFinalized.outcomeClass === "interrupted") {
4178
- harnessError = "context capacity exhausted before the plan completed";
4179
- }
4180
- const attemptError = harnessError ??
4181
- (planFinalized.deliverablePresent ? null : "planner produced no plan text") ??
4182
- (input.signal?.aborted ? "planner cancelled" : null);
4183
- setAttemptOutcome(telemetry, {
4184
- deliverablePresent: planFinalized.deliverablePresent,
4185
- gatesPassed: null,
4186
- harnessErrored: (harnessError !== null && !webBlocked) || planFinalized.harnessErrored,
4187
- webRequiredUnsatisfied: webBlocked,
4188
- workState: planFinalized.workState,
4189
- });
4190
- if (attemptError) {
4191
- log.emit("harness.completed", {
4192
- harness_id: adapter.id,
4193
- attempt_id: attemptId,
4194
- status: webBlocked ? "blocked" : "failed",
4195
- error: attemptError,
4196
- ...telemetrySummary(telemetry),
4197
- });
4198
- return {
4199
- attemptId,
4200
- harnessId: adapter.id,
4201
- status: webBlocked ? "blocked" : "failed",
4202
- outcomeClass: planFinalized.outcomeClass,
4203
- error: attemptError,
4204
- text: null,
4205
- telemetry,
4206
- budgetDenied: false,
4207
- };
4208
- }
4209
- const text = planText || "(no output)";
4210
- log.emit("harness.completed", {
4211
- harness_id: adapter.id,
4212
- attempt_id: attemptId,
4213
- status: "success",
4214
- ...telemetrySummary(telemetry),
4215
- });
3895
+ /** Bind private route/session preparation to the planner-attempt owner. */
3896
+ plannerAttemptDeps() {
4216
3897
  return {
4217
- attemptId,
4218
- harnessId: adapter.id,
4219
- status: "success",
4220
- outcomeClass: planFinalized.outcomeClass,
4221
- error: null,
4222
- text,
4223
- telemetry,
4224
- budgetDenied: false,
3898
+ billingKnowledge: (input, harnessId) => this.routeBillingKnowledge(input, harnessId),
3899
+ inactivityTimeoutMs: (repoRoot) => harnessInactivityTimeoutMs(this.config(repoRoot)),
3900
+ quotaEventSink: this.deps.quotaEventSink,
3901
+ prepare: async (args) => {
3902
+ const { input, contract, taskId, runId, log, store, paths, routed, attemptId } = args;
3903
+ const adapter = routed.adapter;
3904
+ const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
3905
+ const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
3906
+ const sessionFields = await this.sessionSpecFields(input, adapter.id, log, routed.authRouteEstimate);
3907
+ const laneContinuity = args.laneRun
3908
+ ? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, sessionFields.resume_session_id !== null, sessionFields, store, paths, this.execRootOf(input), log)
3909
+ : null;
3910
+ const spec = HarnessRunSpec.parse({
3911
+ session_id: newId("ses"),
3912
+ intent: args.intent,
3913
+ prompt: laneContinuity?.pointerLine
3914
+ ? `${args.promptBody}\n\n${laneContinuity.pointerLine}`
3915
+ : args.promptBody,
3916
+ cwd: this.execRootOf(input),
3917
+ access: "readonly",
3918
+ attachments: input.attachments ?? [],
3919
+ ...sessionFields,
3920
+ ...this.harnessSpecKnobs(contract, knobs, args.intent),
3921
+ env_inheritance: envInheritance(this.config(input.repoRoot)),
3922
+ env: (args.laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? args.fallbackHome,
3923
+ });
3924
+ const plannerAbort = new AbortController();
3925
+ spec.extra["abortSignal"] = input.signal
3926
+ ? AbortSignal.any([input.signal, plannerAbort.signal])
3927
+ : plannerAbort.signal;
3928
+ const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
3929
+ if (planInteraction)
3930
+ spec.extra["interactionChannel"] = planInteraction;
3931
+ const planWorkMode = this.applyWorkEnvelope(spec, this.workReportEnvelopeFor(routed, contract, Boolean(planInteraction)));
3932
+ return {
3933
+ knobs,
3934
+ effectiveWeb,
3935
+ spec,
3936
+ plannerAbort,
3937
+ planInteraction,
3938
+ planWorkMode,
3939
+ };
3940
+ },
4225
3941
  };
4226
3942
  }
3943
+ /** One read-only planner spawn shared by solo fallback, Council drafts, and merge. */
3944
+ async runPlannerAttempt(args) {
3945
+ return executePlannerAttempt(this.plannerAttemptDeps(), args);
3946
+ }
4227
3947
  async runPlan(input, announce) {
4228
3948
  const taskId = input.taskId ?? newId("task");
4229
3949
  const runId = input.runId ?? newId("run");
4230
3950
  // Plan runs get the same immutable contract truth as every other mode;
4231
3951
  // contract validation runs BEFORE the run is announced (see runRace).
4232
3952
  const contract = this.buildContract(input, taskId, "plan");
4233
- const store = this.artifactStore(input);
4234
- const paths = store.createRun(runId);
4235
- const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
4236
- safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
4237
- log.emit("run.created", { mode: "plan", prompt: redactSecrets(input.prompt) });
4238
- const ledger = this.rootLedger(input, contract, log);
4239
- announce?.(announcedRunContext({ log, store, paths, runId, taskId, mode: "plan", phase: "plan" }, ledger, () => this.deps.delegationBudgetAuthority?.hasParent(runId) === true));
3953
+ const quotaSnapshots = this.quotaSnapshotPreflight();
3954
+ const { store, paths, log, ledger } = beginAnnouncedRun({
3955
+ input,
3956
+ contract,
3957
+ quotaSnapshots,
3958
+ store: this.artifactStore(input),
3959
+ authority: this.deps.delegationBudgetAuthority,
3960
+ runId,
3961
+ taskId,
3962
+ mode: "plan",
3963
+ phase: "plan",
3964
+ prompt: input.prompt,
3965
+ }, announce);
4240
3966
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
4241
3967
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
4242
3968
  // W3.3: ONE resolved read-only context — the routing point-probe and every
@@ -4344,6 +4070,7 @@ export class Orchestrator {
4344
4070
  roHome,
4345
4071
  contextSection,
4346
4072
  laneRun,
4073
+ estimateUsdFloor: this.estimateUsdFloor(input.repoRoot),
4347
4074
  });
4348
4075
  }
4349
4076
  const plans = [];
@@ -4373,6 +4100,7 @@ export class Orchestrator {
4373
4100
  fallbackHome: roHome.env,
4374
4101
  promptBody: planPrompt(input.prompt) + contextSection,
4375
4102
  intent: "plan",
4103
+ reservationEstimateUsd: this.reservationEstimateUsd(input),
4376
4104
  });
4377
4105
  if (outcome.budgetDenied) {
4378
4106
  // QA-050: retain the denied planner slot before breaking so the
@@ -4451,7 +4179,6 @@ export class Orchestrator {
4451
4179
  }
4452
4180
  if (plans.length === 0) {
4453
4181
  return writePlanHarnessFailure(this.planRunDeps(), {
4454
- input,
4455
4182
  contract,
4456
4183
  taskId,
4457
4184
  runId,
@@ -4520,17 +4247,8 @@ export class Orchestrator {
4520
4247
  defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
4521
4248
  }, announce);
4522
4249
  }
4523
- resolvePaidBudget(inputBudget, cfg) {
4524
- return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
4525
- }
4526
- rootLedger(input, contract, log) {
4527
- return createRootLedger({
4528
- input,
4529
- contract,
4530
- log,
4531
- authority: this.deps.delegationBudgetAuthority,
4532
- quotaSnapshots: this.deps.quotaSnapshots?.() ?? [],
4533
- });
4250
+ quotaSnapshotPreflight() {
4251
+ return [...(this.deps.quotaSnapshots?.() ?? [])].map((snapshot) => QuotaSnapshotSchema.parse(snapshot));
4534
4252
  }
4535
4253
  routeBillingKnowledge(input, harnessId) {
4536
4254
  // A selected profile's credential_kind decides billing (round-18 #2).
@@ -4560,10 +4278,10 @@ export class Orchestrator {
4560
4278
  // scout reserve) so a subscription route is not refused for lacking a
4561
4279
  // cash quote.
4562
4280
  attemptCostEvidence(harnessId, attemptId, this.estimateUsdFloor(input.repoRoot), this.routeBillingKnowledge(input, harnessId)),
4563
- buildSpec: (routed, homeEnv, prompt, attemptId) => {
4281
+ buildSpec: async (routed, homeEnv, prompt, attemptId) => {
4564
4282
  const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
4565
4283
  const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4566
- const sessionFields = this.sessionSpecFields(input, routed.adapter.id, log);
4284
+ const sessionFields = await this.sessionSpecFields(input, routed.adapter.id, log, routed.authRouteEstimate);
4567
4285
  const spec = HarnessRunSpec.parse({
4568
4286
  session_id: newId("ses"),
4569
4287
  intent: "synthesize",
@@ -4604,13 +4322,19 @@ export class Orchestrator {
4604
4322
  // Contract validation BEFORE the run is announced (see runRace). The
4605
4323
  // recorded user intent is the CALLER's goal.
4606
4324
  const contract = this.buildContract({ ...input, prompt: opts.contractIntent ?? prompt }, taskId, opts.mode);
4607
- const store = this.artifactStore(input);
4608
- const paths = store.createRun(runId);
4609
- const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
4610
- safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
4611
- log.emit("run.created", { mode: opts.mode, prompt: redactSecrets(prompt) });
4612
- const ledger = this.rootLedger(input, contract, log);
4613
- announce?.(announcedRunContext({ log, store, paths, runId, taskId, mode: opts.mode, phase: "report" }, ledger, () => this.deps.delegationBudgetAuthority?.hasParent(runId) === true));
4325
+ const quotaSnapshots = this.quotaSnapshotPreflight();
4326
+ const { store, paths, log, ledger } = beginAnnouncedRun({
4327
+ input,
4328
+ contract,
4329
+ quotaSnapshots,
4330
+ store: this.artifactStore(input),
4331
+ authority: this.deps.delegationBudgetAuthority,
4332
+ runId,
4333
+ taskId,
4334
+ mode: opts.mode,
4335
+ phase: "report",
4336
+ prompt,
4337
+ }, announce);
4614
4338
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
4615
4339
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
4616
4340
  // The ask/deep-scan report stays bare (its scouts read the tree themselves);
@@ -4752,10 +4476,10 @@ export class Orchestrator {
4752
4476
  intent: opts.intent,
4753
4477
  harnessId: adapter.id,
4754
4478
  // QA-019: an n>1 deep-scan scout admits under a FINITE estimate floor
4755
- // (mirror of the candidate loop): the first scout reserves without a
4756
- // floor, but later scouts pass the repo's usd floor so a subscription
4757
- // swarm is not refused for lacking a per-attempt cash quote under a cap.
4758
- cost: attemptCostEvidence(adapter.id, attemptId, opts.deepScan && idx > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, adapter.id)),
4479
+ // (mirror of the candidate loop): the first top-level scout reserves
4480
+ // without a floor; later scouts and every real Delegate child pass the
4481
+ // repo floor because they overlap an existing family unit.
4482
+ cost: attemptCostEvidence(adapter.id, attemptId, this.reservationEstimateUsd(input, opts.deepScan && idx > 0), this.routeBillingKnowledge(input, adapter.id)),
4759
4483
  });
4760
4484
  if (!lease.granted) {
4761
4485
  log.emit("budget.lease.created", {
@@ -4784,67 +4508,120 @@ export class Orchestrator {
4784
4508
  }
4785
4509
  return { status: "budget_denied", reason: lease.reason ?? "budget lease denied" };
4786
4510
  }
4787
- // Lease granted: the attempt is now committed to run — disclose the launch.
4788
- onLaunch?.();
4789
- const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
4790
- const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4791
- const explorerPrompt = (opts.deepScan
4792
- ? `${prompt}\n\nExplorer ${idx + 1}/${adapters.length}: focus on a distinct slice. Emit evidence-cited findings, explicit unknowns/omissions, and follow-up questions. Do not edit files.`
4793
- : prompt) + contextSection;
4794
- const sessionFields = this.sessionSpecFields(input, adapter.id, log);
4795
- const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
4796
- if (grantResume)
4797
- resumeGranted.add(adapter.id);
4798
- // Continuity (INV-137): a thread ASK turn is a chat turn — hydrate a lane
4799
- // switch/gap with a packet and disclose it. Gated on laneRun (deep-scan
4800
- // scouts are excluded from laneRun); native resume is available only when
4801
- // this slot was granted the lane's recorded session.
4802
- const laneContinuity = laneRun
4803
- ? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, store, paths, this.execRootOf(input), log)
4804
- : null;
4805
- // D-16d: the continuation packet pointer rides after the lane pointer so
4806
- // the fresh session is re-grounded in the exhausted attempt's work.
4807
- const promptWithPointers = [explorerPrompt, laneContinuity?.pointerLine, continuationPointer]
4808
- .filter((p) => Boolean(p))
4809
- .join("\n\n");
4810
- let spec = HarnessRunSpec.parse({
4811
- session_id: newId("ses"),
4812
- intent: opts.intent,
4813
- prompt: promptWithPointers,
4814
- cwd: this.execRootOf(input),
4815
- access: "readonly",
4816
- // ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
4817
- // a live "describe this image" turn sent an image that was being dropped here, so
4818
- // the model honestly reported it saw nothing (the v0.13 attachment bug).
4819
- attachments: input.attachments ?? [],
4820
- auth_preference: sessionFields.auth_preference,
4821
- credential_profile: sessionFields.credential_profile,
4822
- resume_session_id: grantResume ? sessionFields.resume_session_id : null,
4823
- ...this.harnessSpecKnobs(contract, knobs, opts.intent),
4824
- env_inheritance: envInheritance(this.config(input.repoRoot)),
4825
- // A thread lane turn spawns in its DURABLE per-lane home so the native
4826
- // session it records is reachable for resume next turn; everything else
4827
- // uses the disposable route-context home.
4828
- env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
4829
- });
4830
- const reportAbort = new AbortController();
4831
- spec.extra["abortSignal"] = input.signal
4832
- ? AbortSignal.any([input.signal, reportAbort.signal])
4833
- : reportAbort.signal;
4834
- const reportInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
4835
- if (reportInteraction)
4836
- spec.extra["interactionChannel"] = reportInteraction;
4837
- // D-16: compile the WorkReport envelope for the read-only lane.
4838
- const readonlyWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(reportInteraction));
4839
- const readonlyWorkMode = this.applyWorkEnvelope(spec, readonlyWorkEnvelope);
4840
- const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
4841
- const answer = new AnswerAssembly();
4842
- const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
4843
- knobs.webPolicy === "cached" ||
4844
- knobs.webPolicy === "live", effectiveWeb, [],
4845
- // Requested-model capture so ask/audit route receipts detect a silent
4846
- // model downgrade (typed model_mismatch), not just agent runs.
4847
- knobs.model);
4511
+ // As with planners, the granted lease owns profile/continuity/spec
4512
+ // preparation. Contain a pre-stream rejection as this attempt's failure;
4513
+ // parallel siblings can then finish before the shared HOME is disposed.
4514
+ const preparation = await (async () => {
4515
+ // Lease granted: the attempt is now committed to run — disclose the launch.
4516
+ onLaunch?.();
4517
+ const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
4518
+ const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4519
+ const explorerPrompt = (opts.deepScan
4520
+ ? `${prompt}\n\nExplorer ${idx + 1}/${adapters.length}: focus on a distinct slice. Emit evidence-cited findings, explicit unknowns/omissions, and follow-up questions. Do not edit files.`
4521
+ : prompt) + contextSection;
4522
+ const sessionFields = await this.sessionSpecFields(input, adapter.id, log, routed.authRouteEstimate);
4523
+ const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
4524
+ if (grantResume)
4525
+ resumeGranted.add(adapter.id);
4526
+ // Continuity (INV-137): a thread ASK turn is a chat turn — hydrate a lane
4527
+ // switch/gap with a packet and disclose it. Gated on laneRun (deep-scan
4528
+ // scouts are excluded from laneRun); native resume is available only when
4529
+ // this slot was granted the lane's recorded session.
4530
+ const laneContinuity = laneRun
4531
+ ? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, sessionFields, store, paths, this.execRootOf(input), log)
4532
+ : null;
4533
+ // D-16d: the continuation packet pointer rides after the lane pointer so
4534
+ // the fresh session is re-grounded in the exhausted attempt's work.
4535
+ const promptWithPointers = [
4536
+ explorerPrompt,
4537
+ laneContinuity?.pointerLine,
4538
+ continuationPointer,
4539
+ ]
4540
+ .filter((p) => Boolean(p))
4541
+ .join("\n\n");
4542
+ const spec = HarnessRunSpec.parse({
4543
+ session_id: newId("ses"),
4544
+ intent: opts.intent,
4545
+ prompt: promptWithPointers,
4546
+ cwd: this.execRootOf(input),
4547
+ access: "readonly",
4548
+ // ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
4549
+ // a live "describe this image" turn sent an image that was being dropped here, so
4550
+ // the model honestly reported it saw nothing (the v0.13 attachment bug).
4551
+ attachments: input.attachments ?? [],
4552
+ auth_preference: sessionFields.auth_preference,
4553
+ credential_profile: sessionFields.credential_profile,
4554
+ resume_session_id: grantResume ? sessionFields.resume_session_id : null,
4555
+ ...this.harnessSpecKnobs(contract, knobs, opts.intent),
4556
+ env_inheritance: envInheritance(this.config(input.repoRoot)),
4557
+ // A thread lane turn spawns in its DURABLE per-lane home so the native
4558
+ // session it records is reachable for resume next turn; everything else
4559
+ // uses the disposable route-context home.
4560
+ env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
4561
+ });
4562
+ const reportAbort = new AbortController();
4563
+ spec.extra["abortSignal"] = input.signal
4564
+ ? AbortSignal.any([input.signal, reportAbort.signal])
4565
+ : reportAbort.signal;
4566
+ const reportInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
4567
+ if (reportInteraction)
4568
+ spec.extra["interactionChannel"] = reportInteraction;
4569
+ // D-16: compile the WorkReport envelope for the read-only lane.
4570
+ const readonlyWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(reportInteraction));
4571
+ const readonlyWorkMode = this.applyWorkEnvelope(spec, readonlyWorkEnvelope);
4572
+ const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
4573
+ const answer = new AnswerAssembly();
4574
+ const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
4575
+ knobs.webPolicy === "cached" ||
4576
+ knobs.webPolicy === "live", effectiveWeb, [],
4577
+ // Requested-model capture so ask/audit route receipts detect a silent
4578
+ // model downgrade (typed model_mismatch), not just agent runs.
4579
+ knobs.model);
4580
+ return {
4581
+ knobs,
4582
+ spec,
4583
+ reportAbort,
4584
+ reportInteraction,
4585
+ readonlyWorkMode,
4586
+ attemptEventsPath,
4587
+ answer,
4588
+ telemetry,
4589
+ };
4590
+ })().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
4591
+ if (!preparation.ok) {
4592
+ const message = `read-only attempt setup failed: ${safeErrorMessage(preparation.error)}`;
4593
+ AC.settleGrantedAttemptLease({
4594
+ ledger,
4595
+ leaseId: lease.lease?.lease_id ?? "",
4596
+ attemptId,
4597
+ harnessId: adapter.id,
4598
+ costUsd: 0,
4599
+ costEstimated: false,
4600
+ preStreamFailureSource: "readonly-pre-stream",
4601
+ });
4602
+ const telemetry = createAttemptTelemetry(contract.external_context.policy, contract.external_context.web_required, contract.external_context.effective_mode);
4603
+ setAttemptOutcome(telemetry, {
4604
+ deliverablePresent: false,
4605
+ gatesPassed: null,
4606
+ harnessErrored: true,
4607
+ webRequiredUnsatisfied: false,
4608
+ });
4609
+ attempts.push({
4610
+ attemptId,
4611
+ harnessId: adapter.id,
4612
+ status: "failed",
4613
+ report: "",
4614
+ error: message,
4615
+ telemetry,
4616
+ });
4617
+ attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
4618
+ if (opts.deepScan) {
4619
+ store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${message}\n`);
4620
+ }
4621
+ return { status: "launched" };
4622
+ }
4623
+ const { knobs, spec: preparedSpec, reportAbort, reportInteraction, readonlyWorkMode, attemptEventsPath, answer, telemetry, } = preparation.value;
4624
+ let spec = preparedSpec;
4848
4625
  const retryPolicy = transientRetryPolicy(this.config(input.repoRoot));
4849
4626
  let activeSessionId = spec.session_id;
4850
4627
  const onAbort = () => {
@@ -4884,11 +4661,13 @@ export class Orchestrator {
4884
4661
  try {
4885
4662
  const watchedReport = withInactivityWatchdog(adapter.run(runSpec), {
4886
4663
  timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
4664
+ countsAsProgress: countsAsAgentProgress,
4887
4665
  onTimeout: () => {
4888
4666
  reportAbort.abort();
4889
4667
  void adapter.cancel?.(activeSessionId)?.catch(() => { });
4890
4668
  },
4891
4669
  isSuspended: () => (reportInteraction?.pendingCount?.() ?? 0) > 0,
4670
+ suspensionVersion: () => reportInteraction?.suspensionVersion?.() ?? 0,
4892
4671
  });
4893
4672
  for await (const ev of watchedReport) {
4894
4673
  if (input.signal?.aborted)
@@ -4952,13 +4731,18 @@ export class Orchestrator {
4952
4731
  // W5.4 reactive failover, READ-ONLY lane (same contract as the
4953
4732
  // candidate lane; typed limits only, never plain transients).
4954
4733
  if (harnessError && !input.signal?.aborted) {
4734
+ const rotationPolicy = this.profilePolicy(input.repoRoot, adapter.id);
4735
+ const readyProfileIds = sawTypedLimit && reportSoFar.length === 0 && rotationPolicy.limit_action === "rotate"
4736
+ ? await this.readyProfileIdsForRotation(input, adapter.id, spec.credential_profile ?? null, triedProfiles)
4737
+ : new Set();
4955
4738
  const rotated = rotateSpecOnTypedLimit({
4956
4739
  spec,
4957
4740
  harnessId: adapter.id,
4958
4741
  attemptId,
4959
- policy: this.profilePolicy(input.repoRoot, adapter.id),
4742
+ policy: rotationPolicy,
4960
4743
  registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
4961
4744
  snapshots: this.deps.quotaSnapshots?.() ?? [],
4745
+ readyProfileIds,
4962
4746
  triedProfiles,
4963
4747
  sawTypedLimit,
4964
4748
  deliverableEmpty: reportSoFar.length === 0,
@@ -5000,7 +4784,17 @@ export class Orchestrator {
5000
4784
  }
5001
4785
  finally {
5002
4786
  input.signal?.removeEventListener("abort", onAbort);
5003
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
4787
+ AC.settleGrantedAttemptLease({
4788
+ ledger,
4789
+ leaseId: lease.lease?.lease_id ?? "",
4790
+ attemptId,
4791
+ harnessId: adapter.id,
4792
+ costUsd: cost,
4793
+ costEstimated,
4794
+ authMode: telemetry.authMode,
4795
+ usageCost: telemetry.usageCost,
4796
+ preStreamFailureSource: "readonly-pre-stream",
4797
+ });
5004
4798
  }
5005
4799
  if (harnessError && telemetry.transientFailures.length > 0) {
5006
4800
  log.emit("route.transient.exhausted", {
@@ -5011,11 +4805,14 @@ export class Orchestrator {
5011
4805
  });
5012
4806
  }
5013
4807
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
5014
- // D-16: un-nest the {work_report, output} envelope; the OUTPUT is the report.
4808
+ // D-16: remove the WorkReport transport; the deliverable is the report.
5015
4809
  const roUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", readonlyWorkMode, {
5016
4810
  sideToolReport: telemetry.sideToolWorkReport ?? undefined,
5017
4811
  });
5018
- const report = redactSecrets(roUnwrapped.deliverable);
4812
+ // Trim symmetrically with the plan path: a whitespace-only answer is not
4813
+ // a delivered report (the final-artifact wrapper heading would otherwise
4814
+ // make it read as present content by construction).
4815
+ const report = redactSecrets(roUnwrapped.deliverable).trim();
5019
4816
  const unrecovered = unrecoveredToolErrors(telemetry);
5020
4817
  const webBlocked = webUnsatisfied(telemetry);
5021
4818
  const reportPresent = report.length > 0;
@@ -5043,6 +4840,13 @@ export class Orchestrator {
5043
4840
  harnessErrored: harnessError !== null && !webBlocked,
5044
4841
  webRequiredUnsatisfied: webBlocked,
5045
4842
  workState: roFinalized.workState,
4843
+ // A read-only attempt that completed CLEANLY with an honestly empty
4844
+ // answer is a success with deliverable_present=false ("(no output)"),
4845
+ // never a fake contract failure — the trim above must not convert a
4846
+ // phantom deliverable into a phantom harness failure. Only the clean
4847
+ // finalizer class qualifies: contract failures, vetoes, and context
4848
+ // interruptions keep the strict deliverable requirement.
4849
+ emptyDeliverableAllowed: roFinalized.outcomeClass === "clean",
5046
4850
  });
5047
4851
  if (harnessError) {
5048
4852
  log.emit("harness.completed", {
@@ -5473,7 +5277,7 @@ export class Orchestrator {
5473
5277
  }
5474
5278
  this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null), deepScanSynthesis);
5475
5279
  log.emit("output.ready", {
5476
- kind: opts.mode === "ask" ? "answer" : "report",
5280
+ kind: opts.deepScan ? "report" : "answer",
5477
5281
  path: `final/${opts.artifactName}`,
5478
5282
  });
5479
5283
  if (opts.deepScan) {
@@ -5531,11 +5335,16 @@ export class Orchestrator {
5531
5335
  .map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
5532
5336
  .join(", ");
5533
5337
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harnesses: ${harnessLabel}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${report}\n`);
5338
+ const reportProducerAttemptId = opts.deepScan &&
5339
+ deepScanSynthesis?.status === "succeeded" &&
5340
+ deepScanSynthesis.reducer_attempt_id
5341
+ ? deepScanSynthesis.reducer_attempt_id
5342
+ : (succeeded[0]?.attemptId ?? "a01");
5534
5343
  store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
5535
5344
  id: newId("wp"),
5536
5345
  kind: "report",
5537
5346
  source_task_id: taskId,
5538
- producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
5347
+ producer_attempt_id: reportProducerAttemptId,
5539
5348
  files: Object.fromEntries([[opts.artifactName, join(paths.finalDir, opts.artifactName)]]),
5540
5349
  meta: {
5541
5350
  harnesses: attempts.map((a) => a.harnessId),
@@ -5544,7 +5353,7 @@ export class Orchestrator {
5544
5353
  read_only: true,
5545
5354
  },
5546
5355
  });
5547
- log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
5356
+ log.emit("work_product.emitted", { kind: "report", winner: reportProducerAttemptId });
5548
5357
  const workVetoed = terminalFacts.work_state?.state === "needs_input" ||
5549
5358
  terminalFacts.work_state?.state === "incomplete";
5550
5359
  if (terminalFacts.lifecycle !== "succeeded") {
@@ -5596,9 +5405,4 @@ export class Orchestrator {
5596
5405
  };
5597
5406
  }
5598
5407
  }
5599
- function assertNoSecretLikeTokens(label, text) {
5600
- if (containsSecretLikeToken(text)) {
5601
- throw new Error(`${label} contains secret-like token; refusing to persist artifact`);
5602
- }
5603
- }
5604
5408
  //# sourceMappingURL=orchestrator.js.map