@claudexor/orchestrator 1.0.1 → 2.1.3
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.
- package/dist/attemptTelemetry.d.ts +47 -2
- package/dist/attemptTelemetry.d.ts.map +1 -1
- package/dist/attemptTelemetry.js +100 -2
- package/dist/attemptTelemetry.js.map +1 -1
- package/dist/attemptUsage.d.ts +19 -0
- package/dist/attemptUsage.d.ts.map +1 -0
- package/dist/attemptUsage.js +29 -0
- package/dist/attemptUsage.js.map +1 -0
- package/dist/candidateEvidence.d.ts +22 -0
- package/dist/candidateEvidence.d.ts.map +1 -0
- package/dist/candidateEvidence.js +42 -0
- package/dist/candidateEvidence.js.map +1 -0
- package/dist/candidateOutputs.d.ts +43 -0
- package/dist/candidateOutputs.d.ts.map +1 -0
- package/dist/candidateOutputs.js +146 -0
- package/dist/candidateOutputs.js.map +1 -0
- package/dist/contract-gates.d.ts +21 -0
- package/dist/contract-gates.d.ts.map +1 -0
- package/dist/contract-gates.js +93 -0
- package/dist/contract-gates.js.map +1 -0
- package/dist/credential-profiles.d.ts +162 -0
- package/dist/credential-profiles.d.ts.map +1 -0
- package/dist/credential-profiles.js +357 -0
- package/dist/credential-profiles.js.map +1 -0
- package/dist/diffReview.d.ts +12 -1
- package/dist/diffReview.d.ts.map +1 -1
- package/dist/diffReview.js +117 -18
- package/dist/diffReview.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modelGovernance.d.ts +6 -1
- package/dist/modelGovernance.d.ts.map +1 -1
- package/dist/modelGovernance.js +8 -2
- package/dist/modelGovernance.js.map +1 -1
- package/dist/orchestrateExecutor.d.ts +43 -0
- package/dist/orchestrateExecutor.d.ts.map +1 -0
- package/dist/orchestrateExecutor.js +207 -0
- package/dist/orchestrateExecutor.js.map +1 -0
- package/dist/orchestratePlanner.d.ts.map +1 -1
- package/dist/orchestratePlanner.js.map +1 -1
- package/dist/orchestrator.d.ts +97 -85
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +1056 -1090
- package/dist/orchestrator.js.map +1 -1
- package/dist/outcomeReducer.d.ts +25 -0
- package/dist/outcomeReducer.d.ts.map +1 -0
- package/dist/outcomeReducer.js +121 -0
- package/dist/outcomeReducer.js.map +1 -0
- package/dist/policyFindings.d.ts +18 -0
- package/dist/policyFindings.d.ts.map +1 -0
- package/dist/policyFindings.js +166 -0
- package/dist/policyFindings.js.map +1 -0
- package/dist/requestRequirements.d.ts +38 -0
- package/dist/requestRequirements.d.ts.map +1 -0
- package/dist/requestRequirements.js +127 -0
- package/dist/requestRequirements.js.map +1 -0
- package/dist/reviewerPanel.d.ts +15 -1
- package/dist/reviewerPanel.d.ts.map +1 -1
- package/dist/reviewerPanel.js +85 -2
- package/dist/reviewerPanel.js.map +1 -1
- package/dist/routeContext.d.ts +37 -0
- package/dist/routeContext.d.ts.map +1 -0
- package/dist/routeContext.js +38 -0
- package/dist/routeContext.js.map +1 -0
- package/dist/runSupport.d.ts +37 -21
- package/dist/runSupport.d.ts.map +1 -1
- package/dist/runSupport.js +102 -78
- package/dist/runSupport.js.map +1 -1
- package/dist/runTelemetryWriter.d.ts +20 -0
- package/dist/runTelemetryWriter.d.ts.map +1 -0
- package/dist/runTelemetryWriter.js +68 -0
- package/dist/runTelemetryWriter.js.map +1 -0
- package/dist/runTerminals.d.ts +7 -1
- package/dist/runTerminals.d.ts.map +1 -1
- package/dist/runTerminals.js +42 -4
- package/dist/runTerminals.js.map +1 -1
- package/dist/structuredOutput.d.ts +33 -0
- package/dist/structuredOutput.d.ts.map +1 -0
- package/dist/structuredOutput.js +106 -0
- package/dist/structuredOutput.js.map +1 -0
- package/package.json +19 -18
- package/dist/finalVerifier.d.ts +0 -25
- package/dist/finalVerifier.d.ts.map +0 -1
- package/dist/finalVerifier.js +0 -110
- package/dist/finalVerifier.js.map +0 -1
package/dist/orchestrator.js
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
|
|
3
|
+
import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
|
|
4
|
+
import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
|
|
5
|
+
import { processAttemptUsage } from "./attemptUsage.js";
|
|
6
|
+
import { toCandidateEvidence } from "./candidateEvidence.js";
|
|
7
|
+
import { capabilityIntents } from "@claudexor/gateway";
|
|
8
|
+
import { policyFindings } from "./policyFindings.js";
|
|
2
9
|
import { join } from "node:path";
|
|
3
|
-
import { HarnessRunSpec, OrchestrateContract as OrchestrateContractSchema,
|
|
10
|
+
import { HarnessRunSpec, OrchestrateContract as OrchestrateContractSchema, DecisionRecord as DecisionRecordSchema, WorkProduct as WorkProductSchema, SessionReboundLineage as SessionReboundLineageSchema, SpecPack as SpecPackZ, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_ACCESS_CODE, FrozenTaskContractArtifact as TaskContractSchema, isBlocking, orchestratePlanJsonSchema, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
|
|
4
11
|
import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
|
|
5
12
|
import { specPackToTaskContract } from "@claudexor/interview";
|
|
6
|
-
import { HarnessUnavailableError, summarizeDiffPaths as diffStats,
|
|
13
|
+
import { AnswerAssembly, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
|
|
7
14
|
import { assertRouteModelsAllowed } from "./modelGovernance.js";
|
|
15
|
+
import { RequestRequirementsResolver } from "./requestRequirements.js";
|
|
8
16
|
import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
17
|
+
import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
|
|
18
|
+
import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary, readRunPatch, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, buildEnvelopeSubInput, deliverPlanAnswer, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, relayPriorPlansSection, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
|
|
19
|
+
import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
|
|
20
|
+
import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
|
|
11
21
|
import { buildOrchestratePlannerPrompt, extractOrchestratePlan } from "./orchestratePlanner.js";
|
|
12
|
-
import {
|
|
22
|
+
import { orchestrateFailureFor, readRunStatus } from "./outcomeReducer.js";
|
|
23
|
+
import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
|
|
13
24
|
import { runDiffReview } from "./diffReview.js";
|
|
14
|
-
import {
|
|
25
|
+
import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
|
|
15
26
|
import { interactionChannelFor } from "./interaction.js";
|
|
27
|
+
import { gateSpecsFromContract, renderTestsEvidence, resolveContractGates, } from "./contract-gates.js";
|
|
16
28
|
import { ArtifactStore } from "@claudexor/artifact-store";
|
|
17
29
|
import { EventLog } from "@claudexor/event-log";
|
|
18
|
-
import { assertMandatoryContext, buildContextPack,
|
|
19
|
-
import { WorkspaceManager,
|
|
20
|
-
import {
|
|
30
|
+
import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
|
|
31
|
+
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
32
|
+
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
|
|
21
33
|
import { HarnessGateway } from "@claudexor/gateway";
|
|
22
34
|
import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
|
|
23
35
|
import { arbitrate } from "@claudexor/arbitration";
|
|
24
36
|
import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
|
|
25
|
-
import { BudgetLedger, loadHarnessMetrics, promptFingerprint,
|
|
26
|
-
import { classifyRisk, DEFAULT_REQUIRE_HUMAN_PATHS, requireHuman, reviewDepthForRisk, } from "@claudexor/policy";
|
|
37
|
+
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
|
|
27
38
|
import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
28
39
|
const LABELS = "ABCDEFGHIJ".split("");
|
|
29
40
|
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
30
|
-
const REVIEW_EVIDENCE_DIRNAME = ".claudexor-review-evidence";
|
|
31
41
|
/** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
|
|
32
42
|
const MAX_PARALLEL_CANDIDATES = 4;
|
|
33
43
|
/** Default wait for one interactive answer before a benign decline. */
|
|
@@ -66,6 +76,11 @@ async function runBounded(items, limit, work) {
|
|
|
66
76
|
export class Orchestrator {
|
|
67
77
|
deps;
|
|
68
78
|
gateway;
|
|
79
|
+
requestRequirements = new RequestRequirementsResolver();
|
|
80
|
+
/** Per-attempt cap on forwarded live delta chunks (W-C4 flood guard, sol
|
|
81
|
+
* #10): past this the deltas are dropped and the cutoff is disclosed once;
|
|
82
|
+
* the complete message always still lands. */
|
|
83
|
+
static MAX_DELTAS_PER_ATTEMPT = 4000;
|
|
69
84
|
constructor(deps) {
|
|
70
85
|
this.deps = deps;
|
|
71
86
|
this.gateway = new HarnessGateway(deps.registry);
|
|
@@ -83,9 +98,17 @@ export class Orchestrator {
|
|
|
83
98
|
const resolved = this.resolveRunInput(input);
|
|
84
99
|
// INV-062 at the ENGINE boundary: every surface fences prompts already,
|
|
85
100
|
// but a direct embedder (or the daemon-less local REPL fallback) reaches
|
|
86
|
-
// this entry without one. Prompts
|
|
87
|
-
//
|
|
88
|
-
|
|
101
|
+
// this entry without one. Prompts, per-run instructions, AND outputSchema
|
|
102
|
+
// are durable artifacts (all land in the TaskContract) — the hard block
|
|
103
|
+
// applies here too, so no in-process path can ever bypass it. outputSchema
|
|
104
|
+
// rides the schema-aware branch: its property NAMES are field names (a
|
|
105
|
+
// `token` field is legitimate), but string VALUES (const/default/enum) are
|
|
106
|
+
// scanned for real secrets, matching the HTTP boundary exactly.
|
|
107
|
+
assertNoInlineSecretValues({
|
|
108
|
+
prompt: resolved.prompt,
|
|
109
|
+
instructions: resolved.instructions,
|
|
110
|
+
outputSchema: resolved.outputSchema ?? undefined,
|
|
111
|
+
}, "$", "run input");
|
|
89
112
|
const parsedMode = ModeKindSchema.safeParse(resolved.mode ?? "agent");
|
|
90
113
|
if (!parsedMode.success) {
|
|
91
114
|
throw new Error(`unknown mode: ${String(resolved.mode)}`);
|
|
@@ -95,9 +118,39 @@ export class Orchestrator {
|
|
|
95
118
|
// executor's plan steps — on any other mode it would be a silent no-op
|
|
96
119
|
// knob. The CLI and control API validate this already; a direct embedder
|
|
97
120
|
// must get the same loud refusal, not quiet acceptance.
|
|
98
|
-
if (resolved.maxToolCalls !== undefined &&
|
|
121
|
+
if (resolved.maxToolCalls !== undefined &&
|
|
122
|
+
resolved.maxToolCalls !== null &&
|
|
123
|
+
mode !== "orchestrate") {
|
|
99
124
|
throw new Error(`maxToolCalls caps the orchestrate EXECUTOR's plan steps and only applies to mode=orchestrate (got mode=${mode}); drop the knob or switch modes`);
|
|
100
125
|
}
|
|
126
|
+
// denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
|
|
127
|
+
// only exists on envelope/isolated runs — an in-place run mutates the live
|
|
128
|
+
// tree directly, so the gate could not contain a violation. Refuse loudly
|
|
129
|
+
// rather than accept a knob the engine cannot honor (INV-023).
|
|
130
|
+
if ((resolved.denyPaths?.length ?? 0) > 0 && resolved.inPlace === true) {
|
|
131
|
+
throw new Error("denyPaths requires an isolated/envelope run: the post-diff policy gate blocks a violating patch before delivery, which an in-place run cannot guarantee; drop --deny-path or run isolated");
|
|
132
|
+
}
|
|
133
|
+
// outputSchema constrains the run's final ANSWER. It is honored exactly
|
|
134
|
+
// where a final answer is delivered (agent race incl. synthesis, and ask);
|
|
135
|
+
// every other strategy refuses loudly rather than carrying a contract the
|
|
136
|
+
// engine would not validate (INV-023). The schema itself is normalized for
|
|
137
|
+
// the native structured-output routes here at the boundary — unsupported
|
|
138
|
+
// shapes ($ref, non-object root) are a typed refusal, not a mid-run 400.
|
|
139
|
+
if (resolved.outputSchema !== undefined && resolved.outputSchema !== null) {
|
|
140
|
+
if (mode !== "agent" && mode !== "ask") {
|
|
141
|
+
throw new Error(`outputSchema constrains the final answer and applies to agent/ask runs (got mode=${mode}); drop the schema or switch modes`);
|
|
142
|
+
}
|
|
143
|
+
if (resolved.untilClean || (resolved.attempts !== undefined && resolved.attempts !== null)) {
|
|
144
|
+
throw new Error("outputSchema is not supported with convergence flags (--until-clean/--attempts): convergence delivers a gated patch, not a structured answer; drop the schema or the convergence flags");
|
|
145
|
+
}
|
|
146
|
+
// Shape-refuse unsupported schemas, then PROVE it compiles under the same
|
|
147
|
+
// ajv the engine validator uses — a malformed schema is a preflight
|
|
148
|
+
// refusal here (before any run dir), never a mid-run validator crash. The
|
|
149
|
+
// contract keeps the ORIGINAL (conformance authority); strictify is a
|
|
150
|
+
// transport-only transform applied per-lane in harnessSpecKnobs.
|
|
151
|
+
resolved.outputSchema = normalizeUserOutputSchema(resolved.outputSchema);
|
|
152
|
+
assertOutputSchemaCompiles(resolved.outputSchema);
|
|
153
|
+
}
|
|
101
154
|
// P1: a versioned `mandatory_files` contract is enforced UNIFORMLY here, for
|
|
102
155
|
// every mode, so the same repo state can't pass `run`/`ask` while failing
|
|
103
156
|
// `audit`. No-op when the list is empty (the default) or for no-project runs.
|
|
@@ -119,7 +172,9 @@ export class Orchestrator {
|
|
|
119
172
|
return this.runAsk(resolved, announce);
|
|
120
173
|
case "audit":
|
|
121
174
|
// `--swarm` selects the bounded read-only research swarm (old `explore`).
|
|
122
|
-
return resolved.swarm
|
|
175
|
+
return resolved.swarm
|
|
176
|
+
? this.runExplore(resolved, announce)
|
|
177
|
+
: this.runAudit(resolved, announce);
|
|
123
178
|
case "agent":
|
|
124
179
|
// Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
|
|
125
180
|
// and `--attempts` select the convergence loop; `--n` selects the race
|
|
@@ -149,73 +204,15 @@ export class Orchestrator {
|
|
|
149
204
|
if (this.deps.reviewerPanel && this.deps.reviewerPanel.length > 0) {
|
|
150
205
|
return this.resolveExplicitReviewerPanel(cwd, this.deps.reviewerPanel, runAuthPreference);
|
|
151
206
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (!m || m.kind === "fake" || seen.has(m.provider_family))
|
|
166
|
-
continue;
|
|
167
|
-
// Per-harness settings gate reviewers before doctor/model probes: a disabled
|
|
168
|
-
// harness must not spend auth/API-key readiness checks.
|
|
169
|
-
if (harnessSettings[adapter.id]?.enabled === false)
|
|
170
|
-
continue;
|
|
171
|
-
const authPreference = this.authPreferenceForHarness(cwd, adapter.id, runAuthPreference);
|
|
172
|
-
let report = null;
|
|
173
|
-
try {
|
|
174
|
-
report = await adapter.doctor({ cwd, env: reviewHome.env, authPreference });
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (report.status !== "ok")
|
|
180
|
-
continue; // reviewer eligibility needs scoped doctor-OK.
|
|
181
|
-
if (!report.enabled_intents.includes("review"))
|
|
182
|
-
continue;
|
|
183
|
-
if (!m.capabilities.review || !m.access_profiles_supported.includes("readonly"))
|
|
184
|
-
continue;
|
|
185
|
-
// Explicit per-family override first, then the user's per-harness
|
|
186
|
-
// default model: an explicit model request makes the route provable
|
|
187
|
-
// (accepted_model_arg) on CLIs that never echo their model.
|
|
188
|
-
const requestedModel = this.deps.reviewerModels?.[m.provider_family] ??
|
|
189
|
-
harnessSettings[adapter.id]?.default_model ??
|
|
190
|
-
null;
|
|
191
|
-
// STRICT: the auto panel applies the SAME model truth gate as the
|
|
192
|
-
// explicit panel — a doomed reviewer model is refused here, never
|
|
193
|
-
// forwarded to die as an opaque native error mid-review.
|
|
194
|
-
if (requestedModel) {
|
|
195
|
-
const check = validateModel(requestedModel, typeof adapter.models === "function"
|
|
196
|
-
? (await adapter.models({ cwd, env: reviewHome.env, authPreference })).map((x) => x.id)
|
|
197
|
-
: m.capabilities.known_models, typeof adapter.models === "function" ? "api" : "manifest");
|
|
198
|
-
if (check.status !== "ok") {
|
|
199
|
-
throw new HarnessUnavailableError(`auto-selected reviewer harness '${adapter.id}' refused model '${requestedModel}': ${check.message}; ` +
|
|
200
|
-
`fix the reviewer model override or harnesses.${adapter.id}.default_model, or run \`claudexor models --harness ${adapter.id}\``);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
seen.add(m.provider_family);
|
|
204
|
-
specs.push({
|
|
205
|
-
adapter,
|
|
206
|
-
providerFamily: m.provider_family,
|
|
207
|
-
requestedModel,
|
|
208
|
-
requestedEffort: this.deps.reviewerEfforts?.[m.provider_family] ?? null,
|
|
209
|
-
authPreference,
|
|
210
|
-
});
|
|
211
|
-
if (specs.length >= 2)
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
finally {
|
|
216
|
-
reviewHome.dispose();
|
|
217
|
-
}
|
|
218
|
-
return specs;
|
|
207
|
+
return resolveAutoReviewerPanel({
|
|
208
|
+
cwd,
|
|
209
|
+
registry: this.deps.registry,
|
|
210
|
+
harnessSettings: this.config(cwd)?.global.harnesses ?? {},
|
|
211
|
+
authPreferenceFor: (id) => this.authPreferenceForHarness(cwd, id, runAuthPreference),
|
|
212
|
+
}, {
|
|
213
|
+
reviewerModels: this.deps.reviewerModels,
|
|
214
|
+
reviewerEfforts: this.deps.reviewerEfforts,
|
|
215
|
+
});
|
|
219
216
|
}
|
|
220
217
|
/**
|
|
221
218
|
* Resolve reviewers INSIDE a strategy, after run-dir creation: an explicit
|
|
@@ -285,31 +282,6 @@ export class Orchestrator {
|
|
|
285
282
|
candidateIntent(input) {
|
|
286
283
|
return input.create === true ? "create_from_scratch" : "implement";
|
|
287
284
|
}
|
|
288
|
-
/**
|
|
289
|
-
* Resolve the per-harness browser-tool wiring for a run spec. Returns null
|
|
290
|
-
* (no browser) unless: the run opted in (`input.browser`), the harness has the
|
|
291
|
-
* `browser_tool` capability, AND web policy is not `off` (the browser is live
|
|
292
|
-
* egress and must ride `external_context_policy`). Screenshots/PDFs are written
|
|
293
|
-
* into the run's artifact tree so they surface in the Canvas gallery. Headed by
|
|
294
|
-
* default so the user can watch; `cdp_endpoint` is filled by the headed-Chromium
|
|
295
|
-
* launcher (7B) for the shared, mirrored window.
|
|
296
|
-
*/
|
|
297
|
-
browserSpecFor(input, routed, webPolicy, access, paths) {
|
|
298
|
-
if (!input?.browser || !routed.supportsBrowser || webPolicy === "off")
|
|
299
|
-
return null;
|
|
300
|
-
// The browser MCP drives a real Chromium (subprocess + live network). Codex's
|
|
301
|
-
// workspace-write sandbox cancels the navigation (live-verified across
|
|
302
|
-
// network_access / approval_policy / external-CDP variants) — only full access
|
|
303
|
-
// lets it through. Require full access rather than silently inject a browser
|
|
304
|
-
// whose first navigation will fail. The composer discloses this when the user
|
|
305
|
-
// arms the tool; a non-full run drops the browser honestly (no broken tool).
|
|
306
|
-
// headless:false -> a real headed window is the live view (locked design:
|
|
307
|
-
// the user watches the browser itself, not a mirrored feed); output_dir
|
|
308
|
-
// captures navigation snapshots into the run tree.
|
|
309
|
-
if (access !== "full" && access !== "external_sandbox_full")
|
|
310
|
-
return null;
|
|
311
|
-
return { output_dir: join(paths.root, "browser"), headless: false };
|
|
312
|
-
}
|
|
313
285
|
/**
|
|
314
286
|
* Session fields for a route's run spec: auth route preference + native
|
|
315
287
|
* resume id. Preference precedence: explicit per-run > per-harness
|
|
@@ -324,8 +296,9 @@ export class Orchestrator {
|
|
|
324
296
|
execRootOf(input) {
|
|
325
297
|
return input.executionRoot ?? input.repoRoot;
|
|
326
298
|
}
|
|
327
|
-
sessionSpecFields(input, harnessId) {
|
|
299
|
+
sessionSpecFields(input, harnessId, log) {
|
|
328
300
|
const cfg = this.config(input.repoRoot)?.global;
|
|
301
|
+
const profile = this.preflightProfile(input, harnessId, log);
|
|
329
302
|
const explicit = (v) => (v && v !== "auto" ? v : undefined);
|
|
330
303
|
return {
|
|
331
304
|
// "auto" at ANY level falls through (thread turns send the thread default
|
|
@@ -334,22 +307,49 @@ export class Orchestrator {
|
|
|
334
307
|
explicit(cfg?.harnesses?.[harnessId]?.auth_preference) ??
|
|
335
308
|
explicit(cfg?.routing?.auth_preference) ??
|
|
336
309
|
"auto",
|
|
337
|
-
resume_session_id: input.resumeSessions?.[harnessId]
|
|
310
|
+
resume_session_id: resumeSessionForProfile(input.resumeSessions?.[harnessId], profile),
|
|
311
|
+
credential_profile: profile,
|
|
338
312
|
};
|
|
339
313
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
314
|
+
resolveCredentialProfile(input, harnessId) {
|
|
315
|
+
if (!input.credentialProfileId)
|
|
316
|
+
return null;
|
|
317
|
+
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
318
|
+
return resolveCredentialProfile(registry, input.credentialProfileId, harnessId);
|
|
319
|
+
}
|
|
320
|
+
/** The typed effective auth route for a SELECTED credential profile
|
|
321
|
+
* (round-18 #2): adapters execute strictly by credential_kind, so routing,
|
|
322
|
+
* billing classification, model truth, and quota lookup must share this
|
|
323
|
+
* one fact — never the default store's sources or a previous default-route
|
|
324
|
+
* metric. null = no profile selected or it does not resolve here. */
|
|
325
|
+
profileAuthRoute(input, harnessId) {
|
|
326
|
+
try {
|
|
327
|
+
const profile = this.resolveCredentialProfile(input, harnessId);
|
|
328
|
+
if (!profile)
|
|
329
|
+
return null;
|
|
330
|
+
return profile.credential_kind === "api_key" ? "api_key" : "local_session";
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
profilePolicy(repoRoot, harnessId) {
|
|
337
|
+
const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
|
|
338
|
+
return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
|
|
339
|
+
}
|
|
340
|
+
preflightProfile(input, harnessId, log) {
|
|
341
|
+
const profile = this.resolveCredentialProfile(input, harnessId);
|
|
342
|
+
const policy = this.profilePolicy(input.repoRoot, harnessId);
|
|
343
|
+
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
344
|
+
const snapshots = this.deps.quotaSnapshots?.() ?? [];
|
|
345
|
+
const emit = (type, payload) => log?.emit(type, payload);
|
|
346
|
+
if (!profile) {
|
|
347
|
+
// Unpinned runs (INV-135 auto-balance): under `rotate`, a fresh
|
|
348
|
+
// default-subject headroom breach starts on the next eligible
|
|
349
|
+
// subscription profile instead; `fail`/`ask` change nothing.
|
|
350
|
+
return preflightDefaultSubject({ harnessId, policy, registry, snapshots, emit });
|
|
352
351
|
}
|
|
352
|
+
return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
|
|
353
353
|
}
|
|
354
354
|
/**
|
|
355
355
|
* Lift an adapter's auth-route override marker into the typed
|
|
@@ -361,7 +361,7 @@ export class Orchestrator {
|
|
|
361
361
|
/**
|
|
362
362
|
* Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
|
|
363
363
|
* **capability-gate** to those that can actually produce work for `intent` (e.g. a
|
|
364
|
-
*
|
|
364
|
+
* a planner-only adapter with `implement: false` is dropped from an implement race), and
|
|
365
365
|
* expand to n. Fails loudly if nothing can perform the intent.
|
|
366
366
|
*/
|
|
367
367
|
resolveRunInput(input) {
|
|
@@ -420,16 +420,16 @@ export class Orchestrator {
|
|
|
420
420
|
primaryHarness,
|
|
421
421
|
model: undefined,
|
|
422
422
|
models,
|
|
423
|
-
|
|
424
|
-
this.deps.
|
|
425
|
-
cfg?.project.budget?.
|
|
426
|
-
cfg?.global.
|
|
427
|
-
"
|
|
423
|
+
routingGoal: input.routingGoal ??
|
|
424
|
+
this.deps.routingGoal ??
|
|
425
|
+
cfg?.project.budget?.routing_goal ??
|
|
426
|
+
cfg?.global.routing.goal ??
|
|
427
|
+
"auto",
|
|
428
428
|
web,
|
|
429
429
|
externalContextPolicy: web,
|
|
430
430
|
};
|
|
431
431
|
}
|
|
432
|
-
async resolveCandidateAdapters(input, intent, ledger) {
|
|
432
|
+
async resolveCandidateAdapters(input, intent, ledger, routeContext) {
|
|
433
433
|
let ids = input.harnesses;
|
|
434
434
|
const explicitPool = Boolean(ids && ids.length > 0);
|
|
435
435
|
const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
|
|
@@ -442,22 +442,47 @@ export class Orchestrator {
|
|
|
442
442
|
const statuses = probeIds.length > 0 ? await this.gateway.statusAll({ cwd: input.repoRoot }, probeIds) : [];
|
|
443
443
|
const statusById = new Map(statuses.map((s) => [s.id, s]));
|
|
444
444
|
if (!ids || ids.length === 0) {
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
445
|
+
// INV-135 (round-18 BLOCK): an explicit credential profile NAMES its
|
|
446
|
+
// harness — the implicit pool is exactly the profile's enabled
|
|
447
|
+
// harness(es) from the registry, and the profile probe (below) is the
|
|
448
|
+
// auth verdict. Deriving the pool from default doctor-OK status would
|
|
449
|
+
// exclude a valid profile whose default store is logged out while
|
|
450
|
+
// keeping unrelated harnesses that later fail profile resolution.
|
|
451
|
+
const profilePool = input.credentialProfileId
|
|
452
|
+
? [
|
|
453
|
+
...new Set((this.config(input.repoRoot)?.global.credential_profiles ?? [])
|
|
454
|
+
.filter((p) => p.enabled && p.profile_id === input.credentialProfileId)
|
|
455
|
+
.map((p) => p.harness_id)
|
|
456
|
+
.filter((hid) => !disabledHarnessIds.has(hid) && this.deps.registry.has(hid))),
|
|
457
|
+
]
|
|
458
|
+
: [];
|
|
459
|
+
if (profilePool.length > 0) {
|
|
460
|
+
ids = profilePool;
|
|
461
|
+
}
|
|
462
|
+
else if (input.credentialProfileId) {
|
|
463
|
+
// Fable-checkpoint NIT: an unknown/disabled profile id must refuse
|
|
464
|
+
// HERE, not fall through to the default auto-pool — that would run on
|
|
465
|
+
// the DEFAULT credentials while the caller explicitly named an
|
|
466
|
+
// account, surfacing later as a per-harness "not registered" error.
|
|
467
|
+
const registered = (this.config(input.repoRoot)?.global.credential_profiles ?? []).filter((p) => p.profile_id === input.credentialProfileId);
|
|
468
|
+
throw new HarnessUnavailableError(registered.length === 0
|
|
469
|
+
? `credential profile "${input.credentialProfileId}" is not registered (see \`claudexor profiles list\`)`
|
|
470
|
+
: registered.every((p) => !p.enabled)
|
|
471
|
+
? `credential profile "${input.credentialProfileId}" is disabled`
|
|
472
|
+
: `credential profile "${input.credentialProfileId}" belongs to unavailable harness(es): ${registered.map((p) => p.harness_id).join(", ")}`);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
// Auto-pools take only doctor-OK harnesses (BIBLE §2: doctor decides
|
|
476
|
+
// readiness; a key string or degraded route is visible but not routable).
|
|
477
|
+
ids = statuses
|
|
478
|
+
.filter((s) => s.manifest?.kind !== "fake" && s.status === "ok" && s.enabledIntents.includes(intent))
|
|
479
|
+
.map((s) => s.id);
|
|
480
|
+
if (ids.length === 0) {
|
|
481
|
+
throw new HarnessUnavailableError("no doctor-ok harness for this mode; install/login codex/claude/cursor/opencode (see `claudexor doctor`), or pass --harness explicitly");
|
|
482
|
+
}
|
|
452
483
|
}
|
|
453
484
|
}
|
|
454
485
|
const policy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
455
|
-
// Vision is a capability: a run carrying an image attachment must route to a
|
|
456
|
-
// harness that can actually deliver it (image_input != "none"). Routing an
|
|
457
|
-
// image to cursor/opencode (image_input="none") silently drops it and the
|
|
458
|
-
// model honestly reports it saw nothing — the schema's attachment contract
|
|
459
|
-
// (attachment.ts) promises the opposite. Gate the pool below, mirroring web.
|
|
460
|
-
const needsVision = (input.attachments ?? []).some((a) => a.kind === "image");
|
|
461
486
|
const pool = [];
|
|
462
487
|
const dropped = [];
|
|
463
488
|
for (const id of ids) {
|
|
@@ -484,26 +509,61 @@ export class Orchestrator {
|
|
|
484
509
|
dropped.push(why);
|
|
485
510
|
continue;
|
|
486
511
|
}
|
|
487
|
-
|
|
512
|
+
// W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
|
|
513
|
+
// resolved env/cwd its run will spawn with (see routeContext.ts).
|
|
514
|
+
let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
|
|
488
515
|
const manifest = status?.manifest ?? null;
|
|
489
516
|
if (!status || !manifest) {
|
|
490
517
|
dropped.push(`${id} (unavailable)`);
|
|
491
518
|
continue;
|
|
492
519
|
}
|
|
493
|
-
// Doctor status is the readiness truth
|
|
494
|
-
// routes, and explicitly selecting an UNAVAILABLE harness fails loudly
|
|
495
|
-
// with the doctor's reasons. A DEGRADED harness (e.g. key present but
|
|
520
|
+
// Doctor status is the readiness truth. A DEGRADED harness (e.g. key present but
|
|
496
521
|
// unproven by isolated smoke) is admitted only by explicit user
|
|
497
522
|
// selection — degraded means usable-with-caveats, and the caveats are
|
|
498
523
|
// visible in doctor output and run events.
|
|
499
|
-
|
|
524
|
+
// INV-135 (round-13, extended by the round-18 BLOCK): an EXPLICIT
|
|
525
|
+
// profile is authenticated by ITS store — the profile probe overrides
|
|
526
|
+
// the default auth verdict for ANY non-ok default status, and a
|
|
527
|
+
// profile-admitted route joins even an AUTO pool (the run spawns with
|
|
528
|
+
// the profile's transport, so the default store's state is not the
|
|
529
|
+
// routing truth). Capability/manifest gating above still applies.
|
|
530
|
+
let profileAdmitted = false;
|
|
531
|
+
const profileAdapter = this.deps.registry.get(id);
|
|
532
|
+
const profileVerdict = await selectedProfileAvailability({
|
|
533
|
+
registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
|
|
534
|
+
profileId: input.credentialProfileId,
|
|
535
|
+
harnessId: id,
|
|
536
|
+
probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
|
|
537
|
+
});
|
|
538
|
+
if (profileVerdict !== null) {
|
|
539
|
+
if (profileVerdict === "available") {
|
|
540
|
+
profileAdmitted = true;
|
|
541
|
+
// A valid profile restores manifest intent truth when the default store failed.
|
|
542
|
+
if (status.status !== "ok") {
|
|
543
|
+
status = {
|
|
544
|
+
...status,
|
|
545
|
+
status: "degraded",
|
|
546
|
+
enabledIntents: capabilityIntents(manifest.capabilities),
|
|
547
|
+
};
|
|
548
|
+
statusById.set(id, status);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const why = `${id} credential profile is not ready: ${profileVerdict}`;
|
|
553
|
+
if (explicitPool)
|
|
554
|
+
throw new HarnessUnavailableError(why);
|
|
555
|
+
dropped.push(why);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (status.status === "unavailable" && !profileAdmitted) {
|
|
500
560
|
const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
|
|
501
561
|
if (explicitPool)
|
|
502
562
|
throw new HarnessUnavailableError(why);
|
|
503
563
|
dropped.push(why);
|
|
504
564
|
continue;
|
|
505
565
|
}
|
|
506
|
-
if (status.status !== "ok" && !explicitPool) {
|
|
566
|
+
if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
|
|
507
567
|
dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
|
|
508
568
|
continue;
|
|
509
569
|
}
|
|
@@ -512,18 +572,13 @@ export class Orchestrator {
|
|
|
512
572
|
intent === "explain" ||
|
|
513
573
|
intent === "audit" ||
|
|
514
574
|
intent === "orchestrate";
|
|
515
|
-
|
|
516
|
-
// when the run does not request a profile explicitly.
|
|
517
|
-
const requiredAccess = readOnlyIntent
|
|
575
|
+
const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
|
|
518
576
|
? "readonly"
|
|
519
|
-
: (input.access ?? this.config(input.repoRoot).trust.access_default);
|
|
577
|
+
: (input.access ?? this.config(input.repoRoot).trust.access_default));
|
|
520
578
|
const accessSupported = !requiredAccess || manifest.access_profiles_supported.includes(requiredAccess);
|
|
521
579
|
const webSupport = manifest.capabilities.web_policy;
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
// applies the same rule when building the spec), so the capability gate
|
|
525
|
-
// must judge that effective policy — not admit a route whose configured
|
|
526
|
-
// default it could never honor.
|
|
580
|
+
// Match routeSpecKnobs: a per-harness web default upgrades run-level auto,
|
|
581
|
+
// so judge the effective per-route policy.
|
|
527
582
|
const routePolicy = policy === "auto" && cfgEntry?.web && cfgEntry.web !== "auto" ? cfgEntry.web : policy;
|
|
528
583
|
const routeWebRequired = routePolicy === "cached" || routePolicy === "live";
|
|
529
584
|
// Web policy is a capability: `off` needs an enforceable off state and a
|
|
@@ -541,34 +596,44 @@ export class Orchestrator {
|
|
|
541
596
|
dropped.push(why);
|
|
542
597
|
continue;
|
|
543
598
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (needsVision && manifest.capability_profile.image_input === "none") {
|
|
547
|
-
const why = `${id} cannot accept image attachments (manifest image_input=none); choose a vision-capable harness (see \`claudexor doctor\` — capability image_input) or remove the image attachment`;
|
|
599
|
+
const attachmentRefusal = this.requestRequirements.attachmentRefusal(id, input.attachments ?? [], manifest.capability_profile.attachment_inputs);
|
|
600
|
+
if (attachmentRefusal) {
|
|
548
601
|
if (explicitPool)
|
|
549
|
-
throw new HarnessUnavailableError(
|
|
550
|
-
dropped.push(
|
|
602
|
+
throw new HarnessUnavailableError(attachmentRefusal);
|
|
603
|
+
dropped.push(attachmentRefusal);
|
|
551
604
|
continue;
|
|
552
605
|
}
|
|
553
606
|
const reason = status.reasons.length > 0 ? `: ${status.reasons.join("; ")}` : "";
|
|
554
607
|
if (status.enabledIntents.includes(intent) && accessSupported) {
|
|
555
608
|
pool.push({
|
|
556
609
|
adapter,
|
|
610
|
+
adapterAccess: requiredAccess,
|
|
557
611
|
webSupport,
|
|
558
612
|
providerFamily: manifest.provider_family,
|
|
559
613
|
supportsMaxTurns: manifest.capabilities.max_turns,
|
|
560
614
|
supportsToolLists: manifest.capabilities.tool_lists,
|
|
561
|
-
|
|
615
|
+
browserRequirement: this.requestRequirements.resolveBrowser({
|
|
616
|
+
harnessId: id,
|
|
617
|
+
requested: input.browser === true,
|
|
618
|
+
manifestCapable: manifest.capabilities.browser_tool,
|
|
619
|
+
webPolicy: routePolicy,
|
|
620
|
+
access: requiredAccess,
|
|
621
|
+
}),
|
|
622
|
+
denyRequirement: this.requestRequirements.resolveDenyPaths(id, (input.denyPaths?.length ?? 0) > 0),
|
|
562
623
|
effortLevels: manifest.capabilities.effort_levels,
|
|
563
624
|
knownModels: manifest.capabilities.known_models,
|
|
625
|
+
// A selected profile's credential_kind IS the route (round-18 #2);
|
|
626
|
+
// the default store's sources apply only to profile-less runs.
|
|
627
|
+
authRouteEstimate: this.profileAuthRoute(input, id) ??
|
|
628
|
+
estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
|
|
564
629
|
supportsInteractive: manifest.capabilities.interactive,
|
|
565
630
|
supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
|
|
631
|
+
implementationTransport: manifest.capabilities.implementation_transport,
|
|
566
632
|
settings: cfgEntry
|
|
567
633
|
? {
|
|
568
634
|
defaultModel: cfgEntry.default_model,
|
|
569
635
|
effort: cfgEntry.effort,
|
|
570
636
|
web: cfgEntry.web === "auto" ? null : cfgEntry.web,
|
|
571
|
-
maxUsd: cfgEntry.max_usd,
|
|
572
637
|
maxTurns: cfgEntry.max_turns,
|
|
573
638
|
maxRounds: cfgEntry.max_rounds,
|
|
574
639
|
toolsAllow: cfgEntry.tools_allow,
|
|
@@ -584,69 +649,116 @@ export class Orchestrator {
|
|
|
584
649
|
if (pool.length === 0) {
|
|
585
650
|
throw new HarnessUnavailableError(`no harness can perform '${intent}' for this mode${dropped.length ? ` (skipped: ${dropped.join(", ")})` : ""}`);
|
|
586
651
|
}
|
|
587
|
-
const ordered = this.orderPool(pool, input, statusById, ledger);
|
|
652
|
+
const ordered = this.orderPool(pool, input, intent, statusById, ledger);
|
|
653
|
+
if (ordered.length === 0) {
|
|
654
|
+
throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
|
|
655
|
+
}
|
|
588
656
|
const n = input.n ?? ordered.length;
|
|
589
657
|
const out = [];
|
|
590
658
|
for (let i = 0; i < n; i++)
|
|
591
659
|
out.push(ordered[i % ordered.length]);
|
|
660
|
+
this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
|
|
661
|
+
// outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
|
|
662
|
+
// natively constrain its final message would deliver best-effort text —
|
|
663
|
+
// that is a typed preflight refusal, never silent degradation. The
|
|
664
|
+
// interactive stream-json transport x --json-schema is an unverified
|
|
665
|
+
// vendor combination, so lanes that would ride it refuse too.
|
|
666
|
+
if (input.outputSchema !== undefined && input.outputSchema !== null) {
|
|
667
|
+
const incapable = out.filter((lane) => !lane.supportsJsonSchemaOutput);
|
|
668
|
+
if (incapable.length > 0) {
|
|
669
|
+
throw new HarnessUnavailableError(`outputSchema is mandatory but selected lane(s) cannot constrain output natively: ${[...new Set(incapable.map((lane) => lane.adapter.id))].join(", ")} (manifest capabilities.json_schema_output=false); choose schema-capable harnesses or drop the schema`);
|
|
670
|
+
}
|
|
671
|
+
// NOTE (DT2.1-16): the daemon ALWAYS arms an interaction channel, so an
|
|
672
|
+
// interactive-capable lane (claude) is refused for outputSchema on every
|
|
673
|
+
// daemon/CLI run today — the --json-schema x stream-json interactive combo
|
|
674
|
+
// is not yet live-verified. Structured-output runs therefore route through
|
|
675
|
+
// a non-interactive lane (codex). The message names that reality instead
|
|
676
|
+
// of pointing at a channel a daemon caller cannot turn off.
|
|
677
|
+
const interactive = Boolean(input.onInteraction)
|
|
678
|
+
? out.filter((lane) => lane.supportsInteractive)
|
|
679
|
+
: [];
|
|
680
|
+
if (interactive.length > 0) {
|
|
681
|
+
throw new HarnessUnavailableError(`outputSchema is not yet available on interactive-transport lane(s): ${[...new Set(interactive.map((lane) => lane.adapter.id))].join(", ")} (the --json-schema x stream-json combination is unverified). Route structured-output runs through a non-interactive schema-capable harness (e.g. codex), or drop the schema`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
592
684
|
// Strict pre-run model gate (INV-104) — see modelGovernance.ts.
|
|
593
685
|
await assertRouteModelsAllowed(out, input.models, this.execRootOf(input));
|
|
594
686
|
return out;
|
|
595
687
|
}
|
|
596
688
|
/**
|
|
597
|
-
* Order the eligible pool by
|
|
689
|
+
* Order the eligible pool by the selected routing goal (budget router): an
|
|
598
690
|
* explicit user pool keeps the user's order; an explicit primary harness is
|
|
599
691
|
* always pinned first. Cross-family diversity is encouraged for later slots.
|
|
600
692
|
*/
|
|
601
|
-
orderPool(pool, input, statusById, ledger) {
|
|
693
|
+
orderPool(pool, input, intent, statusById, ledger) {
|
|
602
694
|
let ordered = pool;
|
|
603
|
-
|
|
604
|
-
if (!explicitPool && pool.length > 1) {
|
|
695
|
+
if (pool.length > 0) {
|
|
605
696
|
const routeLedger = ledger ?? new BudgetLedger();
|
|
606
|
-
const
|
|
697
|
+
const config = this.config(input.repoRoot).global;
|
|
698
|
+
const goal = input.routingGoal ?? this.deps.routingGoal ?? config.routing.goal;
|
|
607
699
|
const byId = new Map(pool.map((r) => [r.adapter.id, r]));
|
|
608
|
-
//
|
|
609
|
-
// (single producer: attempt settlement) and operator-declared per-family
|
|
610
|
-
// quality priors. Absent data rides the router's neutral defaults.
|
|
700
|
+
// Settled cost is evidence for economy routing, never a provider quality prior.
|
|
611
701
|
const metrics = loadHarnessMetrics(globalConfigDir());
|
|
612
|
-
const priors = this.config(input.repoRoot).global.routing.quality_priors;
|
|
613
702
|
const remaining = pool.map((r) => {
|
|
614
703
|
const authModes = statusById.get(r.adapter.id)?.manifest?.auth_modes ?? [];
|
|
615
704
|
const metric = metrics[r.adapter.id];
|
|
705
|
+
// Auth mode for routing: prefer the ROUTE EVIDENCE from the
|
|
706
|
+
// last settled attempt (adapter-disclosed, persisted in metrics) over
|
|
707
|
+
// the manifest capability guess — auth_modes lists what a harness CAN
|
|
708
|
+
// use, not what it actually runs under.
|
|
709
|
+
const guessedAuthMode = authModes.includes("local_session")
|
|
710
|
+
? "local_session"
|
|
711
|
+
: authModes.includes("api_key")
|
|
712
|
+
? "api_key"
|
|
713
|
+
: "unknown";
|
|
714
|
+
// A selected profile's credential_kind decides the route outright
|
|
715
|
+
// (round-18 #2): an api_key profile must never inherit a
|
|
716
|
+
// subscription classification from the default store's metric.
|
|
717
|
+
const authMode = this.profileAuthRoute(input, r.adapter.id) ??
|
|
718
|
+
(input.authPreference === "api_key"
|
|
719
|
+
? "api_key"
|
|
720
|
+
: input.authPreference === "subscription"
|
|
721
|
+
? "local_session"
|
|
722
|
+
: (metric?.last_auth_mode ?? guessedAuthMode));
|
|
723
|
+
// The quota subject this candidate would actually run as (release
|
|
724
|
+
// wave round-16 #2): the resolved profile id, or null for the engine
|
|
725
|
+
// default — so profile A's cooldown never excludes profile B or the
|
|
726
|
+
// default. A profile that does not resolve for this harness routes
|
|
727
|
+
// as unknown (undefined) and stays conservatively any-subject.
|
|
728
|
+
let credentialSubjectId;
|
|
729
|
+
try {
|
|
730
|
+
credentialSubjectId =
|
|
731
|
+
this.resolveCredentialProfile(input, r.adapter.id)?.profile_id ?? null;
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
credentialSubjectId = undefined;
|
|
735
|
+
}
|
|
616
736
|
return {
|
|
617
737
|
harnessId: r.adapter.id,
|
|
618
|
-
providerFamily: r.providerFamily,
|
|
619
738
|
available: true,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
739
|
+
model: input.models?.[r.adapter.id] ??
|
|
740
|
+
config.harnesses[r.adapter.id]?.default_model ??
|
|
741
|
+
undefined,
|
|
742
|
+
effort: input.effort ?? config.harnesses[r.adapter.id]?.effort ?? undefined,
|
|
743
|
+
billingKnowledge: authMode === "api_key" ? "metered" : "unknown",
|
|
744
|
+
incrementalCostUsd: authMode === "api_key" ? (metric?.avg_cost_usd ?? null) : null,
|
|
745
|
+
credentialRoute: authMode === "api_key"
|
|
746
|
+
? "managed_api_key"
|
|
747
|
+
: authMode === "local_session"
|
|
748
|
+
? "vendor_native"
|
|
749
|
+
: undefined,
|
|
750
|
+
credentialSubjectId,
|
|
628
751
|
};
|
|
629
752
|
});
|
|
630
|
-
const ranked =
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const idx = remaining.findIndex((c) => c.harnessId === best.harnessId);
|
|
640
|
-
remaining.splice(idx, 1);
|
|
641
|
-
const routed = byId.get(best.harnessId);
|
|
642
|
-
if (routed)
|
|
643
|
-
ranked.push(routed);
|
|
644
|
-
}
|
|
645
|
-
for (const c of remaining) {
|
|
646
|
-
const routed = byId.get(c.harnessId);
|
|
647
|
-
if (routed)
|
|
648
|
-
ranked.push(routed);
|
|
649
|
-
}
|
|
753
|
+
const ranked = rankHarnesses(remaining, {
|
|
754
|
+
goal,
|
|
755
|
+
paidFallback: config.routing.paid_fallback,
|
|
756
|
+
intent,
|
|
757
|
+
qualityTiers: config.routing.quality_tiers,
|
|
758
|
+
ledger: routeLedger,
|
|
759
|
+
})
|
|
760
|
+
.map((candidate) => byId.get(candidate.harnessId))
|
|
761
|
+
.filter((candidate) => Boolean(candidate));
|
|
650
762
|
ordered = ranked;
|
|
651
763
|
}
|
|
652
764
|
if (input.primaryHarness) {
|
|
@@ -695,22 +807,6 @@ export class Orchestrator {
|
|
|
695
807
|
"",
|
|
696
808
|
].join("\n");
|
|
697
809
|
}
|
|
698
|
-
/**
|
|
699
|
-
* Ledger a routed harness reserves from: harnesses with a configured
|
|
700
|
-
* `max_usd` get a child sub-ledger (spend rolls up to the run cap), so one
|
|
701
|
-
* harness exhausting its own budget cannot drain the whole run.
|
|
702
|
-
*/
|
|
703
|
-
harnessLedger(map, parent, routed) {
|
|
704
|
-
const cap = routed.settings?.maxUsd;
|
|
705
|
-
if (!cap || cap <= 0)
|
|
706
|
-
return parent;
|
|
707
|
-
let child = map.get(routed.adapter.id);
|
|
708
|
-
if (!child) {
|
|
709
|
-
child = parent.child({ maxUsd: cap });
|
|
710
|
-
map.set(routed.adapter.id, child);
|
|
711
|
-
}
|
|
712
|
-
return child;
|
|
713
|
-
}
|
|
714
810
|
/**
|
|
715
811
|
* The web mode a routed harness actually executes for a requested policy.
|
|
716
812
|
* Tools-permissioned web (e.g. claude) has no cached index: `cached` upgrades
|
|
@@ -762,7 +858,10 @@ export class Orchestrator {
|
|
|
762
858
|
// turn (TurnEnqueueError.code), so surfaces key remedies on the CODE —
|
|
763
859
|
// never on substring-matching this human message.
|
|
764
860
|
throw Object.assign(new Error(`access profile 'full' requires allow_full_access: true in the user-level trust file for this repo ` +
|
|
765
|
-
`(${trustConfigPath(input.repoRoot)}); enable it with \`claudexor trust --allow-full-access\` — refusing to run unsandboxed`),
|
|
861
|
+
`(${trustConfigPath(input.repoRoot)}); enable it with \`claudexor trust --allow-full-access\` — refusing to run unsandboxed`),
|
|
862
|
+
// Refusal semantics are born at the throw (W24): the one-time grant is
|
|
863
|
+
// a 403, and the daemon persists this status onto the job record.
|
|
864
|
+
{ code: TRUST_FULL_ACCESS_CODE, status: 403 });
|
|
766
865
|
}
|
|
767
866
|
const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
768
867
|
// A frozen SpecPack's CONTENT reaches the contract (success criteria,
|
|
@@ -785,7 +884,7 @@ export class Orchestrator {
|
|
|
785
884
|
repoRoot: input.repoRoot,
|
|
786
885
|
mode,
|
|
787
886
|
baseRef: input.baseRef,
|
|
788
|
-
|
|
887
|
+
paidBudget: input.paidBudget,
|
|
789
888
|
});
|
|
790
889
|
specFields = {
|
|
791
890
|
success_criteria: fromSpec.success_criteria,
|
|
@@ -795,7 +894,12 @@ export class Orchestrator {
|
|
|
795
894
|
task_graph: fromSpec.task_graph,
|
|
796
895
|
constraints: fromSpec.constraints,
|
|
797
896
|
};
|
|
798
|
-
specTestCommands = fromSpec.tests.commands.map((
|
|
897
|
+
specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
|
|
898
|
+
program,
|
|
899
|
+
args,
|
|
900
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
901
|
+
envAllowlist,
|
|
902
|
+
}));
|
|
799
903
|
}
|
|
800
904
|
catch (err) {
|
|
801
905
|
// An unreadable/unfrozen spec must fail the run loudly, never silently
|
|
@@ -806,22 +910,18 @@ export class Orchestrator {
|
|
|
806
910
|
// Deterministic gate commands come from the frozen SpecPack, explicit run
|
|
807
911
|
// input, then versioned project config. Without these, gateSpecs is empty
|
|
808
912
|
// and convergence is review-only; with them, convergence is test-driven.
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
return true;
|
|
818
|
-
})
|
|
819
|
-
.map((command, i) => {
|
|
820
|
-
assertNoSecretLikeTokens(`gate command ${i + 1}`, command);
|
|
821
|
-
return { id: `gate-${i + 1}`, command, required: true };
|
|
913
|
+
const resolvedGates = resolveContractGates({
|
|
914
|
+
repoRoot: input.repoRoot,
|
|
915
|
+
effectiveAccess,
|
|
916
|
+
config: cfg,
|
|
917
|
+
trustGrants: resolvedCfg.trust.test_command_grants,
|
|
918
|
+
specCommands: specTestCommands,
|
|
919
|
+
operatorCommands: input.tests ?? [],
|
|
920
|
+
projectCommands: cfg.tests?.commands ?? [],
|
|
822
921
|
});
|
|
922
|
+
const commands = resolvedGates.commands;
|
|
823
923
|
const protectedPaths = [...new Set(specFields.constraints?.protected_paths ?? [])];
|
|
824
|
-
const autoProtectedPaths =
|
|
924
|
+
const autoProtectedPaths = resolvedGates.autoProtectedPaths;
|
|
825
925
|
const protectedPathApprovals = [
|
|
826
926
|
...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
|
|
827
927
|
];
|
|
@@ -832,6 +932,17 @@ export class Orchestrator {
|
|
|
832
932
|
repo: { root: input.repoRoot, base_ref: input.baseRef ?? "HEAD", dirty_policy: "snapshot" },
|
|
833
933
|
mode: { kind: mode },
|
|
834
934
|
user_intent: { raw: redactSecrets(input.prompt) },
|
|
935
|
+
// Redacted for symmetry with user_intent.raw — a no-op on fenced input
|
|
936
|
+
// (the inline-secret fence already blocked any secret-like value at every
|
|
937
|
+
// ingress incl. this engine boundary), so task-producing lanes read back
|
|
938
|
+
// the real instructions via harnessSpecKnobs().
|
|
939
|
+
instructions: input.instructions === undefined ? undefined : redactSecrets(input.instructions),
|
|
940
|
+
// Already normalized/strictified at the engine boundary (run() refuses
|
|
941
|
+
// unsupported shapes before any run dir exists).
|
|
942
|
+
output_schema: input.outputSchema ?? null,
|
|
943
|
+
auth_preference: input.authPreference ?? "auto",
|
|
944
|
+
credential_profile_id: input.credentialProfileId ?? null,
|
|
945
|
+
max_turns: input.maxTurns ?? null,
|
|
835
946
|
spec: input.specId || input.specHash || input.specPath
|
|
836
947
|
? {
|
|
837
948
|
id: input.specId,
|
|
@@ -842,6 +953,7 @@ export class Orchestrator {
|
|
|
842
953
|
...specFields,
|
|
843
954
|
constraints: {
|
|
844
955
|
protected_paths: protectedPaths,
|
|
956
|
+
deny_paths: [...new Set(input.denyPaths ?? [])],
|
|
845
957
|
auto_protected_paths: autoProtectedPaths,
|
|
846
958
|
protected_path_approvals: protectedPathApprovals,
|
|
847
959
|
},
|
|
@@ -866,11 +978,8 @@ export class Orchestrator {
|
|
|
866
978
|
deny: [],
|
|
867
979
|
},
|
|
868
980
|
budget: {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// configured global per-run default. ($/day caps were removed; the budget
|
|
872
|
-
// priority is respecting harness-reported subscription/OAuth quota.)
|
|
873
|
-
max_usd: this.resolveMaxUsdCap(input.maxUsd, resolvedCfg),
|
|
981
|
+
routing_goal: input.routingGoal ?? this.deps.routingGoal ?? cfg?.budget?.routing_goal ?? "auto",
|
|
982
|
+
paid_budget: this.resolvePaidBudget(input.paidBudget, resolvedCfg),
|
|
874
983
|
},
|
|
875
984
|
// The resolved harness-scoped model map (scalar already expanded to the
|
|
876
985
|
// primary by resolveRunInput). The contract is what route spec building
|
|
@@ -878,61 +987,43 @@ export class Orchestrator {
|
|
|
878
987
|
routing_models: input.models ?? {},
|
|
879
988
|
});
|
|
880
989
|
}
|
|
881
|
-
gateSpecs(contract) {
|
|
882
|
-
return contract.tests.commands.map((c) => ({
|
|
883
|
-
id: c.id,
|
|
884
|
-
command: c.command,
|
|
885
|
-
required: c.required,
|
|
886
|
-
}));
|
|
887
|
-
}
|
|
888
|
-
testsEvidence(contract, gates) {
|
|
889
|
-
const specs = this.gateSpecs(contract);
|
|
890
|
-
if (gates === undefined) {
|
|
891
|
-
if (specs.length === 0)
|
|
892
|
-
return "(no test commands configured)";
|
|
893
|
-
return [
|
|
894
|
-
"Configured test commands (not run yet):",
|
|
895
|
-
...specs.map((spec) => `- ${spec.id}${spec.required === false ? " (optional)" : ""}: ${spec.command}`),
|
|
896
|
-
].join("\n");
|
|
897
|
-
}
|
|
898
|
-
if (gates.length === 0) {
|
|
899
|
-
if (specs.length === 0)
|
|
900
|
-
return "(no test commands configured)";
|
|
901
|
-
return [
|
|
902
|
-
"Configured test commands did not produce gate results before this review:",
|
|
903
|
-
...specs.map((spec) => `- ${spec.id}${spec.required === false ? " (optional)" : ""}: ${spec.command}`),
|
|
904
|
-
].join("\n");
|
|
905
|
-
}
|
|
906
|
-
const required = gates.filter((gate) => gate.required);
|
|
907
|
-
const requiredPassed = required.filter((gate) => gate.status === "passed").length;
|
|
908
|
-
const lines = [
|
|
909
|
-
`Gate results: required ${requiredPassed}/${required.length} passed; total ${gates.length}.`,
|
|
910
|
-
];
|
|
911
|
-
const appendTail = (label, text) => {
|
|
912
|
-
if (!text)
|
|
913
|
-
return;
|
|
914
|
-
lines.push(` ${label}: |`);
|
|
915
|
-
for (const line of text.split(/\r?\n/))
|
|
916
|
-
lines.push(` ${line}`);
|
|
917
|
-
};
|
|
918
|
-
for (const gate of gates) {
|
|
919
|
-
lines.push(`- ${gate.id}${gate.required === false ? " (optional)" : ""}: ${gate.status}; exit=${gate.exit_code ?? "null"}; duration_ms=${gate.duration_ms}`);
|
|
920
|
-
lines.push(` command: ${gate.command}`);
|
|
921
|
-
if (gate.output_truncated)
|
|
922
|
-
lines.push(" output_truncated: true");
|
|
923
|
-
appendTail("stdout_tail", gate.stdout_tail);
|
|
924
|
-
appendTail("stderr_tail", gate.stderr_tail);
|
|
925
|
-
}
|
|
926
|
-
return lines.join("\n");
|
|
927
|
-
}
|
|
928
|
-
writeTestsEvidence(evidenceDir, contract, gates) {
|
|
929
|
-
writeText(join(evidenceDir, "TESTS.txt"), this.testsEvidence(contract, gates).trim() + "\n");
|
|
930
|
-
}
|
|
931
990
|
/**
|
|
932
991
|
* Per-harness settings applied to one route's run spec (model/effort/web
|
|
933
992
|
* defaults, max_turns, tool lists). Knobs the manifest does not support are
|
|
934
993
|
* RETURNED as ignored reasons (disclosed by the caller), never silently sent.
|
|
935
994
|
*/
|
|
995
|
+
/**
|
|
996
|
+
* The HarnessRunSpec fields every TASK-PRODUCING lane shares (primary,
|
|
997
|
+
* candidate, planner, explorer, orchestrate-planner). Extracting the identical
|
|
998
|
+
* block into ONE owner means a new task-producing field lands here once —
|
|
999
|
+
* never forgotten in one of the HarnessRunSpec.parse sites (the multi-path
|
|
1000
|
+
* trap). Per-run `instructions` ride every task-producing lane but are withheld
|
|
1001
|
+
* from `synthesize` (a merge of existing candidates, not a fresh task
|
|
1002
|
+
* execution — owner Quiz-5a); reviewers and the auth smoke build their own
|
|
1003
|
+
* specs and never call this.
|
|
1004
|
+
*/
|
|
1005
|
+
harnessSpecKnobs(contract, knobs, intent) {
|
|
1006
|
+
return {
|
|
1007
|
+
external_context_policy: knobs.webPolicy,
|
|
1008
|
+
tool_permission_policy: {
|
|
1009
|
+
web: knobs.webPolicy,
|
|
1010
|
+
allow: [...new Set([...contract.tool_permission_policy.allow, ...knobs.toolsAllow])],
|
|
1011
|
+
deny: [...new Set([...contract.tool_permission_policy.deny, ...knobs.toolsDeny])],
|
|
1012
|
+
},
|
|
1013
|
+
model_hint: knobs.model,
|
|
1014
|
+
effort_hint: knobs.effort,
|
|
1015
|
+
max_turns: knobs.maxTurns,
|
|
1016
|
+
...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
|
|
1017
|
+
// The user's answer contract rides every answer-producing lane INCLUDING
|
|
1018
|
+
// synthesis (its answer can become the final one); the orchestrate
|
|
1019
|
+
// planner owns its own plan schema instead (set at its spec site). The
|
|
1020
|
+
// adapter gets the vendor-STRICT transport form; the engine validator
|
|
1021
|
+
// keeps the ORIGINAL contract as the conformance authority.
|
|
1022
|
+
...(intent !== "orchestrate" && contract.output_schema
|
|
1023
|
+
? { output_schema: strictifyOutputSchema(contract.output_schema) }
|
|
1024
|
+
: {}),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
936
1027
|
routeSpecKnobs(routed, contract, overrideModel, effortHint) {
|
|
937
1028
|
const s = routed.settings;
|
|
938
1029
|
const contractPolicy = contract.external_context.policy;
|
|
@@ -940,11 +1031,13 @@ export class Orchestrator {
|
|
|
940
1031
|
let maxTurns = null;
|
|
941
1032
|
let toolsAllow = [];
|
|
942
1033
|
let toolsDeny = [];
|
|
943
|
-
|
|
1034
|
+
// Run-level cap beats per-harness settings (specific beats general).
|
|
1035
|
+
const requestedMaxTurns = contract.max_turns ?? s?.maxTurns ?? null;
|
|
1036
|
+
if (requestedMaxTurns) {
|
|
944
1037
|
if (routed.supportsMaxTurns)
|
|
945
|
-
maxTurns =
|
|
1038
|
+
maxTurns = requestedMaxTurns;
|
|
946
1039
|
else
|
|
947
|
-
ignored.push(`max_turns=${
|
|
1040
|
+
ignored.push(`max_turns=${requestedMaxTurns} (manifest capabilities.max_turns=false for ${routed.adapter.id})`);
|
|
948
1041
|
}
|
|
949
1042
|
if ((s?.toolsAllow.length ?? 0) > 0 || (s?.toolsDeny.length ?? 0) > 0) {
|
|
950
1043
|
if (routed.supportsToolLists) {
|
|
@@ -980,44 +1073,37 @@ export class Orchestrator {
|
|
|
980
1073
|
ignored,
|
|
981
1074
|
};
|
|
982
1075
|
}
|
|
983
|
-
|
|
984
|
-
async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput) {
|
|
1076
|
+
async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput, streamDeltas = false, fileBackedContext) {
|
|
985
1077
|
const adapter = routed.adapter;
|
|
986
1078
|
const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
|
|
987
|
-
//
|
|
988
|
-
// (no scoped HOME), so the vendor's own session store is reachable: the turn
|
|
989
|
-
// RESUMES the native CLI session (real continuity, like the read-only paths).
|
|
990
|
-
// Isolated envelopes (race candidates) get a fresh scoped home where that
|
|
991
|
-
// session id cannot exist — they run fresh, with a typed session.rebound
|
|
992
|
-
// disclosure, never a deterministic session-not-found failure.
|
|
1079
|
+
// Isolated scoped-home sessions are never retained after disposal.
|
|
993
1080
|
const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
|
|
994
|
-
const
|
|
995
|
-
const
|
|
1081
|
+
const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
|
|
1082
|
+
const sessionFields = runInput ? this.sessionSpecFields(runInput, adapter.id, log) : undefined;
|
|
1083
|
+
let spec = HarnessRunSpec.parse({
|
|
996
1084
|
session_id: newId("ses"),
|
|
997
1085
|
intent,
|
|
998
1086
|
prompt: promptWithProtectedPathConstraint(prompt, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
|
|
999
1087
|
attachments: runInput?.attachments ?? [],
|
|
1000
|
-
browser: this.
|
|
1088
|
+
browser: this.requestRequirements.browserSpec(routed.browserRequirement, join(paths.root, "browser")),
|
|
1001
1089
|
cwd: envelope.worktree_path,
|
|
1002
|
-
access,
|
|
1003
|
-
|
|
1004
|
-
tool_permission_policy: {
|
|
1005
|
-
web: knobs.webPolicy,
|
|
1006
|
-
allow: [...new Set([...contract.tool_permission_policy.allow, ...knobs.toolsAllow])],
|
|
1007
|
-
deny: [...new Set([...contract.tool_permission_policy.deny, ...knobs.toolsDeny])],
|
|
1008
|
-
},
|
|
1009
|
-
model_hint: knobs.model,
|
|
1010
|
-
effort_hint: knobs.effort,
|
|
1011
|
-
max_turns: knobs.maxTurns,
|
|
1012
|
-
max_usd: routed.settings?.maxUsd ?? null,
|
|
1090
|
+
access: routed.adapterAccess,
|
|
1091
|
+
...this.harnessSpecKnobs(contract, knobs, intent),
|
|
1013
1092
|
env_inheritance: envInheritance(this.config(contract.repo.root)),
|
|
1014
|
-
...(sessionFields
|
|
1093
|
+
...(sessionFields
|
|
1094
|
+
? {
|
|
1095
|
+
auth_preference: sessionFields.auth_preference,
|
|
1096
|
+
credential_profile: sessionFields.credential_profile,
|
|
1097
|
+
}
|
|
1098
|
+
: {}),
|
|
1015
1099
|
...(inPlaceEnvelope && sessionFields?.resume_session_id
|
|
1016
1100
|
? { resume_session_id: sessionFields.resume_session_id }
|
|
1017
1101
|
: {}),
|
|
1018
1102
|
// Scoped harness home only for isolated envelopes; in-place runs use the
|
|
1019
1103
|
// native environment so the resumed vendor session is actually reachable.
|
|
1020
1104
|
...(inPlaceEnvelope ? {} : { env: wsm.envFor(envelope) }),
|
|
1105
|
+
raw_context_packet: rawContextPacket,
|
|
1106
|
+
stream_deltas: streamDeltas,
|
|
1021
1107
|
});
|
|
1022
1108
|
if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
|
|
1023
1109
|
log?.emit("session.rebound", SessionReboundLineageSchema.parse({
|
|
@@ -1034,15 +1120,19 @@ export class Orchestrator {
|
|
|
1034
1120
|
const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
|
|
1035
1121
|
const attemptStartedMs = Date.now();
|
|
1036
1122
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
1123
|
+
const triedProfiles = new Set(); // W5.4 failover: each profile at most once
|
|
1037
1124
|
let cost = 0;
|
|
1038
1125
|
let costEstimated = false;
|
|
1039
1126
|
let harnessErrored = false;
|
|
1127
|
+
// W-C4 delta flood budget (per attempt): counts forwarded delta chunks.
|
|
1128
|
+
let deltaCount = 0;
|
|
1129
|
+
let deltaCutoffDisclosed = false;
|
|
1040
1130
|
const errors = [];
|
|
1041
|
-
const
|
|
1131
|
+
const answer = new AnswerAssembly();
|
|
1042
1132
|
const retryPolicy = transientRetryPolicy(this.config(contract.repo.root));
|
|
1043
1133
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
1044
1134
|
knobs.webPolicy === "cached" ||
|
|
1045
|
-
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy);
|
|
1135
|
+
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model);
|
|
1046
1136
|
let activeSessionId = spec.session_id;
|
|
1047
1137
|
const onAbort = () => {
|
|
1048
1138
|
void adapter.cancel?.(activeSessionId)?.catch(() => { });
|
|
@@ -1055,6 +1145,7 @@ export class Orchestrator {
|
|
|
1055
1145
|
}
|
|
1056
1146
|
try {
|
|
1057
1147
|
for (let nativeTry = 0; !signal?.aborted; nativeTry += 1) {
|
|
1148
|
+
const clearFileBackedContext = stageFileBackedContext(envelope.worktree_path, fileBackedContext);
|
|
1058
1149
|
const runSpec = nativeTry === 0
|
|
1059
1150
|
? spec
|
|
1060
1151
|
: HarnessRunSpec.parse({ ...spec, session_id: newId("ses"), extra: { ...spec.extra } });
|
|
@@ -1070,6 +1161,8 @@ export class Orchestrator {
|
|
|
1070
1161
|
: attemptAbort.signal;
|
|
1071
1162
|
activeSessionId = runSpec.session_id;
|
|
1072
1163
|
const transientStart = telemetry.transientFailures.length;
|
|
1164
|
+
const rateLimitStart = telemetry.rateLimits.length;
|
|
1165
|
+
let rawPatch = null;
|
|
1073
1166
|
try {
|
|
1074
1167
|
const watched = withInactivityWatchdog(adapter.run(runSpec), {
|
|
1075
1168
|
timeoutMs: inactivityMs,
|
|
@@ -1084,7 +1177,31 @@ export class Orchestrator {
|
|
|
1084
1177
|
for await (const ev of watched) {
|
|
1085
1178
|
if (signal?.aborted)
|
|
1086
1179
|
break;
|
|
1180
|
+
rawPatch = captureRawPatchEnvelope(rawContextPacket !== null, rawPatch, ev);
|
|
1181
|
+
if (ev.type === "patch_produced")
|
|
1182
|
+
continue;
|
|
1087
1183
|
const safeEv = redactHarnessEvent(ev);
|
|
1184
|
+
// W-C4 flood guard (review sol #10): a per-character delta stream
|
|
1185
|
+
// would otherwise persist/SSE one journal event PER CHUNK without
|
|
1186
|
+
// bound. Delta messages are DISPLAY-only (the complete message
|
|
1187
|
+
// still follows and carries the authoritative text), so past a
|
|
1188
|
+
// per-attempt budget we DROP further deltas and disclose the
|
|
1189
|
+
// cutoff ONCE — the final answer is unaffected.
|
|
1190
|
+
if (safeEv.type === "message" && safeEv.payload?.["delta"] === true) {
|
|
1191
|
+
deltaCount += 1;
|
|
1192
|
+
if (deltaCount > Orchestrator.MAX_DELTAS_PER_ATTEMPT) {
|
|
1193
|
+
if (!deltaCutoffDisclosed) {
|
|
1194
|
+
deltaCutoffDisclosed = true;
|
|
1195
|
+
log?.emit("harness.event", {
|
|
1196
|
+
harness_id: adapter.id,
|
|
1197
|
+
attempt_id: attemptId,
|
|
1198
|
+
type: "status",
|
|
1199
|
+
title: `live delta stream capped at ${Orchestrator.MAX_DELTAS_PER_ATTEMPT} chunks; the complete message still lands`,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
continue; // drop this delta; never journal past the budget
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1088
1205
|
safeInvoke(onHarnessEvent, safeEv);
|
|
1089
1206
|
// In-place turns run in the live tree under the native environment, so
|
|
1090
1207
|
// the session they emit IS reachable for the next turn: record it. An
|
|
@@ -1092,38 +1209,35 @@ export class Orchestrator {
|
|
|
1092
1209
|
// deletes, so observing it would poison the thread resume map with
|
|
1093
1210
|
// unreachable ids — skip it there.
|
|
1094
1211
|
if (inPlaceEnvelope)
|
|
1095
|
-
|
|
1212
|
+
observeNativeSessionEvent(runInput, adapter.id, safeEv);
|
|
1096
1213
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
1097
1214
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
1098
1215
|
// Live plan checklist: forward the adapter's typed plan
|
|
1099
1216
|
// progress as a run event (LAST WINS; the UI renders the latest).
|
|
1100
1217
|
if (safeEv.plan_progress) {
|
|
1101
|
-
log?.emit("plan.progress", {
|
|
1102
|
-
}
|
|
1103
|
-
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
1104
|
-
cost += safeEv.usage.cost_usd;
|
|
1105
|
-
if (safeEv.usage.estimated)
|
|
1106
|
-
costEstimated = true;
|
|
1107
|
-
log?.emit("budget.observation", {
|
|
1108
|
-
harness_id: adapter.id,
|
|
1218
|
+
log?.emit("plan.progress", {
|
|
1109
1219
|
attempt_id: attemptId,
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1220
|
+
harness_id: adapter.id,
|
|
1221
|
+
items: safeEv.plan_progress.items,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
if (safeEv.type === "usage") {
|
|
1225
|
+
const usage = processAttemptUsage({
|
|
1226
|
+
event: safeEv,
|
|
1227
|
+
telemetry,
|
|
1228
|
+
harnessId: adapter.id,
|
|
1229
|
+
attemptId,
|
|
1230
|
+
cost,
|
|
1231
|
+
costEstimated,
|
|
1232
|
+
emit: (type, payload) => log?.emit(type, payload),
|
|
1233
|
+
budgetGuard,
|
|
1234
|
+
cancel: () => void adapter.cancel?.(runSpec.session_id)?.catch(() => { }),
|
|
1113
1235
|
});
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
if (budgetGuard?.(cost)) {
|
|
1236
|
+
cost = usage.cost;
|
|
1237
|
+
costEstimated = usage.costEstimated;
|
|
1238
|
+
if (usage.hardCapReached) {
|
|
1118
1239
|
harnessErrored = true;
|
|
1119
1240
|
errors.push("budget hard cap reached mid-attempt; stream aborted");
|
|
1120
|
-
log?.emit("budget.observation", {
|
|
1121
|
-
harness_id: adapter.id,
|
|
1122
|
-
attempt_id: attemptId,
|
|
1123
|
-
kind: "cooldown",
|
|
1124
|
-
detail: "hard cap mid-flight abort",
|
|
1125
|
-
});
|
|
1126
|
-
void adapter.cancel?.(runSpec.session_id)?.catch(() => { });
|
|
1127
1241
|
break;
|
|
1128
1242
|
}
|
|
1129
1243
|
}
|
|
@@ -1132,16 +1246,21 @@ export class Orchestrator {
|
|
|
1132
1246
|
errors.push(redactSecrets(safeEv.error ?? safeEv.text ?? "harness emitted error"));
|
|
1133
1247
|
}
|
|
1134
1248
|
// Capture assistant prose so an answer-only turn (no file changes) still
|
|
1135
|
-
// has an honest output artifact
|
|
1136
|
-
|
|
1137
|
-
safeEv.text &&
|
|
1138
|
-
safeEv.payload?.["auth_switched"] !== true) {
|
|
1139
|
-
pushUniqueText(messageParts, safeEv.text);
|
|
1140
|
-
}
|
|
1249
|
+
// has an honest output artifact; a TYPED final message wins verbatim.
|
|
1250
|
+
answer.observe(safeEv);
|
|
1141
1251
|
// Observe ALL budget/quota signals (one codex usage event carries
|
|
1142
1252
|
// BOTH spend and quota); pressure disclosed once per attempt.
|
|
1143
1253
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
1254
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
1144
1255
|
}
|
|
1256
|
+
if (rawContextPacket && !harnessErrored)
|
|
1257
|
+
await consumeRawPatchEnvelope({
|
|
1258
|
+
repoRoot: envelope.repo_root,
|
|
1259
|
+
worktreePath: envelope.worktree_path,
|
|
1260
|
+
baseCommitSha: envelope.base_sha ?? "HEAD",
|
|
1261
|
+
context: rawContextPacket,
|
|
1262
|
+
envelope: rawPatch,
|
|
1263
|
+
});
|
|
1145
1264
|
}
|
|
1146
1265
|
catch (err) {
|
|
1147
1266
|
// A throwing adapter must not lose the cost already streamed: record the
|
|
@@ -1149,11 +1268,40 @@ export class Orchestrator {
|
|
|
1149
1268
|
harnessErrored = true;
|
|
1150
1269
|
errors.push(safeErrorMessage(err));
|
|
1151
1270
|
}
|
|
1271
|
+
finally {
|
|
1272
|
+
clearFileBackedContext();
|
|
1273
|
+
}
|
|
1152
1274
|
const transient = telemetry.transientFailures.at(-1) ?? null;
|
|
1153
1275
|
const sawTransient = telemetry.transientFailures.length > transientStart;
|
|
1276
|
+
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
1154
1277
|
const currentDiff = await wsm.diff(envelope);
|
|
1155
|
-
const currentAnswer =
|
|
1278
|
+
const currentAnswer = answer.text();
|
|
1156
1279
|
const deliverableEmpty = currentDiff.trim().length === 0 && currentAnswer.length === 0;
|
|
1280
|
+
// W5.4 failover: a typed-limit hit rebuilds the spec on a NEW vendor
|
|
1281
|
+
// session under the next profile with provenance (vendor_limit_rejected).
|
|
1282
|
+
if (harnessErrored && runInput && !signal?.aborted) {
|
|
1283
|
+
const rotated = rotateSpecOnTypedLimit({
|
|
1284
|
+
spec,
|
|
1285
|
+
harnessId: adapter.id,
|
|
1286
|
+
attemptId,
|
|
1287
|
+
policy: this.profilePolicy(contract.repo.root, adapter.id),
|
|
1288
|
+
registry: this.config(contract.repo.root)?.global.credential_profiles ?? [],
|
|
1289
|
+
snapshots: this.deps.quotaSnapshots?.() ?? [],
|
|
1290
|
+
triedProfiles,
|
|
1291
|
+
sawTypedLimit,
|
|
1292
|
+
deliverableEmpty,
|
|
1293
|
+
lastLimit: telemetry.rateLimits.at(-1) ?? null,
|
|
1294
|
+
emit: (type, payload) => log?.emit(type, payload),
|
|
1295
|
+
newSessionId: () => newId("ses"),
|
|
1296
|
+
defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
|
|
1297
|
+
});
|
|
1298
|
+
if (rotated) {
|
|
1299
|
+
spec = rotated;
|
|
1300
|
+
errors.length = 0;
|
|
1301
|
+
harnessErrored = false;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1157
1305
|
if (!harnessErrored ||
|
|
1158
1306
|
!sawTransient ||
|
|
1159
1307
|
!deliverableEmpty ||
|
|
@@ -1194,7 +1342,7 @@ export class Orchestrator {
|
|
|
1194
1342
|
errors.push(`web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`);
|
|
1195
1343
|
}
|
|
1196
1344
|
const diff = await wsm.diff(envelope);
|
|
1197
|
-
const answerText =
|
|
1345
|
+
const answerText = answer.text() || undefined;
|
|
1198
1346
|
const deliverablePresent = diff.trim().length > 0 || Boolean(answerText);
|
|
1199
1347
|
// Cancelled attempts skip gates entirely: the operator asked to
|
|
1200
1348
|
// stop NOW; running a 600s-per-gate suite after the abort delays the ack
|
|
@@ -1202,11 +1350,14 @@ export class Orchestrator {
|
|
|
1202
1350
|
// still land, so partial work stays inspectable.
|
|
1203
1351
|
const gateSignalAborted = signal?.aborted === true;
|
|
1204
1352
|
if (!gateSignalAborted) {
|
|
1205
|
-
log?.emit("gate.started", {
|
|
1353
|
+
log?.emit("gate.started", {
|
|
1354
|
+
attempt_id: attemptId,
|
|
1355
|
+
gates: gateSpecsFromContract(contract).length,
|
|
1356
|
+
});
|
|
1206
1357
|
}
|
|
1207
1358
|
const gates = gateSignalAborted
|
|
1208
1359
|
? []
|
|
1209
|
-
: await runGates(
|
|
1360
|
+
: await runGates(gateSpecsFromContract(contract), {
|
|
1210
1361
|
cwd: envelope.worktree_path,
|
|
1211
1362
|
env: wsm.envFor(envelope),
|
|
1212
1363
|
signal,
|
|
@@ -1243,34 +1394,32 @@ export class Orchestrator {
|
|
|
1243
1394
|
// must carry it so the slot catch settles the TRUE cost, not 0.
|
|
1244
1395
|
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { costUsd: cost });
|
|
1245
1396
|
}
|
|
1246
|
-
store.writeText(join(attemptDir, "patch.diff"), diff);
|
|
1247
|
-
// Routing metrics (one owner in runSupport; clean attempts only).
|
|
1248
1397
|
recordCleanAttemptMetrics(globalConfigDir(), adapter.id, {
|
|
1249
1398
|
costUsd: cost,
|
|
1250
1399
|
streamMs: attemptStreamEndedMs - attemptStartedMs,
|
|
1251
1400
|
errored,
|
|
1252
1401
|
aborted: signal?.aborted === true,
|
|
1402
|
+
authMode: telemetry.authMode,
|
|
1253
1403
|
});
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1404
|
+
const producedFiles = writeCandidateAttemptArtifacts({
|
|
1405
|
+
store,
|
|
1406
|
+
attemptDir,
|
|
1407
|
+
worktreePath: envelope.worktree_path,
|
|
1408
|
+
diff,
|
|
1409
|
+
answerText,
|
|
1410
|
+
record: {
|
|
1411
|
+
attempt_id: attemptId,
|
|
1412
|
+
harness_id: adapter.id,
|
|
1413
|
+
label,
|
|
1414
|
+
cost_usd: cost,
|
|
1415
|
+
cost_estimated: costEstimated,
|
|
1416
|
+
errored,
|
|
1417
|
+
errors: errors.slice(0, 5),
|
|
1418
|
+
...telemetrySummary(telemetry),
|
|
1419
|
+
outcome: telemetry.outcome,
|
|
1420
|
+
gates: gates.map((g) => ({ id: g.id, status: g.status })),
|
|
1421
|
+
branch: envelope.branch_name,
|
|
1272
1422
|
},
|
|
1273
|
-
branch: envelope.branch_name,
|
|
1274
1423
|
});
|
|
1275
1424
|
return {
|
|
1276
1425
|
attemptId,
|
|
@@ -1280,6 +1429,7 @@ export class Orchestrator {
|
|
|
1280
1429
|
answerText,
|
|
1281
1430
|
reviewCwd: envelope.worktree_path,
|
|
1282
1431
|
baseSha: envelope.base_sha ?? undefined,
|
|
1432
|
+
producedFiles,
|
|
1283
1433
|
gates,
|
|
1284
1434
|
cost,
|
|
1285
1435
|
errored,
|
|
@@ -1288,51 +1438,6 @@ export class Orchestrator {
|
|
|
1288
1438
|
telemetry,
|
|
1289
1439
|
};
|
|
1290
1440
|
}
|
|
1291
|
-
toEvidence(run, contract, findings, finalReviewClean, reviewVerified = false) {
|
|
1292
|
-
const passed = gatesPassed(run.gates) && !run.errored;
|
|
1293
|
-
// Honest acceptance evidence: 0/0 when the contract has no success criteria
|
|
1294
|
-
// (no spec). The old code fabricated a 1/1 ("AC-implicit") cover, which made
|
|
1295
|
-
// arbitration report a vacuous "acceptance=100%" that just restated gates.
|
|
1296
|
-
const acTotal = contract.success_criteria.length;
|
|
1297
|
-
const acCovered = passed && contract.success_criteria.length > 0
|
|
1298
|
-
? contract.success_criteria.map((c) => c.id)
|
|
1299
|
-
: [];
|
|
1300
|
-
// Treat a harness error as a failed required gate so it cannot win arbitration.
|
|
1301
|
-
const gates = run.errored
|
|
1302
|
-
? [
|
|
1303
|
-
...run.gates,
|
|
1304
|
-
{
|
|
1305
|
-
id: "harness",
|
|
1306
|
-
command: "harness",
|
|
1307
|
-
exit_code: 1,
|
|
1308
|
-
status: "failed",
|
|
1309
|
-
duration_ms: 0,
|
|
1310
|
-
required: true,
|
|
1311
|
-
stdout_tail: null,
|
|
1312
|
-
stderr_tail: null,
|
|
1313
|
-
output_truncated: false,
|
|
1314
|
-
},
|
|
1315
|
-
]
|
|
1316
|
-
: run.gates;
|
|
1317
|
-
return {
|
|
1318
|
-
attemptId: run.attemptId,
|
|
1319
|
-
label: run.label,
|
|
1320
|
-
gates,
|
|
1321
|
-
acceptanceCovered: acCovered,
|
|
1322
|
-
acceptanceTotal: acTotal,
|
|
1323
|
-
findings,
|
|
1324
|
-
// Counted from the EVIDENCE gates (including the injected harness-failure
|
|
1325
|
-
// gate), so an errored candidate scores 0/1 — never a vacuous 0/0.
|
|
1326
|
-
testsPassed: gates.filter((g) => g.status === "passed").length,
|
|
1327
|
-
testsTotal: gates.length,
|
|
1328
|
-
finalReviewClean,
|
|
1329
|
-
reviewVerified,
|
|
1330
|
-
toolWarningsCount: run.telemetry.outcome?.toolWarningsCount ?? toolWarnings(run.telemetry).length,
|
|
1331
|
-
diffSize: run.diff.split("\n").length,
|
|
1332
|
-
diffBytes: Buffer.byteLength(run.diff, "utf8"),
|
|
1333
|
-
costUsd: run.cost,
|
|
1334
|
-
};
|
|
1335
|
-
}
|
|
1336
1441
|
interactionChannelFor(input, log, runId, taskId, attemptId, harnessId,
|
|
1337
1442
|
// REQUIRED (no default): every call site must state the routed manifest's
|
|
1338
1443
|
// `interactive` capability, or a future site would silently bypass the gate.
|
|
@@ -1342,8 +1447,8 @@ export class Orchestrator {
|
|
|
1342
1447
|
}
|
|
1343
1448
|
/**
|
|
1344
1449
|
* Guarantee a git boundary for write-mode runs. Non-git project folders are
|
|
1345
|
-
* initialized in place (
|
|
1346
|
-
*
|
|
1450
|
+
* initialized in place (`git init`, deterministic baseline commit) without
|
|
1451
|
+
* creating or editing `.gitignore`, and the action is announced via a
|
|
1347
1452
|
* `project.git.initialized` event. Returns the failure message when the
|
|
1348
1453
|
* boundary cannot be established (the terminal failure events are already
|
|
1349
1454
|
* emitted); null on success.
|
|
@@ -1406,8 +1511,17 @@ export class Orchestrator {
|
|
|
1406
1511
|
const wsm = new WorkspaceManager(execRoot);
|
|
1407
1512
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
1408
1513
|
log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
|
|
1409
|
-
const ledger =
|
|
1410
|
-
announce?.({
|
|
1514
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
1515
|
+
announce?.({
|
|
1516
|
+
log,
|
|
1517
|
+
store,
|
|
1518
|
+
paths,
|
|
1519
|
+
runId,
|
|
1520
|
+
taskId,
|
|
1521
|
+
mode,
|
|
1522
|
+
phase: "race",
|
|
1523
|
+
spend: () => ledger.spend(),
|
|
1524
|
+
});
|
|
1411
1525
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
1412
1526
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
1413
1527
|
// Write modes need a git boundary for worktree isolation and honest diffs.
|
|
@@ -1448,7 +1562,7 @@ export class Orchestrator {
|
|
|
1448
1562
|
writeEvidencePacket(reviewDir, {
|
|
1449
1563
|
userIntent: redactSecrets(input.prompt),
|
|
1450
1564
|
diff: "(per-candidate diffs are supplied to reviewers individually)\n",
|
|
1451
|
-
tests:
|
|
1565
|
+
tests: renderTestsEvidence(contract),
|
|
1452
1566
|
});
|
|
1453
1567
|
let adapters;
|
|
1454
1568
|
try {
|
|
@@ -1487,7 +1601,6 @@ export class Orchestrator {
|
|
|
1487
1601
|
return reviewersOutcome.failed;
|
|
1488
1602
|
const reviewers = reviewersOutcome.reviewers;
|
|
1489
1603
|
const reviewVerified = this.routeVerified(reviewers);
|
|
1490
|
-
const harnessLedgers = new Map();
|
|
1491
1604
|
const reviewEnvelopes = [];
|
|
1492
1605
|
const disposeReviewEnvelopes = async () => {
|
|
1493
1606
|
const envelopes = reviewEnvelopes.splice(0);
|
|
@@ -1497,25 +1610,17 @@ export class Orchestrator {
|
|
|
1497
1610
|
const candidateAccess = contract.access.effective_profile;
|
|
1498
1611
|
let budgetStopped = false;
|
|
1499
1612
|
let softWarned = false;
|
|
1500
|
-
// The USER-requested race width, before any budget trimming.
|
|
1501
1613
|
const requestedSingleCandidate = adapters.length === 1;
|
|
1502
1614
|
const slots = [];
|
|
1503
1615
|
for (let i = 0; i < adapters.length; i++) {
|
|
1504
1616
|
const routed = adapters[i];
|
|
1505
1617
|
const attemptId = `a${String(i + 1).padStart(2, "0")}`;
|
|
1506
|
-
|
|
1507
|
-
const lease = this.harnessLedger(harnessLedgers, ledger, routed).reserve({
|
|
1618
|
+
const lease = ledger.reserve({
|
|
1508
1619
|
taskId,
|
|
1509
1620
|
attemptId,
|
|
1510
1621
|
intent: this.candidateIntent(input),
|
|
1511
1622
|
harnessId: routed.adapter.id,
|
|
1512
|
-
|
|
1513
|
-
// floor at reservation, so concurrent candidates are visible to the
|
|
1514
|
-
// breaker BEFORE any usage streams and a parallel wave cannot blow
|
|
1515
|
-
// past the cap between settlements. The first slot holds nothing —
|
|
1516
|
-
// a cap smaller than the floor must still run ONE candidate and stop
|
|
1517
|
-
// on real usage, never zero.
|
|
1518
|
-
...(i > 0 ? { estimateUsd: this.estimateUsdFloor(input.repoRoot) } : {}),
|
|
1623
|
+
cost: attemptCostEvidence(routed.adapter.id, attemptId, i > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, routed.adapter.id)),
|
|
1519
1624
|
});
|
|
1520
1625
|
log.emit("budget.lease.created", {
|
|
1521
1626
|
granted: lease.granted,
|
|
@@ -1538,17 +1643,16 @@ export class Orchestrator {
|
|
|
1538
1643
|
});
|
|
1539
1644
|
}
|
|
1540
1645
|
const runsBySlot = new Array(slots.length);
|
|
1541
|
-
const slotLedger = (slot) => this.harnessLedger(harnessLedgers, ledger, slot.routed);
|
|
1542
1646
|
const runSlot = async (slot, slotIdx) => {
|
|
1543
1647
|
if (input.signal?.aborted) {
|
|
1544
|
-
|
|
1648
|
+
ledger.cancel(slot.leaseId);
|
|
1545
1649
|
return;
|
|
1546
1650
|
}
|
|
1547
1651
|
// Leases are granted upfront (before spend exists); a worker still
|
|
1548
1652
|
// re-checks the circuit breaker so queued slots beyond the parallel wave
|
|
1549
1653
|
// do not start after earlier candidates already blew the hard cap.
|
|
1550
|
-
if (budgetStopped ||
|
|
1551
|
-
|
|
1654
|
+
if (budgetStopped || ledger.tier() === "hard") {
|
|
1655
|
+
ledger.cancel(slot.leaseId);
|
|
1552
1656
|
log.emit("budget.lease.created", {
|
|
1553
1657
|
granted: false,
|
|
1554
1658
|
reason: "budget exhausted (hard cap reached)",
|
|
@@ -1563,7 +1667,7 @@ export class Orchestrator {
|
|
|
1563
1667
|
// Soft + downgrade breaker (before the hard cap): soft = a one-time
|
|
1564
1668
|
// warning; downgrade = run this attempt on the per-harness fallback_model
|
|
1565
1669
|
// (cheaper) instead of hard-killing — gives fallback_model a real job.
|
|
1566
|
-
const breakerTier =
|
|
1670
|
+
const breakerTier = ledger.tier();
|
|
1567
1671
|
if (breakerTier === "soft" && !softWarned) {
|
|
1568
1672
|
softWarned = true;
|
|
1569
1673
|
log.emit("budget.observation", {
|
|
@@ -1598,28 +1702,24 @@ export class Orchestrator {
|
|
|
1598
1702
|
baseRef: contract.repo.base_ref,
|
|
1599
1703
|
dirtyPolicy: "snapshot",
|
|
1600
1704
|
accessProfile: candidateAccess,
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
// guard trimmed it to one slot still runs enveloped + adoption —
|
|
1607
|
-
// never a silent switch to direct live-tree mutation.
|
|
1608
|
-
inPlace: input.inPlace === true && requestedSingleCandidate,
|
|
1705
|
+
// Direct-workspace singletons run in place. Races and patch-envelope
|
|
1706
|
+
// transports stay isolated and adopt through the delivery service.
|
|
1707
|
+
inPlace: input.inPlace === true &&
|
|
1708
|
+
requestedSingleCandidate &&
|
|
1709
|
+
slot.routed.implementationTransport !== "git_patch_envelope",
|
|
1609
1710
|
});
|
|
1610
1711
|
const run = await this.runCandidateInEnvelope(slot.routed, envelope, slot.attemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
1611
1712
|
const safeEv = redactHarnessEvent(ev);
|
|
1612
1713
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
1613
1714
|
log.emit("harness.event", harnessEventPayload(adapter.id, slot.attemptId, safeEv));
|
|
1614
1715
|
}, input.signal, downgradeModel ?? undefined, input.effort, this.candidateIntent(input), log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, slot.attemptId, adapter.id, slot.routed.supportsInteractive), (streamedUsd) => {
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
if (lg.tier() !== "hard")
|
|
1716
|
+
ledger.updateHold(slot.leaseId, streamedUsd);
|
|
1717
|
+
if (ledger.tier() !== "hard")
|
|
1618
1718
|
return false;
|
|
1619
1719
|
budgetStopped = true;
|
|
1620
1720
|
return true;
|
|
1621
|
-
}, input);
|
|
1622
|
-
|
|
1721
|
+
}, input, requestedSingleCandidate);
|
|
1722
|
+
ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
1623
1723
|
log.emit("harness.completed", {
|
|
1624
1724
|
harness_id: adapter.id,
|
|
1625
1725
|
attempt_id: slot.attemptId,
|
|
@@ -1637,8 +1737,10 @@ export class Orchestrator {
|
|
|
1637
1737
|
// post-stream throw (e.g. the secret-token assertion) carries its
|
|
1638
1738
|
// streamed spend on the error — settle the TRUE cost, never launder
|
|
1639
1739
|
// real spend down to 0.
|
|
1640
|
-
const carriedCost = typeof err?.costUsd === "number"
|
|
1641
|
-
|
|
1740
|
+
const carriedCost = typeof err?.costUsd === "number"
|
|
1741
|
+
? err.costUsd
|
|
1742
|
+
: 0;
|
|
1743
|
+
ledger.settle(slot.leaseId, unknownCostSettlement("post-stream-error", carriedCost));
|
|
1642
1744
|
const message = safeErrorMessage(err);
|
|
1643
1745
|
// envelope is still undefined when wsm.create() itself threw — that is
|
|
1644
1746
|
// a workspace-phase infrastructure failure, not a harness error.
|
|
@@ -1669,7 +1771,7 @@ export class Orchestrator {
|
|
|
1669
1771
|
errored: true,
|
|
1670
1772
|
costEstimated: false,
|
|
1671
1773
|
errors: [message],
|
|
1672
|
-
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb),
|
|
1774
|
+
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb, [slot.routed.browserRequirement, slot.routed.denyRequirement], knobs.model),
|
|
1673
1775
|
infraPhase,
|
|
1674
1776
|
};
|
|
1675
1777
|
}
|
|
@@ -1702,10 +1804,14 @@ export class Orchestrator {
|
|
|
1702
1804
|
}
|
|
1703
1805
|
if (input.signal?.aborted) {
|
|
1704
1806
|
await disposeReviewEnvelopes();
|
|
1705
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1807
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1808
|
+
attemptId: r.attemptId,
|
|
1809
|
+
harnessId: r.harnessId,
|
|
1810
|
+
telemetry: r.telemetry,
|
|
1811
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1706
1812
|
}
|
|
1707
1813
|
if (runs.length === 0) {
|
|
1708
|
-
const status = budgetStopped ? "exhausted" : "failed";
|
|
1814
|
+
const status = ledger.terminal() ?? (budgetStopped ? "exhausted" : "failed");
|
|
1709
1815
|
const why = budgetStopped
|
|
1710
1816
|
? "budget exhausted before any candidate run"
|
|
1711
1817
|
: "no candidates produced";
|
|
@@ -1721,7 +1827,7 @@ export class Orchestrator {
|
|
|
1721
1827
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Phase: budget\n\n${why}\n`);
|
|
1722
1828
|
writeFailure(store, paths, {
|
|
1723
1829
|
phase: "budget",
|
|
1724
|
-
category: status
|
|
1830
|
+
category: isBudgetTerminal(status) ? "budget" : "internal",
|
|
1725
1831
|
safeMessage: why,
|
|
1726
1832
|
runDir: paths.root,
|
|
1727
1833
|
});
|
|
@@ -1831,7 +1937,11 @@ export class Orchestrator {
|
|
|
1831
1937
|
await disposeReviewEnvelopes();
|
|
1832
1938
|
}
|
|
1833
1939
|
if (input.signal?.aborted) {
|
|
1834
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1940
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1941
|
+
attemptId: r.attemptId,
|
|
1942
|
+
harnessId: r.harnessId,
|
|
1943
|
+
telemetry: r.telemetry,
|
|
1944
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1835
1945
|
}
|
|
1836
1946
|
// Synthesis: if worthwhile, run a synthesizer as a NEW, re-checked candidate.
|
|
1837
1947
|
const synth = decideSynthesis(evidences, input.synthesis ?? "auto");
|
|
@@ -1839,22 +1949,22 @@ export class Orchestrator {
|
|
|
1839
1949
|
log.emit("synthesis.started", { synthesize: synth.synthesize, reason: synth.reason });
|
|
1840
1950
|
if (synth.synthesize && !budgetStopped) {
|
|
1841
1951
|
const synthRouted = adapters[0];
|
|
1842
|
-
|
|
1843
|
-
// synthesizer harness's own cap, not only the run cap.
|
|
1844
|
-
const synthLedger = this.harnessLedger(harnessLedgers, ledger, synthRouted);
|
|
1845
|
-
const lease = synthLedger.reserve({
|
|
1952
|
+
const lease = ledger.reserve({
|
|
1846
1953
|
taskId,
|
|
1847
1954
|
attemptId: "synth",
|
|
1848
1955
|
intent: "synthesize",
|
|
1849
1956
|
harnessId: synthRouted.adapter.id,
|
|
1957
|
+
cost: attemptCostEvidence(synthRouted.adapter.id, "synth", undefined, this.routeBillingKnowledge(input, synthRouted.adapter.id)),
|
|
1850
1958
|
});
|
|
1851
1959
|
if (lease.granted) {
|
|
1852
1960
|
let envelope;
|
|
1853
1961
|
try {
|
|
1854
1962
|
const plan = buildSynthesisPlan(evidences);
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
.
|
|
1963
|
+
const synthesisInput = buildFileBackedSynthesisInput({
|
|
1964
|
+
instructions: plan.instructions,
|
|
1965
|
+
findings: plan.fixFindings,
|
|
1966
|
+
candidates: workingRuns,
|
|
1967
|
+
});
|
|
1858
1968
|
const synthAdapter = synthRouted.adapter;
|
|
1859
1969
|
// Disclose against the PER-ROUTE policy (per-harness web defaults
|
|
1860
1970
|
// included), exactly like the candidate slots do.
|
|
@@ -1867,20 +1977,23 @@ export class Orchestrator {
|
|
|
1867
1977
|
dirtyPolicy: "snapshot",
|
|
1868
1978
|
accessProfile: candidateAccess,
|
|
1869
1979
|
});
|
|
1870
|
-
const
|
|
1871
|
-
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthPrompt, store, paths, wsm, synthLedger, candidateAccess, (ev) => {
|
|
1980
|
+
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthesisInput.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
1872
1981
|
const safeEv = redactHarnessEvent(ev);
|
|
1873
1982
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
1874
1983
|
log.emit("harness.event", harnessEventPayload(synthAdapter.id, "synth", safeEv));
|
|
1875
|
-
}, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input);
|
|
1876
|
-
|
|
1984
|
+
}, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input, false, synthesisInput.content);
|
|
1985
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
1877
1986
|
reviewEnvelopes.push(envelope);
|
|
1878
1987
|
envelope = undefined;
|
|
1879
1988
|
try {
|
|
1880
1989
|
const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
|
|
1881
1990
|
evidences.push(...synthEvidence);
|
|
1882
1991
|
if (input.signal?.aborted) {
|
|
1883
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1992
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1993
|
+
attemptId: r.attemptId,
|
|
1994
|
+
harnessId: r.harnessId,
|
|
1995
|
+
telemetry: r.telemetry,
|
|
1996
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1884
1997
|
}
|
|
1885
1998
|
}
|
|
1886
1999
|
finally {
|
|
@@ -1890,7 +2003,7 @@ export class Orchestrator {
|
|
|
1890
2003
|
workingRuns.push(run);
|
|
1891
2004
|
}
|
|
1892
2005
|
catch (err) {
|
|
1893
|
-
|
|
2006
|
+
ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("synthesis-error"));
|
|
1894
2007
|
log.emit("harness.completed", {
|
|
1895
2008
|
attempt_id: "synth",
|
|
1896
2009
|
status: "failed",
|
|
@@ -1904,7 +2017,11 @@ export class Orchestrator {
|
|
|
1904
2017
|
}
|
|
1905
2018
|
}
|
|
1906
2019
|
if (input.signal?.aborted) {
|
|
1907
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
2020
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
2021
|
+
attemptId: r.attemptId,
|
|
2022
|
+
harnessId: r.harnessId,
|
|
2023
|
+
telemetry: r.telemetry,
|
|
2024
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1908
2025
|
}
|
|
1909
2026
|
let result;
|
|
1910
2027
|
try {
|
|
@@ -1933,41 +2050,48 @@ export class Orchestrator {
|
|
|
1933
2050
|
? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
|
|
1934
2051
|
: evidences.length > 0 && evidences.every((e) => e.reviewVerified);
|
|
1935
2052
|
let status = needsHuman && result.decision.status !== "success" ? "blocked" : result.decision.status;
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
//
|
|
1939
|
-
// BLOCKS the run with a typed reason instead of shipping it.
|
|
2053
|
+
if (status === "success" && ledger.terminal() !== null)
|
|
2054
|
+
status = ledger.terminal();
|
|
2055
|
+
// FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
|
|
1940
2056
|
let finalVerify = null;
|
|
1941
2057
|
let finalVerifyFailed = false;
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
|
|
1947
|
-
const
|
|
2058
|
+
let deliveryFailureReason = null;
|
|
2059
|
+
let raceDeliveryReceipt = null;
|
|
2060
|
+
// A single in-place turn already mutated its execution tree; race adoption
|
|
2061
|
+
// instead defers verification until immediately before delivery.
|
|
2062
|
+
const inPlaceWinner = winnerRun?.reviewCwd === execRoot;
|
|
2063
|
+
const deferredRaceVerify = input.inPlace === true && !inPlaceWinner;
|
|
1948
2064
|
if (winnerRun &&
|
|
1949
2065
|
!inPlaceWinner &&
|
|
2066
|
+
!deferredRaceVerify &&
|
|
1950
2067
|
winnerRun.diff.trim().length > 0 &&
|
|
1951
2068
|
(status === "success" || status === "ungated") &&
|
|
1952
2069
|
!input.signal?.aborted) {
|
|
1953
|
-
finalVerify = await finalVerifyPatch(execRoot, winnerRun,
|
|
1954
|
-
//
|
|
1955
|
-
// shared verdict owner (finalVerifyBlocks). accept_risk stays available.
|
|
2070
|
+
finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
|
|
2071
|
+
// Verify errors block like proven failures; accept_risk stays available.
|
|
1956
2072
|
finalVerifyFailed = finalVerifyBlocks(finalVerify);
|
|
1957
2073
|
if (finalVerifyFailed)
|
|
1958
2074
|
status = "blocked";
|
|
1959
2075
|
}
|
|
1960
2076
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1961
2077
|
...result.decision,
|
|
1962
|
-
//
|
|
1963
|
-
|
|
1964
|
-
|
|
2078
|
+
// A blocked terminal overrides the persisted green arbitration fields.
|
|
2079
|
+
...(status === "blocked"
|
|
2080
|
+
? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
|
|
2081
|
+
: {}),
|
|
1965
2082
|
review_verified: actualReviewVerified,
|
|
1966
2083
|
final_verify: finalVerify,
|
|
1967
2084
|
});
|
|
1968
2085
|
store.writeYaml(join(paths.arbitrationDir, "pairwise.yaml"), result.pairwise);
|
|
1969
2086
|
const decisionPath = join(paths.arbitrationDir, "decision.yaml");
|
|
1970
2087
|
if (winnerRun) {
|
|
2088
|
+
for (const path of materializeWinnerOutputs({
|
|
2089
|
+
attemptDir: join(paths.attemptsDir, winnerRun.attemptId),
|
|
2090
|
+
runRoot: paths.root,
|
|
2091
|
+
paths: winnerRun.producedFiles ?? [],
|
|
2092
|
+
})) {
|
|
2093
|
+
log.emit("output.ready", { kind: "artifact", path });
|
|
2094
|
+
}
|
|
1971
2095
|
assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
|
|
1972
2096
|
const patchSha256 = sha256(winnerRun.diff);
|
|
1973
2097
|
store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
|
|
@@ -1977,45 +2101,55 @@ export class Orchestrator {
|
|
|
1977
2101
|
const blockers = winnerEvidence
|
|
1978
2102
|
? winnerEvidence.findings.filter((f) => isBlocking(f)).length
|
|
1979
2103
|
: 0;
|
|
1980
|
-
//
|
|
1981
|
-
// and the honest result_kind is "answer", not a misleading "patch").
|
|
2104
|
+
// Prose from an empty-diff winner is an answer, never a patch.
|
|
1982
2105
|
const winnerAnswer = winnerRun.answerText?.trim() ?? "";
|
|
1983
2106
|
const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
|
|
1984
|
-
|
|
2107
|
+
// The winner's final MESSAGE is the human-facing answer and materializes
|
|
2108
|
+
// for diff-ful runs too: the chat renders final/answer.md (the projection
|
|
2109
|
+
// prefers it), never the arbitration summary — "Run … Winner: a01 …" is
|
|
2110
|
+
// machine telemetry, not what the agent said. The diff stays in the
|
|
2111
|
+
// Diff tab; summary.md remains a diagnostics artifact.
|
|
2112
|
+
if (winnerAnswer.length > 0) {
|
|
1985
2113
|
store.writeText(join(paths.finalDir, "answer.md"), winnerAnswer + "\n");
|
|
1986
2114
|
}
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
//
|
|
2000
|
-
const adoptable = status === "success"
|
|
2115
|
+
// The run's structured-output contract: ONE engine validator, called on
|
|
2116
|
+
// the winner's answer regardless of diff presence (a non-conformant
|
|
2117
|
+
// answer stays success-with-warnings; the receipt is the truth).
|
|
2118
|
+
if (contract.output_schema) {
|
|
2119
|
+
finalizeStructuredOutput({
|
|
2120
|
+
store,
|
|
2121
|
+
finalDir: paths.finalDir,
|
|
2122
|
+
log,
|
|
2123
|
+
schema: contract.output_schema,
|
|
2124
|
+
answerText: winnerAnswer,
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
// Only a fully verified success may auto-adopt; ungated remains an artifact.
|
|
2128
|
+
const adoptable = status === "success";
|
|
2001
2129
|
let adopted = null;
|
|
2002
2130
|
let applyState = "not_applied";
|
|
2003
2131
|
let postTurnSha = null;
|
|
2132
|
+
let revertAnchorId = null;
|
|
2004
2133
|
if (input.inPlace === true && hasDiff) {
|
|
2005
|
-
if (
|
|
2134
|
+
if (inPlaceWinner) {
|
|
2006
2135
|
// Already live: the candidate ran in-place and wrote the tree itself.
|
|
2007
2136
|
adopted = true;
|
|
2008
2137
|
applyState = adoptable ? "applied" : "applied_review_blocked";
|
|
2009
|
-
//
|
|
2010
|
-
// edits made during review/arbitration are not folded into the target.
|
|
2138
|
+
// The pre-review fence excludes later user edits from the target.
|
|
2011
2139
|
postTurnSha = earlyPostTurnSha;
|
|
2012
2140
|
}
|
|
2013
2141
|
else if (adoptable) {
|
|
2014
|
-
// Protected
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2142
|
+
// Protected apply preserves the live tree or reports tree_mutated.
|
|
2143
|
+
const applied = await verifyAndDeliver(execRoot, winnerRun.diff, { mode: "apply", protectedApply: true }, gateSpecsFromContract(contract), (freshVerify) => {
|
|
2144
|
+
finalVerify = freshVerify;
|
|
2145
|
+
return finalVerifyBlocks(freshVerify)
|
|
2146
|
+
? (freshVerify.reason ?? "final verify failed before race adoption")
|
|
2147
|
+
: null;
|
|
2148
|
+
}, log);
|
|
2149
|
+
raceDeliveryReceipt = applied;
|
|
2150
|
+
store.writeYaml(join(paths.finalDir, "delivery_receipt.yaml"), applied);
|
|
2151
|
+
finalVerify = applied.finalVerify;
|
|
2152
|
+
if (applied.applied) {
|
|
2019
2153
|
adopted = true;
|
|
2020
2154
|
applyState = "applied";
|
|
2021
2155
|
log.emit("work_product.adopted", {
|
|
@@ -2023,17 +2157,21 @@ export class Orchestrator {
|
|
|
2023
2157
|
patch_sha256: patchSha256,
|
|
2024
2158
|
winner: winnerRun.attemptId,
|
|
2025
2159
|
});
|
|
2026
|
-
// Race winner: snapshot immediately after applying (minimal window).
|
|
2027
2160
|
try {
|
|
2028
2161
|
postTurnSha = await snapshotTree(execRoot);
|
|
2029
2162
|
}
|
|
2030
2163
|
catch {
|
|
2031
2164
|
postTurnSha = null;
|
|
2032
2165
|
}
|
|
2166
|
+
revertAnchorId = createRevertAnchorFromPatchOrNull(execRoot, winnerRun.diff);
|
|
2033
2167
|
}
|
|
2034
2168
|
else {
|
|
2035
2169
|
adopted = false;
|
|
2036
2170
|
applyState = "not_applied";
|
|
2171
|
+
deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
|
|
2172
|
+
status = "blocked";
|
|
2173
|
+
if (finalVerifyBlocks(finalVerify))
|
|
2174
|
+
finalVerifyFailed = true;
|
|
2037
2175
|
log.emit("work_product.adopted", {
|
|
2038
2176
|
applied: false,
|
|
2039
2177
|
patch_sha256: patchSha256,
|
|
@@ -2043,18 +2181,30 @@ export class Orchestrator {
|
|
|
2043
2181
|
}
|
|
2044
2182
|
}
|
|
2045
2183
|
}
|
|
2184
|
+
writeRaceDeliveryDecision(store, decisionPath, {
|
|
2185
|
+
decision: result.decision,
|
|
2186
|
+
status,
|
|
2187
|
+
reviewVerified: actualReviewVerified,
|
|
2188
|
+
finalVerify,
|
|
2189
|
+
deliveryFailureReason,
|
|
2190
|
+
deliveryReceiptPath: raceDeliveryReceipt ? "final/delivery_receipt.yaml" : null,
|
|
2191
|
+
});
|
|
2192
|
+
if (inPlaceWinner && requestedSingleCandidate && adopted === true) {
|
|
2193
|
+
revertAnchorId = await createRevertAnchorOrNull(execRoot, preTurnSha, postTurnSha);
|
|
2194
|
+
}
|
|
2046
2195
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
2047
2196
|
id: newId("wp"),
|
|
2048
2197
|
kind: input.create === true ? "new_repo" : "patch",
|
|
2049
2198
|
source_task_id: taskId,
|
|
2050
2199
|
producer_attempt_id: winnerRun.attemptId,
|
|
2200
|
+
...(raceDeliveryReceipt
|
|
2201
|
+
? { files: { delivery_receipt: "final/delivery_receipt.yaml" } }
|
|
2202
|
+
: {}),
|
|
2051
2203
|
meta: {
|
|
2052
2204
|
harness_id: winnerRun.harnessId,
|
|
2053
2205
|
synthesis: synth,
|
|
2054
2206
|
mode,
|
|
2055
|
-
//
|
|
2056
|
-
// apply path enforces the same state bar as the daemon gate (a
|
|
2057
|
-
// blocked race must read as blocked from the run dir alone).
|
|
2207
|
+
// Artifact-only apply reads the same terminal status as the daemon.
|
|
2058
2208
|
status,
|
|
2059
2209
|
review_verified: actualReviewVerified,
|
|
2060
2210
|
budget_stopped: budgetStopped,
|
|
@@ -2070,6 +2220,7 @@ export class Orchestrator {
|
|
|
2070
2220
|
apply_state: applyState,
|
|
2071
2221
|
pre_turn_sha: preTurnSha,
|
|
2072
2222
|
post_turn_sha: postTurnSha,
|
|
2223
|
+
revert_anchor_id: revertAnchorId,
|
|
2073
2224
|
},
|
|
2074
2225
|
});
|
|
2075
2226
|
store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, status }, evidences, synth.reason, actualReviewVerified));
|
|
@@ -2077,14 +2228,17 @@ export class Orchestrator {
|
|
|
2077
2228
|
log.emit("output.ready", {
|
|
2078
2229
|
kind: "summary",
|
|
2079
2230
|
path: "final/summary.md",
|
|
2080
|
-
|
|
2231
|
+
state: status === "success" || winnerAnswer.length > 0 ? "ready" : "diagnostic",
|
|
2081
2232
|
});
|
|
2082
2233
|
}
|
|
2083
2234
|
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({ attemptId: r.attemptId, harnessId: r.harnessId, telemetry: r.telemetry })), result.decision.status === "success"
|
|
2084
2235
|
? result.decision.winner
|
|
2085
2236
|
: (winnerRun?.attemptId ?? null));
|
|
2086
2237
|
const honestTerminal = status === "no_op" || status === "ungated" || status === "review_not_run";
|
|
2087
|
-
if (finalVerifyFailed) {
|
|
2238
|
+
if (deliveryFailureReason && !finalVerifyFailed) {
|
|
2239
|
+
writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
|
|
2240
|
+
}
|
|
2241
|
+
else if (finalVerifyFailed) {
|
|
2088
2242
|
writeFailure(store, paths, {
|
|
2089
2243
|
phase: "verification",
|
|
2090
2244
|
// RunFailure.category is a closed enum; "validation" is the honest
|
|
@@ -2112,7 +2266,7 @@ export class Orchestrator {
|
|
|
2112
2266
|
? "policy"
|
|
2113
2267
|
: winnerRun?.errored
|
|
2114
2268
|
? "harness_error"
|
|
2115
|
-
: status
|
|
2269
|
+
: isBudgetTerminal(status)
|
|
2116
2270
|
? "budget"
|
|
2117
2271
|
: "internal",
|
|
2118
2272
|
harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
|
|
@@ -2154,7 +2308,11 @@ export class Orchestrator {
|
|
|
2154
2308
|
// phase "verification", not "review").
|
|
2155
2309
|
log.emit("run.blocked", {
|
|
2156
2310
|
status,
|
|
2157
|
-
phase:
|
|
2311
|
+
phase: deliveryFailureReason && !finalVerifyFailed
|
|
2312
|
+
? "delivery"
|
|
2313
|
+
: finalVerifyFailed
|
|
2314
|
+
? "verification"
|
|
2315
|
+
: "review",
|
|
2158
2316
|
failure_ref: "final/failure.yaml",
|
|
2159
2317
|
});
|
|
2160
2318
|
}
|
|
@@ -2181,156 +2339,27 @@ export class Orchestrator {
|
|
|
2181
2339
|
}
|
|
2182
2340
|
/** Single-owner telemetry artifact (final/telemetry.yaml); surfaces project it, never recompute. */
|
|
2183
2341
|
writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId) {
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
schema_version: SCHEMA_VERSION,
|
|
2191
|
-
run_id: runId,
|
|
2192
|
-
task_id: taskId,
|
|
2342
|
+
writeRunTelemetryArtifact({
|
|
2343
|
+
store,
|
|
2344
|
+
finalDir: paths.finalDir,
|
|
2345
|
+
contract,
|
|
2346
|
+
runId,
|
|
2347
|
+
taskId,
|
|
2193
2348
|
mode,
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
effective_web_mode: finalRecord?.web.effective_mode ?? contract.external_context.effective_mode,
|
|
2198
|
-
web_required: contract.external_context.web_required,
|
|
2199
|
-
final_attempt_id: finalAttemptId,
|
|
2200
|
-
web: runWeb,
|
|
2201
|
-
attempts: records,
|
|
2202
|
-
tool_warnings_total: records.reduce((sum, r) => sum + r.outcome.tool_warnings_count, 0),
|
|
2203
|
-
generated_at: nowIso(),
|
|
2349
|
+
attempts,
|
|
2350
|
+
finalAttemptId,
|
|
2351
|
+
resolveAuthPreference: (harnessId) => this.authPreferenceForHarness(contract.repo.root, harnessId, contract.auth_preference),
|
|
2204
2352
|
});
|
|
2205
|
-
store.writeYaml(join(paths.finalDir, "telemetry.yaml"), telemetry);
|
|
2206
2353
|
}
|
|
2207
2354
|
/** Review a set of runs and return their evidence (with finalReviewClean + review_verified caveat). */
|
|
2208
|
-
/**
|
|
2209
|
-
* Deterministic policy findings from the typed diff (no LLM, no regex over
|
|
2210
|
-
* prose): protected-path changes and critical-risk diffs escalate NEEDS_HUMAN;
|
|
2211
|
-
* a high-risk diff without a cross-family panel escalates as well. Each
|
|
2212
|
-
* finding cites the matched files as evidence (BIBLE: evidence beats summaries).
|
|
2213
|
-
*/
|
|
2214
|
-
policyFindings(run, reviewVerified, protectedPaths = [], autoProtectedPaths = [], protectedPathApprovals = []) {
|
|
2215
|
-
const stats = diffStats(run.diff);
|
|
2216
|
-
const approvalPatterns = protectedPathApprovals.map((approval) => approval.path);
|
|
2217
|
-
const unapprovedExistingAutoProtectedPaths = stats.existingPaths.filter((path) => !matchAny(path, approvalPatterns));
|
|
2218
|
-
const specProtectedOnly = requireHuman(stats.existingPaths, protectedPaths);
|
|
2219
|
-
const autoProtectedOnly = requireHuman(unapprovedExistingAutoProtectedPaths, autoProtectedPaths);
|
|
2220
|
-
const protectedOnly = {
|
|
2221
|
-
required: specProtectedOnly.required || autoProtectedOnly.required,
|
|
2222
|
-
reasons: [...new Set([...specProtectedOnly.reasons, ...autoProtectedOnly.reasons])],
|
|
2223
|
-
matchedPaths: [
|
|
2224
|
-
...new Set([...specProtectedOnly.matchedPaths, ...autoProtectedOnly.matchedPaths]),
|
|
2225
|
-
],
|
|
2226
|
-
};
|
|
2227
|
-
const risk = classifyRisk({
|
|
2228
|
-
changedPaths: stats.paths,
|
|
2229
|
-
additions: stats.additions,
|
|
2230
|
-
deletions: stats.deletions,
|
|
2231
|
-
protectedPaths: protectedOnly.matchedPaths,
|
|
2232
|
-
});
|
|
2233
|
-
const findings = [];
|
|
2234
|
-
const reviewer = {
|
|
2235
|
-
harness_id: "policy",
|
|
2236
|
-
requested_model: null,
|
|
2237
|
-
requested_effort: null,
|
|
2238
|
-
observed_model: null,
|
|
2239
|
-
route_proof_status: "verified",
|
|
2240
|
-
};
|
|
2241
|
-
const evidenceFor = (reasons) => ({
|
|
2242
|
-
files: stats.paths
|
|
2243
|
-
.filter((p) => reasons.some((r) => r.includes(p)))
|
|
2244
|
-
.map((path) => ({ path, lines: null })),
|
|
2245
|
-
});
|
|
2246
|
-
// Structured matched-path evidence (never reconstructed from prose).
|
|
2247
|
-
const evidenceFromPaths = (paths) => ({
|
|
2248
|
-
files: paths.map((path) => ({ path, lines: null })),
|
|
2249
|
-
});
|
|
2250
|
-
const reportedRisk = protectedOnly.required
|
|
2251
|
-
? {
|
|
2252
|
-
level: "critical",
|
|
2253
|
-
reasons: [...new Set([...risk.reasons, ...protectedOnly.reasons])],
|
|
2254
|
-
matchedPaths: [...new Set([...risk.matchedPaths, ...protectedOnly.matchedPaths])],
|
|
2255
|
-
}
|
|
2256
|
-
: risk;
|
|
2257
|
-
if (protectedOnly.required) {
|
|
2258
|
-
findings.push(ReviewFindingSchema.parse({
|
|
2259
|
-
id: newId("find"),
|
|
2260
|
-
severity: "BLOCK",
|
|
2261
|
-
category: "test_gap",
|
|
2262
|
-
claim: `candidate changed protected path(s): ${protectedOnly.matchedPaths.join(", ")}`,
|
|
2263
|
-
evidence: evidenceFromPaths(protectedOnly.matchedPaths),
|
|
2264
|
-
reviewer,
|
|
2265
|
-
status: "accepted",
|
|
2266
|
-
}));
|
|
2267
|
-
}
|
|
2268
|
-
// Contract protected_paths escalate the human gate only for tampering with
|
|
2269
|
-
// existing protected files. Creating a new test/package file for create or
|
|
2270
|
-
// test-authoring flows is not tamper by itself; built-in critical paths still
|
|
2271
|
-
// apply to all changed paths.
|
|
2272
|
-
const builtInHumanPaths = [...new Set([...stats.paths, ...stats.existingPaths])];
|
|
2273
|
-
const builtInHuman = requireHuman(builtInHumanPaths, DEFAULT_REQUIRE_HUMAN_PATHS);
|
|
2274
|
-
const human = {
|
|
2275
|
-
required: builtInHuman.required || protectedOnly.required,
|
|
2276
|
-
reasons: [...new Set([...builtInHuman.reasons, ...protectedOnly.reasons])],
|
|
2277
|
-
matchedPaths: [...new Set([...builtInHuman.matchedPaths, ...protectedOnly.matchedPaths])],
|
|
2278
|
-
};
|
|
2279
|
-
if (human.required) {
|
|
2280
|
-
findings.push(ReviewFindingSchema.parse({
|
|
2281
|
-
id: newId("find"),
|
|
2282
|
-
severity: "NEEDS_HUMAN",
|
|
2283
|
-
category: "security",
|
|
2284
|
-
claim: `protected-path change requires human approval: ${human.reasons.join("; ")}`,
|
|
2285
|
-
evidence: evidenceFromPaths(human.matchedPaths),
|
|
2286
|
-
reviewer,
|
|
2287
|
-
status: "accepted",
|
|
2288
|
-
}));
|
|
2289
|
-
}
|
|
2290
|
-
const depth = reviewDepthForRisk(reportedRisk.level);
|
|
2291
|
-
if (depth.humanApproval) {
|
|
2292
|
-
findings.push(ReviewFindingSchema.parse({
|
|
2293
|
-
id: newId("find"),
|
|
2294
|
-
severity: "NEEDS_HUMAN",
|
|
2295
|
-
category: "security",
|
|
2296
|
-
claim: `critical-risk diff requires human approval: ${reportedRisk.reasons.join("; ")}`,
|
|
2297
|
-
evidence: reportedRisk.matchedPaths.length > 0
|
|
2298
|
-
? evidenceFromPaths(reportedRisk.matchedPaths)
|
|
2299
|
-
: evidenceFor(reportedRisk.reasons),
|
|
2300
|
-
reviewer,
|
|
2301
|
-
status: "accepted",
|
|
2302
|
-
}));
|
|
2303
|
-
}
|
|
2304
|
-
else if (depth.crossFamily && !reviewVerified) {
|
|
2305
|
-
findings.push(ReviewFindingSchema.parse({
|
|
2306
|
-
id: newId("find"),
|
|
2307
|
-
severity: "NEEDS_HUMAN",
|
|
2308
|
-
category: "architecture",
|
|
2309
|
-
claim: `high-risk diff requires a cross-family review panel (>=2 provider families), which is not available: ${reportedRisk.reasons.join("; ")}`,
|
|
2310
|
-
evidence: reportedRisk.matchedPaths.length > 0
|
|
2311
|
-
? evidenceFromPaths(reportedRisk.matchedPaths)
|
|
2312
|
-
: evidenceFor(reportedRisk.reasons),
|
|
2313
|
-
reviewer,
|
|
2314
|
-
status: "accepted",
|
|
2315
|
-
}));
|
|
2316
|
-
}
|
|
2317
|
-
return {
|
|
2318
|
-
findings,
|
|
2319
|
-
risk: {
|
|
2320
|
-
level: reportedRisk.level,
|
|
2321
|
-
reasons: reportedRisk.reasons,
|
|
2322
|
-
changedFiles: stats.paths.length,
|
|
2323
|
-
},
|
|
2324
|
-
};
|
|
2325
|
-
}
|
|
2326
2355
|
/**
|
|
2327
2356
|
* SINGLE funnel for every reviewer-panel invocation: run it inside a per-review
|
|
2328
|
-
* scoped harness HOME (Bible §6) so reviewer
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
*
|
|
2332
|
-
*
|
|
2333
|
-
* settles (resolve OR reject).
|
|
2357
|
+
* scoped harness HOME (Bible §6) so reviewer scratch state and injected auth
|
|
2358
|
+
* routes do not enter the project or ordinary operator HOME. Native
|
|
2359
|
+
* Codex/Claude routes deliberately keep their vendor-owned host-user stores;
|
|
2360
|
+
* no credential file is copied into the scoped home. Every call site MUST go
|
|
2361
|
+
* through here so the non-native scoping cannot drift. Disposed once the
|
|
2362
|
+
* panel settles (resolve OR reject).
|
|
2334
2363
|
*/
|
|
2335
2364
|
reviewScoped(input) {
|
|
2336
2365
|
const reviewHome = new WorkspaceManager(input.cwd).readOnlyHomeEnv();
|
|
@@ -2347,7 +2376,7 @@ export class Orchestrator {
|
|
|
2347
2376
|
const candidateCwd = run.reviewCwd ?? cwd;
|
|
2348
2377
|
const candidateEvidenceDir = this.prepareReviewEvidenceDir(reviewDir, candidateCwd);
|
|
2349
2378
|
try {
|
|
2350
|
-
|
|
2379
|
+
writeText(join(candidateEvidenceDir, "TESTS.txt"), renderTestsEvidence(contract, run.gates).trim() + "\n");
|
|
2351
2380
|
// a candidate that changed NO files has nothing to review — never
|
|
2352
2381
|
// spend a reviewer panel on "(empty diff)" (a trivial greeting in agent mode used to
|
|
2353
2382
|
// cost two reviewers). It still flows through policy gates and arbitration
|
|
@@ -2360,6 +2389,7 @@ export class Orchestrator {
|
|
|
2360
2389
|
attemptId: run.attemptId,
|
|
2361
2390
|
intent: "review",
|
|
2362
2391
|
harnessId: "review-panel",
|
|
2392
|
+
cost: attemptCostEvidence("review-panel", run.attemptId),
|
|
2363
2393
|
})
|
|
2364
2394
|
: undefined;
|
|
2365
2395
|
const result = hasDiff && reviewers.length > 0 && (reviewLease?.granted ?? true)
|
|
@@ -2385,15 +2415,21 @@ export class Orchestrator {
|
|
|
2385
2415
|
distinctProviders: [],
|
|
2386
2416
|
reviewSpendUsd: 0,
|
|
2387
2417
|
reviewSpendEstimated: false,
|
|
2418
|
+
reviewCashUsd: 0,
|
|
2419
|
+
reviewValuationUsd: 0,
|
|
2420
|
+
reviewUnknownUsd: 0,
|
|
2388
2421
|
};
|
|
2389
2422
|
if (reviewLease?.granted) {
|
|
2390
|
-
ledger?.settle(reviewLease.lease?.lease_id ?? "", result.
|
|
2423
|
+
ledger?.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`attempt:${run.attemptId}`, "review:panel"], result.reviewUnknownUsd));
|
|
2391
2424
|
if ((result.reviewSpendUsd ?? 0) > 0) {
|
|
2392
2425
|
log.emit("budget.observation", {
|
|
2393
2426
|
harness_id: "review-panel",
|
|
2394
2427
|
attempt_id: run.attemptId,
|
|
2395
2428
|
kind: "spend",
|
|
2396
2429
|
usd: result.reviewSpendUsd,
|
|
2430
|
+
cash_usd: result.reviewCashUsd,
|
|
2431
|
+
valuation_usd: result.reviewValuationUsd,
|
|
2432
|
+
unknown_usd: result.reviewUnknownUsd,
|
|
2397
2433
|
estimated: result.reviewSpendEstimated === true,
|
|
2398
2434
|
});
|
|
2399
2435
|
}
|
|
@@ -2417,7 +2453,7 @@ export class Orchestrator {
|
|
|
2417
2453
|
// Mirrors the convergence path (actualReviewVerified).
|
|
2418
2454
|
const candidateReviewVerified = reviewVerified && result.crossFamilyHealthy && result.crossFamilyVerified;
|
|
2419
2455
|
// Typed policy gate (risk + protected paths) merges with reviewer findings.
|
|
2420
|
-
const policy =
|
|
2456
|
+
const policy = policyFindings(run, candidateReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
|
|
2421
2457
|
const allFindings = [...policy.findings, ...revalidated];
|
|
2422
2458
|
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
2423
2459
|
const noBlockers = !allFindings.some((f) => isBlocking(f));
|
|
@@ -2425,6 +2461,7 @@ export class Orchestrator {
|
|
|
2425
2461
|
store.writeYaml(join(paths.reviewsDir, `${run.attemptId}.yaml`), {
|
|
2426
2462
|
attempt_id: run.attemptId,
|
|
2427
2463
|
review_verified: candidateReviewVerified,
|
|
2464
|
+
final_review_clean: reviewClean,
|
|
2428
2465
|
cross_family_healthy: result.crossFamilyHealthy,
|
|
2429
2466
|
cross_family_verified: result.crossFamilyVerified,
|
|
2430
2467
|
healthy_providers: result.healthyProviders,
|
|
@@ -2440,7 +2477,7 @@ export class Orchestrator {
|
|
|
2440
2477
|
severity: f.severity,
|
|
2441
2478
|
status: f.status,
|
|
2442
2479
|
});
|
|
2443
|
-
evidences.push(
|
|
2480
|
+
evidences.push(toCandidateEvidence(run, contract, allFindings, reviewClean, candidateReviewVerified));
|
|
2444
2481
|
}
|
|
2445
2482
|
finally {
|
|
2446
2483
|
this.recordReviewEvidenceCleanup(store, join(paths.reviewsDir, `${run.attemptId}-evidence-cleanup.yaml`), run.attemptId, candidateEvidenceDir, candidateCwd);
|
|
@@ -2448,23 +2485,15 @@ export class Orchestrator {
|
|
|
2448
2485
|
}
|
|
2449
2486
|
return evidences;
|
|
2450
2487
|
}
|
|
2451
|
-
prepareReviewEvidenceDir(sourceDir,
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2488
|
+
prepareReviewEvidenceDir(sourceDir, _candidateCwd) {
|
|
2489
|
+
// Evidence is an external runtime artifact. ReviewEngine builds a separate
|
|
2490
|
+
// reviewer workspace and copies the packet there; writing/copying it into
|
|
2491
|
+
// the candidate tree would contaminate the Git diff and, worse, overwrite a
|
|
2492
|
+
// user-owned path with the same name.
|
|
2456
2493
|
if (!existsSync(sourceDir)) {
|
|
2457
2494
|
throw new Error(`review evidence preflight failed for ${sourceDir}: source packet missing`);
|
|
2458
2495
|
}
|
|
2459
|
-
|
|
2460
|
-
rmSync(targetDir, { recursive: true, force: true });
|
|
2461
|
-
cpSync(sourceDir, targetDir, { recursive: true });
|
|
2462
|
-
}
|
|
2463
|
-
catch (err) {
|
|
2464
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2465
|
-
throw new Error(`review evidence copy into candidate tree failed: ${message}`);
|
|
2466
|
-
}
|
|
2467
|
-
return this.requireReviewEvidence(targetDir);
|
|
2496
|
+
return this.requireReviewEvidence(sourceDir);
|
|
2468
2497
|
}
|
|
2469
2498
|
requireReviewEvidence(dir) {
|
|
2470
2499
|
const result = preflightEvidence(dir);
|
|
@@ -2474,20 +2503,9 @@ export class Orchestrator {
|
|
|
2474
2503
|
const empty = result.empty.length ? `empty=${result.empty.join(",")}` : "";
|
|
2475
2504
|
throw new Error(`review evidence preflight failed for ${dir}: ${[missing, empty].filter(Boolean).join(" ")}`);
|
|
2476
2505
|
}
|
|
2477
|
-
cleanupReviewEvidenceDir(
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
rmSync(candidateEvidenceDir, { recursive: true, force: true });
|
|
2481
|
-
}
|
|
2482
|
-
catch (error) {
|
|
2483
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
2484
|
-
return {
|
|
2485
|
-
review_evidence_cleanup: "failed",
|
|
2486
|
-
candidate_evidence_dir: candidateEvidenceDir,
|
|
2487
|
-
error: redactSecrets(detail),
|
|
2488
|
-
};
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2506
|
+
cleanupReviewEvidenceDir(_candidateEvidenceDir, _candidateCwd) {
|
|
2507
|
+
// No candidate-tree packet exists in v2; external runtime retention is
|
|
2508
|
+
// governed by the artifact/journal lifecycle rather than best-effort rm.
|
|
2491
2509
|
return null;
|
|
2492
2510
|
}
|
|
2493
2511
|
recordReviewEvidenceCleanup(store, metadataPath, attemptId, candidateEvidenceDir, candidateCwd) {
|
|
@@ -2521,11 +2539,20 @@ export class Orchestrator {
|
|
|
2521
2539
|
const execRoot = this.execRootOf(input);
|
|
2522
2540
|
const wsm = new WorkspaceManager(execRoot);
|
|
2523
2541
|
const readiness = new ReadinessLedger();
|
|
2524
|
-
const ledger =
|
|
2542
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
2525
2543
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
2526
2544
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
2527
2545
|
log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
|
|
2528
|
-
announce?.({
|
|
2546
|
+
announce?.({
|
|
2547
|
+
log,
|
|
2548
|
+
store,
|
|
2549
|
+
paths,
|
|
2550
|
+
runId,
|
|
2551
|
+
taskId,
|
|
2552
|
+
mode,
|
|
2553
|
+
phase: "convergence",
|
|
2554
|
+
spend: () => ledger.spend(),
|
|
2555
|
+
});
|
|
2529
2556
|
// Live (in-place) isolation deliberately tolerates non-git stateful
|
|
2530
2557
|
// environments; only envelope isolation needs the git boundary.
|
|
2531
2558
|
if (!input.inPlace) {
|
|
@@ -2548,7 +2575,7 @@ export class Orchestrator {
|
|
|
2548
2575
|
writeEvidencePacket(reviewDir, {
|
|
2549
2576
|
userIntent: redactSecrets(input.prompt),
|
|
2550
2577
|
diff: "(per-attempt)\n",
|
|
2551
|
-
tests:
|
|
2578
|
+
tests: renderTestsEvidence(contract),
|
|
2552
2579
|
});
|
|
2553
2580
|
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, mode);
|
|
2554
2581
|
if ("failed" in reviewersOutcome)
|
|
@@ -2558,7 +2585,8 @@ export class Orchestrator {
|
|
|
2558
2585
|
// One envelope carried forward across attempts so the harness can repair its own work.
|
|
2559
2586
|
let adapterPool;
|
|
2560
2587
|
try {
|
|
2561
|
-
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input));
|
|
2588
|
+
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
|
|
2589
|
+
this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
|
|
2562
2590
|
}
|
|
2563
2591
|
catch (err) {
|
|
2564
2592
|
const message = safeErrorMessage(err);
|
|
@@ -2655,7 +2683,6 @@ export class Orchestrator {
|
|
|
2655
2683
|
const stallThreshold = input.untilClean === true ? 4 : 2;
|
|
2656
2684
|
const allCooledDown = () => adapterPool.every((a) => ledger.cooldownActive(a.adapter.id));
|
|
2657
2685
|
const attemptTelemetries = [];
|
|
2658
|
-
const harnessLedgers = new Map();
|
|
2659
2686
|
let lastDiffStable = true;
|
|
2660
2687
|
let reviewSpendEstimated = false;
|
|
2661
2688
|
try {
|
|
@@ -2707,12 +2734,12 @@ export class Orchestrator {
|
|
|
2707
2734
|
exhausted = true;
|
|
2708
2735
|
break;
|
|
2709
2736
|
}
|
|
2710
|
-
|
|
2711
|
-
const lease = this.harnessLedger(harnessLedgers, ledger, routed).reserve({
|
|
2737
|
+
const lease = ledger.reserve({
|
|
2712
2738
|
taskId,
|
|
2713
2739
|
attemptId,
|
|
2714
2740
|
intent: "repair",
|
|
2715
2741
|
harnessId: adapter.id,
|
|
2742
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
2716
2743
|
});
|
|
2717
2744
|
if (!lease.granted) {
|
|
2718
2745
|
exhausted = true;
|
|
@@ -2733,11 +2760,10 @@ export class Orchestrator {
|
|
|
2733
2760
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
2734
2761
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
2735
2762
|
}, input.signal, undefined, input.effort, "repair", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive), (streamedUsd) => {
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
this.harnessLedger(harnessLedgers, ledger, routed).settle(lease.lease?.lease_id ?? "", run.cost);
|
|
2763
|
+
ledger.updateHold(lease.lease?.lease_id ?? "", streamedUsd);
|
|
2764
|
+
return ledger.tier() === "hard";
|
|
2765
|
+
}, input, true);
|
|
2766
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
2741
2767
|
log.emit("harness.completed", {
|
|
2742
2768
|
harness_id: adapter.id,
|
|
2743
2769
|
attempt_id: attemptId,
|
|
@@ -2749,7 +2775,7 @@ export class Orchestrator {
|
|
|
2749
2775
|
catch (err) {
|
|
2750
2776
|
// Envelope/setup failure before the stream; stream errors are absorbed
|
|
2751
2777
|
// inside runCandidateInEnvelope with their real accumulated cost.
|
|
2752
|
-
|
|
2778
|
+
ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("attempt-error"));
|
|
2753
2779
|
log.emit("harness.completed", {
|
|
2754
2780
|
harness_id: adapter.id,
|
|
2755
2781
|
attempt_id: attemptId,
|
|
@@ -2766,7 +2792,7 @@ export class Orchestrator {
|
|
|
2766
2792
|
errored: true,
|
|
2767
2793
|
costEstimated: false,
|
|
2768
2794
|
errors: [safeErrorMessage(err)],
|
|
2769
|
-
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb),
|
|
2795
|
+
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb, [routed.browserRequirement, routed.denyRequirement], knobs.model),
|
|
2770
2796
|
};
|
|
2771
2797
|
}
|
|
2772
2798
|
lastRun = run;
|
|
@@ -2790,7 +2816,7 @@ export class Orchestrator {
|
|
|
2790
2816
|
const candidateReviewCwd = run.reviewCwd ?? input.repoRoot;
|
|
2791
2817
|
const candidateReviewEvidenceDir = this.prepareReviewEvidenceDir(reviewDir, candidateReviewCwd);
|
|
2792
2818
|
try {
|
|
2793
|
-
|
|
2819
|
+
writeText(join(candidateReviewEvidenceDir, "TESTS.txt"), renderTestsEvidence(contract, run.gates).trim() + "\n");
|
|
2794
2820
|
// Reviewer panels spend real money in convergence too: reserve before,
|
|
2795
2821
|
// settle the observed cost, and surface it as a budget observation
|
|
2796
2822
|
// (parity with the race path's reviewRuns metering).
|
|
@@ -2800,6 +2826,7 @@ export class Orchestrator {
|
|
|
2800
2826
|
attemptId,
|
|
2801
2827
|
intent: "review",
|
|
2802
2828
|
harnessId: "review-panel",
|
|
2829
|
+
cost: attemptCostEvidence("review-panel", attemptId),
|
|
2803
2830
|
})
|
|
2804
2831
|
: null;
|
|
2805
2832
|
const reviewResult = reviewers.length > 0 && (reviewLease?.granted ?? false)
|
|
@@ -2824,15 +2851,21 @@ export class Orchestrator {
|
|
|
2824
2851
|
distinctProviders: [],
|
|
2825
2852
|
reviewSpendUsd: 0,
|
|
2826
2853
|
reviewSpendEstimated: false,
|
|
2854
|
+
reviewCashUsd: 0,
|
|
2855
|
+
reviewValuationUsd: 0,
|
|
2856
|
+
reviewUnknownUsd: 0,
|
|
2827
2857
|
};
|
|
2828
2858
|
if (reviewLease?.granted) {
|
|
2829
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewResult.
|
|
2859
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(reviewResult.reviewCashUsd, reviewResult.reviewValuationUsd, reviewResult.reviewSpendEstimated, [`attempt:${attemptId}`, "review:panel"], reviewResult.reviewUnknownUsd));
|
|
2830
2860
|
if ((reviewResult.reviewSpendUsd ?? 0) > 0) {
|
|
2831
2861
|
log.emit("budget.observation", {
|
|
2832
2862
|
harness_id: "review-panel",
|
|
2833
2863
|
attempt_id: attemptId,
|
|
2834
2864
|
kind: "spend",
|
|
2835
2865
|
usd: reviewResult.reviewSpendUsd,
|
|
2866
|
+
cash_usd: reviewResult.reviewCashUsd,
|
|
2867
|
+
valuation_usd: reviewResult.reviewValuationUsd,
|
|
2868
|
+
unknown_usd: reviewResult.reviewUnknownUsd,
|
|
2836
2869
|
estimated: reviewResult.reviewSpendEstimated === true,
|
|
2837
2870
|
});
|
|
2838
2871
|
if (reviewResult.reviewSpendEstimated === true)
|
|
@@ -2856,12 +2889,18 @@ export class Orchestrator {
|
|
|
2856
2889
|
evidenceDir: candidateReviewEvidenceDir,
|
|
2857
2890
|
});
|
|
2858
2891
|
// Typed policy gate (risk + protected paths) merges with reviewer findings.
|
|
2859
|
-
const policy =
|
|
2892
|
+
const policy = policyFindings(run, actualReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
|
|
2860
2893
|
const allFindings = [...policy.findings, ...revalidated];
|
|
2861
2894
|
lastFindings = allFindings;
|
|
2895
|
+
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
2896
|
+
const finalReviewClean = reviewResult.crossFamilyHealthy &&
|
|
2897
|
+
reviewResult.crossFamilyVerified &&
|
|
2898
|
+
!inconclusive &&
|
|
2899
|
+
!allFindings.some((f) => isBlocking(f));
|
|
2862
2900
|
store.writeYaml(join(paths.reviewsDir, `${attemptId}.yaml`), {
|
|
2863
2901
|
attempt_id: attemptId,
|
|
2864
2902
|
review_verified: actualReviewVerified,
|
|
2903
|
+
final_review_clean: finalReviewClean,
|
|
2865
2904
|
cross_family_healthy: reviewResult.crossFamilyHealthy,
|
|
2866
2905
|
cross_family_verified: reviewResult.crossFamilyVerified,
|
|
2867
2906
|
healthy_providers: reviewResult.healthyProviders,
|
|
@@ -2871,11 +2910,6 @@ export class Orchestrator {
|
|
|
2871
2910
|
findings: allFindings,
|
|
2872
2911
|
route_proofs: reviewResult.routeProofs,
|
|
2873
2912
|
});
|
|
2874
|
-
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
2875
|
-
const finalReviewClean = reviewResult.crossFamilyHealthy &&
|
|
2876
|
-
reviewResult.crossFamilyVerified &&
|
|
2877
|
-
!inconclusive &&
|
|
2878
|
-
!allFindings.some((f) => isBlocking(f));
|
|
2879
2913
|
lastFinalReviewClean = finalReviewClean;
|
|
2880
2914
|
// Measure diff stability instead of asserting it: the tree must not have
|
|
2881
2915
|
// changed between the candidate diff capture and the end of review.
|
|
@@ -2994,10 +3028,12 @@ export class Orchestrator {
|
|
|
2994
3028
|
: exhausted
|
|
2995
3029
|
? "exhausted"
|
|
2996
3030
|
: "not_converged";
|
|
3031
|
+
if (status === "success" && ledger.terminal() !== null)
|
|
3032
|
+
status = ledger.terminal();
|
|
2997
3033
|
let decision = null;
|
|
2998
3034
|
if (lastRun) {
|
|
2999
3035
|
const arb = arbitrate([
|
|
3000
|
-
|
|
3036
|
+
toCandidateEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
|
|
3001
3037
|
], {
|
|
3002
3038
|
spendUsd: ledger.spend(),
|
|
3003
3039
|
estimatedSpend: lastRun.costEstimated || reviewSpendEstimated,
|
|
@@ -3027,7 +3063,7 @@ export class Orchestrator {
|
|
|
3027
3063
|
lastRun.diff.trim().length > 0 &&
|
|
3028
3064
|
(status === "success" || status === "ungated") &&
|
|
3029
3065
|
!input.signal?.aborted) {
|
|
3030
|
-
convFinalVerify = await finalVerifyPatch(execRoot, lastRun,
|
|
3066
|
+
convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
|
|
3031
3067
|
if (finalVerifyBlocks(convFinalVerify))
|
|
3032
3068
|
status = "blocked";
|
|
3033
3069
|
}
|
|
@@ -3036,7 +3072,9 @@ export class Orchestrator {
|
|
|
3036
3072
|
// overrides the persisted decision; final_verify is recorded either way.
|
|
3037
3073
|
decision = {
|
|
3038
3074
|
...decision,
|
|
3039
|
-
...(status === "blocked"
|
|
3075
|
+
...(status === "blocked"
|
|
3076
|
+
? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
|
|
3077
|
+
: {}),
|
|
3040
3078
|
final_verify: convFinalVerify,
|
|
3041
3079
|
};
|
|
3042
3080
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
@@ -3058,6 +3096,9 @@ export class Orchestrator {
|
|
|
3058
3096
|
? "applied"
|
|
3059
3097
|
: "applied_review_blocked"
|
|
3060
3098
|
: "not_applied";
|
|
3099
|
+
const revertAnchorId = convAdopted === true
|
|
3100
|
+
? await createRevertAnchorOrNull(execRoot, preTurnSha, lastPostTurnSha)
|
|
3101
|
+
: null;
|
|
3061
3102
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
3062
3103
|
id: newId("wp"),
|
|
3063
3104
|
kind: "patch",
|
|
@@ -3065,6 +3106,7 @@ export class Orchestrator {
|
|
|
3065
3106
|
producer_attempt_id: lastRun.attemptId,
|
|
3066
3107
|
meta: {
|
|
3067
3108
|
harness_id: lastRun.harnessId,
|
|
3109
|
+
result_kind: "patch",
|
|
3068
3110
|
mode,
|
|
3069
3111
|
attempts: attempt,
|
|
3070
3112
|
status,
|
|
@@ -3074,6 +3116,7 @@ export class Orchestrator {
|
|
|
3074
3116
|
apply_state: convApplyState,
|
|
3075
3117
|
pre_turn_sha: convAdopted === true ? preTurnSha : null,
|
|
3076
3118
|
post_turn_sha: convAdopted === true ? lastPostTurnSha : null,
|
|
3119
|
+
revert_anchor_id: revertAnchorId,
|
|
3077
3120
|
},
|
|
3078
3121
|
});
|
|
3079
3122
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Attempts: ${attempt}\n- Winner: ${lastRun.attemptId}\n- Review verified (cross-family): ${actualReviewVerified}\n- Apply recommendation: ${decision?.apply_recommendation ?? "inspect"}${stuckNoProgressReason ? `\n- No-progress reason: ${stuckNoProgressReason}` : ""}\n`);
|
|
@@ -3088,7 +3131,7 @@ export class Orchestrator {
|
|
|
3088
3131
|
if (!converged) {
|
|
3089
3132
|
writeFailure(store, paths, {
|
|
3090
3133
|
phase: "convergence",
|
|
3091
|
-
category: status
|
|
3134
|
+
category: isBudgetTerminal(status)
|
|
3092
3135
|
? "budget"
|
|
3093
3136
|
: status === "cancelled"
|
|
3094
3137
|
? "cancelled"
|
|
@@ -3204,19 +3247,32 @@ export class Orchestrator {
|
|
|
3204
3247
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
3205
3248
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
3206
3249
|
log.emit("run.created", { mode: "plan", prompt: redactSecrets(input.prompt) });
|
|
3207
|
-
const ledger =
|
|
3208
|
-
announce?.({
|
|
3250
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
3251
|
+
announce?.({
|
|
3252
|
+
log,
|
|
3253
|
+
store,
|
|
3254
|
+
paths,
|
|
3255
|
+
runId,
|
|
3256
|
+
taskId,
|
|
3257
|
+
mode: "plan",
|
|
3258
|
+
phase: "plan",
|
|
3259
|
+
spend: () => ledger.spend(),
|
|
3260
|
+
});
|
|
3209
3261
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3210
3262
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3211
3263
|
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
|
|
3212
3264
|
if ("failed" in reviewersOutcome)
|
|
3213
3265
|
return reviewersOutcome.failed;
|
|
3214
3266
|
const reviewers = reviewersOutcome.reviewers;
|
|
3267
|
+
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3268
|
+
// planner spawn consume the SAME scoped env (see routeContext.ts).
|
|
3269
|
+
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
3215
3270
|
let adapters;
|
|
3216
3271
|
try {
|
|
3217
|
-
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan");
|
|
3272
|
+
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
|
|
3218
3273
|
}
|
|
3219
3274
|
catch (err) {
|
|
3275
|
+
roHome.dispose();
|
|
3220
3276
|
const message = safeErrorMessage(err);
|
|
3221
3277
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
3222
3278
|
writeFailure(store, paths, {
|
|
@@ -3251,6 +3307,7 @@ export class Orchestrator {
|
|
|
3251
3307
|
contextSection = await this.lazyContextSection(input, contract, store, paths, log);
|
|
3252
3308
|
}
|
|
3253
3309
|
catch (err) {
|
|
3310
|
+
roHome.dispose();
|
|
3254
3311
|
const message = safeErrorMessage(err);
|
|
3255
3312
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
|
|
3256
3313
|
writeFailure(store, paths, {
|
|
@@ -3282,17 +3339,19 @@ export class Orchestrator {
|
|
|
3282
3339
|
const plans = [];
|
|
3283
3340
|
const planAttempts = [];
|
|
3284
3341
|
const attemptTelemetries = [];
|
|
3285
|
-
// scope the planners' HOME/config dirs so claude-code plan files (and any
|
|
3286
|
-
// native session state) stay inside the run's scoped home, never the
|
|
3287
|
-
// operator's real ~/.claude/plans. Disposed after the planners finish.
|
|
3288
|
-
const roHome = new WorkspaceManager(this.execRootOf(input)).readOnlyHomeEnv();
|
|
3289
3342
|
try {
|
|
3290
3343
|
for (const [idx, routed] of adapters.entries()) {
|
|
3291
3344
|
if (input.signal?.aborted)
|
|
3292
3345
|
break;
|
|
3293
3346
|
const adapter = routed.adapter;
|
|
3294
3347
|
const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
|
|
3295
|
-
const lease = ledger.reserve({
|
|
3348
|
+
const lease = ledger.reserve({
|
|
3349
|
+
taskId,
|
|
3350
|
+
attemptId,
|
|
3351
|
+
intent: "plan",
|
|
3352
|
+
harnessId: adapter.id,
|
|
3353
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3354
|
+
});
|
|
3296
3355
|
if (!lease.granted) {
|
|
3297
3356
|
log.emit("budget.lease.created", {
|
|
3298
3357
|
granted: false,
|
|
@@ -3313,16 +3372,8 @@ export class Orchestrator {
|
|
|
3313
3372
|
// Planners must SEE any image/file the user attached (e.g. "plan a fix for
|
|
3314
3373
|
// what's in this screenshot"), not just agent/race runs.
|
|
3315
3374
|
attachments: input.attachments ?? [],
|
|
3316
|
-
...this.sessionSpecFields(input, adapter.id),
|
|
3317
|
-
|
|
3318
|
-
tool_permission_policy: {
|
|
3319
|
-
web: knobs.webPolicy,
|
|
3320
|
-
allow: [...new Set([...contract.tool_permission_policy.allow, ...knobs.toolsAllow])],
|
|
3321
|
-
deny: [...new Set([...contract.tool_permission_policy.deny, ...knobs.toolsDeny])],
|
|
3322
|
-
},
|
|
3323
|
-
model_hint: knobs.model,
|
|
3324
|
-
effort_hint: knobs.effort,
|
|
3325
|
-
max_turns: knobs.maxTurns,
|
|
3375
|
+
...this.sessionSpecFields(input, adapter.id, log),
|
|
3376
|
+
...this.harnessSpecKnobs(contract, knobs, "plan"),
|
|
3326
3377
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3327
3378
|
env: roHome.env,
|
|
3328
3379
|
});
|
|
@@ -3334,10 +3385,13 @@ export class Orchestrator {
|
|
|
3334
3385
|
if (planInteraction)
|
|
3335
3386
|
spec.extra["interactionChannel"] = planInteraction;
|
|
3336
3387
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3337
|
-
const
|
|
3388
|
+
const answer = new AnswerAssembly();
|
|
3338
3389
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3339
3390
|
knobs.webPolicy === "cached" ||
|
|
3340
|
-
knobs.webPolicy === "live", effectiveWeb
|
|
3391
|
+
knobs.webPolicy === "live", effectiveWeb, [],
|
|
3392
|
+
// Requested-model capture: a plan lane silently downgraded to another
|
|
3393
|
+
// model surfaces the mismatch in its route receipt, just like agent.
|
|
3394
|
+
knobs.model);
|
|
3341
3395
|
const onAbort = () => {
|
|
3342
3396
|
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3343
3397
|
};
|
|
@@ -3348,6 +3402,7 @@ export class Orchestrator {
|
|
|
3348
3402
|
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3349
3403
|
}
|
|
3350
3404
|
let cost = 0;
|
|
3405
|
+
let costEstimated = false;
|
|
3351
3406
|
let harnessError = null;
|
|
3352
3407
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
3353
3408
|
try {
|
|
@@ -3371,23 +3426,29 @@ export class Orchestrator {
|
|
|
3371
3426
|
break;
|
|
3372
3427
|
const safeEv = redactHarnessEvent(ev);
|
|
3373
3428
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3374
|
-
// NOT observed for resume:
|
|
3375
|
-
//
|
|
3376
|
-
//
|
|
3377
|
-
//
|
|
3378
|
-
// `if (inPlaceEnvelope)` guard. Codex-review-confirmed.
|
|
3429
|
+
// NOT observed for resume: a read-only planner is not a chat turn,
|
|
3430
|
+
// and attaching its session id would poison thread continuity (and
|
|
3431
|
+
// race parallel planner/reviewer sessions), regardless of whether
|
|
3432
|
+
// the vendor stored that session in the scoped or native store.
|
|
3379
3433
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
3380
3434
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
3381
3435
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
3382
3436
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
3383
3437
|
if (safeEv.plan_progress) {
|
|
3384
|
-
log.emit("plan.progress", {
|
|
3438
|
+
log.emit("plan.progress", {
|
|
3439
|
+
attempt_id: attemptId,
|
|
3440
|
+
harness_id: adapter.id,
|
|
3441
|
+
items: safeEv.plan_progress.items,
|
|
3442
|
+
});
|
|
3385
3443
|
}
|
|
3386
3444
|
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
3387
3445
|
// the loudest) — same single owner as the agent loop.
|
|
3388
3446
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
3447
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
3389
3448
|
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
3390
3449
|
cost += safeEv.usage.cost_usd;
|
|
3450
|
+
if (safeEv.usage.estimated)
|
|
3451
|
+
costEstimated = true;
|
|
3391
3452
|
log.emit("budget.observation", {
|
|
3392
3453
|
harness_id: adapter.id,
|
|
3393
3454
|
attempt_id: attemptId,
|
|
@@ -3396,11 +3457,8 @@ export class Orchestrator {
|
|
|
3396
3457
|
estimated: safeEv.usage.estimated === true,
|
|
3397
3458
|
});
|
|
3398
3459
|
}
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
safeEv.payload?.["auth_switched"] !== true) {
|
|
3402
|
-
pushUniqueText(parts, safeEv.text);
|
|
3403
|
-
}
|
|
3460
|
+
// A TYPED final message wins verbatim over joined narration.
|
|
3461
|
+
answer.observe(safeEv);
|
|
3404
3462
|
if (safeEv.type === "error")
|
|
3405
3463
|
harnessError = safeEv.error
|
|
3406
3464
|
? redactSecrets(safeEv.error)
|
|
@@ -3413,7 +3471,7 @@ export class Orchestrator {
|
|
|
3413
3471
|
}
|
|
3414
3472
|
finally {
|
|
3415
3473
|
input.signal?.removeEventListener("abort", onAbort);
|
|
3416
|
-
ledger.settle(lease.lease?.lease_id ?? "", cost);
|
|
3474
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
|
|
3417
3475
|
}
|
|
3418
3476
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
|
|
3419
3477
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
@@ -3443,7 +3501,7 @@ export class Orchestrator {
|
|
|
3443
3501
|
});
|
|
3444
3502
|
continue;
|
|
3445
3503
|
}
|
|
3446
|
-
const text =
|
|
3504
|
+
const text = answer.text() || "(no output)";
|
|
3447
3505
|
log.emit("harness.completed", {
|
|
3448
3506
|
harness_id: adapter.id,
|
|
3449
3507
|
attempt_id: attemptId,
|
|
@@ -3456,7 +3514,7 @@ export class Orchestrator {
|
|
|
3456
3514
|
}
|
|
3457
3515
|
}
|
|
3458
3516
|
finally {
|
|
3459
|
-
// Planners done (or threw) —
|
|
3517
|
+
// Planners done (or threw) — reclaim scoped scratch/API-route state.
|
|
3460
3518
|
roHome.dispose();
|
|
3461
3519
|
}
|
|
3462
3520
|
if (input.signal?.aborted) {
|
|
@@ -3464,7 +3522,7 @@ export class Orchestrator {
|
|
|
3464
3522
|
attemptId: p.attemptId,
|
|
3465
3523
|
harnessId: p.harnessId,
|
|
3466
3524
|
status: p.status,
|
|
3467
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend());
|
|
3525
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3468
3526
|
}
|
|
3469
3527
|
if (plans.length === 0) {
|
|
3470
3528
|
const blocked = planAttempts.some((p) => p.status === "blocked");
|
|
@@ -3523,7 +3581,7 @@ export class Orchestrator {
|
|
|
3523
3581
|
userIntent: redactSecrets(input.prompt),
|
|
3524
3582
|
planAccepted: planEvidence,
|
|
3525
3583
|
diff: planReviewDiff,
|
|
3526
|
-
tests:
|
|
3584
|
+
tests: renderTestsEvidence(contract),
|
|
3527
3585
|
});
|
|
3528
3586
|
// Reserve BEFORE spending: a hard budget tier must stop the paid plan
|
|
3529
3587
|
// review from starting, not account for it after the fact.
|
|
@@ -3532,10 +3590,12 @@ export class Orchestrator {
|
|
|
3532
3590
|
attemptId: "plan-review",
|
|
3533
3591
|
intent: "review",
|
|
3534
3592
|
harnessId: "review-panel",
|
|
3593
|
+
cost: attemptCostEvidence("review-panel", "plan-review"),
|
|
3535
3594
|
});
|
|
3536
3595
|
if (lease.granted) {
|
|
3537
3596
|
const res = await this.reviewScoped({
|
|
3538
3597
|
candidateLabel: "Plan",
|
|
3598
|
+
reviewSubject: "plan",
|
|
3539
3599
|
diff: planReviewDiff,
|
|
3540
3600
|
evidenceDir: reviewDir,
|
|
3541
3601
|
artifactsDir: join(paths.reviewsDir, "plan-reviewers"),
|
|
@@ -3555,12 +3615,15 @@ export class Orchestrator {
|
|
|
3555
3615
|
route_proofs: res.routeProofs,
|
|
3556
3616
|
reviewer_requests: res.reviewerRequests,
|
|
3557
3617
|
});
|
|
3558
|
-
ledger.settle(lease.lease?.lease_id ?? "", res.
|
|
3618
|
+
ledger.settle(lease.lease?.lease_id ?? "", reviewUsageCostSettlement(res.reviewCashUsd, res.reviewValuationUsd, res.reviewSpendEstimated, ["attempt:plan-review", "review:panel"], res.reviewUnknownUsd));
|
|
3559
3619
|
if ((res.reviewSpendUsd ?? 0) > 0) {
|
|
3560
3620
|
log.emit("budget.observation", {
|
|
3561
3621
|
harness_id: "review-panel",
|
|
3562
3622
|
kind: "spend",
|
|
3563
3623
|
usd: res.reviewSpendUsd,
|
|
3624
|
+
cash_usd: res.reviewCashUsd,
|
|
3625
|
+
valuation_usd: res.reviewValuationUsd,
|
|
3626
|
+
unknown_usd: res.reviewUnknownUsd,
|
|
3564
3627
|
estimated: res.reviewSpendEstimated,
|
|
3565
3628
|
});
|
|
3566
3629
|
}
|
|
@@ -3579,7 +3642,7 @@ export class Orchestrator {
|
|
|
3579
3642
|
attemptId: p.attemptId,
|
|
3580
3643
|
harnessId: p.harnessId,
|
|
3581
3644
|
status: p.status,
|
|
3582
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend());
|
|
3645
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3583
3646
|
}
|
|
3584
3647
|
const failedPlanners = planAttempts.filter((p) => p.status !== "success");
|
|
3585
3648
|
// ALL review findings are shown (severity-marked), so a BLOCK like "the
|
|
@@ -3690,18 +3753,40 @@ export class Orchestrator {
|
|
|
3690
3753
|
defaultPrompt: "audit this repository",
|
|
3691
3754
|
}, announce);
|
|
3692
3755
|
}
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3756
|
+
resolvePaidBudget(inputBudget, cfg) {
|
|
3757
|
+
return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
|
|
3758
|
+
}
|
|
3759
|
+
rootLedger(input, contract, log) {
|
|
3760
|
+
// A passed-in ledger (orchestrate sub-runs) keeps its OWNER's cash
|
|
3761
|
+
// disclosure — the parent run owns the budget, so its event log gets the
|
|
3762
|
+
// budget.cash events. A fresh root ledger discloses into THIS run's log:
|
|
3763
|
+
// the ledger is the one owner of the cash fact (subscription-entitled
|
|
3764
|
+
// work settles to 0 there), and the UI renders `budget.cash` verbatim —
|
|
3765
|
+
// never inferring money from route labels (W4.3 sol #15).
|
|
3766
|
+
const ledger = input.budgetLedger ??
|
|
3767
|
+
new BudgetLedger(contract.budget.paid_budget, undefined, {
|
|
3768
|
+
onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
|
|
3769
|
+
cash_spend_usd: cashSpendUsd,
|
|
3770
|
+
valuation_usd: valuationUsd,
|
|
3771
|
+
}),
|
|
3772
|
+
});
|
|
3773
|
+
for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
|
|
3774
|
+
ledger.observeQuotaSnapshot(snapshot);
|
|
3775
|
+
}
|
|
3776
|
+
return ledger;
|
|
3777
|
+
}
|
|
3778
|
+
routeBillingKnowledge(input, harnessId) {
|
|
3779
|
+
// A selected profile's credential_kind decides billing (round-18 #2).
|
|
3780
|
+
const profileRoute = this.profileAuthRoute(input, harnessId);
|
|
3781
|
+
if (profileRoute)
|
|
3782
|
+
return profileRoute === "api_key" ? "metered" : "unknown";
|
|
3783
|
+
if (input.authPreference === "api_key")
|
|
3784
|
+
return "metered";
|
|
3785
|
+
if (input.authPreference === "subscription")
|
|
3786
|
+
return "unknown";
|
|
3787
|
+
return loadHarnessMetrics(globalConfigDir())[harnessId]?.last_auth_mode === "api_key"
|
|
3788
|
+
? "metered"
|
|
3789
|
+
: "unknown";
|
|
3705
3790
|
}
|
|
3706
3791
|
async runOrchestrate(input, announce) {
|
|
3707
3792
|
// "Doctor-verified" must mean status ok — degraded key-present routes are
|
|
@@ -3715,15 +3800,11 @@ export class Orchestrator {
|
|
|
3715
3800
|
// the executor below is its consumer. Default `suggest` (plan-only) preserves
|
|
3716
3801
|
// the read-only contract when no autonomy is requested.
|
|
3717
3802
|
const autonomy = input.autonomy ?? "suggest";
|
|
3718
|
-
|
|
3719
|
-
// orchestrator deps -> operator config default. Without the fallback an
|
|
3720
|
-
// operator's max_usd_per_run capped every sub-run individually but never
|
|
3721
|
-
// the aggregate (R33 finding).
|
|
3722
|
-
const aggregateMaxUsd = this.resolveMaxUsdCap(input.maxUsd, this.config(input.repoRoot));
|
|
3803
|
+
const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
|
|
3723
3804
|
const orchestrateContract = OrchestrateContractSchema.parse({
|
|
3724
3805
|
thread_id: input.threadId ?? newId("th"),
|
|
3725
3806
|
goal,
|
|
3726
|
-
budget: {
|
|
3807
|
+
budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
|
|
3727
3808
|
autonomy,
|
|
3728
3809
|
});
|
|
3729
3810
|
const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
|
|
@@ -3762,8 +3843,17 @@ export class Orchestrator {
|
|
|
3762
3843
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
3763
3844
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
3764
3845
|
log.emit("run.created", { mode: opts.mode, prompt: redactSecrets(prompt) });
|
|
3765
|
-
const ledger =
|
|
3766
|
-
announce?.({
|
|
3846
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
3847
|
+
announce?.({
|
|
3848
|
+
log,
|
|
3849
|
+
store,
|
|
3850
|
+
paths,
|
|
3851
|
+
runId,
|
|
3852
|
+
taskId,
|
|
3853
|
+
mode: opts.mode,
|
|
3854
|
+
phase: "report",
|
|
3855
|
+
spend: () => ledger.spend(),
|
|
3856
|
+
});
|
|
3767
3857
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3768
3858
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3769
3859
|
if (opts.orchestrateContract) {
|
|
@@ -3814,9 +3904,12 @@ export class Orchestrator {
|
|
|
3814
3904
|
: externalContextPolicy === "off"
|
|
3815
3905
|
? 1
|
|
3816
3906
|
: Math.min(Math.max(input.n ?? 2, 1), 3);
|
|
3907
|
+
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3908
|
+
// read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
|
|
3909
|
+
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
3817
3910
|
let adapters;
|
|
3818
3911
|
try {
|
|
3819
|
-
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent);
|
|
3912
|
+
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
|
|
3820
3913
|
if (!opts.swarm) {
|
|
3821
3914
|
const seen = new Set();
|
|
3822
3915
|
adapters = adapters.filter((routed) => {
|
|
@@ -3828,6 +3921,7 @@ export class Orchestrator {
|
|
|
3828
3921
|
}
|
|
3829
3922
|
}
|
|
3830
3923
|
catch (err) {
|
|
3924
|
+
roHome.dispose();
|
|
3831
3925
|
const message = safeErrorMessage(err);
|
|
3832
3926
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
3833
3927
|
writeFailure(store, paths, {
|
|
@@ -3855,11 +3949,6 @@ export class Orchestrator {
|
|
|
3855
3949
|
candidates: [],
|
|
3856
3950
|
};
|
|
3857
3951
|
}
|
|
3858
|
-
// read-only routes still spawn harness processes that write native
|
|
3859
|
-
// state (plan files, session rollouts). Scope their HOME/config dirs so they
|
|
3860
|
-
// cannot escape into the operator's real ~/.claude, ~/.codex, etc. — the
|
|
3861
|
-
// adapters seed auth into these scoped dirs (§6). Disposed at run end.
|
|
3862
|
-
const roHome = new WorkspaceManager(this.execRootOf(input)).readOnlyHomeEnv();
|
|
3863
3952
|
const attempts = [];
|
|
3864
3953
|
const attemptTelemetries = [];
|
|
3865
3954
|
let fallbackOpen = false;
|
|
@@ -3880,6 +3969,7 @@ export class Orchestrator {
|
|
|
3880
3969
|
attemptId,
|
|
3881
3970
|
intent: opts.intent,
|
|
3882
3971
|
harnessId: adapter.id,
|
|
3972
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3883
3973
|
});
|
|
3884
3974
|
if (!lease.granted) {
|
|
3885
3975
|
log.emit("budget.lease.created", {
|
|
@@ -3896,11 +3986,11 @@ export class Orchestrator {
|
|
|
3896
3986
|
const explorerPrompt = (opts.swarm
|
|
3897
3987
|
? `${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.`
|
|
3898
3988
|
: prompt) + contextSection;
|
|
3899
|
-
const sessionFields = this.sessionSpecFields(input, adapter.id);
|
|
3989
|
+
const sessionFields = this.sessionSpecFields(input, adapter.id, log);
|
|
3900
3990
|
const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
|
|
3901
3991
|
if (grantResume)
|
|
3902
3992
|
resumeGranted.add(adapter.id);
|
|
3903
|
-
|
|
3993
|
+
let spec = HarnessRunSpec.parse({
|
|
3904
3994
|
session_id: newId("ses"),
|
|
3905
3995
|
intent: opts.intent,
|
|
3906
3996
|
prompt: explorerPrompt,
|
|
@@ -3911,16 +4001,9 @@ export class Orchestrator {
|
|
|
3911
4001
|
// the model honestly reported it saw nothing (the v0.13 attachment bug).
|
|
3912
4002
|
attachments: input.attachments ?? [],
|
|
3913
4003
|
auth_preference: sessionFields.auth_preference,
|
|
4004
|
+
credential_profile: sessionFields.credential_profile,
|
|
3914
4005
|
resume_session_id: grantResume ? sessionFields.resume_session_id : null,
|
|
3915
|
-
|
|
3916
|
-
tool_permission_policy: {
|
|
3917
|
-
web: knobs.webPolicy,
|
|
3918
|
-
allow: [...new Set([...contract.tool_permission_policy.allow, ...knobs.toolsAllow])],
|
|
3919
|
-
deny: [...new Set([...contract.tool_permission_policy.deny, ...knobs.toolsDeny])],
|
|
3920
|
-
},
|
|
3921
|
-
model_hint: knobs.model,
|
|
3922
|
-
effort_hint: knobs.effort,
|
|
3923
|
-
max_turns: knobs.maxTurns,
|
|
4006
|
+
...this.harnessSpecKnobs(contract, knobs, opts.intent),
|
|
3924
4007
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3925
4008
|
env: roHome.env,
|
|
3926
4009
|
// Structured output: the orchestrate PLANNER's deliverable IS the
|
|
@@ -3944,10 +4027,13 @@ export class Orchestrator {
|
|
|
3944
4027
|
if (reportInteraction)
|
|
3945
4028
|
spec.extra["interactionChannel"] = reportInteraction;
|
|
3946
4029
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3947
|
-
const
|
|
4030
|
+
const answer = new AnswerAssembly();
|
|
3948
4031
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3949
4032
|
knobs.webPolicy === "cached" ||
|
|
3950
|
-
knobs.webPolicy === "live", effectiveWeb
|
|
4033
|
+
knobs.webPolicy === "live", effectiveWeb, [],
|
|
4034
|
+
// Requested-model capture so ask/audit route receipts detect a silent
|
|
4035
|
+
// model downgrade (typed model_mismatch), not just agent runs.
|
|
4036
|
+
knobs.model);
|
|
3951
4037
|
const retryPolicy = transientRetryPolicy(this.config(input.repoRoot));
|
|
3952
4038
|
let activeSessionId = spec.session_id;
|
|
3953
4039
|
const onAbort = () => {
|
|
@@ -3960,8 +4046,10 @@ export class Orchestrator {
|
|
|
3960
4046
|
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3961
4047
|
}
|
|
3962
4048
|
let cost = 0;
|
|
4049
|
+
let costEstimated = false;
|
|
3963
4050
|
let harnessError = null;
|
|
3964
4051
|
try {
|
|
4052
|
+
const triedProfiles = new Set(); // W5.4 failover: each profile at most once
|
|
3965
4053
|
for (let nativeTry = 0; !input.signal?.aborted; nativeTry += 1) {
|
|
3966
4054
|
const runSpec = nativeTry === 0
|
|
3967
4055
|
? spec
|
|
@@ -3973,6 +4061,7 @@ export class Orchestrator {
|
|
|
3973
4061
|
});
|
|
3974
4062
|
activeSessionId = runSpec.session_id;
|
|
3975
4063
|
const transientStart = telemetry.transientFailures.length;
|
|
4064
|
+
const rateLimitStart = telemetry.rateLimits.length;
|
|
3976
4065
|
log.emit("harness.started", {
|
|
3977
4066
|
harness_id: adapter.id,
|
|
3978
4067
|
attempt_id: attemptId,
|
|
@@ -3995,23 +4084,28 @@ export class Orchestrator {
|
|
|
3995
4084
|
break;
|
|
3996
4085
|
const safeEv = redactHarnessEvent(ev);
|
|
3997
4086
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3998
|
-
// NOT observed for resume: this read-only
|
|
3999
|
-
//
|
|
4000
|
-
//
|
|
4001
|
-
// map with dead ids — the read-side mirror of the agent path's
|
|
4002
|
-
// `if (inPlaceEnvelope)` guard. Codex-review-confirmed.
|
|
4087
|
+
// NOT observed for resume: this read-only attempt is not a chat
|
|
4088
|
+
// turn. Recording its id would poison thread continuity and can
|
|
4089
|
+
// race parallel read-only sessions, regardless of storage route.
|
|
4003
4090
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
4004
4091
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
4005
4092
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
4006
4093
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
4007
4094
|
if (safeEv.plan_progress) {
|
|
4008
|
-
log.emit("plan.progress", {
|
|
4095
|
+
log.emit("plan.progress", {
|
|
4096
|
+
attempt_id: attemptId,
|
|
4097
|
+
harness_id: adapter.id,
|
|
4098
|
+
items: safeEv.plan_progress.items,
|
|
4099
|
+
});
|
|
4009
4100
|
}
|
|
4010
4101
|
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
4011
4102
|
// the loudest) — same single owner as the agent loop.
|
|
4012
4103
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
4104
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
4013
4105
|
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
4014
4106
|
cost += safeEv.usage.cost_usd;
|
|
4107
|
+
if (safeEv.usage.estimated)
|
|
4108
|
+
costEstimated = true;
|
|
4015
4109
|
log.emit("budget.observation", {
|
|
4016
4110
|
harness_id: adapter.id,
|
|
4017
4111
|
attempt_id: attemptId,
|
|
@@ -4020,11 +4114,8 @@ export class Orchestrator {
|
|
|
4020
4114
|
estimated: safeEv.usage.estimated === true,
|
|
4021
4115
|
});
|
|
4022
4116
|
}
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
safeEv.payload?.["auth_switched"] !== true) {
|
|
4026
|
-
pushUniqueText(parts, safeEv.text);
|
|
4027
|
-
}
|
|
4117
|
+
// A TYPED final message wins verbatim over joined narration.
|
|
4118
|
+
answer.observe(safeEv);
|
|
4028
4119
|
if (safeEv.type === "error")
|
|
4029
4120
|
harnessError = safeEv.error
|
|
4030
4121
|
? redactSecrets(safeEv.error)
|
|
@@ -4036,7 +4127,32 @@ export class Orchestrator {
|
|
|
4036
4127
|
}
|
|
4037
4128
|
const transient = telemetry.transientFailures.at(-1) ?? null;
|
|
4038
4129
|
const sawTransient = telemetry.transientFailures.length > transientStart;
|
|
4039
|
-
const
|
|
4130
|
+
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
4131
|
+
const reportSoFar = answer.text();
|
|
4132
|
+
// W5.4 reactive failover, READ-ONLY lane (same contract as the
|
|
4133
|
+
// candidate lane; typed limits only, never plain transients).
|
|
4134
|
+
if (harnessError && !input.signal?.aborted) {
|
|
4135
|
+
const rotated = rotateSpecOnTypedLimit({
|
|
4136
|
+
spec,
|
|
4137
|
+
harnessId: adapter.id,
|
|
4138
|
+
attemptId,
|
|
4139
|
+
policy: this.profilePolicy(input.repoRoot, adapter.id),
|
|
4140
|
+
registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
|
|
4141
|
+
snapshots: this.deps.quotaSnapshots?.() ?? [],
|
|
4142
|
+
triedProfiles,
|
|
4143
|
+
sawTypedLimit,
|
|
4144
|
+
deliverableEmpty: reportSoFar.length === 0,
|
|
4145
|
+
lastLimit: telemetry.rateLimits.at(-1) ?? null,
|
|
4146
|
+
emit: (type, payload) => log.emit(type, payload),
|
|
4147
|
+
newSessionId: () => newId("ses"),
|
|
4148
|
+
defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
|
|
4149
|
+
});
|
|
4150
|
+
if (rotated) {
|
|
4151
|
+
spec = rotated;
|
|
4152
|
+
harnessError = null;
|
|
4153
|
+
continue;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4040
4156
|
if (!harnessError ||
|
|
4041
4157
|
!sawTransient ||
|
|
4042
4158
|
reportSoFar.length > 0 ||
|
|
@@ -4063,7 +4179,7 @@ export class Orchestrator {
|
|
|
4063
4179
|
}
|
|
4064
4180
|
finally {
|
|
4065
4181
|
input.signal?.removeEventListener("abort", onAbort);
|
|
4066
|
-
ledger.settle(lease.lease?.lease_id ?? "", cost);
|
|
4182
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
|
|
4067
4183
|
}
|
|
4068
4184
|
if (harnessError && telemetry.transientFailures.length > 0) {
|
|
4069
4185
|
log.emit("route.transient.exhausted", {
|
|
@@ -4073,7 +4189,7 @@ export class Orchestrator {
|
|
|
4073
4189
|
});
|
|
4074
4190
|
}
|
|
4075
4191
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
|
|
4076
|
-
const report = redactSecrets(
|
|
4192
|
+
const report = redactSecrets(answer.text());
|
|
4077
4193
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
4078
4194
|
const webBlocked = webUnsatisfied(telemetry);
|
|
4079
4195
|
const deliverablePresent = report.length > 0;
|
|
@@ -4213,8 +4329,8 @@ export class Orchestrator {
|
|
|
4213
4329
|
}
|
|
4214
4330
|
}
|
|
4215
4331
|
finally {
|
|
4216
|
-
// All read-only attempts done (or threw) — reclaim
|
|
4217
|
-
//
|
|
4332
|
+
// All read-only attempts done (or threw) — reclaim scoped scratch and
|
|
4333
|
+
// injected API-route state. Vendor-owned native credentials were not copied.
|
|
4218
4334
|
roHome.dispose();
|
|
4219
4335
|
}
|
|
4220
4336
|
if (input.signal?.aborted) {
|
|
@@ -4222,7 +4338,7 @@ export class Orchestrator {
|
|
|
4222
4338
|
attemptId: a.attemptId,
|
|
4223
4339
|
harnessId: a.harnessId,
|
|
4224
4340
|
status: a.status,
|
|
4225
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend());
|
|
4341
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
4226
4342
|
}
|
|
4227
4343
|
const succeededReadonly = attempts.filter((a) => a.status === "success");
|
|
4228
4344
|
if (!opts.swarm && succeededReadonly.length === 0) {
|
|
@@ -4377,6 +4493,18 @@ export class Orchestrator {
|
|
|
4377
4493
|
].join("\n")
|
|
4378
4494
|
: (succeeded[0]?.report ?? "(no output)");
|
|
4379
4495
|
store.writeText(join(paths.finalDir, opts.artifactName), `# ${opts.title}\n\n${report}\n`);
|
|
4496
|
+
// ask is the only read-only strategy that can carry a structured-output
|
|
4497
|
+
// contract (the boundary refuses the rest); validate the RAW answer text,
|
|
4498
|
+
// not the titled artifact wrapper.
|
|
4499
|
+
if (opts.mode === "ask" && contract.output_schema) {
|
|
4500
|
+
finalizeStructuredOutput({
|
|
4501
|
+
store,
|
|
4502
|
+
finalDir: paths.finalDir,
|
|
4503
|
+
log,
|
|
4504
|
+
schema: contract.output_schema,
|
|
4505
|
+
answerText: succeeded[0]?.report ?? "",
|
|
4506
|
+
});
|
|
4507
|
+
}
|
|
4380
4508
|
// orchestrate: the planner's plan is a TYPED artifact, not just prose. Extract
|
|
4381
4509
|
// the required fenced JSON block, validate it against the tool belt, and
|
|
4382
4510
|
// persist final/orchestration.yaml; a missing/invalid block is disclosed in
|
|
@@ -4436,6 +4564,10 @@ export class Orchestrator {
|
|
|
4436
4564
|
// terminal outcome (success / blocked / failed) becomes the run's terminal.
|
|
4437
4565
|
const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
|
|
4438
4566
|
let terminal = "success";
|
|
4567
|
+
let orchestrateReadOnly = true;
|
|
4568
|
+
let orchestrateReceiptRefs = [];
|
|
4569
|
+
if (ledger.terminal() !== null)
|
|
4570
|
+
terminal = ledger.terminal();
|
|
4439
4571
|
// orchestrate's contract output IS the typed plan. If the planner failed to
|
|
4440
4572
|
// produce a valid one, the run is NOT a clean success — disclose it honestly
|
|
4441
4573
|
// (the markdown plan stays as a diagnostic artifact) rather than reporting
|
|
@@ -4446,11 +4578,22 @@ export class Orchestrator {
|
|
|
4446
4578
|
// Thread the GENERATED runId onto input so the executor's answer_question
|
|
4447
4579
|
// step keys the interaction registry by this orchestrate run's id (callers
|
|
4448
4580
|
// often invoke run() without a preassigned runId).
|
|
4449
|
-
const
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4581
|
+
const executionInput = { ...input, runId };
|
|
4582
|
+
const exec = await executeOrchestratePlan({
|
|
4583
|
+
plan: orchestratePlan,
|
|
4584
|
+
autonomy,
|
|
4585
|
+
maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
|
|
4586
|
+
signal: input.signal,
|
|
4587
|
+
store,
|
|
4588
|
+
paths,
|
|
4589
|
+
log,
|
|
4590
|
+
ledger,
|
|
4591
|
+
executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
|
|
4592
|
+
executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
|
|
4593
|
+
});
|
|
4453
4594
|
terminal = exec.terminal;
|
|
4595
|
+
orchestrateReadOnly = exec.readOnly;
|
|
4596
|
+
orchestrateReceiptRefs = exec.receiptRefs;
|
|
4454
4597
|
typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
|
|
4455
4598
|
}
|
|
4456
4599
|
const harnessLabel = attempts
|
|
@@ -4462,69 +4605,32 @@ export class Orchestrator {
|
|
|
4462
4605
|
kind: "report",
|
|
4463
4606
|
source_task_id: taskId,
|
|
4464
4607
|
producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
|
|
4465
|
-
files:
|
|
4608
|
+
files: Object.fromEntries([
|
|
4609
|
+
[opts.artifactName, join(paths.finalDir, opts.artifactName)],
|
|
4610
|
+
...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
|
|
4611
|
+
]),
|
|
4466
4612
|
meta: {
|
|
4467
4613
|
harnesses: attempts.map((a) => a.harnessId),
|
|
4468
4614
|
mode: opts.mode,
|
|
4469
4615
|
intent: opts.intent,
|
|
4470
|
-
read_only:
|
|
4616
|
+
read_only: orchestrateReadOnly,
|
|
4471
4617
|
},
|
|
4472
4618
|
});
|
|
4473
4619
|
log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
category: "policy",
|
|
4478
|
-
safeMessage: "orchestrate executor stopped at a risky step (apply) under auto_safe; awaiting a human decision",
|
|
4479
|
-
runDir: paths.root,
|
|
4480
|
-
nextActions: [
|
|
4481
|
-
"Review the proposed apply",
|
|
4482
|
-
"Approve via the run decision endpoint",
|
|
4483
|
-
"Re-run with auto_full to apply automatically",
|
|
4484
|
-
],
|
|
4485
|
-
});
|
|
4620
|
+
const orchestrateFailure = orchestrateFailureFor(terminal);
|
|
4621
|
+
if (terminal === "blocked" && orchestrateFailure) {
|
|
4622
|
+
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4486
4623
|
log.emit("run.blocked", {
|
|
4487
4624
|
status: terminal,
|
|
4488
|
-
phase:
|
|
4625
|
+
phase: orchestrateFailure.phase,
|
|
4489
4626
|
failure_ref: "final/failure.yaml",
|
|
4490
4627
|
});
|
|
4491
4628
|
}
|
|
4492
|
-
else if (
|
|
4493
|
-
writeFailure(store, paths, {
|
|
4494
|
-
phase: "executor",
|
|
4495
|
-
category: "internal",
|
|
4496
|
-
safeMessage: "orchestrate executor failed: a safe step errored fatally (see final/orchestration_progress.yaml)",
|
|
4497
|
-
runDir: paths.root,
|
|
4498
|
-
nextActions: [
|
|
4499
|
-
"Inspect final/orchestration_progress.yaml",
|
|
4500
|
-
"Open the failed sub-run",
|
|
4501
|
-
"Re-run after the cause is fixed",
|
|
4502
|
-
],
|
|
4503
|
-
});
|
|
4629
|
+
else if (orchestrateFailure) {
|
|
4630
|
+
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4504
4631
|
log.emit("run.failed", {
|
|
4505
4632
|
status: terminal,
|
|
4506
|
-
phase:
|
|
4507
|
-
failure_ref: "final/failure.yaml",
|
|
4508
|
-
});
|
|
4509
|
-
}
|
|
4510
|
-
else if (terminal === "not_converged") {
|
|
4511
|
-
// Orchestrate's typed-plan contract failed (the planner produced no valid
|
|
4512
|
-
// plan): a failure-shaped terminal with artifacts, never run.completed —
|
|
4513
|
-
// jobs.json and events.jsonl must agree the run did not converge.
|
|
4514
|
-
writeFailure(store, paths, {
|
|
4515
|
-
phase: "plan",
|
|
4516
|
-
category: "harness_error",
|
|
4517
|
-
safeMessage: "orchestrate planner produced no valid typed plan (see final/orchestration_parse_error.md); the markdown report is diagnostic only",
|
|
4518
|
-
runDir: paths.root,
|
|
4519
|
-
nextActions: [
|
|
4520
|
-
"Inspect final/orchestration_parse_error.md",
|
|
4521
|
-
"Re-run orchestrate",
|
|
4522
|
-
"Check the planner harness doctor status",
|
|
4523
|
-
],
|
|
4524
|
-
});
|
|
4525
|
-
log.emit("run.failed", {
|
|
4526
|
-
status: terminal,
|
|
4527
|
-
phase: "plan",
|
|
4633
|
+
phase: orchestrateFailure.phase,
|
|
4528
4634
|
failure_ref: "final/failure.yaml",
|
|
4529
4635
|
});
|
|
4530
4636
|
}
|
|
@@ -4534,26 +4640,6 @@ export class Orchestrator {
|
|
|
4534
4640
|
// mistake an operator abort for a clean report.
|
|
4535
4641
|
log.emit("run.failed", { status: terminal });
|
|
4536
4642
|
}
|
|
4537
|
-
else if (terminal === "exhausted") {
|
|
4538
|
-
// A budget-truncated plan is failure-shaped: skipped steps mean the
|
|
4539
|
-
// goal was NOT met — `claudexor follow` and jobs.json must not read a
|
|
4540
|
-
// cut-short auto_full run as a clean success (exit 0).
|
|
4541
|
-
writeFailure(store, paths, {
|
|
4542
|
-
phase: "executor",
|
|
4543
|
-
category: "budget",
|
|
4544
|
-
safeMessage: "orchestrate executor exhausted its budget before completing the plan (see final/orchestration_progress.yaml for the skipped steps)",
|
|
4545
|
-
runDir: paths.root,
|
|
4546
|
-
nextActions: [
|
|
4547
|
-
"Inspect final/orchestration_progress.yaml",
|
|
4548
|
-
"Re-run with a higher --max-usd / --max-tool-calls",
|
|
4549
|
-
],
|
|
4550
|
-
});
|
|
4551
|
-
log.emit("run.failed", {
|
|
4552
|
-
status: terminal,
|
|
4553
|
-
phase: "executor",
|
|
4554
|
-
failure_ref: "final/failure.yaml",
|
|
4555
|
-
});
|
|
4556
|
-
}
|
|
4557
4643
|
else {
|
|
4558
4644
|
log.emit("run.completed", { status: terminal });
|
|
4559
4645
|
}
|
|
@@ -4573,190 +4659,20 @@ export class Orchestrator {
|
|
|
4573
4659
|
})),
|
|
4574
4660
|
};
|
|
4575
4661
|
}
|
|
4576
|
-
/**
|
|
4577
|
-
* Execute a typed orchestration plan under auto_safe / auto_full. Runs the
|
|
4578
|
-
* tool_calls IN ORDER, classifying each via toolRisk (FAIL-CLOSED). Persists
|
|
4579
|
-
* final/orchestration_progress.yaml and emits progress events. Returns the
|
|
4580
|
-
* executor's terminal status (success / blocked / failed) and a short note.
|
|
4581
|
-
*
|
|
4582
|
-
* SAFETY INVARIANTS (see CLAUDEXOR doctrine):
|
|
4583
|
-
* 1. A SAFE step NEVER mutates the live tree: start_run/race run as isolated
|
|
4584
|
-
* ENVELOPE sub-runs (inPlace=false, asserted), review/status/answer are reads.
|
|
4585
|
-
* 2. Risk is fail-closed (toolRisk): any unknown/undeclared tool is risky.
|
|
4586
|
-
* 3. auto_safe STOPS at the first risky step (apply) without executing it; the
|
|
4587
|
-
* run ends `blocked` awaiting a human decision.
|
|
4588
|
-
* 4. answer_question / status / review are read-only w.r.t. the tree.
|
|
4589
|
-
*/
|
|
4590
|
-
async executeOrchestratePlan(input, plan, autonomy, contract, store, paths, log, brainSpentUsd = 0) {
|
|
4591
|
-
const maxToolCalls = contract?.budget.max_tool_calls ?? null;
|
|
4592
|
-
// The RESOLVED aggregate cap (input -> deps -> operator config) rides the
|
|
4593
|
-
// contract; raw input.maxUsd alone would ignore the config default.
|
|
4594
|
-
const aggregateMaxUsd = contract?.budget.max_usd ?? input.maxUsd ?? null;
|
|
4595
|
-
const steps = plan.tool_calls.map((call, index) => ({
|
|
4596
|
-
index,
|
|
4597
|
-
tool: call.tool,
|
|
4598
|
-
risk: toolRisk(call.tool),
|
|
4599
|
-
status: "pending",
|
|
4600
|
-
run_id: null,
|
|
4601
|
-
detail: null,
|
|
4602
|
-
}));
|
|
4603
|
-
let stoppedReason = null;
|
|
4604
|
-
let terminal = "success";
|
|
4605
|
-
const persist = () => {
|
|
4606
|
-
const progress = { steps, autonomy, stopped_reason: stoppedReason };
|
|
4607
|
-
store.writeYaml(join(paths.finalDir, "orchestration_progress.yaml"), progress);
|
|
4608
|
-
};
|
|
4609
|
-
persist();
|
|
4610
|
-
log.emit("output.ready", { kind: "report", path: "final/orchestration_progress.yaml" });
|
|
4611
|
-
let executed = 0;
|
|
4612
|
-
// Aggregate budget: the planner's own spend plus every sub-run share
|
|
4613
|
-
// ONE cap. Each sequential sub-run gets the REMAINING headroom (cap minus
|
|
4614
|
-
// settled spend so far) — never the full cap again per step.
|
|
4615
|
-
let aggregateSpentUsd = brainSpentUsd;
|
|
4616
|
-
for (let i = 0; i < plan.tool_calls.length; i++) {
|
|
4617
|
-
const call = plan.tool_calls[i];
|
|
4618
|
-
const step = steps[i];
|
|
4619
|
-
// Honor input.signal abort: stop, mark remaining steps skipped.
|
|
4620
|
-
if (input.signal?.aborted) {
|
|
4621
|
-
step.status = "skipped";
|
|
4622
|
-
step.detail = "run cancelled before this step";
|
|
4623
|
-
stoppedReason = "cancelled";
|
|
4624
|
-
terminal = "cancelled";
|
|
4625
|
-
persist();
|
|
4626
|
-
break;
|
|
4627
|
-
}
|
|
4628
|
-
// Aggregate USD cap: stop before a step that has no headroom left.
|
|
4629
|
-
if (aggregateMaxUsd !== null && aggregateSpentUsd >= aggregateMaxUsd) {
|
|
4630
|
-
step.status = "skipped";
|
|
4631
|
-
step.detail = `aggregate budget exhausted (${aggregateSpentUsd.toFixed(2)} of ${aggregateMaxUsd} USD spent)`;
|
|
4632
|
-
stoppedReason = `aggregate budget exhausted after ${executed} step(s)`;
|
|
4633
|
-
terminal = "exhausted";
|
|
4634
|
-
persist();
|
|
4635
|
-
break;
|
|
4636
|
-
}
|
|
4637
|
-
// Honor the budget cap on tool calls (count attempted executions).
|
|
4638
|
-
// Same honesty as the USD cap: a plan cut short by a budget knob ends
|
|
4639
|
-
// `exhausted`, never a quiet "success" whose note miscounts the steps.
|
|
4640
|
-
if (maxToolCalls !== null && executed >= maxToolCalls) {
|
|
4641
|
-
step.status = "skipped";
|
|
4642
|
-
step.detail = `budget max_tool_calls=${maxToolCalls} reached`;
|
|
4643
|
-
stoppedReason = `budget max_tool_calls=${maxToolCalls} reached after ${executed} step(s)`;
|
|
4644
|
-
terminal = "exhausted";
|
|
4645
|
-
persist();
|
|
4646
|
-
break;
|
|
4647
|
-
}
|
|
4648
|
-
const risk = toolRisk(call.tool);
|
|
4649
|
-
// RISKY step (apply, or any fail-closed-risky tool).
|
|
4650
|
-
if (risk === "risky") {
|
|
4651
|
-
if (autonomy === "auto_safe") {
|
|
4652
|
-
// STOP: do not execute the risky step; block awaiting a human decision.
|
|
4653
|
-
step.status = "blocked";
|
|
4654
|
-
step.detail = "risky step requires human approval (auto_safe)";
|
|
4655
|
-
stoppedReason = `blocked at risky step #${i} (${call.tool}) under auto_safe`;
|
|
4656
|
-
terminal = "blocked";
|
|
4657
|
-
log.emit("orchestrate.step.blocked", { index: i, tool: call.tool, autonomy });
|
|
4658
|
-
persist();
|
|
4659
|
-
break;
|
|
4660
|
-
}
|
|
4661
|
-
// auto_full: execute the risky step (apply) via the single gate.
|
|
4662
|
-
step.status = "running";
|
|
4663
|
-
persist();
|
|
4664
|
-
executed++;
|
|
4665
|
-
try {
|
|
4666
|
-
const r = await this.executeApplyStep(input, call);
|
|
4667
|
-
step.status = r.ok ? "done" : "failed";
|
|
4668
|
-
step.run_id = r.runId;
|
|
4669
|
-
step.detail = r.detail;
|
|
4670
|
-
log.emit("orchestrate.step.done", {
|
|
4671
|
-
index: i,
|
|
4672
|
-
tool: call.tool,
|
|
4673
|
-
ok: r.ok,
|
|
4674
|
-
run_id: r.runId,
|
|
4675
|
-
});
|
|
4676
|
-
if (!r.ok) {
|
|
4677
|
-
terminal = "failed";
|
|
4678
|
-
stoppedReason = `apply step #${i} failed: ${r.detail}`;
|
|
4679
|
-
persist();
|
|
4680
|
-
break;
|
|
4681
|
-
}
|
|
4682
|
-
}
|
|
4683
|
-
catch (err) {
|
|
4684
|
-
step.status = "failed";
|
|
4685
|
-
step.detail = safeErrorMessage(err);
|
|
4686
|
-
terminal = "failed";
|
|
4687
|
-
stoppedReason = `apply step #${i} threw: ${safeErrorMessage(err)}`;
|
|
4688
|
-
persist();
|
|
4689
|
-
break;
|
|
4690
|
-
}
|
|
4691
|
-
persist();
|
|
4692
|
-
continue;
|
|
4693
|
-
}
|
|
4694
|
-
// SAFE step: execute as an isolated sub-run / pure read.
|
|
4695
|
-
step.status = "running";
|
|
4696
|
-
persist();
|
|
4697
|
-
executed++;
|
|
4698
|
-
try {
|
|
4699
|
-
const remainingUsd = aggregateMaxUsd === null ? null : Math.max(0, aggregateMaxUsd - aggregateSpentUsd);
|
|
4700
|
-
const r = await this.executeSafeStep(input, call, log, store, paths, remainingUsd);
|
|
4701
|
-
aggregateSpentUsd += r.spendUsd ?? 0;
|
|
4702
|
-
step.status = r.status;
|
|
4703
|
-
step.run_id = r.runId;
|
|
4704
|
-
step.detail = r.detail;
|
|
4705
|
-
log.emit("orchestrate.step.done", {
|
|
4706
|
-
index: i,
|
|
4707
|
-
tool: call.tool,
|
|
4708
|
-
status: r.status,
|
|
4709
|
-
run_id: r.runId,
|
|
4710
|
-
});
|
|
4711
|
-
if (r.status === "failed") {
|
|
4712
|
-
terminal = "failed";
|
|
4713
|
-
stoppedReason = `safe step #${i} (${call.tool}) errored: ${r.detail}`;
|
|
4714
|
-
persist();
|
|
4715
|
-
break;
|
|
4716
|
-
}
|
|
4717
|
-
}
|
|
4718
|
-
catch (err) {
|
|
4719
|
-
step.status = "failed";
|
|
4720
|
-
step.detail = safeErrorMessage(err);
|
|
4721
|
-
terminal = "failed";
|
|
4722
|
-
stoppedReason = `safe step #${i} (${call.tool}) threw: ${safeErrorMessage(err)}`;
|
|
4723
|
-
persist();
|
|
4724
|
-
break;
|
|
4725
|
-
}
|
|
4726
|
-
persist();
|
|
4727
|
-
}
|
|
4728
|
-
// A FINAL step can overspend its remaining headroom (spend is charged
|
|
4729
|
-
// after the step; no later pre-step check exists to trip). An overshot
|
|
4730
|
-
// cap must not read "success — all steps done".
|
|
4731
|
-
if (terminal === "success" && aggregateMaxUsd !== null && aggregateSpentUsd > aggregateMaxUsd) {
|
|
4732
|
-
terminal = "exhausted";
|
|
4733
|
-
stoppedReason = `aggregate budget overshot on the final step (${aggregateSpentUsd.toFixed(2)} of ${aggregateMaxUsd} USD)`;
|
|
4734
|
-
}
|
|
4735
|
-
persist();
|
|
4736
|
-
const done = steps.filter((s) => s.status === "done").length;
|
|
4737
|
-
const note = terminal === "blocked"
|
|
4738
|
-
? `blocked at a risky step (${done}/${steps.length} safe steps done)`
|
|
4739
|
-
: terminal === "failed"
|
|
4740
|
-
? `failed (${done}/${steps.length} steps done; ${stoppedReason ?? "see progress"})`
|
|
4741
|
-
: terminal === "cancelled"
|
|
4742
|
-
? `cancelled (${done}/${steps.length} steps done)`
|
|
4743
|
-
: terminal === "exhausted"
|
|
4744
|
-
? `budget exhausted (${done}/${steps.length} steps done; ${stoppedReason ?? "see progress"})`
|
|
4745
|
-
: `all ${done}/${steps.length} steps done`;
|
|
4746
|
-
return { terminal, note };
|
|
4747
|
-
}
|
|
4748
4662
|
/**
|
|
4749
4663
|
* Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
|
|
4750
4664
|
* (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
|
|
4751
4665
|
* answer delivery that never mutate the live tree.
|
|
4752
4666
|
*/
|
|
4753
|
-
async executeSafeStep(input, call, log, store, paths,
|
|
4667
|
+
async executeSafeStep(input, call, log, store, paths, ledger) {
|
|
4754
4668
|
switch (call.tool) {
|
|
4755
4669
|
case "start_run":
|
|
4756
4670
|
case "race": {
|
|
4757
4671
|
// Isolated envelope sub-run (construction owned by runSupport);
|
|
4758
4672
|
// recursion guard via orchestrateDepth+1, may NOT orchestrate.
|
|
4759
|
-
const subInput = {
|
|
4673
|
+
const subInput = {
|
|
4674
|
+
...buildEnvelopeSubInput(input, call, ledger),
|
|
4675
|
+
};
|
|
4760
4676
|
// SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
|
|
4761
4677
|
// isolated envelope — never a live in-place turn.
|
|
4762
4678
|
assertEnvelopeSubRun(subInput);
|
|
@@ -4768,6 +4684,9 @@ export class Orchestrator {
|
|
|
4768
4684
|
const res = await this.run(subInput);
|
|
4769
4685
|
return {
|
|
4770
4686
|
status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
|
|
4687
|
+
terminalStatus: res.status,
|
|
4688
|
+
terminalSource: "subrun",
|
|
4689
|
+
evidenceRefs: [`run:${res.runId}`],
|
|
4771
4690
|
runId: res.runId,
|
|
4772
4691
|
spendUsd: res.spendUsd ?? null,
|
|
4773
4692
|
detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
|
|
@@ -4778,8 +4697,11 @@ export class Orchestrator {
|
|
|
4778
4697
|
const read = readRunStatus(input.repoRoot, call.run_id);
|
|
4779
4698
|
return {
|
|
4780
4699
|
status: read ? "done" : "skipped",
|
|
4700
|
+
terminalStatus: read?.status ?? null,
|
|
4701
|
+
terminalSource: "subrun",
|
|
4702
|
+
evidenceRefs: read?.evidenceRefs ?? [],
|
|
4781
4703
|
runId: call.run_id,
|
|
4782
|
-
detail: read ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4704
|
+
detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4783
4705
|
};
|
|
4784
4706
|
}
|
|
4785
4707
|
case "review": {
|
|
@@ -4791,15 +4713,21 @@ export class Orchestrator {
|
|
|
4791
4713
|
if (diff === null)
|
|
4792
4714
|
return {
|
|
4793
4715
|
status: "skipped",
|
|
4716
|
+
terminalStatus: null,
|
|
4717
|
+
terminalSource: "review",
|
|
4718
|
+
evidenceRefs: [],
|
|
4794
4719
|
runId: call.run_id,
|
|
4795
4720
|
detail: `run ${call.run_id} has no patch.diff to review`,
|
|
4796
4721
|
};
|
|
4797
4722
|
// Aggregate honesty: reviewer panels spend real money on
|
|
4798
4723
|
// API-keyed routes and the spend is charged AFTER the fact — with no
|
|
4799
4724
|
// remaining headroom the review must not start at all.
|
|
4800
|
-
if (
|
|
4725
|
+
if (ledger.terminal() !== null) {
|
|
4801
4726
|
return {
|
|
4802
4727
|
status: "skipped",
|
|
4728
|
+
terminalStatus: null,
|
|
4729
|
+
terminalSource: "review",
|
|
4730
|
+
evidenceRefs: [],
|
|
4803
4731
|
runId: call.run_id,
|
|
4804
4732
|
detail: "aggregate budget exhausted before the review step",
|
|
4805
4733
|
};
|
|
@@ -4808,9 +4736,28 @@ export class Orchestrator {
|
|
|
4808
4736
|
if (reviewers.length === 0)
|
|
4809
4737
|
return {
|
|
4810
4738
|
status: "skipped",
|
|
4739
|
+
terminalStatus: null,
|
|
4740
|
+
terminalSource: "review",
|
|
4741
|
+
evidenceRefs: [],
|
|
4811
4742
|
runId: call.run_id,
|
|
4812
4743
|
detail: "no doctor-OK reviewers available",
|
|
4813
4744
|
};
|
|
4745
|
+
const reviewLease = ledger.reserve({
|
|
4746
|
+
taskId: input.taskId ?? "orchestrate",
|
|
4747
|
+
attemptId: `review-${call.run_id}`,
|
|
4748
|
+
intent: "review",
|
|
4749
|
+
harnessId: "review-panel",
|
|
4750
|
+
cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
|
|
4751
|
+
});
|
|
4752
|
+
if (!reviewLease.granted)
|
|
4753
|
+
return {
|
|
4754
|
+
status: "skipped",
|
|
4755
|
+
terminalStatus: ledger.terminal(),
|
|
4756
|
+
terminalSource: "review",
|
|
4757
|
+
evidenceRefs: [],
|
|
4758
|
+
runId: call.run_id,
|
|
4759
|
+
detail: reviewLease.reason ?? "root paid budget refused the review step",
|
|
4760
|
+
};
|
|
4814
4761
|
const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
|
|
4815
4762
|
writeEvidencePacket(evidenceDir, {
|
|
4816
4763
|
userIntent: redactSecrets(input.prompt),
|
|
@@ -4829,7 +4776,11 @@ export class Orchestrator {
|
|
|
4829
4776
|
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
4830
4777
|
signal: input.signal,
|
|
4831
4778
|
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
4779
|
+
}).catch((error) => {
|
|
4780
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
|
|
4781
|
+
throw error;
|
|
4832
4782
|
});
|
|
4783
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`orchestrate:review:${call.run_id}`], result.reviewUnknownUsd));
|
|
4833
4784
|
const revalidated = await revalidateFindings(result.findings, {
|
|
4834
4785
|
candidateRoot: input.repoRoot,
|
|
4835
4786
|
evidenceDir,
|
|
@@ -4844,16 +4795,25 @@ export class Orchestrator {
|
|
|
4844
4795
|
const blockers = revalidated.filter((f) => isBlocking(f)).length;
|
|
4845
4796
|
return {
|
|
4846
4797
|
status: "done",
|
|
4798
|
+
terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
|
|
4799
|
+
terminalSource: "review",
|
|
4800
|
+
evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
|
|
4847
4801
|
runId: call.run_id,
|
|
4848
4802
|
detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
|
|
4849
4803
|
// Reviewer panels can spend real money on API-keyed routes; the
|
|
4850
4804
|
// aggregate cap must charge it like any other step.
|
|
4851
|
-
spendUsd: result.
|
|
4805
|
+
spendUsd: result.reviewCashUsd,
|
|
4852
4806
|
};
|
|
4853
4807
|
}
|
|
4854
4808
|
case "answer_question": {
|
|
4855
4809
|
// Delivery + registry-keying rationale owned by runSupport.
|
|
4856
|
-
|
|
4810
|
+
const answer = await deliverPlanAnswer(input, call);
|
|
4811
|
+
return {
|
|
4812
|
+
...answer,
|
|
4813
|
+
terminalStatus: answer.status === "done" ? "success" : null,
|
|
4814
|
+
terminalSource: "executor",
|
|
4815
|
+
evidenceRefs: [],
|
|
4816
|
+
};
|
|
4857
4817
|
}
|
|
4858
4818
|
default: {
|
|
4859
4819
|
// FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
|
|
@@ -4862,13 +4822,8 @@ export class Orchestrator {
|
|
|
4862
4822
|
}
|
|
4863
4823
|
}
|
|
4864
4824
|
}
|
|
4865
|
-
/**
|
|
4866
|
-
|
|
4867
|
-
* apply gate (`validateApplyGate`) + `deliver` — the same path
|
|
4868
|
-
* accept_clean_patch uses. Reads the referenced run's patch + work_product +
|
|
4869
|
-
* decision artifacts; refuses unless the gate passes.
|
|
4870
|
-
*/
|
|
4871
|
-
async executeApplyStep(input, call) {
|
|
4825
|
+
/** Execute an auto_full apply through the shared fresh-verification gate. */
|
|
4826
|
+
async executeApplyStep(input, call, log) {
|
|
4872
4827
|
const store = new ArtifactStore(input.repoRoot);
|
|
4873
4828
|
const sub = store.runPaths(call.run_id);
|
|
4874
4829
|
const patchPath = join(sub.finalDir, "patch.diff");
|
|
@@ -4877,25 +4832,29 @@ export class Orchestrator {
|
|
|
4877
4832
|
return {
|
|
4878
4833
|
ok: false,
|
|
4879
4834
|
runId: call.run_id,
|
|
4880
|
-
detail: `run ${call.run_id} has no patch.diff
|
|
4835
|
+
detail: `run ${call.run_id} has no patch.diff`,
|
|
4836
|
+
receipt: null,
|
|
4881
4837
|
};
|
|
4882
4838
|
if (containsSecretLikeToken(patchText))
|
|
4883
4839
|
return {
|
|
4884
4840
|
ok: false,
|
|
4885
4841
|
runId: call.run_id,
|
|
4886
4842
|
detail: "patch contains a secret-like token; refusing apply",
|
|
4843
|
+
receipt: null,
|
|
4887
4844
|
};
|
|
4888
4845
|
const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
|
|
4889
4846
|
const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
|
|
4847
|
+
const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
|
|
4890
4848
|
const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
|
|
4891
4849
|
const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4850
|
+
if (!taskContract.success)
|
|
4851
|
+
return {
|
|
4852
|
+
ok: false,
|
|
4853
|
+
runId: call.run_id,
|
|
4854
|
+
detail: "fresh verification contract is missing",
|
|
4855
|
+
receipt: null,
|
|
4856
|
+
};
|
|
4857
|
+
const applyGateInput = {
|
|
4899
4858
|
state: null,
|
|
4900
4859
|
decision: parsedDecision?.success ? parsedDecision.data : null,
|
|
4901
4860
|
workProduct: parsedWp?.success ? parsedWp.data : null,
|
|
@@ -4903,16 +4862,23 @@ export class Orchestrator {
|
|
|
4903
4862
|
originalRepoRoot: input.repoRoot,
|
|
4904
4863
|
targetRepoRoot: input.repoRoot,
|
|
4905
4864
|
operatorDecision: null,
|
|
4906
|
-
}
|
|
4865
|
+
};
|
|
4866
|
+
const gateError = validateApplyGate(applyGateInput);
|
|
4907
4867
|
if (gateError)
|
|
4908
|
-
return {
|
|
4909
|
-
|
|
4868
|
+
return {
|
|
4869
|
+
ok: false,
|
|
4870
|
+
runId: call.run_id,
|
|
4871
|
+
detail: `apply gate refused: ${gateError}`,
|
|
4872
|
+
receipt: null,
|
|
4873
|
+
};
|
|
4874
|
+
const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
|
|
4910
4875
|
return {
|
|
4911
4876
|
ok: delivered.applied,
|
|
4912
4877
|
runId: call.run_id,
|
|
4913
4878
|
detail: delivered.applied
|
|
4914
4879
|
? `applied (${call.mode})`
|
|
4915
4880
|
: `deliver failed: ${delivered.detail ?? "unknown"}`,
|
|
4881
|
+
receipt: delivered,
|
|
4916
4882
|
};
|
|
4917
4883
|
}
|
|
4918
4884
|
}
|