@claudexor/orchestrator 1.0.1 → 2.1.2
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 +38 -2
- package/dist/attemptTelemetry.d.ts.map +1 -1
- package/dist/attemptTelemetry.js +75 -2
- package/dist/attemptTelemetry.js.map +1 -1
- 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 +152 -0
- package/dist/credential-profiles.d.ts.map +1 -0
- package/dist/credential-profiles.js +270 -0
- package/dist/credential-profiles.js.map +1 -0
- package/dist/diffReview.d.ts +9 -1
- package/dist/diffReview.d.ts.map +1 -1
- package/dist/diffReview.js +114 -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 +100 -83
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +1008 -993
- 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,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, } from "./credential-profiles.js";
|
|
3
|
+
import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
|
|
4
|
+
import { capabilityIntents } from "@claudexor/gateway";
|
|
5
|
+
import { policyFindings } from "./policyFindings.js";
|
|
2
6
|
import { join } from "node:path";
|
|
3
|
-
import { HarnessRunSpec, OrchestrateContract as OrchestrateContractSchema,
|
|
7
|
+
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
8
|
import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
|
|
5
9
|
import { specPackToTaskContract } from "@claudexor/interview";
|
|
6
|
-
import { HarnessUnavailableError, summarizeDiffPaths as diffStats,
|
|
10
|
+
import { AnswerAssembly, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
|
|
7
11
|
import { assertRouteModelsAllowed } from "./modelGovernance.js";
|
|
12
|
+
import { RequestRequirementsResolver } from "./requestRequirements.js";
|
|
8
13
|
import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
14
|
+
import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
|
|
15
|
+
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";
|
|
16
|
+
import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
|
|
17
|
+
import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
|
|
11
18
|
import { buildOrchestratePlannerPrompt, extractOrchestratePlan } from "./orchestratePlanner.js";
|
|
12
|
-
import {
|
|
19
|
+
import { orchestrateFailureFor, readRunStatus } from "./outcomeReducer.js";
|
|
20
|
+
import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
|
|
13
21
|
import { runDiffReview } from "./diffReview.js";
|
|
14
|
-
import {
|
|
22
|
+
import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
|
|
15
23
|
import { interactionChannelFor } from "./interaction.js";
|
|
24
|
+
import { gateSpecsFromContract, renderTestsEvidence, resolveContractGates, } from "./contract-gates.js";
|
|
16
25
|
import { ArtifactStore } from "@claudexor/artifact-store";
|
|
17
26
|
import { EventLog } from "@claudexor/event-log";
|
|
18
|
-
import { assertMandatoryContext, buildContextPack,
|
|
19
|
-
import { WorkspaceManager,
|
|
20
|
-
import {
|
|
27
|
+
import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
|
|
28
|
+
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
29
|
+
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
|
|
21
30
|
import { HarnessGateway } from "@claudexor/gateway";
|
|
22
31
|
import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
|
|
23
32
|
import { arbitrate } from "@claudexor/arbitration";
|
|
24
33
|
import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
|
|
25
|
-
import { BudgetLedger, loadHarnessMetrics, promptFingerprint,
|
|
26
|
-
import { classifyRisk, DEFAULT_REQUIRE_HUMAN_PATHS, requireHuman, reviewDepthForRisk, } from "@claudexor/policy";
|
|
34
|
+
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, usageCostSettlement, rankHarnesses, } from "@claudexor/budget";
|
|
27
35
|
import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
28
36
|
const LABELS = "ABCDEFGHIJ".split("");
|
|
29
37
|
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
30
|
-
const REVIEW_EVIDENCE_DIRNAME = ".claudexor-review-evidence";
|
|
31
38
|
/** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
|
|
32
39
|
const MAX_PARALLEL_CANDIDATES = 4;
|
|
33
40
|
/** Default wait for one interactive answer before a benign decline. */
|
|
@@ -66,6 +73,11 @@ async function runBounded(items, limit, work) {
|
|
|
66
73
|
export class Orchestrator {
|
|
67
74
|
deps;
|
|
68
75
|
gateway;
|
|
76
|
+
requestRequirements = new RequestRequirementsResolver();
|
|
77
|
+
/** Per-attempt cap on forwarded live delta chunks (W-C4 flood guard, sol
|
|
78
|
+
* #10): past this the deltas are dropped and the cutoff is disclosed once;
|
|
79
|
+
* the complete message always still lands. */
|
|
80
|
+
static MAX_DELTAS_PER_ATTEMPT = 4000;
|
|
69
81
|
constructor(deps) {
|
|
70
82
|
this.deps = deps;
|
|
71
83
|
this.gateway = new HarnessGateway(deps.registry);
|
|
@@ -83,9 +95,17 @@ export class Orchestrator {
|
|
|
83
95
|
const resolved = this.resolveRunInput(input);
|
|
84
96
|
// INV-062 at the ENGINE boundary: every surface fences prompts already,
|
|
85
97
|
// but a direct embedder (or the daemon-less local REPL fallback) reaches
|
|
86
|
-
// this entry without one. Prompts
|
|
87
|
-
//
|
|
88
|
-
|
|
98
|
+
// this entry without one. Prompts, per-run instructions, AND outputSchema
|
|
99
|
+
// are durable artifacts (all land in the TaskContract) — the hard block
|
|
100
|
+
// applies here too, so no in-process path can ever bypass it. outputSchema
|
|
101
|
+
// rides the schema-aware branch: its property NAMES are field names (a
|
|
102
|
+
// `token` field is legitimate), but string VALUES (const/default/enum) are
|
|
103
|
+
// scanned for real secrets, matching the HTTP boundary exactly.
|
|
104
|
+
assertNoInlineSecretValues({
|
|
105
|
+
prompt: resolved.prompt,
|
|
106
|
+
instructions: resolved.instructions,
|
|
107
|
+
outputSchema: resolved.outputSchema ?? undefined,
|
|
108
|
+
}, "$", "run input");
|
|
89
109
|
const parsedMode = ModeKindSchema.safeParse(resolved.mode ?? "agent");
|
|
90
110
|
if (!parsedMode.success) {
|
|
91
111
|
throw new Error(`unknown mode: ${String(resolved.mode)}`);
|
|
@@ -95,9 +115,39 @@ export class Orchestrator {
|
|
|
95
115
|
// executor's plan steps — on any other mode it would be a silent no-op
|
|
96
116
|
// knob. The CLI and control API validate this already; a direct embedder
|
|
97
117
|
// must get the same loud refusal, not quiet acceptance.
|
|
98
|
-
if (resolved.maxToolCalls !== undefined &&
|
|
118
|
+
if (resolved.maxToolCalls !== undefined &&
|
|
119
|
+
resolved.maxToolCalls !== null &&
|
|
120
|
+
mode !== "orchestrate") {
|
|
99
121
|
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
122
|
}
|
|
123
|
+
// denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
|
|
124
|
+
// only exists on envelope/isolated runs — an in-place run mutates the live
|
|
125
|
+
// tree directly, so the gate could not contain a violation. Refuse loudly
|
|
126
|
+
// rather than accept a knob the engine cannot honor (INV-023).
|
|
127
|
+
if ((resolved.denyPaths?.length ?? 0) > 0 && resolved.inPlace === true) {
|
|
128
|
+
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");
|
|
129
|
+
}
|
|
130
|
+
// outputSchema constrains the run's final ANSWER. It is honored exactly
|
|
131
|
+
// where a final answer is delivered (agent race incl. synthesis, and ask);
|
|
132
|
+
// every other strategy refuses loudly rather than carrying a contract the
|
|
133
|
+
// engine would not validate (INV-023). The schema itself is normalized for
|
|
134
|
+
// the native structured-output routes here at the boundary — unsupported
|
|
135
|
+
// shapes ($ref, non-object root) are a typed refusal, not a mid-run 400.
|
|
136
|
+
if (resolved.outputSchema !== undefined && resolved.outputSchema !== null) {
|
|
137
|
+
if (mode !== "agent" && mode !== "ask") {
|
|
138
|
+
throw new Error(`outputSchema constrains the final answer and applies to agent/ask runs (got mode=${mode}); drop the schema or switch modes`);
|
|
139
|
+
}
|
|
140
|
+
if (resolved.untilClean || (resolved.attempts !== undefined && resolved.attempts !== null)) {
|
|
141
|
+
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");
|
|
142
|
+
}
|
|
143
|
+
// Shape-refuse unsupported schemas, then PROVE it compiles under the same
|
|
144
|
+
// ajv the engine validator uses — a malformed schema is a preflight
|
|
145
|
+
// refusal here (before any run dir), never a mid-run validator crash. The
|
|
146
|
+
// contract keeps the ORIGINAL (conformance authority); strictify is a
|
|
147
|
+
// transport-only transform applied per-lane in harnessSpecKnobs.
|
|
148
|
+
resolved.outputSchema = normalizeUserOutputSchema(resolved.outputSchema);
|
|
149
|
+
assertOutputSchemaCompiles(resolved.outputSchema);
|
|
150
|
+
}
|
|
101
151
|
// P1: a versioned `mandatory_files` contract is enforced UNIFORMLY here, for
|
|
102
152
|
// every mode, so the same repo state can't pass `run`/`ask` while failing
|
|
103
153
|
// `audit`. No-op when the list is empty (the default) or for no-project runs.
|
|
@@ -119,7 +169,9 @@ export class Orchestrator {
|
|
|
119
169
|
return this.runAsk(resolved, announce);
|
|
120
170
|
case "audit":
|
|
121
171
|
// `--swarm` selects the bounded read-only research swarm (old `explore`).
|
|
122
|
-
return resolved.swarm
|
|
172
|
+
return resolved.swarm
|
|
173
|
+
? this.runExplore(resolved, announce)
|
|
174
|
+
: this.runAudit(resolved, announce);
|
|
123
175
|
case "agent":
|
|
124
176
|
// Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
|
|
125
177
|
// and `--attempts` select the convergence loop; `--n` selects the race
|
|
@@ -149,73 +201,15 @@ export class Orchestrator {
|
|
|
149
201
|
if (this.deps.reviewerPanel && this.deps.reviewerPanel.length > 0) {
|
|
150
202
|
return this.resolveExplicitReviewerPanel(cwd, this.deps.reviewerPanel, runAuthPreference);
|
|
151
203
|
}
|
|
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;
|
|
204
|
+
return resolveAutoReviewerPanel({
|
|
205
|
+
cwd,
|
|
206
|
+
registry: this.deps.registry,
|
|
207
|
+
harnessSettings: this.config(cwd)?.global.harnesses ?? {},
|
|
208
|
+
authPreferenceFor: (id) => this.authPreferenceForHarness(cwd, id, runAuthPreference),
|
|
209
|
+
}, {
|
|
210
|
+
reviewerModels: this.deps.reviewerModels,
|
|
211
|
+
reviewerEfforts: this.deps.reviewerEfforts,
|
|
212
|
+
});
|
|
219
213
|
}
|
|
220
214
|
/**
|
|
221
215
|
* Resolve reviewers INSIDE a strategy, after run-dir creation: an explicit
|
|
@@ -285,31 +279,6 @@ export class Orchestrator {
|
|
|
285
279
|
candidateIntent(input) {
|
|
286
280
|
return input.create === true ? "create_from_scratch" : "implement";
|
|
287
281
|
}
|
|
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
282
|
/**
|
|
314
283
|
* Session fields for a route's run spec: auth route preference + native
|
|
315
284
|
* resume id. Preference precedence: explicit per-run > per-harness
|
|
@@ -324,8 +293,9 @@ export class Orchestrator {
|
|
|
324
293
|
execRootOf(input) {
|
|
325
294
|
return input.executionRoot ?? input.repoRoot;
|
|
326
295
|
}
|
|
327
|
-
sessionSpecFields(input, harnessId) {
|
|
296
|
+
sessionSpecFields(input, harnessId, log) {
|
|
328
297
|
const cfg = this.config(input.repoRoot)?.global;
|
|
298
|
+
const profile = this.preflightProfile(input, harnessId, log);
|
|
329
299
|
const explicit = (v) => (v && v !== "auto" ? v : undefined);
|
|
330
300
|
return {
|
|
331
301
|
// "auto" at ANY level falls through (thread turns send the thread default
|
|
@@ -334,22 +304,72 @@ export class Orchestrator {
|
|
|
334
304
|
explicit(cfg?.harnesses?.[harnessId]?.auth_preference) ??
|
|
335
305
|
explicit(cfg?.routing?.auth_preference) ??
|
|
336
306
|
"auto",
|
|
337
|
-
resume_session_id: input.resumeSessions?.[harnessId]
|
|
307
|
+
resume_session_id: resumeSessionForProfile(input.resumeSessions?.[harnessId], profile),
|
|
308
|
+
credential_profile: profile,
|
|
338
309
|
};
|
|
339
310
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
311
|
+
resolveCredentialProfile(input, harnessId) {
|
|
312
|
+
if (!input.credentialProfileId)
|
|
313
|
+
return null;
|
|
314
|
+
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
315
|
+
return resolveCredentialProfile(registry, input.credentialProfileId, harnessId);
|
|
316
|
+
}
|
|
317
|
+
/** The typed effective auth route for a SELECTED credential profile
|
|
318
|
+
* (round-18 #2): adapters execute strictly by credential_kind, so routing,
|
|
319
|
+
* billing classification, model truth, and quota lookup must share this
|
|
320
|
+
* one fact — never the default store's sources or a previous default-route
|
|
321
|
+
* metric. null = no profile selected or it does not resolve here. */
|
|
322
|
+
profileAuthRoute(input, harnessId) {
|
|
323
|
+
try {
|
|
324
|
+
const profile = this.resolveCredentialProfile(input, harnessId);
|
|
325
|
+
if (!profile)
|
|
326
|
+
return null;
|
|
327
|
+
return profile.credential_kind === "api_key" ? "api_key" : "local_session";
|
|
352
328
|
}
|
|
329
|
+
catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
profilePolicy(repoRoot, harnessId) {
|
|
334
|
+
const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
|
|
335
|
+
return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
|
|
336
|
+
}
|
|
337
|
+
/** null = no profile selected (default verdict stands); "available" = the
|
|
338
|
+
* profile's own probe admits the route; any other string = typed refusal. */
|
|
339
|
+
async profileAvailabilityOverride(input, harnessId) {
|
|
340
|
+
if (!input.credentialProfileId)
|
|
341
|
+
return null;
|
|
342
|
+
let profile;
|
|
343
|
+
try {
|
|
344
|
+
profile = this.resolveCredentialProfile(input, harnessId);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
return err instanceof Error ? err.message : String(err);
|
|
348
|
+
}
|
|
349
|
+
if (!profile)
|
|
350
|
+
return null;
|
|
351
|
+
const adapter = this.deps.registry.get(harnessId);
|
|
352
|
+
if (!adapter?.probeCredentialProfile) {
|
|
353
|
+
return `harness "${harnessId}" has no profile probe`;
|
|
354
|
+
}
|
|
355
|
+
const probe = await adapter.probeCredentialProfile(profile);
|
|
356
|
+
if (probe.availability === "available")
|
|
357
|
+
return "available";
|
|
358
|
+
return probe.detail ?? `${probe.availability}/${probe.verification}`;
|
|
359
|
+
}
|
|
360
|
+
preflightProfile(input, harnessId, log) {
|
|
361
|
+
const profile = this.resolveCredentialProfile(input, harnessId);
|
|
362
|
+
const policy = this.profilePolicy(input.repoRoot, harnessId);
|
|
363
|
+
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
364
|
+
const snapshots = this.deps.quotaSnapshots?.() ?? [];
|
|
365
|
+
const emit = (type, payload) => log?.emit(type, payload);
|
|
366
|
+
if (!profile) {
|
|
367
|
+
// Unpinned runs (INV-135 auto-balance): under `rotate`, a fresh
|
|
368
|
+
// default-subject headroom breach starts on the next eligible
|
|
369
|
+
// subscription profile instead; `fail`/`ask` change nothing.
|
|
370
|
+
return preflightDefaultSubject({ harnessId, policy, registry, snapshots, emit });
|
|
371
|
+
}
|
|
372
|
+
return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
|
|
353
373
|
}
|
|
354
374
|
/**
|
|
355
375
|
* Lift an adapter's auth-route override marker into the typed
|
|
@@ -361,7 +381,7 @@ export class Orchestrator {
|
|
|
361
381
|
/**
|
|
362
382
|
* Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
|
|
363
383
|
* **capability-gate** to those that can actually produce work for `intent` (e.g. a
|
|
364
|
-
*
|
|
384
|
+
* a planner-only adapter with `implement: false` is dropped from an implement race), and
|
|
365
385
|
* expand to n. Fails loudly if nothing can perform the intent.
|
|
366
386
|
*/
|
|
367
387
|
resolveRunInput(input) {
|
|
@@ -420,16 +440,16 @@ export class Orchestrator {
|
|
|
420
440
|
primaryHarness,
|
|
421
441
|
model: undefined,
|
|
422
442
|
models,
|
|
423
|
-
|
|
424
|
-
this.deps.
|
|
425
|
-
cfg?.project.budget?.
|
|
426
|
-
cfg?.global.
|
|
427
|
-
"
|
|
443
|
+
routingGoal: input.routingGoal ??
|
|
444
|
+
this.deps.routingGoal ??
|
|
445
|
+
cfg?.project.budget?.routing_goal ??
|
|
446
|
+
cfg?.global.routing.goal ??
|
|
447
|
+
"auto",
|
|
428
448
|
web,
|
|
429
449
|
externalContextPolicy: web,
|
|
430
450
|
};
|
|
431
451
|
}
|
|
432
|
-
async resolveCandidateAdapters(input, intent, ledger) {
|
|
452
|
+
async resolveCandidateAdapters(input, intent, ledger, routeContext) {
|
|
433
453
|
let ids = input.harnesses;
|
|
434
454
|
const explicitPool = Boolean(ids && ids.length > 0);
|
|
435
455
|
const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
|
|
@@ -442,22 +462,47 @@ export class Orchestrator {
|
|
|
442
462
|
const statuses = probeIds.length > 0 ? await this.gateway.statusAll({ cwd: input.repoRoot }, probeIds) : [];
|
|
443
463
|
const statusById = new Map(statuses.map((s) => [s.id, s]));
|
|
444
464
|
if (!ids || ids.length === 0) {
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
465
|
+
// INV-135 (round-18 BLOCK): an explicit credential profile NAMES its
|
|
466
|
+
// harness — the implicit pool is exactly the profile's enabled
|
|
467
|
+
// harness(es) from the registry, and the profile probe (below) is the
|
|
468
|
+
// auth verdict. Deriving the pool from default doctor-OK status would
|
|
469
|
+
// exclude a valid profile whose default store is logged out while
|
|
470
|
+
// keeping unrelated harnesses that later fail profile resolution.
|
|
471
|
+
const profilePool = input.credentialProfileId
|
|
472
|
+
? [
|
|
473
|
+
...new Set((this.config(input.repoRoot)?.global.credential_profiles ?? [])
|
|
474
|
+
.filter((p) => p.enabled && p.profile_id === input.credentialProfileId)
|
|
475
|
+
.map((p) => p.harness_id)
|
|
476
|
+
.filter((hid) => !disabledHarnessIds.has(hid) && this.deps.registry.has(hid))),
|
|
477
|
+
]
|
|
478
|
+
: [];
|
|
479
|
+
if (profilePool.length > 0) {
|
|
480
|
+
ids = profilePool;
|
|
481
|
+
}
|
|
482
|
+
else if (input.credentialProfileId) {
|
|
483
|
+
// Fable-checkpoint NIT: an unknown/disabled profile id must refuse
|
|
484
|
+
// HERE, not fall through to the default auto-pool — that would run on
|
|
485
|
+
// the DEFAULT credentials while the caller explicitly named an
|
|
486
|
+
// account, surfacing later as a per-harness "not registered" error.
|
|
487
|
+
const registered = (this.config(input.repoRoot)?.global.credential_profiles ?? []).filter((p) => p.profile_id === input.credentialProfileId);
|
|
488
|
+
throw new HarnessUnavailableError(registered.length === 0
|
|
489
|
+
? `credential profile "${input.credentialProfileId}" is not registered (see \`claudexor profiles list\`)`
|
|
490
|
+
: registered.every((p) => !p.enabled)
|
|
491
|
+
? `credential profile "${input.credentialProfileId}" is disabled`
|
|
492
|
+
: `credential profile "${input.credentialProfileId}" belongs to unavailable harness(es): ${registered.map((p) => p.harness_id).join(", ")}`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Auto-pools take only doctor-OK harnesses (BIBLE §2: doctor decides
|
|
496
|
+
// readiness; a key string or degraded route is visible but not routable).
|
|
497
|
+
ids = statuses
|
|
498
|
+
.filter((s) => s.manifest?.kind !== "fake" && s.status === "ok" && s.enabledIntents.includes(intent))
|
|
499
|
+
.map((s) => s.id);
|
|
500
|
+
if (ids.length === 0) {
|
|
501
|
+
throw new HarnessUnavailableError("no doctor-ok harness for this mode; install/login codex/claude/cursor/opencode (see `claudexor doctor`), or pass --harness explicitly");
|
|
502
|
+
}
|
|
452
503
|
}
|
|
453
504
|
}
|
|
454
505
|
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
506
|
const pool = [];
|
|
462
507
|
const dropped = [];
|
|
463
508
|
for (const id of ids) {
|
|
@@ -484,7 +529,9 @@ export class Orchestrator {
|
|
|
484
529
|
dropped.push(why);
|
|
485
530
|
continue;
|
|
486
531
|
}
|
|
487
|
-
|
|
532
|
+
// W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
|
|
533
|
+
// resolved env/cwd its run will spawn with (see routeContext.ts).
|
|
534
|
+
let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
|
|
488
535
|
const manifest = status?.manifest ?? null;
|
|
489
536
|
if (!status || !manifest) {
|
|
490
537
|
dropped.push(`${id} (unavailable)`);
|
|
@@ -496,14 +543,43 @@ export class Orchestrator {
|
|
|
496
543
|
// unproven by isolated smoke) is admitted only by explicit user
|
|
497
544
|
// selection — degraded means usable-with-caveats, and the caveats are
|
|
498
545
|
// visible in doctor output and run events.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
546
|
+
// INV-135 (round-13, extended by the round-18 BLOCK): an EXPLICIT
|
|
547
|
+
// profile is authenticated by ITS store — the profile probe overrides
|
|
548
|
+
// the default auth verdict for ANY non-ok default status, and a
|
|
549
|
+
// profile-admitted route joins even an AUTO pool (the run spawns with
|
|
550
|
+
// the profile's transport, so the default store's state is not the
|
|
551
|
+
// routing truth). Capability/manifest gating above still applies.
|
|
552
|
+
let profileAdmitted = false;
|
|
553
|
+
if (status.status !== "ok") {
|
|
554
|
+
const profileVerdict = await this.profileAvailabilityOverride(input, id);
|
|
555
|
+
if (profileVerdict === "available") {
|
|
556
|
+
// The default-store failure may have zeroed enabled_intents; with
|
|
557
|
+
// the profile probe as the auth verdict, MANIFEST capabilities are
|
|
558
|
+
// the honest intent truth (degraded = usable-with-caveats).
|
|
559
|
+
status = {
|
|
560
|
+
...status,
|
|
561
|
+
status: "degraded",
|
|
562
|
+
enabledIntents: capabilityIntents(manifest.capabilities),
|
|
563
|
+
};
|
|
564
|
+
statusById.set(id, status);
|
|
565
|
+
profileAdmitted = true;
|
|
566
|
+
}
|
|
567
|
+
else if (profileVerdict !== null) {
|
|
568
|
+
const why = `${id} credential profile is not ready: ${profileVerdict}`;
|
|
569
|
+
if (explicitPool)
|
|
570
|
+
throw new HarnessUnavailableError(why);
|
|
571
|
+
dropped.push(why);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
else if (status.status === "unavailable") {
|
|
575
|
+
const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
|
|
576
|
+
if (explicitPool)
|
|
577
|
+
throw new HarnessUnavailableError(why);
|
|
578
|
+
dropped.push(why);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
505
581
|
}
|
|
506
|
-
if (status.status !== "ok" && !explicitPool) {
|
|
582
|
+
if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
|
|
507
583
|
dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
|
|
508
584
|
continue;
|
|
509
585
|
}
|
|
@@ -512,18 +588,13 @@ export class Orchestrator {
|
|
|
512
588
|
intent === "explain" ||
|
|
513
589
|
intent === "audit" ||
|
|
514
590
|
intent === "orchestrate";
|
|
515
|
-
|
|
516
|
-
// when the run does not request a profile explicitly.
|
|
517
|
-
const requiredAccess = readOnlyIntent
|
|
591
|
+
const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
|
|
518
592
|
? "readonly"
|
|
519
|
-
: (input.access ?? this.config(input.repoRoot).trust.access_default);
|
|
593
|
+
: (input.access ?? this.config(input.repoRoot).trust.access_default));
|
|
520
594
|
const accessSupported = !requiredAccess || manifest.access_profiles_supported.includes(requiredAccess);
|
|
521
595
|
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.
|
|
596
|
+
// Match routeSpecKnobs: a per-harness web default upgrades run-level auto,
|
|
597
|
+
// so judge the effective per-route policy.
|
|
527
598
|
const routePolicy = policy === "auto" && cfgEntry?.web && cfgEntry.web !== "auto" ? cfgEntry.web : policy;
|
|
528
599
|
const routeWebRequired = routePolicy === "cached" || routePolicy === "live";
|
|
529
600
|
// Web policy is a capability: `off` needs an enforceable off state and a
|
|
@@ -541,34 +612,44 @@ export class Orchestrator {
|
|
|
541
612
|
dropped.push(why);
|
|
542
613
|
continue;
|
|
543
614
|
}
|
|
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`;
|
|
615
|
+
const attachmentRefusal = this.requestRequirements.attachmentRefusal(id, input.attachments ?? [], manifest.capability_profile.attachment_inputs);
|
|
616
|
+
if (attachmentRefusal) {
|
|
548
617
|
if (explicitPool)
|
|
549
|
-
throw new HarnessUnavailableError(
|
|
550
|
-
dropped.push(
|
|
618
|
+
throw new HarnessUnavailableError(attachmentRefusal);
|
|
619
|
+
dropped.push(attachmentRefusal);
|
|
551
620
|
continue;
|
|
552
621
|
}
|
|
553
622
|
const reason = status.reasons.length > 0 ? `: ${status.reasons.join("; ")}` : "";
|
|
554
623
|
if (status.enabledIntents.includes(intent) && accessSupported) {
|
|
555
624
|
pool.push({
|
|
556
625
|
adapter,
|
|
626
|
+
adapterAccess: requiredAccess,
|
|
557
627
|
webSupport,
|
|
558
628
|
providerFamily: manifest.provider_family,
|
|
559
629
|
supportsMaxTurns: manifest.capabilities.max_turns,
|
|
560
630
|
supportsToolLists: manifest.capabilities.tool_lists,
|
|
561
|
-
|
|
631
|
+
browserRequirement: this.requestRequirements.resolveBrowser({
|
|
632
|
+
harnessId: id,
|
|
633
|
+
requested: input.browser === true,
|
|
634
|
+
manifestCapable: manifest.capabilities.browser_tool,
|
|
635
|
+
webPolicy: routePolicy,
|
|
636
|
+
access: requiredAccess,
|
|
637
|
+
}),
|
|
638
|
+
denyRequirement: this.requestRequirements.resolveDenyPaths(id, (input.denyPaths?.length ?? 0) > 0),
|
|
562
639
|
effortLevels: manifest.capabilities.effort_levels,
|
|
563
640
|
knownModels: manifest.capabilities.known_models,
|
|
641
|
+
// A selected profile's credential_kind IS the route (round-18 #2);
|
|
642
|
+
// the default store's sources apply only to profile-less runs.
|
|
643
|
+
authRouteEstimate: this.profileAuthRoute(input, id) ??
|
|
644
|
+
estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
|
|
564
645
|
supportsInteractive: manifest.capabilities.interactive,
|
|
565
646
|
supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
|
|
647
|
+
implementationTransport: manifest.capabilities.implementation_transport,
|
|
566
648
|
settings: cfgEntry
|
|
567
649
|
? {
|
|
568
650
|
defaultModel: cfgEntry.default_model,
|
|
569
651
|
effort: cfgEntry.effort,
|
|
570
652
|
web: cfgEntry.web === "auto" ? null : cfgEntry.web,
|
|
571
|
-
maxUsd: cfgEntry.max_usd,
|
|
572
653
|
maxTurns: cfgEntry.max_turns,
|
|
573
654
|
maxRounds: cfgEntry.max_rounds,
|
|
574
655
|
toolsAllow: cfgEntry.tools_allow,
|
|
@@ -584,69 +665,116 @@ export class Orchestrator {
|
|
|
584
665
|
if (pool.length === 0) {
|
|
585
666
|
throw new HarnessUnavailableError(`no harness can perform '${intent}' for this mode${dropped.length ? ` (skipped: ${dropped.join(", ")})` : ""}`);
|
|
586
667
|
}
|
|
587
|
-
const ordered = this.orderPool(pool, input, statusById, ledger);
|
|
668
|
+
const ordered = this.orderPool(pool, input, intent, statusById, ledger);
|
|
669
|
+
if (ordered.length === 0) {
|
|
670
|
+
throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
|
|
671
|
+
}
|
|
588
672
|
const n = input.n ?? ordered.length;
|
|
589
673
|
const out = [];
|
|
590
674
|
for (let i = 0; i < n; i++)
|
|
591
675
|
out.push(ordered[i % ordered.length]);
|
|
676
|
+
this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
|
|
677
|
+
// outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
|
|
678
|
+
// natively constrain its final message would deliver best-effort text —
|
|
679
|
+
// that is a typed preflight refusal, never silent degradation. The
|
|
680
|
+
// interactive stream-json transport x --json-schema is an unverified
|
|
681
|
+
// vendor combination, so lanes that would ride it refuse too.
|
|
682
|
+
if (input.outputSchema !== undefined && input.outputSchema !== null) {
|
|
683
|
+
const incapable = out.filter((lane) => !lane.supportsJsonSchemaOutput);
|
|
684
|
+
if (incapable.length > 0) {
|
|
685
|
+
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`);
|
|
686
|
+
}
|
|
687
|
+
// NOTE (DT2.1-16): the daemon ALWAYS arms an interaction channel, so an
|
|
688
|
+
// interactive-capable lane (claude) is refused for outputSchema on every
|
|
689
|
+
// daemon/CLI run today — the --json-schema x stream-json interactive combo
|
|
690
|
+
// is not yet live-verified. Structured-output runs therefore route through
|
|
691
|
+
// a non-interactive lane (codex). The message names that reality instead
|
|
692
|
+
// of pointing at a channel a daemon caller cannot turn off.
|
|
693
|
+
const interactive = Boolean(input.onInteraction)
|
|
694
|
+
? out.filter((lane) => lane.supportsInteractive)
|
|
695
|
+
: [];
|
|
696
|
+
if (interactive.length > 0) {
|
|
697
|
+
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`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
592
700
|
// Strict pre-run model gate (INV-104) — see modelGovernance.ts.
|
|
593
701
|
await assertRouteModelsAllowed(out, input.models, this.execRootOf(input));
|
|
594
702
|
return out;
|
|
595
703
|
}
|
|
596
704
|
/**
|
|
597
|
-
* Order the eligible pool by
|
|
705
|
+
* Order the eligible pool by the selected routing goal (budget router): an
|
|
598
706
|
* explicit user pool keeps the user's order; an explicit primary harness is
|
|
599
707
|
* always pinned first. Cross-family diversity is encouraged for later slots.
|
|
600
708
|
*/
|
|
601
|
-
orderPool(pool, input, statusById, ledger) {
|
|
709
|
+
orderPool(pool, input, intent, statusById, ledger) {
|
|
602
710
|
let ordered = pool;
|
|
603
|
-
|
|
604
|
-
if (!explicitPool && pool.length > 1) {
|
|
711
|
+
if (pool.length > 0) {
|
|
605
712
|
const routeLedger = ledger ?? new BudgetLedger();
|
|
606
|
-
const
|
|
713
|
+
const config = this.config(input.repoRoot).global;
|
|
714
|
+
const goal = input.routingGoal ?? this.deps.routingGoal ?? config.routing.goal;
|
|
607
715
|
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.
|
|
716
|
+
// Settled cost is evidence for economy routing, never a provider quality prior.
|
|
611
717
|
const metrics = loadHarnessMetrics(globalConfigDir());
|
|
612
|
-
const priors = this.config(input.repoRoot).global.routing.quality_priors;
|
|
613
718
|
const remaining = pool.map((r) => {
|
|
614
719
|
const authModes = statusById.get(r.adapter.id)?.manifest?.auth_modes ?? [];
|
|
615
720
|
const metric = metrics[r.adapter.id];
|
|
721
|
+
// Auth mode for routing: prefer the ROUTE EVIDENCE from the
|
|
722
|
+
// last settled attempt (adapter-disclosed, persisted in metrics) over
|
|
723
|
+
// the manifest capability guess — auth_modes lists what a harness CAN
|
|
724
|
+
// use, not what it actually runs under.
|
|
725
|
+
const guessedAuthMode = authModes.includes("local_session")
|
|
726
|
+
? "local_session"
|
|
727
|
+
: authModes.includes("api_key")
|
|
728
|
+
? "api_key"
|
|
729
|
+
: "unknown";
|
|
730
|
+
// A selected profile's credential_kind decides the route outright
|
|
731
|
+
// (round-18 #2): an api_key profile must never inherit a
|
|
732
|
+
// subscription classification from the default store's metric.
|
|
733
|
+
const authMode = this.profileAuthRoute(input, r.adapter.id) ??
|
|
734
|
+
(input.authPreference === "api_key"
|
|
735
|
+
? "api_key"
|
|
736
|
+
: input.authPreference === "subscription"
|
|
737
|
+
? "local_session"
|
|
738
|
+
: (metric?.last_auth_mode ?? guessedAuthMode));
|
|
739
|
+
// The quota subject this candidate would actually run as (release
|
|
740
|
+
// wave round-16 #2): the resolved profile id, or null for the engine
|
|
741
|
+
// default — so profile A's cooldown never excludes profile B or the
|
|
742
|
+
// default. A profile that does not resolve for this harness routes
|
|
743
|
+
// as unknown (undefined) and stays conservatively any-subject.
|
|
744
|
+
let credentialSubjectId;
|
|
745
|
+
try {
|
|
746
|
+
credentialSubjectId =
|
|
747
|
+
this.resolveCredentialProfile(input, r.adapter.id)?.profile_id ?? null;
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
credentialSubjectId = undefined;
|
|
751
|
+
}
|
|
616
752
|
return {
|
|
617
753
|
harnessId: r.adapter.id,
|
|
618
|
-
providerFamily: r.providerFamily,
|
|
619
754
|
available: true,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
755
|
+
model: input.models?.[r.adapter.id] ??
|
|
756
|
+
config.harnesses[r.adapter.id]?.default_model ??
|
|
757
|
+
undefined,
|
|
758
|
+
effort: input.effort ?? config.harnesses[r.adapter.id]?.effort ?? undefined,
|
|
759
|
+
billingKnowledge: authMode === "api_key" ? "metered" : "unknown",
|
|
760
|
+
incrementalCostUsd: authMode === "api_key" ? (metric?.avg_cost_usd ?? null) : null,
|
|
761
|
+
credentialRoute: authMode === "api_key"
|
|
762
|
+
? "managed_api_key"
|
|
763
|
+
: authMode === "local_session"
|
|
764
|
+
? "vendor_native"
|
|
765
|
+
: undefined,
|
|
766
|
+
credentialSubjectId,
|
|
628
767
|
};
|
|
629
768
|
});
|
|
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
|
-
}
|
|
769
|
+
const ranked = rankHarnesses(remaining, {
|
|
770
|
+
goal,
|
|
771
|
+
paidFallback: config.routing.paid_fallback,
|
|
772
|
+
intent,
|
|
773
|
+
qualityTiers: config.routing.quality_tiers,
|
|
774
|
+
ledger: routeLedger,
|
|
775
|
+
})
|
|
776
|
+
.map((candidate) => byId.get(candidate.harnessId))
|
|
777
|
+
.filter((candidate) => Boolean(candidate));
|
|
650
778
|
ordered = ranked;
|
|
651
779
|
}
|
|
652
780
|
if (input.primaryHarness) {
|
|
@@ -695,22 +823,6 @@ export class Orchestrator {
|
|
|
695
823
|
"",
|
|
696
824
|
].join("\n");
|
|
697
825
|
}
|
|
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
826
|
/**
|
|
715
827
|
* The web mode a routed harness actually executes for a requested policy.
|
|
716
828
|
* Tools-permissioned web (e.g. claude) has no cached index: `cached` upgrades
|
|
@@ -762,7 +874,10 @@ export class Orchestrator {
|
|
|
762
874
|
// turn (TurnEnqueueError.code), so surfaces key remedies on the CODE —
|
|
763
875
|
// never on substring-matching this human message.
|
|
764
876
|
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`),
|
|
877
|
+
`(${trustConfigPath(input.repoRoot)}); enable it with \`claudexor trust --allow-full-access\` — refusing to run unsandboxed`),
|
|
878
|
+
// Refusal semantics are born at the throw (W24): the one-time grant is
|
|
879
|
+
// a 403, and the daemon persists this status onto the job record.
|
|
880
|
+
{ code: TRUST_FULL_ACCESS_CODE, status: 403 });
|
|
766
881
|
}
|
|
767
882
|
const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
768
883
|
// A frozen SpecPack's CONTENT reaches the contract (success criteria,
|
|
@@ -785,7 +900,7 @@ export class Orchestrator {
|
|
|
785
900
|
repoRoot: input.repoRoot,
|
|
786
901
|
mode,
|
|
787
902
|
baseRef: input.baseRef,
|
|
788
|
-
|
|
903
|
+
paidBudget: input.paidBudget,
|
|
789
904
|
});
|
|
790
905
|
specFields = {
|
|
791
906
|
success_criteria: fromSpec.success_criteria,
|
|
@@ -795,7 +910,12 @@ export class Orchestrator {
|
|
|
795
910
|
task_graph: fromSpec.task_graph,
|
|
796
911
|
constraints: fromSpec.constraints,
|
|
797
912
|
};
|
|
798
|
-
specTestCommands = fromSpec.tests.commands.map((
|
|
913
|
+
specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
|
|
914
|
+
program,
|
|
915
|
+
args,
|
|
916
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
917
|
+
envAllowlist,
|
|
918
|
+
}));
|
|
799
919
|
}
|
|
800
920
|
catch (err) {
|
|
801
921
|
// An unreadable/unfrozen spec must fail the run loudly, never silently
|
|
@@ -806,22 +926,18 @@ export class Orchestrator {
|
|
|
806
926
|
// Deterministic gate commands come from the frozen SpecPack, explicit run
|
|
807
927
|
// input, then versioned project config. Without these, gateSpecs is empty
|
|
808
928
|
// 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 };
|
|
929
|
+
const resolvedGates = resolveContractGates({
|
|
930
|
+
repoRoot: input.repoRoot,
|
|
931
|
+
effectiveAccess,
|
|
932
|
+
config: cfg,
|
|
933
|
+
trustGrants: resolvedCfg.trust.test_command_grants,
|
|
934
|
+
specCommands: specTestCommands,
|
|
935
|
+
operatorCommands: input.tests ?? [],
|
|
936
|
+
projectCommands: cfg.tests?.commands ?? [],
|
|
822
937
|
});
|
|
938
|
+
const commands = resolvedGates.commands;
|
|
823
939
|
const protectedPaths = [...new Set(specFields.constraints?.protected_paths ?? [])];
|
|
824
|
-
const autoProtectedPaths =
|
|
940
|
+
const autoProtectedPaths = resolvedGates.autoProtectedPaths;
|
|
825
941
|
const protectedPathApprovals = [
|
|
826
942
|
...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
|
|
827
943
|
];
|
|
@@ -832,6 +948,17 @@ export class Orchestrator {
|
|
|
832
948
|
repo: { root: input.repoRoot, base_ref: input.baseRef ?? "HEAD", dirty_policy: "snapshot" },
|
|
833
949
|
mode: { kind: mode },
|
|
834
950
|
user_intent: { raw: redactSecrets(input.prompt) },
|
|
951
|
+
// Redacted for symmetry with user_intent.raw — a no-op on fenced input
|
|
952
|
+
// (the inline-secret fence already blocked any secret-like value at every
|
|
953
|
+
// ingress incl. this engine boundary), so task-producing lanes read back
|
|
954
|
+
// the real instructions via harnessSpecKnobs().
|
|
955
|
+
instructions: input.instructions === undefined ? undefined : redactSecrets(input.instructions),
|
|
956
|
+
// Already normalized/strictified at the engine boundary (run() refuses
|
|
957
|
+
// unsupported shapes before any run dir exists).
|
|
958
|
+
output_schema: input.outputSchema ?? null,
|
|
959
|
+
auth_preference: input.authPreference ?? "auto",
|
|
960
|
+
credential_profile_id: input.credentialProfileId ?? null,
|
|
961
|
+
max_turns: input.maxTurns ?? null,
|
|
835
962
|
spec: input.specId || input.specHash || input.specPath
|
|
836
963
|
? {
|
|
837
964
|
id: input.specId,
|
|
@@ -842,6 +969,7 @@ export class Orchestrator {
|
|
|
842
969
|
...specFields,
|
|
843
970
|
constraints: {
|
|
844
971
|
protected_paths: protectedPaths,
|
|
972
|
+
deny_paths: [...new Set(input.denyPaths ?? [])],
|
|
845
973
|
auto_protected_paths: autoProtectedPaths,
|
|
846
974
|
protected_path_approvals: protectedPathApprovals,
|
|
847
975
|
},
|
|
@@ -866,11 +994,8 @@ export class Orchestrator {
|
|
|
866
994
|
deny: [],
|
|
867
995
|
},
|
|
868
996
|
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),
|
|
997
|
+
routing_goal: input.routingGoal ?? this.deps.routingGoal ?? cfg?.budget?.routing_goal ?? "auto",
|
|
998
|
+
paid_budget: this.resolvePaidBudget(input.paidBudget, resolvedCfg),
|
|
874
999
|
},
|
|
875
1000
|
// The resolved harness-scoped model map (scalar already expanded to the
|
|
876
1001
|
// primary by resolveRunInput). The contract is what route spec building
|
|
@@ -878,61 +1003,43 @@ export class Orchestrator {
|
|
|
878
1003
|
routing_models: input.models ?? {},
|
|
879
1004
|
});
|
|
880
1005
|
}
|
|
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
1006
|
/**
|
|
932
1007
|
* Per-harness settings applied to one route's run spec (model/effort/web
|
|
933
1008
|
* defaults, max_turns, tool lists). Knobs the manifest does not support are
|
|
934
1009
|
* RETURNED as ignored reasons (disclosed by the caller), never silently sent.
|
|
935
1010
|
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* The HarnessRunSpec fields every TASK-PRODUCING lane shares (primary,
|
|
1013
|
+
* candidate, planner, explorer, orchestrate-planner). Extracting the identical
|
|
1014
|
+
* block into ONE owner means a new task-producing field lands here once —
|
|
1015
|
+
* never forgotten in one of the HarnessRunSpec.parse sites (the multi-path
|
|
1016
|
+
* trap). Per-run `instructions` ride every task-producing lane but are withheld
|
|
1017
|
+
* from `synthesize` (a merge of existing candidates, not a fresh task
|
|
1018
|
+
* execution — owner Quiz-5a); reviewers and the auth smoke build their own
|
|
1019
|
+
* specs and never call this.
|
|
1020
|
+
*/
|
|
1021
|
+
harnessSpecKnobs(contract, knobs, intent) {
|
|
1022
|
+
return {
|
|
1023
|
+
external_context_policy: knobs.webPolicy,
|
|
1024
|
+
tool_permission_policy: {
|
|
1025
|
+
web: knobs.webPolicy,
|
|
1026
|
+
allow: [...new Set([...contract.tool_permission_policy.allow, ...knobs.toolsAllow])],
|
|
1027
|
+
deny: [...new Set([...contract.tool_permission_policy.deny, ...knobs.toolsDeny])],
|
|
1028
|
+
},
|
|
1029
|
+
model_hint: knobs.model,
|
|
1030
|
+
effort_hint: knobs.effort,
|
|
1031
|
+
max_turns: knobs.maxTurns,
|
|
1032
|
+
...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
|
|
1033
|
+
// The user's answer contract rides every answer-producing lane INCLUDING
|
|
1034
|
+
// synthesis (its answer can become the final one); the orchestrate
|
|
1035
|
+
// planner owns its own plan schema instead (set at its spec site). The
|
|
1036
|
+
// adapter gets the vendor-STRICT transport form; the engine validator
|
|
1037
|
+
// keeps the ORIGINAL contract as the conformance authority.
|
|
1038
|
+
...(intent !== "orchestrate" && contract.output_schema
|
|
1039
|
+
? { output_schema: strictifyOutputSchema(contract.output_schema) }
|
|
1040
|
+
: {}),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
936
1043
|
routeSpecKnobs(routed, contract, overrideModel, effortHint) {
|
|
937
1044
|
const s = routed.settings;
|
|
938
1045
|
const contractPolicy = contract.external_context.policy;
|
|
@@ -940,11 +1047,13 @@ export class Orchestrator {
|
|
|
940
1047
|
let maxTurns = null;
|
|
941
1048
|
let toolsAllow = [];
|
|
942
1049
|
let toolsDeny = [];
|
|
943
|
-
|
|
1050
|
+
// Run-level cap beats per-harness settings (specific beats general).
|
|
1051
|
+
const requestedMaxTurns = contract.max_turns ?? s?.maxTurns ?? null;
|
|
1052
|
+
if (requestedMaxTurns) {
|
|
944
1053
|
if (routed.supportsMaxTurns)
|
|
945
|
-
maxTurns =
|
|
1054
|
+
maxTurns = requestedMaxTurns;
|
|
946
1055
|
else
|
|
947
|
-
ignored.push(`max_turns=${
|
|
1056
|
+
ignored.push(`max_turns=${requestedMaxTurns} (manifest capabilities.max_turns=false for ${routed.adapter.id})`);
|
|
948
1057
|
}
|
|
949
1058
|
if ((s?.toolsAllow.length ?? 0) > 0 || (s?.toolsDeny.length ?? 0) > 0) {
|
|
950
1059
|
if (routed.supportsToolLists) {
|
|
@@ -981,43 +1090,38 @@ export class Orchestrator {
|
|
|
981
1090
|
};
|
|
982
1091
|
}
|
|
983
1092
|
/** Run one candidate inside an already-created envelope. Never creates/disposes the envelope. */
|
|
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) {
|
|
1093
|
+
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) {
|
|
985
1094
|
const adapter = routed.adapter;
|
|
986
1095
|
const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
|
|
987
|
-
// In-place envelopes
|
|
988
|
-
//
|
|
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.
|
|
1096
|
+
// In-place envelopes can resume native sessions; isolated scoped homes cannot,
|
|
1097
|
+
// so their session ids are never retained after disposal.
|
|
993
1098
|
const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
|
|
994
|
-
const
|
|
995
|
-
const
|
|
1099
|
+
const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
|
|
1100
|
+
const sessionFields = runInput ? this.sessionSpecFields(runInput, adapter.id, log) : undefined;
|
|
1101
|
+
let spec = HarnessRunSpec.parse({
|
|
996
1102
|
session_id: newId("ses"),
|
|
997
1103
|
intent,
|
|
998
1104
|
prompt: promptWithProtectedPathConstraint(prompt, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
|
|
999
1105
|
attachments: runInput?.attachments ?? [],
|
|
1000
|
-
browser: this.
|
|
1106
|
+
browser: this.requestRequirements.browserSpec(routed.browserRequirement, join(paths.root, "browser")),
|
|
1001
1107
|
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,
|
|
1108
|
+
access: routed.adapterAccess,
|
|
1109
|
+
...this.harnessSpecKnobs(contract, knobs, intent),
|
|
1013
1110
|
env_inheritance: envInheritance(this.config(contract.repo.root)),
|
|
1014
|
-
...(sessionFields
|
|
1111
|
+
...(sessionFields
|
|
1112
|
+
? {
|
|
1113
|
+
auth_preference: sessionFields.auth_preference,
|
|
1114
|
+
credential_profile: sessionFields.credential_profile,
|
|
1115
|
+
}
|
|
1116
|
+
: {}),
|
|
1015
1117
|
...(inPlaceEnvelope && sessionFields?.resume_session_id
|
|
1016
1118
|
? { resume_session_id: sessionFields.resume_session_id }
|
|
1017
1119
|
: {}),
|
|
1018
1120
|
// Scoped harness home only for isolated envelopes; in-place runs use the
|
|
1019
1121
|
// native environment so the resumed vendor session is actually reachable.
|
|
1020
1122
|
...(inPlaceEnvelope ? {} : { env: wsm.envFor(envelope) }),
|
|
1123
|
+
raw_context_packet: rawContextPacket,
|
|
1124
|
+
stream_deltas: streamDeltas,
|
|
1021
1125
|
});
|
|
1022
1126
|
if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
|
|
1023
1127
|
log?.emit("session.rebound", SessionReboundLineageSchema.parse({
|
|
@@ -1034,15 +1138,19 @@ export class Orchestrator {
|
|
|
1034
1138
|
const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
|
|
1035
1139
|
const attemptStartedMs = Date.now();
|
|
1036
1140
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
1141
|
+
const triedProfiles = new Set(); // W5.4 failover: each profile at most once
|
|
1037
1142
|
let cost = 0;
|
|
1038
1143
|
let costEstimated = false;
|
|
1039
1144
|
let harnessErrored = false;
|
|
1145
|
+
// W-C4 delta flood budget (per attempt): counts forwarded delta chunks.
|
|
1146
|
+
let deltaCount = 0;
|
|
1147
|
+
let deltaCutoffDisclosed = false;
|
|
1040
1148
|
const errors = [];
|
|
1041
|
-
const
|
|
1149
|
+
const answer = new AnswerAssembly();
|
|
1042
1150
|
const retryPolicy = transientRetryPolicy(this.config(contract.repo.root));
|
|
1043
1151
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
1044
1152
|
knobs.webPolicy === "cached" ||
|
|
1045
|
-
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy);
|
|
1153
|
+
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model);
|
|
1046
1154
|
let activeSessionId = spec.session_id;
|
|
1047
1155
|
const onAbort = () => {
|
|
1048
1156
|
void adapter.cancel?.(activeSessionId)?.catch(() => { });
|
|
@@ -1070,6 +1178,8 @@ export class Orchestrator {
|
|
|
1070
1178
|
: attemptAbort.signal;
|
|
1071
1179
|
activeSessionId = runSpec.session_id;
|
|
1072
1180
|
const transientStart = telemetry.transientFailures.length;
|
|
1181
|
+
const rateLimitStart = telemetry.rateLimits.length;
|
|
1182
|
+
let rawPatch = null;
|
|
1073
1183
|
try {
|
|
1074
1184
|
const watched = withInactivityWatchdog(adapter.run(runSpec), {
|
|
1075
1185
|
timeoutMs: inactivityMs,
|
|
@@ -1084,7 +1194,31 @@ export class Orchestrator {
|
|
|
1084
1194
|
for await (const ev of watched) {
|
|
1085
1195
|
if (signal?.aborted)
|
|
1086
1196
|
break;
|
|
1197
|
+
rawPatch = captureRawPatchEnvelope(rawContextPacket !== null, rawPatch, ev);
|
|
1198
|
+
if (ev.type === "patch_produced")
|
|
1199
|
+
continue;
|
|
1087
1200
|
const safeEv = redactHarnessEvent(ev);
|
|
1201
|
+
// W-C4 flood guard (review sol #10): a per-character delta stream
|
|
1202
|
+
// would otherwise persist/SSE one journal event PER CHUNK without
|
|
1203
|
+
// bound. Delta messages are DISPLAY-only (the complete message
|
|
1204
|
+
// still follows and carries the authoritative text), so past a
|
|
1205
|
+
// per-attempt budget we DROP further deltas and disclose the
|
|
1206
|
+
// cutoff ONCE — the final answer is unaffected.
|
|
1207
|
+
if (safeEv.type === "message" && safeEv.payload?.["delta"] === true) {
|
|
1208
|
+
deltaCount += 1;
|
|
1209
|
+
if (deltaCount > Orchestrator.MAX_DELTAS_PER_ATTEMPT) {
|
|
1210
|
+
if (!deltaCutoffDisclosed) {
|
|
1211
|
+
deltaCutoffDisclosed = true;
|
|
1212
|
+
log?.emit("harness.event", {
|
|
1213
|
+
harness_id: adapter.id,
|
|
1214
|
+
attempt_id: attemptId,
|
|
1215
|
+
type: "status",
|
|
1216
|
+
title: `live delta stream capped at ${Orchestrator.MAX_DELTAS_PER_ATTEMPT} chunks; the complete message still lands`,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
continue; // drop this delta; never journal past the budget
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1088
1222
|
safeInvoke(onHarnessEvent, safeEv);
|
|
1089
1223
|
// In-place turns run in the live tree under the native environment, so
|
|
1090
1224
|
// the session they emit IS reachable for the next turn: record it. An
|
|
@@ -1092,13 +1226,17 @@ export class Orchestrator {
|
|
|
1092
1226
|
// deletes, so observing it would poison the thread resume map with
|
|
1093
1227
|
// unreachable ids — skip it there.
|
|
1094
1228
|
if (inPlaceEnvelope)
|
|
1095
|
-
|
|
1229
|
+
observeNativeSessionEvent(runInput, adapter.id, safeEv);
|
|
1096
1230
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
1097
1231
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
1098
1232
|
// Live plan checklist: forward the adapter's typed plan
|
|
1099
1233
|
// progress as a run event (LAST WINS; the UI renders the latest).
|
|
1100
1234
|
if (safeEv.plan_progress) {
|
|
1101
|
-
log?.emit("plan.progress", {
|
|
1235
|
+
log?.emit("plan.progress", {
|
|
1236
|
+
attempt_id: attemptId,
|
|
1237
|
+
harness_id: adapter.id,
|
|
1238
|
+
items: safeEv.plan_progress.items,
|
|
1239
|
+
});
|
|
1102
1240
|
}
|
|
1103
1241
|
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
1104
1242
|
cost += safeEv.usage.cost_usd;
|
|
@@ -1113,8 +1251,9 @@ export class Orchestrator {
|
|
|
1113
1251
|
});
|
|
1114
1252
|
// Mid-flight cap enforcement: the guard raises this attempt's hold
|
|
1115
1253
|
// to the streamed cost; a hard tier aborts NOW instead of letting a
|
|
1116
|
-
// streaming candidate overshoot
|
|
1117
|
-
|
|
1254
|
+
// streaming candidate overshoot the paid budget until settlement.
|
|
1255
|
+
const valuationOnly = safeEv.usage.estimated === true && telemetry.authMode === "local_session";
|
|
1256
|
+
if (!valuationOnly && budgetGuard?.(cost)) {
|
|
1118
1257
|
harnessErrored = true;
|
|
1119
1258
|
errors.push("budget hard cap reached mid-attempt; stream aborted");
|
|
1120
1259
|
log?.emit("budget.observation", {
|
|
@@ -1132,16 +1271,21 @@ export class Orchestrator {
|
|
|
1132
1271
|
errors.push(redactSecrets(safeEv.error ?? safeEv.text ?? "harness emitted error"));
|
|
1133
1272
|
}
|
|
1134
1273
|
// 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
|
-
}
|
|
1274
|
+
// has an honest output artifact; a TYPED final message wins verbatim.
|
|
1275
|
+
answer.observe(safeEv);
|
|
1141
1276
|
// Observe ALL budget/quota signals (one codex usage event carries
|
|
1142
1277
|
// BOTH spend and quota); pressure disclosed once per attempt.
|
|
1143
1278
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
1279
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
1144
1280
|
}
|
|
1281
|
+
if (rawContextPacket && !harnessErrored)
|
|
1282
|
+
await consumeRawPatchEnvelope({
|
|
1283
|
+
repoRoot: envelope.repo_root,
|
|
1284
|
+
worktreePath: envelope.worktree_path,
|
|
1285
|
+
baseCommitSha: envelope.base_sha ?? "HEAD",
|
|
1286
|
+
context: rawContextPacket,
|
|
1287
|
+
envelope: rawPatch,
|
|
1288
|
+
});
|
|
1145
1289
|
}
|
|
1146
1290
|
catch (err) {
|
|
1147
1291
|
// A throwing adapter must not lose the cost already streamed: record the
|
|
@@ -1151,9 +1295,35 @@ export class Orchestrator {
|
|
|
1151
1295
|
}
|
|
1152
1296
|
const transient = telemetry.transientFailures.at(-1) ?? null;
|
|
1153
1297
|
const sawTransient = telemetry.transientFailures.length > transientStart;
|
|
1298
|
+
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
1154
1299
|
const currentDiff = await wsm.diff(envelope);
|
|
1155
|
-
const currentAnswer =
|
|
1300
|
+
const currentAnswer = answer.text();
|
|
1156
1301
|
const deliverableEmpty = currentDiff.trim().length === 0 && currentAnswer.length === 0;
|
|
1302
|
+
// W5.4 failover: a typed-limit hit rebuilds the spec on a NEW vendor
|
|
1303
|
+
// session under the next profile with provenance (vendor_limit_rejected).
|
|
1304
|
+
if (harnessErrored && runInput && !signal?.aborted) {
|
|
1305
|
+
const rotated = rotateSpecOnTypedLimit({
|
|
1306
|
+
spec,
|
|
1307
|
+
harnessId: adapter.id,
|
|
1308
|
+
attemptId,
|
|
1309
|
+
policy: this.profilePolicy(contract.repo.root, adapter.id),
|
|
1310
|
+
registry: this.config(contract.repo.root)?.global.credential_profiles ?? [],
|
|
1311
|
+
snapshots: this.deps.quotaSnapshots?.() ?? [],
|
|
1312
|
+
triedProfiles,
|
|
1313
|
+
sawTypedLimit,
|
|
1314
|
+
deliverableEmpty,
|
|
1315
|
+
lastLimit: telemetry.rateLimits.at(-1) ?? null,
|
|
1316
|
+
emit: (type, payload) => log?.emit(type, payload),
|
|
1317
|
+
newSessionId: () => newId("ses"),
|
|
1318
|
+
defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
|
|
1319
|
+
});
|
|
1320
|
+
if (rotated) {
|
|
1321
|
+
spec = rotated;
|
|
1322
|
+
errors.length = 0;
|
|
1323
|
+
harnessErrored = false;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1157
1327
|
if (!harnessErrored ||
|
|
1158
1328
|
!sawTransient ||
|
|
1159
1329
|
!deliverableEmpty ||
|
|
@@ -1194,7 +1364,7 @@ export class Orchestrator {
|
|
|
1194
1364
|
errors.push(`web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`);
|
|
1195
1365
|
}
|
|
1196
1366
|
const diff = await wsm.diff(envelope);
|
|
1197
|
-
const answerText =
|
|
1367
|
+
const answerText = answer.text() || undefined;
|
|
1198
1368
|
const deliverablePresent = diff.trim().length > 0 || Boolean(answerText);
|
|
1199
1369
|
// Cancelled attempts skip gates entirely: the operator asked to
|
|
1200
1370
|
// stop NOW; running a 600s-per-gate suite after the abort delays the ack
|
|
@@ -1202,11 +1372,14 @@ export class Orchestrator {
|
|
|
1202
1372
|
// still land, so partial work stays inspectable.
|
|
1203
1373
|
const gateSignalAborted = signal?.aborted === true;
|
|
1204
1374
|
if (!gateSignalAborted) {
|
|
1205
|
-
log?.emit("gate.started", {
|
|
1375
|
+
log?.emit("gate.started", {
|
|
1376
|
+
attempt_id: attemptId,
|
|
1377
|
+
gates: gateSpecsFromContract(contract).length,
|
|
1378
|
+
});
|
|
1206
1379
|
}
|
|
1207
1380
|
const gates = gateSignalAborted
|
|
1208
1381
|
? []
|
|
1209
|
-
: await runGates(
|
|
1382
|
+
: await runGates(gateSpecsFromContract(contract), {
|
|
1210
1383
|
cwd: envelope.worktree_path,
|
|
1211
1384
|
env: wsm.envFor(envelope),
|
|
1212
1385
|
signal,
|
|
@@ -1244,12 +1417,14 @@ export class Orchestrator {
|
|
|
1244
1417
|
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { costUsd: cost });
|
|
1245
1418
|
}
|
|
1246
1419
|
store.writeText(join(attemptDir, "patch.diff"), diff);
|
|
1247
|
-
// Routing metrics (one owner in runSupport; clean attempts only
|
|
1420
|
+
// Routing metrics (one owner in runSupport; clean attempts only —
|
|
1421
|
+
// auth-route evidence recorded regardless).
|
|
1248
1422
|
recordCleanAttemptMetrics(globalConfigDir(), adapter.id, {
|
|
1249
1423
|
costUsd: cost,
|
|
1250
1424
|
streamMs: attemptStreamEndedMs - attemptStartedMs,
|
|
1251
1425
|
errored,
|
|
1252
1426
|
aborted: signal?.aborted === true,
|
|
1427
|
+
authMode: telemetry.authMode,
|
|
1253
1428
|
});
|
|
1254
1429
|
const attemptDiffstat = diffStats(diff);
|
|
1255
1430
|
store.writeYaml(join(attemptDir, "attempt.yaml"), {
|
|
@@ -1342,8 +1517,8 @@ export class Orchestrator {
|
|
|
1342
1517
|
}
|
|
1343
1518
|
/**
|
|
1344
1519
|
* Guarantee a git boundary for write-mode runs. Non-git project folders are
|
|
1345
|
-
* initialized in place (
|
|
1346
|
-
*
|
|
1520
|
+
* initialized in place (`git init`, deterministic baseline commit) without
|
|
1521
|
+
* creating or editing `.gitignore`, and the action is announced via a
|
|
1347
1522
|
* `project.git.initialized` event. Returns the failure message when the
|
|
1348
1523
|
* boundary cannot be established (the terminal failure events are already
|
|
1349
1524
|
* emitted); null on success.
|
|
@@ -1406,8 +1581,17 @@ export class Orchestrator {
|
|
|
1406
1581
|
const wsm = new WorkspaceManager(execRoot);
|
|
1407
1582
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
1408
1583
|
log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
|
|
1409
|
-
const ledger =
|
|
1410
|
-
announce?.({
|
|
1584
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
1585
|
+
announce?.({
|
|
1586
|
+
log,
|
|
1587
|
+
store,
|
|
1588
|
+
paths,
|
|
1589
|
+
runId,
|
|
1590
|
+
taskId,
|
|
1591
|
+
mode,
|
|
1592
|
+
phase: "race",
|
|
1593
|
+
spend: () => ledger.spend(),
|
|
1594
|
+
});
|
|
1411
1595
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
1412
1596
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
1413
1597
|
// Write modes need a git boundary for worktree isolation and honest diffs.
|
|
@@ -1448,7 +1632,7 @@ export class Orchestrator {
|
|
|
1448
1632
|
writeEvidencePacket(reviewDir, {
|
|
1449
1633
|
userIntent: redactSecrets(input.prompt),
|
|
1450
1634
|
diff: "(per-candidate diffs are supplied to reviewers individually)\n",
|
|
1451
|
-
tests:
|
|
1635
|
+
tests: renderTestsEvidence(contract),
|
|
1452
1636
|
});
|
|
1453
1637
|
let adapters;
|
|
1454
1638
|
try {
|
|
@@ -1487,7 +1671,6 @@ export class Orchestrator {
|
|
|
1487
1671
|
return reviewersOutcome.failed;
|
|
1488
1672
|
const reviewers = reviewersOutcome.reviewers;
|
|
1489
1673
|
const reviewVerified = this.routeVerified(reviewers);
|
|
1490
|
-
const harnessLedgers = new Map();
|
|
1491
1674
|
const reviewEnvelopes = [];
|
|
1492
1675
|
const disposeReviewEnvelopes = async () => {
|
|
1493
1676
|
const envelopes = reviewEnvelopes.splice(0);
|
|
@@ -1497,25 +1680,17 @@ export class Orchestrator {
|
|
|
1497
1680
|
const candidateAccess = contract.access.effective_profile;
|
|
1498
1681
|
let budgetStopped = false;
|
|
1499
1682
|
let softWarned = false;
|
|
1500
|
-
// The USER-requested race width, before any budget trimming.
|
|
1501
1683
|
const requestedSingleCandidate = adapters.length === 1;
|
|
1502
1684
|
const slots = [];
|
|
1503
1685
|
for (let i = 0; i < adapters.length; i++) {
|
|
1504
1686
|
const routed = adapters[i];
|
|
1505
1687
|
const attemptId = `a${String(i + 1).padStart(2, "0")}`;
|
|
1506
|
-
|
|
1507
|
-
const lease = this.harnessLedger(harnessLedgers, ledger, routed).reserve({
|
|
1688
|
+
const lease = ledger.reserve({
|
|
1508
1689
|
taskId,
|
|
1509
1690
|
attemptId,
|
|
1510
1691
|
intent: this.candidateIntent(input),
|
|
1511
1692
|
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) } : {}),
|
|
1693
|
+
cost: attemptCostEvidence(routed.adapter.id, attemptId, i > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, routed.adapter.id)),
|
|
1519
1694
|
});
|
|
1520
1695
|
log.emit("budget.lease.created", {
|
|
1521
1696
|
granted: lease.granted,
|
|
@@ -1538,17 +1713,16 @@ export class Orchestrator {
|
|
|
1538
1713
|
});
|
|
1539
1714
|
}
|
|
1540
1715
|
const runsBySlot = new Array(slots.length);
|
|
1541
|
-
const slotLedger = (slot) => this.harnessLedger(harnessLedgers, ledger, slot.routed);
|
|
1542
1716
|
const runSlot = async (slot, slotIdx) => {
|
|
1543
1717
|
if (input.signal?.aborted) {
|
|
1544
|
-
|
|
1718
|
+
ledger.cancel(slot.leaseId);
|
|
1545
1719
|
return;
|
|
1546
1720
|
}
|
|
1547
1721
|
// Leases are granted upfront (before spend exists); a worker still
|
|
1548
1722
|
// re-checks the circuit breaker so queued slots beyond the parallel wave
|
|
1549
1723
|
// do not start after earlier candidates already blew the hard cap.
|
|
1550
|
-
if (budgetStopped ||
|
|
1551
|
-
|
|
1724
|
+
if (budgetStopped || ledger.tier() === "hard") {
|
|
1725
|
+
ledger.cancel(slot.leaseId);
|
|
1552
1726
|
log.emit("budget.lease.created", {
|
|
1553
1727
|
granted: false,
|
|
1554
1728
|
reason: "budget exhausted (hard cap reached)",
|
|
@@ -1563,7 +1737,7 @@ export class Orchestrator {
|
|
|
1563
1737
|
// Soft + downgrade breaker (before the hard cap): soft = a one-time
|
|
1564
1738
|
// warning; downgrade = run this attempt on the per-harness fallback_model
|
|
1565
1739
|
// (cheaper) instead of hard-killing — gives fallback_model a real job.
|
|
1566
|
-
const breakerTier =
|
|
1740
|
+
const breakerTier = ledger.tier();
|
|
1567
1741
|
if (breakerTier === "soft" && !softWarned) {
|
|
1568
1742
|
softWarned = true;
|
|
1569
1743
|
log.emit("budget.observation", {
|
|
@@ -1598,28 +1772,24 @@ export class Orchestrator {
|
|
|
1598
1772
|
baseRef: contract.repo.base_ref,
|
|
1599
1773
|
dirtyPolicy: "snapshot",
|
|
1600
1774
|
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,
|
|
1775
|
+
// Direct-workspace singletons run in place. Races and patch-envelope
|
|
1776
|
+
// transports stay isolated and adopt through the delivery service.
|
|
1777
|
+
inPlace: input.inPlace === true &&
|
|
1778
|
+
requestedSingleCandidate &&
|
|
1779
|
+
slot.routed.implementationTransport !== "git_patch_envelope",
|
|
1609
1780
|
});
|
|
1610
1781
|
const run = await this.runCandidateInEnvelope(slot.routed, envelope, slot.attemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
1611
1782
|
const safeEv = redactHarnessEvent(ev);
|
|
1612
1783
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
1613
1784
|
log.emit("harness.event", harnessEventPayload(adapter.id, slot.attemptId, safeEv));
|
|
1614
1785
|
}, 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")
|
|
1786
|
+
ledger.updateHold(slot.leaseId, streamedUsd);
|
|
1787
|
+
if (ledger.tier() !== "hard")
|
|
1618
1788
|
return false;
|
|
1619
1789
|
budgetStopped = true;
|
|
1620
1790
|
return true;
|
|
1621
|
-
}, input);
|
|
1622
|
-
|
|
1791
|
+
}, input, requestedSingleCandidate);
|
|
1792
|
+
ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
1623
1793
|
log.emit("harness.completed", {
|
|
1624
1794
|
harness_id: adapter.id,
|
|
1625
1795
|
attempt_id: slot.attemptId,
|
|
@@ -1637,8 +1807,10 @@ export class Orchestrator {
|
|
|
1637
1807
|
// post-stream throw (e.g. the secret-token assertion) carries its
|
|
1638
1808
|
// streamed spend on the error — settle the TRUE cost, never launder
|
|
1639
1809
|
// real spend down to 0.
|
|
1640
|
-
const carriedCost = typeof err?.costUsd === "number"
|
|
1641
|
-
|
|
1810
|
+
const carriedCost = typeof err?.costUsd === "number"
|
|
1811
|
+
? err.costUsd
|
|
1812
|
+
: 0;
|
|
1813
|
+
ledger.settle(slot.leaseId, unknownCostSettlement("post-stream-error", carriedCost));
|
|
1642
1814
|
const message = safeErrorMessage(err);
|
|
1643
1815
|
// envelope is still undefined when wsm.create() itself threw — that is
|
|
1644
1816
|
// a workspace-phase infrastructure failure, not a harness error.
|
|
@@ -1669,7 +1841,7 @@ export class Orchestrator {
|
|
|
1669
1841
|
errored: true,
|
|
1670
1842
|
costEstimated: false,
|
|
1671
1843
|
errors: [message],
|
|
1672
|
-
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb),
|
|
1844
|
+
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb, [slot.routed.browserRequirement, slot.routed.denyRequirement], knobs.model),
|
|
1673
1845
|
infraPhase,
|
|
1674
1846
|
};
|
|
1675
1847
|
}
|
|
@@ -1702,10 +1874,14 @@ export class Orchestrator {
|
|
|
1702
1874
|
}
|
|
1703
1875
|
if (input.signal?.aborted) {
|
|
1704
1876
|
await disposeReviewEnvelopes();
|
|
1705
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1877
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
1878
|
+
attemptId: r.attemptId,
|
|
1879
|
+
harnessId: r.harnessId,
|
|
1880
|
+
telemetry: r.telemetry,
|
|
1881
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1706
1882
|
}
|
|
1707
1883
|
if (runs.length === 0) {
|
|
1708
|
-
const status = budgetStopped ? "exhausted" : "failed";
|
|
1884
|
+
const status = ledger.terminal() ?? (budgetStopped ? "exhausted" : "failed");
|
|
1709
1885
|
const why = budgetStopped
|
|
1710
1886
|
? "budget exhausted before any candidate run"
|
|
1711
1887
|
: "no candidates produced";
|
|
@@ -1721,7 +1897,7 @@ export class Orchestrator {
|
|
|
1721
1897
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Phase: budget\n\n${why}\n`);
|
|
1722
1898
|
writeFailure(store, paths, {
|
|
1723
1899
|
phase: "budget",
|
|
1724
|
-
category: status
|
|
1900
|
+
category: isBudgetTerminal(status) ? "budget" : "internal",
|
|
1725
1901
|
safeMessage: why,
|
|
1726
1902
|
runDir: paths.root,
|
|
1727
1903
|
});
|
|
@@ -1831,7 +2007,11 @@ export class Orchestrator {
|
|
|
1831
2007
|
await disposeReviewEnvelopes();
|
|
1832
2008
|
}
|
|
1833
2009
|
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) => ({
|
|
2010
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
2011
|
+
attemptId: r.attemptId,
|
|
2012
|
+
harnessId: r.harnessId,
|
|
2013
|
+
telemetry: r.telemetry,
|
|
2014
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1835
2015
|
}
|
|
1836
2016
|
// Synthesis: if worthwhile, run a synthesizer as a NEW, re-checked candidate.
|
|
1837
2017
|
const synth = decideSynthesis(evidences, input.synthesis ?? "auto");
|
|
@@ -1839,14 +2019,12 @@ export class Orchestrator {
|
|
|
1839
2019
|
log.emit("synthesis.started", { synthesize: synth.synthesize, reason: synth.reason });
|
|
1840
2020
|
if (synth.synthesize && !budgetStopped) {
|
|
1841
2021
|
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({
|
|
2022
|
+
const lease = ledger.reserve({
|
|
1846
2023
|
taskId,
|
|
1847
2024
|
attemptId: "synth",
|
|
1848
2025
|
intent: "synthesize",
|
|
1849
2026
|
harnessId: synthRouted.adapter.id,
|
|
2027
|
+
cost: attemptCostEvidence(synthRouted.adapter.id, "synth", undefined, this.routeBillingKnowledge(input, synthRouted.adapter.id)),
|
|
1850
2028
|
});
|
|
1851
2029
|
if (lease.granted) {
|
|
1852
2030
|
let envelope;
|
|
@@ -1868,19 +2046,23 @@ export class Orchestrator {
|
|
|
1868
2046
|
accessProfile: candidateAccess,
|
|
1869
2047
|
});
|
|
1870
2048
|
const synthPrompt = `${plan.instructions}\n\nFindings to fix:\n${plan.fixFindings.map((f) => `- ${f}`).join("\n") || "(none)"}\n\nCandidate diffs:\n${sourceDiffs}`;
|
|
1871
|
-
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthPrompt, store, paths, wsm,
|
|
2049
|
+
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthPrompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
1872
2050
|
const safeEv = redactHarnessEvent(ev);
|
|
1873
2051
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
1874
2052
|
log.emit("harness.event", harnessEventPayload(synthAdapter.id, "synth", safeEv));
|
|
1875
2053
|
}, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input);
|
|
1876
|
-
|
|
2054
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
1877
2055
|
reviewEnvelopes.push(envelope);
|
|
1878
2056
|
envelope = undefined;
|
|
1879
2057
|
try {
|
|
1880
2058
|
const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
|
|
1881
2059
|
evidences.push(...synthEvidence);
|
|
1882
2060
|
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) => ({
|
|
2061
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
2062
|
+
attemptId: r.attemptId,
|
|
2063
|
+
harnessId: r.harnessId,
|
|
2064
|
+
telemetry: r.telemetry,
|
|
2065
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1884
2066
|
}
|
|
1885
2067
|
}
|
|
1886
2068
|
finally {
|
|
@@ -1890,7 +2072,7 @@ export class Orchestrator {
|
|
|
1890
2072
|
workingRuns.push(run);
|
|
1891
2073
|
}
|
|
1892
2074
|
catch (err) {
|
|
1893
|
-
|
|
2075
|
+
ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("synthesis-error"));
|
|
1894
2076
|
log.emit("harness.completed", {
|
|
1895
2077
|
attempt_id: "synth",
|
|
1896
2078
|
status: "failed",
|
|
@@ -1904,7 +2086,11 @@ export class Orchestrator {
|
|
|
1904
2086
|
}
|
|
1905
2087
|
}
|
|
1906
2088
|
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) => ({
|
|
2089
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
|
|
2090
|
+
attemptId: r.attemptId,
|
|
2091
|
+
harnessId: r.harnessId,
|
|
2092
|
+
telemetry: r.telemetry,
|
|
2093
|
+
})), null), ledger.spend(), input.signal, store);
|
|
1908
2094
|
}
|
|
1909
2095
|
let result;
|
|
1910
2096
|
try {
|
|
@@ -1933,35 +2119,35 @@ export class Orchestrator {
|
|
|
1933
2119
|
? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
|
|
1934
2120
|
: evidences.length > 0 && evidences.every((e) => e.reviewVerified);
|
|
1935
2121
|
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.
|
|
2122
|
+
if (status === "success" && ledger.terminal() !== null)
|
|
2123
|
+
status = ledger.terminal();
|
|
2124
|
+
// FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
|
|
1940
2125
|
let finalVerify = null;
|
|
1941
2126
|
let finalVerifyFailed = false;
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
|
|
1947
|
-
const
|
|
2127
|
+
let deliveryFailureReason = null;
|
|
2128
|
+
let raceDeliveryReceipt = null;
|
|
2129
|
+
// A single in-place turn already mutated its execution tree; race adoption
|
|
2130
|
+
// instead defers verification until immediately before delivery.
|
|
2131
|
+
const inPlaceWinner = winnerRun?.reviewCwd === execRoot;
|
|
2132
|
+
const deferredRaceVerify = input.inPlace === true && !inPlaceWinner;
|
|
1948
2133
|
if (winnerRun &&
|
|
1949
2134
|
!inPlaceWinner &&
|
|
2135
|
+
!deferredRaceVerify &&
|
|
1950
2136
|
winnerRun.diff.trim().length > 0 &&
|
|
1951
2137
|
(status === "success" || status === "ungated") &&
|
|
1952
2138
|
!input.signal?.aborted) {
|
|
1953
|
-
finalVerify = await finalVerifyPatch(execRoot, winnerRun,
|
|
1954
|
-
//
|
|
1955
|
-
// shared verdict owner (finalVerifyBlocks). accept_risk stays available.
|
|
2139
|
+
finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
|
|
2140
|
+
// Verify errors block like proven failures; accept_risk stays available.
|
|
1956
2141
|
finalVerifyFailed = finalVerifyBlocks(finalVerify);
|
|
1957
2142
|
if (finalVerifyFailed)
|
|
1958
2143
|
status = "blocked";
|
|
1959
2144
|
}
|
|
1960
2145
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1961
2146
|
...result.decision,
|
|
1962
|
-
//
|
|
1963
|
-
|
|
1964
|
-
|
|
2147
|
+
// A blocked terminal overrides the persisted green arbitration fields.
|
|
2148
|
+
...(status === "blocked"
|
|
2149
|
+
? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
|
|
2150
|
+
: {}),
|
|
1965
2151
|
review_verified: actualReviewVerified,
|
|
1966
2152
|
final_verify: finalVerify,
|
|
1967
2153
|
});
|
|
@@ -1977,45 +2163,55 @@ export class Orchestrator {
|
|
|
1977
2163
|
const blockers = winnerEvidence
|
|
1978
2164
|
? winnerEvidence.findings.filter((f) => isBlocking(f)).length
|
|
1979
2165
|
: 0;
|
|
1980
|
-
//
|
|
1981
|
-
// and the honest result_kind is "answer", not a misleading "patch").
|
|
2166
|
+
// Prose from an empty-diff winner is an answer, never a patch.
|
|
1982
2167
|
const winnerAnswer = winnerRun.answerText?.trim() ?? "";
|
|
1983
2168
|
const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
|
|
1984
|
-
|
|
2169
|
+
// The winner's final MESSAGE is the human-facing answer and materializes
|
|
2170
|
+
// for diff-ful runs too: the chat renders final/answer.md (the projection
|
|
2171
|
+
// prefers it), never the arbitration summary — "Run … Winner: a01 …" is
|
|
2172
|
+
// machine telemetry, not what the agent said. The diff stays in the
|
|
2173
|
+
// Diff tab; summary.md remains a diagnostics artifact.
|
|
2174
|
+
if (winnerAnswer.length > 0) {
|
|
1985
2175
|
store.writeText(join(paths.finalDir, "answer.md"), winnerAnswer + "\n");
|
|
1986
2176
|
}
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
//
|
|
2000
|
-
const adoptable = status === "success"
|
|
2177
|
+
// The run's structured-output contract: ONE engine validator, called on
|
|
2178
|
+
// the winner's answer regardless of diff presence (a non-conformant
|
|
2179
|
+
// answer stays success-with-warnings; the receipt is the truth).
|
|
2180
|
+
if (contract.output_schema) {
|
|
2181
|
+
finalizeStructuredOutput({
|
|
2182
|
+
store,
|
|
2183
|
+
finalDir: paths.finalDir,
|
|
2184
|
+
log,
|
|
2185
|
+
schema: contract.output_schema,
|
|
2186
|
+
answerText: winnerAnswer,
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
// Only a fully verified success may auto-adopt; ungated remains an artifact.
|
|
2190
|
+
const adoptable = status === "success";
|
|
2001
2191
|
let adopted = null;
|
|
2002
2192
|
let applyState = "not_applied";
|
|
2003
2193
|
let postTurnSha = null;
|
|
2194
|
+
let revertAnchorId = null;
|
|
2004
2195
|
if (input.inPlace === true && hasDiff) {
|
|
2005
|
-
if (
|
|
2196
|
+
if (inPlaceWinner) {
|
|
2006
2197
|
// Already live: the candidate ran in-place and wrote the tree itself.
|
|
2007
2198
|
adopted = true;
|
|
2008
2199
|
applyState = adoptable ? "applied" : "applied_review_blocked";
|
|
2009
|
-
//
|
|
2010
|
-
// edits made during review/arbitration are not folded into the target.
|
|
2200
|
+
// The pre-review fence excludes later user edits from the target.
|
|
2011
2201
|
postTurnSha = earlyPostTurnSha;
|
|
2012
2202
|
}
|
|
2013
2203
|
else if (adoptable) {
|
|
2014
|
-
// Protected
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2204
|
+
// Protected apply preserves the live tree or reports tree_mutated.
|
|
2205
|
+
const applied = await verifyAndDeliver(execRoot, winnerRun.diff, { mode: "apply", protectedApply: true }, gateSpecsFromContract(contract), (freshVerify) => {
|
|
2206
|
+
finalVerify = freshVerify;
|
|
2207
|
+
return finalVerifyBlocks(freshVerify)
|
|
2208
|
+
? (freshVerify.reason ?? "final verify failed before race adoption")
|
|
2209
|
+
: null;
|
|
2210
|
+
}, log);
|
|
2211
|
+
raceDeliveryReceipt = applied;
|
|
2212
|
+
store.writeYaml(join(paths.finalDir, "delivery_receipt.yaml"), applied);
|
|
2213
|
+
finalVerify = applied.finalVerify;
|
|
2214
|
+
if (applied.applied) {
|
|
2019
2215
|
adopted = true;
|
|
2020
2216
|
applyState = "applied";
|
|
2021
2217
|
log.emit("work_product.adopted", {
|
|
@@ -2023,17 +2219,21 @@ export class Orchestrator {
|
|
|
2023
2219
|
patch_sha256: patchSha256,
|
|
2024
2220
|
winner: winnerRun.attemptId,
|
|
2025
2221
|
});
|
|
2026
|
-
// Race winner: snapshot immediately after applying (minimal window).
|
|
2027
2222
|
try {
|
|
2028
2223
|
postTurnSha = await snapshotTree(execRoot);
|
|
2029
2224
|
}
|
|
2030
2225
|
catch {
|
|
2031
2226
|
postTurnSha = null;
|
|
2032
2227
|
}
|
|
2228
|
+
revertAnchorId = createRevertAnchorFromPatchOrNull(execRoot, winnerRun.diff);
|
|
2033
2229
|
}
|
|
2034
2230
|
else {
|
|
2035
2231
|
adopted = false;
|
|
2036
2232
|
applyState = "not_applied";
|
|
2233
|
+
deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
|
|
2234
|
+
status = "blocked";
|
|
2235
|
+
if (finalVerifyBlocks(finalVerify))
|
|
2236
|
+
finalVerifyFailed = true;
|
|
2037
2237
|
log.emit("work_product.adopted", {
|
|
2038
2238
|
applied: false,
|
|
2039
2239
|
patch_sha256: patchSha256,
|
|
@@ -2043,18 +2243,30 @@ export class Orchestrator {
|
|
|
2043
2243
|
}
|
|
2044
2244
|
}
|
|
2045
2245
|
}
|
|
2246
|
+
writeRaceDeliveryDecision(store, decisionPath, {
|
|
2247
|
+
decision: result.decision,
|
|
2248
|
+
status,
|
|
2249
|
+
reviewVerified: actualReviewVerified,
|
|
2250
|
+
finalVerify,
|
|
2251
|
+
deliveryFailureReason,
|
|
2252
|
+
deliveryReceiptPath: raceDeliveryReceipt ? "final/delivery_receipt.yaml" : null,
|
|
2253
|
+
});
|
|
2254
|
+
if (inPlaceWinner && requestedSingleCandidate && adopted === true) {
|
|
2255
|
+
revertAnchorId = await createRevertAnchorOrNull(execRoot, preTurnSha, postTurnSha);
|
|
2256
|
+
}
|
|
2046
2257
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
2047
2258
|
id: newId("wp"),
|
|
2048
2259
|
kind: input.create === true ? "new_repo" : "patch",
|
|
2049
2260
|
source_task_id: taskId,
|
|
2050
2261
|
producer_attempt_id: winnerRun.attemptId,
|
|
2262
|
+
...(raceDeliveryReceipt
|
|
2263
|
+
? { files: { delivery_receipt: "final/delivery_receipt.yaml" } }
|
|
2264
|
+
: {}),
|
|
2051
2265
|
meta: {
|
|
2052
2266
|
harness_id: winnerRun.harnessId,
|
|
2053
2267
|
synthesis: synth,
|
|
2054
2268
|
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).
|
|
2269
|
+
// Artifact-only apply reads the same terminal status as the daemon.
|
|
2058
2270
|
status,
|
|
2059
2271
|
review_verified: actualReviewVerified,
|
|
2060
2272
|
budget_stopped: budgetStopped,
|
|
@@ -2070,6 +2282,7 @@ export class Orchestrator {
|
|
|
2070
2282
|
apply_state: applyState,
|
|
2071
2283
|
pre_turn_sha: preTurnSha,
|
|
2072
2284
|
post_turn_sha: postTurnSha,
|
|
2285
|
+
revert_anchor_id: revertAnchorId,
|
|
2073
2286
|
},
|
|
2074
2287
|
});
|
|
2075
2288
|
store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, status }, evidences, synth.reason, actualReviewVerified));
|
|
@@ -2084,7 +2297,10 @@ export class Orchestrator {
|
|
|
2084
2297
|
? result.decision.winner
|
|
2085
2298
|
: (winnerRun?.attemptId ?? null));
|
|
2086
2299
|
const honestTerminal = status === "no_op" || status === "ungated" || status === "review_not_run";
|
|
2087
|
-
if (finalVerifyFailed) {
|
|
2300
|
+
if (deliveryFailureReason && !finalVerifyFailed) {
|
|
2301
|
+
writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
|
|
2302
|
+
}
|
|
2303
|
+
else if (finalVerifyFailed) {
|
|
2088
2304
|
writeFailure(store, paths, {
|
|
2089
2305
|
phase: "verification",
|
|
2090
2306
|
// RunFailure.category is a closed enum; "validation" is the honest
|
|
@@ -2112,7 +2328,7 @@ export class Orchestrator {
|
|
|
2112
2328
|
? "policy"
|
|
2113
2329
|
: winnerRun?.errored
|
|
2114
2330
|
? "harness_error"
|
|
2115
|
-
: status
|
|
2331
|
+
: isBudgetTerminal(status)
|
|
2116
2332
|
? "budget"
|
|
2117
2333
|
: "internal",
|
|
2118
2334
|
harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
|
|
@@ -2154,7 +2370,11 @@ export class Orchestrator {
|
|
|
2154
2370
|
// phase "verification", not "review").
|
|
2155
2371
|
log.emit("run.blocked", {
|
|
2156
2372
|
status,
|
|
2157
|
-
phase:
|
|
2373
|
+
phase: deliveryFailureReason && !finalVerifyFailed
|
|
2374
|
+
? "delivery"
|
|
2375
|
+
: finalVerifyFailed
|
|
2376
|
+
? "verification"
|
|
2377
|
+
: "review",
|
|
2158
2378
|
failure_ref: "final/failure.yaml",
|
|
2159
2379
|
});
|
|
2160
2380
|
}
|
|
@@ -2181,156 +2401,27 @@ export class Orchestrator {
|
|
|
2181
2401
|
}
|
|
2182
2402
|
/** Single-owner telemetry artifact (final/telemetry.yaml); surfaces project it, never recompute. */
|
|
2183
2403
|
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,
|
|
2404
|
+
writeRunTelemetryArtifact({
|
|
2405
|
+
store,
|
|
2406
|
+
finalDir: paths.finalDir,
|
|
2407
|
+
contract,
|
|
2408
|
+
runId,
|
|
2409
|
+
taskId,
|
|
2193
2410
|
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(),
|
|
2411
|
+
attempts,
|
|
2412
|
+
finalAttemptId,
|
|
2413
|
+
resolveAuthPreference: (harnessId) => this.authPreferenceForHarness(contract.repo.root, harnessId, contract.auth_preference),
|
|
2204
2414
|
});
|
|
2205
|
-
store.writeYaml(join(paths.finalDir, "telemetry.yaml"), telemetry);
|
|
2206
2415
|
}
|
|
2207
2416
|
/** 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
2417
|
/**
|
|
2327
2418
|
* 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).
|
|
2419
|
+
* scoped harness HOME (Bible §6) so reviewer scratch state and injected auth
|
|
2420
|
+
* routes do not enter the project or ordinary operator HOME. Native
|
|
2421
|
+
* Codex/Claude routes deliberately keep their vendor-owned host-user stores;
|
|
2422
|
+
* no credential file is copied into the scoped home. Every call site MUST go
|
|
2423
|
+
* through here so the non-native scoping cannot drift. Disposed once the
|
|
2424
|
+
* panel settles (resolve OR reject).
|
|
2334
2425
|
*/
|
|
2335
2426
|
reviewScoped(input) {
|
|
2336
2427
|
const reviewHome = new WorkspaceManager(input.cwd).readOnlyHomeEnv();
|
|
@@ -2347,7 +2438,7 @@ export class Orchestrator {
|
|
|
2347
2438
|
const candidateCwd = run.reviewCwd ?? cwd;
|
|
2348
2439
|
const candidateEvidenceDir = this.prepareReviewEvidenceDir(reviewDir, candidateCwd);
|
|
2349
2440
|
try {
|
|
2350
|
-
|
|
2441
|
+
writeText(join(candidateEvidenceDir, "TESTS.txt"), renderTestsEvidence(contract, run.gates).trim() + "\n");
|
|
2351
2442
|
// a candidate that changed NO files has nothing to review — never
|
|
2352
2443
|
// spend a reviewer panel on "(empty diff)" (a trivial greeting in agent mode used to
|
|
2353
2444
|
// cost two reviewers). It still flows through policy gates and arbitration
|
|
@@ -2360,6 +2451,7 @@ export class Orchestrator {
|
|
|
2360
2451
|
attemptId: run.attemptId,
|
|
2361
2452
|
intent: "review",
|
|
2362
2453
|
harnessId: "review-panel",
|
|
2454
|
+
cost: attemptCostEvidence("review-panel", run.attemptId),
|
|
2363
2455
|
})
|
|
2364
2456
|
: undefined;
|
|
2365
2457
|
const result = hasDiff && reviewers.length > 0 && (reviewLease?.granted ?? true)
|
|
@@ -2387,7 +2479,7 @@ export class Orchestrator {
|
|
|
2387
2479
|
reviewSpendEstimated: false,
|
|
2388
2480
|
};
|
|
2389
2481
|
if (reviewLease?.granted) {
|
|
2390
|
-
ledger?.settle(reviewLease.lease?.lease_id ?? "", result.reviewSpendUsd
|
|
2482
|
+
ledger?.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(result.reviewSpendUsd, result.reviewSpendEstimated, "review-usage", [`attempt:${run.attemptId}`, "review:panel"]));
|
|
2391
2483
|
if ((result.reviewSpendUsd ?? 0) > 0) {
|
|
2392
2484
|
log.emit("budget.observation", {
|
|
2393
2485
|
harness_id: "review-panel",
|
|
@@ -2417,7 +2509,7 @@ export class Orchestrator {
|
|
|
2417
2509
|
// Mirrors the convergence path (actualReviewVerified).
|
|
2418
2510
|
const candidateReviewVerified = reviewVerified && result.crossFamilyHealthy && result.crossFamilyVerified;
|
|
2419
2511
|
// Typed policy gate (risk + protected paths) merges with reviewer findings.
|
|
2420
|
-
const policy =
|
|
2512
|
+
const policy = policyFindings(run, candidateReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
|
|
2421
2513
|
const allFindings = [...policy.findings, ...revalidated];
|
|
2422
2514
|
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
2423
2515
|
const noBlockers = !allFindings.some((f) => isBlocking(f));
|
|
@@ -2448,23 +2540,15 @@ export class Orchestrator {
|
|
|
2448
2540
|
}
|
|
2449
2541
|
return evidences;
|
|
2450
2542
|
}
|
|
2451
|
-
prepareReviewEvidenceDir(sourceDir,
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2543
|
+
prepareReviewEvidenceDir(sourceDir, _candidateCwd) {
|
|
2544
|
+
// Evidence is an external runtime artifact. ReviewEngine builds a separate
|
|
2545
|
+
// reviewer workspace and copies the packet there; writing/copying it into
|
|
2546
|
+
// the candidate tree would contaminate the Git diff and, worse, overwrite a
|
|
2547
|
+
// user-owned path with the same name.
|
|
2456
2548
|
if (!existsSync(sourceDir)) {
|
|
2457
2549
|
throw new Error(`review evidence preflight failed for ${sourceDir}: source packet missing`);
|
|
2458
2550
|
}
|
|
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);
|
|
2551
|
+
return this.requireReviewEvidence(sourceDir);
|
|
2468
2552
|
}
|
|
2469
2553
|
requireReviewEvidence(dir) {
|
|
2470
2554
|
const result = preflightEvidence(dir);
|
|
@@ -2474,20 +2558,9 @@ export class Orchestrator {
|
|
|
2474
2558
|
const empty = result.empty.length ? `empty=${result.empty.join(",")}` : "";
|
|
2475
2559
|
throw new Error(`review evidence preflight failed for ${dir}: ${[missing, empty].filter(Boolean).join(" ")}`);
|
|
2476
2560
|
}
|
|
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
|
-
}
|
|
2561
|
+
cleanupReviewEvidenceDir(_candidateEvidenceDir, _candidateCwd) {
|
|
2562
|
+
// No candidate-tree packet exists in v2; external runtime retention is
|
|
2563
|
+
// governed by the artifact/journal lifecycle rather than best-effort rm.
|
|
2491
2564
|
return null;
|
|
2492
2565
|
}
|
|
2493
2566
|
recordReviewEvidenceCleanup(store, metadataPath, attemptId, candidateEvidenceDir, candidateCwd) {
|
|
@@ -2521,11 +2594,20 @@ export class Orchestrator {
|
|
|
2521
2594
|
const execRoot = this.execRootOf(input);
|
|
2522
2595
|
const wsm = new WorkspaceManager(execRoot);
|
|
2523
2596
|
const readiness = new ReadinessLedger();
|
|
2524
|
-
const ledger =
|
|
2597
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
2525
2598
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
2526
2599
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
2527
2600
|
log.emit("run.created", { mode, prompt: redactSecrets(input.prompt) });
|
|
2528
|
-
announce?.({
|
|
2601
|
+
announce?.({
|
|
2602
|
+
log,
|
|
2603
|
+
store,
|
|
2604
|
+
paths,
|
|
2605
|
+
runId,
|
|
2606
|
+
taskId,
|
|
2607
|
+
mode,
|
|
2608
|
+
phase: "convergence",
|
|
2609
|
+
spend: () => ledger.spend(),
|
|
2610
|
+
});
|
|
2529
2611
|
// Live (in-place) isolation deliberately tolerates non-git stateful
|
|
2530
2612
|
// environments; only envelope isolation needs the git boundary.
|
|
2531
2613
|
if (!input.inPlace) {
|
|
@@ -2548,7 +2630,7 @@ export class Orchestrator {
|
|
|
2548
2630
|
writeEvidencePacket(reviewDir, {
|
|
2549
2631
|
userIntent: redactSecrets(input.prompt),
|
|
2550
2632
|
diff: "(per-attempt)\n",
|
|
2551
|
-
tests:
|
|
2633
|
+
tests: renderTestsEvidence(contract),
|
|
2552
2634
|
});
|
|
2553
2635
|
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, mode);
|
|
2554
2636
|
if ("failed" in reviewersOutcome)
|
|
@@ -2558,7 +2640,8 @@ export class Orchestrator {
|
|
|
2558
2640
|
// One envelope carried forward across attempts so the harness can repair its own work.
|
|
2559
2641
|
let adapterPool;
|
|
2560
2642
|
try {
|
|
2561
|
-
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input));
|
|
2643
|
+
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
|
|
2644
|
+
this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
|
|
2562
2645
|
}
|
|
2563
2646
|
catch (err) {
|
|
2564
2647
|
const message = safeErrorMessage(err);
|
|
@@ -2655,7 +2738,6 @@ export class Orchestrator {
|
|
|
2655
2738
|
const stallThreshold = input.untilClean === true ? 4 : 2;
|
|
2656
2739
|
const allCooledDown = () => adapterPool.every((a) => ledger.cooldownActive(a.adapter.id));
|
|
2657
2740
|
const attemptTelemetries = [];
|
|
2658
|
-
const harnessLedgers = new Map();
|
|
2659
2741
|
let lastDiffStable = true;
|
|
2660
2742
|
let reviewSpendEstimated = false;
|
|
2661
2743
|
try {
|
|
@@ -2707,12 +2789,12 @@ export class Orchestrator {
|
|
|
2707
2789
|
exhausted = true;
|
|
2708
2790
|
break;
|
|
2709
2791
|
}
|
|
2710
|
-
|
|
2711
|
-
const lease = this.harnessLedger(harnessLedgers, ledger, routed).reserve({
|
|
2792
|
+
const lease = ledger.reserve({
|
|
2712
2793
|
taskId,
|
|
2713
2794
|
attemptId,
|
|
2714
2795
|
intent: "repair",
|
|
2715
2796
|
harnessId: adapter.id,
|
|
2797
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
2716
2798
|
});
|
|
2717
2799
|
if (!lease.granted) {
|
|
2718
2800
|
exhausted = true;
|
|
@@ -2733,11 +2815,10 @@ export class Orchestrator {
|
|
|
2733
2815
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
2734
2816
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
2735
2817
|
}, 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);
|
|
2818
|
+
ledger.updateHold(lease.lease?.lease_id ?? "", streamedUsd);
|
|
2819
|
+
return ledger.tier() === "hard";
|
|
2820
|
+
}, input, true);
|
|
2821
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
2741
2822
|
log.emit("harness.completed", {
|
|
2742
2823
|
harness_id: adapter.id,
|
|
2743
2824
|
attempt_id: attemptId,
|
|
@@ -2749,7 +2830,7 @@ export class Orchestrator {
|
|
|
2749
2830
|
catch (err) {
|
|
2750
2831
|
// Envelope/setup failure before the stream; stream errors are absorbed
|
|
2751
2832
|
// inside runCandidateInEnvelope with their real accumulated cost.
|
|
2752
|
-
|
|
2833
|
+
ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("attempt-error"));
|
|
2753
2834
|
log.emit("harness.completed", {
|
|
2754
2835
|
harness_id: adapter.id,
|
|
2755
2836
|
attempt_id: attemptId,
|
|
@@ -2766,7 +2847,7 @@ export class Orchestrator {
|
|
|
2766
2847
|
errored: true,
|
|
2767
2848
|
costEstimated: false,
|
|
2768
2849
|
errors: [safeErrorMessage(err)],
|
|
2769
|
-
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb),
|
|
2850
|
+
telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb, [routed.browserRequirement, routed.denyRequirement], knobs.model),
|
|
2770
2851
|
};
|
|
2771
2852
|
}
|
|
2772
2853
|
lastRun = run;
|
|
@@ -2790,7 +2871,7 @@ export class Orchestrator {
|
|
|
2790
2871
|
const candidateReviewCwd = run.reviewCwd ?? input.repoRoot;
|
|
2791
2872
|
const candidateReviewEvidenceDir = this.prepareReviewEvidenceDir(reviewDir, candidateReviewCwd);
|
|
2792
2873
|
try {
|
|
2793
|
-
|
|
2874
|
+
writeText(join(candidateReviewEvidenceDir, "TESTS.txt"), renderTestsEvidence(contract, run.gates).trim() + "\n");
|
|
2794
2875
|
// Reviewer panels spend real money in convergence too: reserve before,
|
|
2795
2876
|
// settle the observed cost, and surface it as a budget observation
|
|
2796
2877
|
// (parity with the race path's reviewRuns metering).
|
|
@@ -2800,6 +2881,7 @@ export class Orchestrator {
|
|
|
2800
2881
|
attemptId,
|
|
2801
2882
|
intent: "review",
|
|
2802
2883
|
harnessId: "review-panel",
|
|
2884
|
+
cost: attemptCostEvidence("review-panel", attemptId),
|
|
2803
2885
|
})
|
|
2804
2886
|
: null;
|
|
2805
2887
|
const reviewResult = reviewers.length > 0 && (reviewLease?.granted ?? false)
|
|
@@ -2826,7 +2908,7 @@ export class Orchestrator {
|
|
|
2826
2908
|
reviewSpendEstimated: false,
|
|
2827
2909
|
};
|
|
2828
2910
|
if (reviewLease?.granted) {
|
|
2829
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewResult.reviewSpendUsd
|
|
2911
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(reviewResult.reviewSpendUsd, reviewResult.reviewSpendEstimated, "review-usage", [`attempt:${attemptId}`, "review:panel"]));
|
|
2830
2912
|
if ((reviewResult.reviewSpendUsd ?? 0) > 0) {
|
|
2831
2913
|
log.emit("budget.observation", {
|
|
2832
2914
|
harness_id: "review-panel",
|
|
@@ -2856,7 +2938,7 @@ export class Orchestrator {
|
|
|
2856
2938
|
evidenceDir: candidateReviewEvidenceDir,
|
|
2857
2939
|
});
|
|
2858
2940
|
// Typed policy gate (risk + protected paths) merges with reviewer findings.
|
|
2859
|
-
const policy =
|
|
2941
|
+
const policy = policyFindings(run, actualReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
|
|
2860
2942
|
const allFindings = [...policy.findings, ...revalidated];
|
|
2861
2943
|
lastFindings = allFindings;
|
|
2862
2944
|
store.writeYaml(join(paths.reviewsDir, `${attemptId}.yaml`), {
|
|
@@ -2994,6 +3076,8 @@ export class Orchestrator {
|
|
|
2994
3076
|
: exhausted
|
|
2995
3077
|
? "exhausted"
|
|
2996
3078
|
: "not_converged";
|
|
3079
|
+
if (status === "success" && ledger.terminal() !== null)
|
|
3080
|
+
status = ledger.terminal();
|
|
2997
3081
|
let decision = null;
|
|
2998
3082
|
if (lastRun) {
|
|
2999
3083
|
const arb = arbitrate([
|
|
@@ -3027,7 +3111,7 @@ export class Orchestrator {
|
|
|
3027
3111
|
lastRun.diff.trim().length > 0 &&
|
|
3028
3112
|
(status === "success" || status === "ungated") &&
|
|
3029
3113
|
!input.signal?.aborted) {
|
|
3030
|
-
convFinalVerify = await finalVerifyPatch(execRoot, lastRun,
|
|
3114
|
+
convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
|
|
3031
3115
|
if (finalVerifyBlocks(convFinalVerify))
|
|
3032
3116
|
status = "blocked";
|
|
3033
3117
|
}
|
|
@@ -3036,7 +3120,9 @@ export class Orchestrator {
|
|
|
3036
3120
|
// overrides the persisted decision; final_verify is recorded either way.
|
|
3037
3121
|
decision = {
|
|
3038
3122
|
...decision,
|
|
3039
|
-
...(status === "blocked"
|
|
3123
|
+
...(status === "blocked"
|
|
3124
|
+
? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
|
|
3125
|
+
: {}),
|
|
3040
3126
|
final_verify: convFinalVerify,
|
|
3041
3127
|
};
|
|
3042
3128
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
@@ -3058,6 +3144,9 @@ export class Orchestrator {
|
|
|
3058
3144
|
? "applied"
|
|
3059
3145
|
: "applied_review_blocked"
|
|
3060
3146
|
: "not_applied";
|
|
3147
|
+
const revertAnchorId = convAdopted === true
|
|
3148
|
+
? await createRevertAnchorOrNull(execRoot, preTurnSha, lastPostTurnSha)
|
|
3149
|
+
: null;
|
|
3061
3150
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
3062
3151
|
id: newId("wp"),
|
|
3063
3152
|
kind: "patch",
|
|
@@ -3065,6 +3154,7 @@ export class Orchestrator {
|
|
|
3065
3154
|
producer_attempt_id: lastRun.attemptId,
|
|
3066
3155
|
meta: {
|
|
3067
3156
|
harness_id: lastRun.harnessId,
|
|
3157
|
+
result_kind: "patch",
|
|
3068
3158
|
mode,
|
|
3069
3159
|
attempts: attempt,
|
|
3070
3160
|
status,
|
|
@@ -3074,6 +3164,7 @@ export class Orchestrator {
|
|
|
3074
3164
|
apply_state: convApplyState,
|
|
3075
3165
|
pre_turn_sha: convAdopted === true ? preTurnSha : null,
|
|
3076
3166
|
post_turn_sha: convAdopted === true ? lastPostTurnSha : null,
|
|
3167
|
+
revert_anchor_id: revertAnchorId,
|
|
3077
3168
|
},
|
|
3078
3169
|
});
|
|
3079
3170
|
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 +3179,7 @@ export class Orchestrator {
|
|
|
3088
3179
|
if (!converged) {
|
|
3089
3180
|
writeFailure(store, paths, {
|
|
3090
3181
|
phase: "convergence",
|
|
3091
|
-
category: status
|
|
3182
|
+
category: isBudgetTerminal(status)
|
|
3092
3183
|
? "budget"
|
|
3093
3184
|
: status === "cancelled"
|
|
3094
3185
|
? "cancelled"
|
|
@@ -3204,19 +3295,32 @@ export class Orchestrator {
|
|
|
3204
3295
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
3205
3296
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
3206
3297
|
log.emit("run.created", { mode: "plan", prompt: redactSecrets(input.prompt) });
|
|
3207
|
-
const ledger =
|
|
3208
|
-
announce?.({
|
|
3298
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
3299
|
+
announce?.({
|
|
3300
|
+
log,
|
|
3301
|
+
store,
|
|
3302
|
+
paths,
|
|
3303
|
+
runId,
|
|
3304
|
+
taskId,
|
|
3305
|
+
mode: "plan",
|
|
3306
|
+
phase: "plan",
|
|
3307
|
+
spend: () => ledger.spend(),
|
|
3308
|
+
});
|
|
3209
3309
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3210
3310
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3211
3311
|
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
|
|
3212
3312
|
if ("failed" in reviewersOutcome)
|
|
3213
3313
|
return reviewersOutcome.failed;
|
|
3214
3314
|
const reviewers = reviewersOutcome.reviewers;
|
|
3315
|
+
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3316
|
+
// planner spawn consume the SAME scoped env (see routeContext.ts).
|
|
3317
|
+
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
3215
3318
|
let adapters;
|
|
3216
3319
|
try {
|
|
3217
|
-
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan");
|
|
3320
|
+
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
|
|
3218
3321
|
}
|
|
3219
3322
|
catch (err) {
|
|
3323
|
+
roHome.dispose();
|
|
3220
3324
|
const message = safeErrorMessage(err);
|
|
3221
3325
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
3222
3326
|
writeFailure(store, paths, {
|
|
@@ -3251,6 +3355,7 @@ export class Orchestrator {
|
|
|
3251
3355
|
contextSection = await this.lazyContextSection(input, contract, store, paths, log);
|
|
3252
3356
|
}
|
|
3253
3357
|
catch (err) {
|
|
3358
|
+
roHome.dispose();
|
|
3254
3359
|
const message = safeErrorMessage(err);
|
|
3255
3360
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
|
|
3256
3361
|
writeFailure(store, paths, {
|
|
@@ -3282,17 +3387,19 @@ export class Orchestrator {
|
|
|
3282
3387
|
const plans = [];
|
|
3283
3388
|
const planAttempts = [];
|
|
3284
3389
|
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
3390
|
try {
|
|
3290
3391
|
for (const [idx, routed] of adapters.entries()) {
|
|
3291
3392
|
if (input.signal?.aborted)
|
|
3292
3393
|
break;
|
|
3293
3394
|
const adapter = routed.adapter;
|
|
3294
3395
|
const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
|
|
3295
|
-
const lease = ledger.reserve({
|
|
3396
|
+
const lease = ledger.reserve({
|
|
3397
|
+
taskId,
|
|
3398
|
+
attemptId,
|
|
3399
|
+
intent: "plan",
|
|
3400
|
+
harnessId: adapter.id,
|
|
3401
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3402
|
+
});
|
|
3296
3403
|
if (!lease.granted) {
|
|
3297
3404
|
log.emit("budget.lease.created", {
|
|
3298
3405
|
granted: false,
|
|
@@ -3313,16 +3420,8 @@ export class Orchestrator {
|
|
|
3313
3420
|
// Planners must SEE any image/file the user attached (e.g. "plan a fix for
|
|
3314
3421
|
// what's in this screenshot"), not just agent/race runs.
|
|
3315
3422
|
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,
|
|
3423
|
+
...this.sessionSpecFields(input, adapter.id, log),
|
|
3424
|
+
...this.harnessSpecKnobs(contract, knobs, "plan"),
|
|
3326
3425
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3327
3426
|
env: roHome.env,
|
|
3328
3427
|
});
|
|
@@ -3334,10 +3433,13 @@ export class Orchestrator {
|
|
|
3334
3433
|
if (planInteraction)
|
|
3335
3434
|
spec.extra["interactionChannel"] = planInteraction;
|
|
3336
3435
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3337
|
-
const
|
|
3436
|
+
const answer = new AnswerAssembly();
|
|
3338
3437
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3339
3438
|
knobs.webPolicy === "cached" ||
|
|
3340
|
-
knobs.webPolicy === "live", effectiveWeb
|
|
3439
|
+
knobs.webPolicy === "live", effectiveWeb, [],
|
|
3440
|
+
// Requested-model capture: a plan lane silently downgraded to another
|
|
3441
|
+
// model surfaces the mismatch in its route receipt, just like agent.
|
|
3442
|
+
knobs.model);
|
|
3341
3443
|
const onAbort = () => {
|
|
3342
3444
|
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3343
3445
|
};
|
|
@@ -3348,6 +3450,7 @@ export class Orchestrator {
|
|
|
3348
3450
|
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3349
3451
|
}
|
|
3350
3452
|
let cost = 0;
|
|
3453
|
+
let costEstimated = false;
|
|
3351
3454
|
let harnessError = null;
|
|
3352
3455
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
3353
3456
|
try {
|
|
@@ -3371,23 +3474,29 @@ export class Orchestrator {
|
|
|
3371
3474
|
break;
|
|
3372
3475
|
const safeEv = redactHarnessEvent(ev);
|
|
3373
3476
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3374
|
-
// NOT observed for resume:
|
|
3375
|
-
//
|
|
3376
|
-
//
|
|
3377
|
-
//
|
|
3378
|
-
// `if (inPlaceEnvelope)` guard. Codex-review-confirmed.
|
|
3477
|
+
// NOT observed for resume: a read-only planner is not a chat turn,
|
|
3478
|
+
// and attaching its session id would poison thread continuity (and
|
|
3479
|
+
// race parallel planner/reviewer sessions), regardless of whether
|
|
3480
|
+
// the vendor stored that session in the scoped or native store.
|
|
3379
3481
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
3380
3482
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
3381
3483
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
3382
3484
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
3383
3485
|
if (safeEv.plan_progress) {
|
|
3384
|
-
log.emit("plan.progress", {
|
|
3486
|
+
log.emit("plan.progress", {
|
|
3487
|
+
attempt_id: attemptId,
|
|
3488
|
+
harness_id: adapter.id,
|
|
3489
|
+
items: safeEv.plan_progress.items,
|
|
3490
|
+
});
|
|
3385
3491
|
}
|
|
3386
3492
|
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
3387
3493
|
// the loudest) — same single owner as the agent loop.
|
|
3388
3494
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
3495
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
3389
3496
|
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
3390
3497
|
cost += safeEv.usage.cost_usd;
|
|
3498
|
+
if (safeEv.usage.estimated)
|
|
3499
|
+
costEstimated = true;
|
|
3391
3500
|
log.emit("budget.observation", {
|
|
3392
3501
|
harness_id: adapter.id,
|
|
3393
3502
|
attempt_id: attemptId,
|
|
@@ -3396,11 +3505,8 @@ export class Orchestrator {
|
|
|
3396
3505
|
estimated: safeEv.usage.estimated === true,
|
|
3397
3506
|
});
|
|
3398
3507
|
}
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
safeEv.payload?.["auth_switched"] !== true) {
|
|
3402
|
-
pushUniqueText(parts, safeEv.text);
|
|
3403
|
-
}
|
|
3508
|
+
// A TYPED final message wins verbatim over joined narration.
|
|
3509
|
+
answer.observe(safeEv);
|
|
3404
3510
|
if (safeEv.type === "error")
|
|
3405
3511
|
harnessError = safeEv.error
|
|
3406
3512
|
? redactSecrets(safeEv.error)
|
|
@@ -3413,7 +3519,7 @@ export class Orchestrator {
|
|
|
3413
3519
|
}
|
|
3414
3520
|
finally {
|
|
3415
3521
|
input.signal?.removeEventListener("abort", onAbort);
|
|
3416
|
-
ledger.settle(lease.lease?.lease_id ?? "", cost);
|
|
3522
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode));
|
|
3417
3523
|
}
|
|
3418
3524
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
|
|
3419
3525
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
@@ -3443,7 +3549,7 @@ export class Orchestrator {
|
|
|
3443
3549
|
});
|
|
3444
3550
|
continue;
|
|
3445
3551
|
}
|
|
3446
|
-
const text =
|
|
3552
|
+
const text = answer.text() || "(no output)";
|
|
3447
3553
|
log.emit("harness.completed", {
|
|
3448
3554
|
harness_id: adapter.id,
|
|
3449
3555
|
attempt_id: attemptId,
|
|
@@ -3456,7 +3562,7 @@ export class Orchestrator {
|
|
|
3456
3562
|
}
|
|
3457
3563
|
}
|
|
3458
3564
|
finally {
|
|
3459
|
-
// Planners done (or threw) —
|
|
3565
|
+
// Planners done (or threw) — reclaim scoped scratch/API-route state.
|
|
3460
3566
|
roHome.dispose();
|
|
3461
3567
|
}
|
|
3462
3568
|
if (input.signal?.aborted) {
|
|
@@ -3464,7 +3570,7 @@ export class Orchestrator {
|
|
|
3464
3570
|
attemptId: p.attemptId,
|
|
3465
3571
|
harnessId: p.harnessId,
|
|
3466
3572
|
status: p.status,
|
|
3467
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend());
|
|
3573
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3468
3574
|
}
|
|
3469
3575
|
if (plans.length === 0) {
|
|
3470
3576
|
const blocked = planAttempts.some((p) => p.status === "blocked");
|
|
@@ -3523,7 +3629,7 @@ export class Orchestrator {
|
|
|
3523
3629
|
userIntent: redactSecrets(input.prompt),
|
|
3524
3630
|
planAccepted: planEvidence,
|
|
3525
3631
|
diff: planReviewDiff,
|
|
3526
|
-
tests:
|
|
3632
|
+
tests: renderTestsEvidence(contract),
|
|
3527
3633
|
});
|
|
3528
3634
|
// Reserve BEFORE spending: a hard budget tier must stop the paid plan
|
|
3529
3635
|
// review from starting, not account for it after the fact.
|
|
@@ -3532,6 +3638,7 @@ export class Orchestrator {
|
|
|
3532
3638
|
attemptId: "plan-review",
|
|
3533
3639
|
intent: "review",
|
|
3534
3640
|
harnessId: "review-panel",
|
|
3641
|
+
cost: attemptCostEvidence("review-panel", "plan-review"),
|
|
3535
3642
|
});
|
|
3536
3643
|
if (lease.granted) {
|
|
3537
3644
|
const res = await this.reviewScoped({
|
|
@@ -3555,7 +3662,10 @@ export class Orchestrator {
|
|
|
3555
3662
|
route_proofs: res.routeProofs,
|
|
3556
3663
|
reviewer_requests: res.reviewerRequests,
|
|
3557
3664
|
});
|
|
3558
|
-
ledger.settle(lease.lease?.lease_id ?? "", res.reviewSpendUsd
|
|
3665
|
+
ledger.settle(lease.lease?.lease_id ?? "", usageCostSettlement(res.reviewSpendUsd, res.reviewSpendEstimated, "review-usage", [
|
|
3666
|
+
"attempt:plan-review",
|
|
3667
|
+
"review:panel",
|
|
3668
|
+
]));
|
|
3559
3669
|
if ((res.reviewSpendUsd ?? 0) > 0) {
|
|
3560
3670
|
log.emit("budget.observation", {
|
|
3561
3671
|
harness_id: "review-panel",
|
|
@@ -3579,7 +3689,7 @@ export class Orchestrator {
|
|
|
3579
3689
|
attemptId: p.attemptId,
|
|
3580
3690
|
harnessId: p.harnessId,
|
|
3581
3691
|
status: p.status,
|
|
3582
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend());
|
|
3692
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3583
3693
|
}
|
|
3584
3694
|
const failedPlanners = planAttempts.filter((p) => p.status !== "success");
|
|
3585
3695
|
// ALL review findings are shown (severity-marked), so a BLOCK like "the
|
|
@@ -3690,18 +3800,40 @@ export class Orchestrator {
|
|
|
3690
3800
|
defaultPrompt: "audit this repository",
|
|
3691
3801
|
}, announce);
|
|
3692
3802
|
}
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3803
|
+
resolvePaidBudget(inputBudget, cfg) {
|
|
3804
|
+
return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
|
|
3805
|
+
}
|
|
3806
|
+
rootLedger(input, contract, log) {
|
|
3807
|
+
// A passed-in ledger (orchestrate sub-runs) keeps its OWNER's cash
|
|
3808
|
+
// disclosure — the parent run owns the budget, so its event log gets the
|
|
3809
|
+
// budget.cash events. A fresh root ledger discloses into THIS run's log:
|
|
3810
|
+
// the ledger is the one owner of the cash fact (subscription-entitled
|
|
3811
|
+
// work settles to 0 there), and the UI renders `budget.cash` verbatim —
|
|
3812
|
+
// never inferring money from route labels (W4.3 sol #15).
|
|
3813
|
+
const ledger = input.budgetLedger ??
|
|
3814
|
+
new BudgetLedger(contract.budget.paid_budget, undefined, {
|
|
3815
|
+
onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
|
|
3816
|
+
cash_spend_usd: cashSpendUsd,
|
|
3817
|
+
valuation_usd: valuationUsd,
|
|
3818
|
+
}),
|
|
3819
|
+
});
|
|
3820
|
+
for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
|
|
3821
|
+
ledger.observeQuotaSnapshot(snapshot);
|
|
3822
|
+
}
|
|
3823
|
+
return ledger;
|
|
3824
|
+
}
|
|
3825
|
+
routeBillingKnowledge(input, harnessId) {
|
|
3826
|
+
// A selected profile's credential_kind decides billing (round-18 #2).
|
|
3827
|
+
const profileRoute = this.profileAuthRoute(input, harnessId);
|
|
3828
|
+
if (profileRoute)
|
|
3829
|
+
return profileRoute === "api_key" ? "metered" : "unknown";
|
|
3830
|
+
if (input.authPreference === "api_key")
|
|
3831
|
+
return "metered";
|
|
3832
|
+
if (input.authPreference === "subscription")
|
|
3833
|
+
return "unknown";
|
|
3834
|
+
return loadHarnessMetrics(globalConfigDir())[harnessId]?.last_auth_mode === "api_key"
|
|
3835
|
+
? "metered"
|
|
3836
|
+
: "unknown";
|
|
3705
3837
|
}
|
|
3706
3838
|
async runOrchestrate(input, announce) {
|
|
3707
3839
|
// "Doctor-verified" must mean status ok — degraded key-present routes are
|
|
@@ -3715,15 +3847,11 @@ export class Orchestrator {
|
|
|
3715
3847
|
// the executor below is its consumer. Default `suggest` (plan-only) preserves
|
|
3716
3848
|
// the read-only contract when no autonomy is requested.
|
|
3717
3849
|
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));
|
|
3850
|
+
const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
|
|
3723
3851
|
const orchestrateContract = OrchestrateContractSchema.parse({
|
|
3724
3852
|
thread_id: input.threadId ?? newId("th"),
|
|
3725
3853
|
goal,
|
|
3726
|
-
budget: {
|
|
3854
|
+
budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
|
|
3727
3855
|
autonomy,
|
|
3728
3856
|
});
|
|
3729
3857
|
const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
|
|
@@ -3762,8 +3890,17 @@ export class Orchestrator {
|
|
|
3762
3890
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
3763
3891
|
safeInvoke(input.onRunStart, { runId, taskId, runDir: paths.root });
|
|
3764
3892
|
log.emit("run.created", { mode: opts.mode, prompt: redactSecrets(prompt) });
|
|
3765
|
-
const ledger =
|
|
3766
|
-
announce?.({
|
|
3893
|
+
const ledger = this.rootLedger(input, contract, log);
|
|
3894
|
+
announce?.({
|
|
3895
|
+
log,
|
|
3896
|
+
store,
|
|
3897
|
+
paths,
|
|
3898
|
+
runId,
|
|
3899
|
+
taskId,
|
|
3900
|
+
mode: opts.mode,
|
|
3901
|
+
phase: "report",
|
|
3902
|
+
spend: () => ledger.spend(),
|
|
3903
|
+
});
|
|
3767
3904
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3768
3905
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3769
3906
|
if (opts.orchestrateContract) {
|
|
@@ -3814,9 +3951,12 @@ export class Orchestrator {
|
|
|
3814
3951
|
: externalContextPolicy === "off"
|
|
3815
3952
|
? 1
|
|
3816
3953
|
: Math.min(Math.max(input.n ?? 2, 1), 3);
|
|
3954
|
+
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3955
|
+
// read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
|
|
3956
|
+
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
3817
3957
|
let adapters;
|
|
3818
3958
|
try {
|
|
3819
|
-
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent);
|
|
3959
|
+
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
|
|
3820
3960
|
if (!opts.swarm) {
|
|
3821
3961
|
const seen = new Set();
|
|
3822
3962
|
adapters = adapters.filter((routed) => {
|
|
@@ -3828,6 +3968,7 @@ export class Orchestrator {
|
|
|
3828
3968
|
}
|
|
3829
3969
|
}
|
|
3830
3970
|
catch (err) {
|
|
3971
|
+
roHome.dispose();
|
|
3831
3972
|
const message = safeErrorMessage(err);
|
|
3832
3973
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
3833
3974
|
writeFailure(store, paths, {
|
|
@@ -3855,11 +3996,6 @@ export class Orchestrator {
|
|
|
3855
3996
|
candidates: [],
|
|
3856
3997
|
};
|
|
3857
3998
|
}
|
|
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
3999
|
const attempts = [];
|
|
3864
4000
|
const attemptTelemetries = [];
|
|
3865
4001
|
let fallbackOpen = false;
|
|
@@ -3880,6 +4016,7 @@ export class Orchestrator {
|
|
|
3880
4016
|
attemptId,
|
|
3881
4017
|
intent: opts.intent,
|
|
3882
4018
|
harnessId: adapter.id,
|
|
4019
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3883
4020
|
});
|
|
3884
4021
|
if (!lease.granted) {
|
|
3885
4022
|
log.emit("budget.lease.created", {
|
|
@@ -3896,11 +4033,11 @@ export class Orchestrator {
|
|
|
3896
4033
|
const explorerPrompt = (opts.swarm
|
|
3897
4034
|
? `${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
4035
|
: prompt) + contextSection;
|
|
3899
|
-
const sessionFields = this.sessionSpecFields(input, adapter.id);
|
|
4036
|
+
const sessionFields = this.sessionSpecFields(input, adapter.id, log);
|
|
3900
4037
|
const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
|
|
3901
4038
|
if (grantResume)
|
|
3902
4039
|
resumeGranted.add(adapter.id);
|
|
3903
|
-
|
|
4040
|
+
let spec = HarnessRunSpec.parse({
|
|
3904
4041
|
session_id: newId("ses"),
|
|
3905
4042
|
intent: opts.intent,
|
|
3906
4043
|
prompt: explorerPrompt,
|
|
@@ -3911,16 +4048,9 @@ export class Orchestrator {
|
|
|
3911
4048
|
// the model honestly reported it saw nothing (the v0.13 attachment bug).
|
|
3912
4049
|
attachments: input.attachments ?? [],
|
|
3913
4050
|
auth_preference: sessionFields.auth_preference,
|
|
4051
|
+
credential_profile: sessionFields.credential_profile,
|
|
3914
4052
|
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,
|
|
4053
|
+
...this.harnessSpecKnobs(contract, knobs, opts.intent),
|
|
3924
4054
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3925
4055
|
env: roHome.env,
|
|
3926
4056
|
// Structured output: the orchestrate PLANNER's deliverable IS the
|
|
@@ -3944,10 +4074,13 @@ export class Orchestrator {
|
|
|
3944
4074
|
if (reportInteraction)
|
|
3945
4075
|
spec.extra["interactionChannel"] = reportInteraction;
|
|
3946
4076
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3947
|
-
const
|
|
4077
|
+
const answer = new AnswerAssembly();
|
|
3948
4078
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3949
4079
|
knobs.webPolicy === "cached" ||
|
|
3950
|
-
knobs.webPolicy === "live", effectiveWeb
|
|
4080
|
+
knobs.webPolicy === "live", effectiveWeb, [],
|
|
4081
|
+
// Requested-model capture so ask/audit route receipts detect a silent
|
|
4082
|
+
// model downgrade (typed model_mismatch), not just agent runs.
|
|
4083
|
+
knobs.model);
|
|
3951
4084
|
const retryPolicy = transientRetryPolicy(this.config(input.repoRoot));
|
|
3952
4085
|
let activeSessionId = spec.session_id;
|
|
3953
4086
|
const onAbort = () => {
|
|
@@ -3960,8 +4093,10 @@ export class Orchestrator {
|
|
|
3960
4093
|
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3961
4094
|
}
|
|
3962
4095
|
let cost = 0;
|
|
4096
|
+
let costEstimated = false;
|
|
3963
4097
|
let harnessError = null;
|
|
3964
4098
|
try {
|
|
4099
|
+
const triedProfiles = new Set(); // W5.4 failover: each profile at most once
|
|
3965
4100
|
for (let nativeTry = 0; !input.signal?.aborted; nativeTry += 1) {
|
|
3966
4101
|
const runSpec = nativeTry === 0
|
|
3967
4102
|
? spec
|
|
@@ -3973,6 +4108,7 @@ export class Orchestrator {
|
|
|
3973
4108
|
});
|
|
3974
4109
|
activeSessionId = runSpec.session_id;
|
|
3975
4110
|
const transientStart = telemetry.transientFailures.length;
|
|
4111
|
+
const rateLimitStart = telemetry.rateLimits.length;
|
|
3976
4112
|
log.emit("harness.started", {
|
|
3977
4113
|
harness_id: adapter.id,
|
|
3978
4114
|
attempt_id: attemptId,
|
|
@@ -3995,23 +4131,28 @@ export class Orchestrator {
|
|
|
3995
4131
|
break;
|
|
3996
4132
|
const safeEv = redactHarnessEvent(ev);
|
|
3997
4133
|
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.
|
|
4134
|
+
// NOT observed for resume: this read-only attempt is not a chat
|
|
4135
|
+
// turn. Recording its id would poison thread continuity and can
|
|
4136
|
+
// race parallel read-only sessions, regardless of storage route.
|
|
4003
4137
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
4004
4138
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
4005
4139
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
4006
4140
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
4007
4141
|
if (safeEv.plan_progress) {
|
|
4008
|
-
log.emit("plan.progress", {
|
|
4142
|
+
log.emit("plan.progress", {
|
|
4143
|
+
attempt_id: attemptId,
|
|
4144
|
+
harness_id: adapter.id,
|
|
4145
|
+
items: safeEv.plan_progress.items,
|
|
4146
|
+
});
|
|
4009
4147
|
}
|
|
4010
4148
|
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
4011
4149
|
// the loudest) — same single owner as the agent loop.
|
|
4012
4150
|
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
4151
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
4013
4152
|
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
4014
4153
|
cost += safeEv.usage.cost_usd;
|
|
4154
|
+
if (safeEv.usage.estimated)
|
|
4155
|
+
costEstimated = true;
|
|
4015
4156
|
log.emit("budget.observation", {
|
|
4016
4157
|
harness_id: adapter.id,
|
|
4017
4158
|
attempt_id: attemptId,
|
|
@@ -4020,11 +4161,8 @@ export class Orchestrator {
|
|
|
4020
4161
|
estimated: safeEv.usage.estimated === true,
|
|
4021
4162
|
});
|
|
4022
4163
|
}
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
safeEv.payload?.["auth_switched"] !== true) {
|
|
4026
|
-
pushUniqueText(parts, safeEv.text);
|
|
4027
|
-
}
|
|
4164
|
+
// A TYPED final message wins verbatim over joined narration.
|
|
4165
|
+
answer.observe(safeEv);
|
|
4028
4166
|
if (safeEv.type === "error")
|
|
4029
4167
|
harnessError = safeEv.error
|
|
4030
4168
|
? redactSecrets(safeEv.error)
|
|
@@ -4036,7 +4174,32 @@ export class Orchestrator {
|
|
|
4036
4174
|
}
|
|
4037
4175
|
const transient = telemetry.transientFailures.at(-1) ?? null;
|
|
4038
4176
|
const sawTransient = telemetry.transientFailures.length > transientStart;
|
|
4039
|
-
const
|
|
4177
|
+
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
4178
|
+
const reportSoFar = answer.text();
|
|
4179
|
+
// W5.4 reactive failover, READ-ONLY lane (same contract as the
|
|
4180
|
+
// candidate lane; typed limits only, never plain transients).
|
|
4181
|
+
if (harnessError && !input.signal?.aborted) {
|
|
4182
|
+
const rotated = rotateSpecOnTypedLimit({
|
|
4183
|
+
spec,
|
|
4184
|
+
harnessId: adapter.id,
|
|
4185
|
+
attemptId,
|
|
4186
|
+
policy: this.profilePolicy(input.repoRoot, adapter.id),
|
|
4187
|
+
registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
|
|
4188
|
+
snapshots: this.deps.quotaSnapshots?.() ?? [],
|
|
4189
|
+
triedProfiles,
|
|
4190
|
+
sawTypedLimit,
|
|
4191
|
+
deliverableEmpty: reportSoFar.length === 0,
|
|
4192
|
+
lastLimit: telemetry.rateLimits.at(-1) ?? null,
|
|
4193
|
+
emit: (type, payload) => log.emit(type, payload),
|
|
4194
|
+
newSessionId: () => newId("ses"),
|
|
4195
|
+
defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
|
|
4196
|
+
});
|
|
4197
|
+
if (rotated) {
|
|
4198
|
+
spec = rotated;
|
|
4199
|
+
harnessError = null;
|
|
4200
|
+
continue;
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4040
4203
|
if (!harnessError ||
|
|
4041
4204
|
!sawTransient ||
|
|
4042
4205
|
reportSoFar.length > 0 ||
|
|
@@ -4063,7 +4226,7 @@ export class Orchestrator {
|
|
|
4063
4226
|
}
|
|
4064
4227
|
finally {
|
|
4065
4228
|
input.signal?.removeEventListener("abort", onAbort);
|
|
4066
|
-
ledger.settle(lease.lease?.lease_id ?? "", cost);
|
|
4229
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode));
|
|
4067
4230
|
}
|
|
4068
4231
|
if (harnessError && telemetry.transientFailures.length > 0) {
|
|
4069
4232
|
log.emit("route.transient.exhausted", {
|
|
@@ -4073,7 +4236,7 @@ export class Orchestrator {
|
|
|
4073
4236
|
});
|
|
4074
4237
|
}
|
|
4075
4238
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
|
|
4076
|
-
const report = redactSecrets(
|
|
4239
|
+
const report = redactSecrets(answer.text());
|
|
4077
4240
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
4078
4241
|
const webBlocked = webUnsatisfied(telemetry);
|
|
4079
4242
|
const deliverablePresent = report.length > 0;
|
|
@@ -4213,8 +4376,8 @@ export class Orchestrator {
|
|
|
4213
4376
|
}
|
|
4214
4377
|
}
|
|
4215
4378
|
finally {
|
|
4216
|
-
// All read-only attempts done (or threw) — reclaim
|
|
4217
|
-
//
|
|
4379
|
+
// All read-only attempts done (or threw) — reclaim scoped scratch and
|
|
4380
|
+
// injected API-route state. Vendor-owned native credentials were not copied.
|
|
4218
4381
|
roHome.dispose();
|
|
4219
4382
|
}
|
|
4220
4383
|
if (input.signal?.aborted) {
|
|
@@ -4222,7 +4385,7 @@ export class Orchestrator {
|
|
|
4222
4385
|
attemptId: a.attemptId,
|
|
4223
4386
|
harnessId: a.harnessId,
|
|
4224
4387
|
status: a.status,
|
|
4225
|
-
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend());
|
|
4388
|
+
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
4226
4389
|
}
|
|
4227
4390
|
const succeededReadonly = attempts.filter((a) => a.status === "success");
|
|
4228
4391
|
if (!opts.swarm && succeededReadonly.length === 0) {
|
|
@@ -4377,6 +4540,18 @@ export class Orchestrator {
|
|
|
4377
4540
|
].join("\n")
|
|
4378
4541
|
: (succeeded[0]?.report ?? "(no output)");
|
|
4379
4542
|
store.writeText(join(paths.finalDir, opts.artifactName), `# ${opts.title}\n\n${report}\n`);
|
|
4543
|
+
// ask is the only read-only strategy that can carry a structured-output
|
|
4544
|
+
// contract (the boundary refuses the rest); validate the RAW answer text,
|
|
4545
|
+
// not the titled artifact wrapper.
|
|
4546
|
+
if (opts.mode === "ask" && contract.output_schema) {
|
|
4547
|
+
finalizeStructuredOutput({
|
|
4548
|
+
store,
|
|
4549
|
+
finalDir: paths.finalDir,
|
|
4550
|
+
log,
|
|
4551
|
+
schema: contract.output_schema,
|
|
4552
|
+
answerText: succeeded[0]?.report ?? "",
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4380
4555
|
// orchestrate: the planner's plan is a TYPED artifact, not just prose. Extract
|
|
4381
4556
|
// the required fenced JSON block, validate it against the tool belt, and
|
|
4382
4557
|
// persist final/orchestration.yaml; a missing/invalid block is disclosed in
|
|
@@ -4436,6 +4611,10 @@ export class Orchestrator {
|
|
|
4436
4611
|
// terminal outcome (success / blocked / failed) becomes the run's terminal.
|
|
4437
4612
|
const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
|
|
4438
4613
|
let terminal = "success";
|
|
4614
|
+
let orchestrateReadOnly = true;
|
|
4615
|
+
let orchestrateReceiptRefs = [];
|
|
4616
|
+
if (ledger.terminal() !== null)
|
|
4617
|
+
terminal = ledger.terminal();
|
|
4439
4618
|
// orchestrate's contract output IS the typed plan. If the planner failed to
|
|
4440
4619
|
// produce a valid one, the run is NOT a clean success — disclose it honestly
|
|
4441
4620
|
// (the markdown plan stays as a diagnostic artifact) rather than reporting
|
|
@@ -4446,11 +4625,22 @@ export class Orchestrator {
|
|
|
4446
4625
|
// Thread the GENERATED runId onto input so the executor's answer_question
|
|
4447
4626
|
// step keys the interaction registry by this orchestrate run's id (callers
|
|
4448
4627
|
// often invoke run() without a preassigned runId).
|
|
4449
|
-
const
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4628
|
+
const executionInput = { ...input, runId };
|
|
4629
|
+
const exec = await executeOrchestratePlan({
|
|
4630
|
+
plan: orchestratePlan,
|
|
4631
|
+
autonomy,
|
|
4632
|
+
maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
|
|
4633
|
+
signal: input.signal,
|
|
4634
|
+
store,
|
|
4635
|
+
paths,
|
|
4636
|
+
log,
|
|
4637
|
+
ledger,
|
|
4638
|
+
executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
|
|
4639
|
+
executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
|
|
4640
|
+
});
|
|
4453
4641
|
terminal = exec.terminal;
|
|
4642
|
+
orchestrateReadOnly = exec.readOnly;
|
|
4643
|
+
orchestrateReceiptRefs = exec.receiptRefs;
|
|
4454
4644
|
typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
|
|
4455
4645
|
}
|
|
4456
4646
|
const harnessLabel = attempts
|
|
@@ -4462,69 +4652,32 @@ export class Orchestrator {
|
|
|
4462
4652
|
kind: "report",
|
|
4463
4653
|
source_task_id: taskId,
|
|
4464
4654
|
producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
|
|
4465
|
-
files:
|
|
4655
|
+
files: Object.fromEntries([
|
|
4656
|
+
[opts.artifactName, join(paths.finalDir, opts.artifactName)],
|
|
4657
|
+
...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
|
|
4658
|
+
]),
|
|
4466
4659
|
meta: {
|
|
4467
4660
|
harnesses: attempts.map((a) => a.harnessId),
|
|
4468
4661
|
mode: opts.mode,
|
|
4469
4662
|
intent: opts.intent,
|
|
4470
|
-
read_only:
|
|
4663
|
+
read_only: orchestrateReadOnly,
|
|
4471
4664
|
},
|
|
4472
4665
|
});
|
|
4473
4666
|
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
|
-
});
|
|
4667
|
+
const orchestrateFailure = orchestrateFailureFor(terminal);
|
|
4668
|
+
if (terminal === "blocked" && orchestrateFailure) {
|
|
4669
|
+
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4486
4670
|
log.emit("run.blocked", {
|
|
4487
4671
|
status: terminal,
|
|
4488
|
-
phase:
|
|
4672
|
+
phase: orchestrateFailure.phase,
|
|
4489
4673
|
failure_ref: "final/failure.yaml",
|
|
4490
4674
|
});
|
|
4491
4675
|
}
|
|
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
|
-
});
|
|
4504
|
-
log.emit("run.failed", {
|
|
4505
|
-
status: terminal,
|
|
4506
|
-
phase: "executor",
|
|
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
|
-
});
|
|
4676
|
+
else if (orchestrateFailure) {
|
|
4677
|
+
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4525
4678
|
log.emit("run.failed", {
|
|
4526
4679
|
status: terminal,
|
|
4527
|
-
phase:
|
|
4680
|
+
phase: orchestrateFailure.phase,
|
|
4528
4681
|
failure_ref: "final/failure.yaml",
|
|
4529
4682
|
});
|
|
4530
4683
|
}
|
|
@@ -4534,26 +4687,6 @@ export class Orchestrator {
|
|
|
4534
4687
|
// mistake an operator abort for a clean report.
|
|
4535
4688
|
log.emit("run.failed", { status: terminal });
|
|
4536
4689
|
}
|
|
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
4690
|
else {
|
|
4558
4691
|
log.emit("run.completed", { status: terminal });
|
|
4559
4692
|
}
|
|
@@ -4573,190 +4706,20 @@ export class Orchestrator {
|
|
|
4573
4706
|
})),
|
|
4574
4707
|
};
|
|
4575
4708
|
}
|
|
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
4709
|
/**
|
|
4749
4710
|
* Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
|
|
4750
4711
|
* (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
|
|
4751
4712
|
* answer delivery that never mutate the live tree.
|
|
4752
4713
|
*/
|
|
4753
|
-
async executeSafeStep(input, call, log, store, paths,
|
|
4714
|
+
async executeSafeStep(input, call, log, store, paths, ledger) {
|
|
4754
4715
|
switch (call.tool) {
|
|
4755
4716
|
case "start_run":
|
|
4756
4717
|
case "race": {
|
|
4757
4718
|
// Isolated envelope sub-run (construction owned by runSupport);
|
|
4758
4719
|
// recursion guard via orchestrateDepth+1, may NOT orchestrate.
|
|
4759
|
-
const subInput = {
|
|
4720
|
+
const subInput = {
|
|
4721
|
+
...buildEnvelopeSubInput(input, call, ledger),
|
|
4722
|
+
};
|
|
4760
4723
|
// SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
|
|
4761
4724
|
// isolated envelope — never a live in-place turn.
|
|
4762
4725
|
assertEnvelopeSubRun(subInput);
|
|
@@ -4768,6 +4731,9 @@ export class Orchestrator {
|
|
|
4768
4731
|
const res = await this.run(subInput);
|
|
4769
4732
|
return {
|
|
4770
4733
|
status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
|
|
4734
|
+
terminalStatus: res.status,
|
|
4735
|
+
terminalSource: "subrun",
|
|
4736
|
+
evidenceRefs: [`run:${res.runId}`],
|
|
4771
4737
|
runId: res.runId,
|
|
4772
4738
|
spendUsd: res.spendUsd ?? null,
|
|
4773
4739
|
detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
|
|
@@ -4778,8 +4744,11 @@ export class Orchestrator {
|
|
|
4778
4744
|
const read = readRunStatus(input.repoRoot, call.run_id);
|
|
4779
4745
|
return {
|
|
4780
4746
|
status: read ? "done" : "skipped",
|
|
4747
|
+
terminalStatus: read?.status ?? null,
|
|
4748
|
+
terminalSource: "subrun",
|
|
4749
|
+
evidenceRefs: read?.evidenceRefs ?? [],
|
|
4781
4750
|
runId: call.run_id,
|
|
4782
|
-
detail: read ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4751
|
+
detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4783
4752
|
};
|
|
4784
4753
|
}
|
|
4785
4754
|
case "review": {
|
|
@@ -4791,15 +4760,21 @@ export class Orchestrator {
|
|
|
4791
4760
|
if (diff === null)
|
|
4792
4761
|
return {
|
|
4793
4762
|
status: "skipped",
|
|
4763
|
+
terminalStatus: null,
|
|
4764
|
+
terminalSource: "review",
|
|
4765
|
+
evidenceRefs: [],
|
|
4794
4766
|
runId: call.run_id,
|
|
4795
4767
|
detail: `run ${call.run_id} has no patch.diff to review`,
|
|
4796
4768
|
};
|
|
4797
4769
|
// Aggregate honesty: reviewer panels spend real money on
|
|
4798
4770
|
// API-keyed routes and the spend is charged AFTER the fact — with no
|
|
4799
4771
|
// remaining headroom the review must not start at all.
|
|
4800
|
-
if (
|
|
4772
|
+
if (ledger.terminal() !== null) {
|
|
4801
4773
|
return {
|
|
4802
4774
|
status: "skipped",
|
|
4775
|
+
terminalStatus: null,
|
|
4776
|
+
terminalSource: "review",
|
|
4777
|
+
evidenceRefs: [],
|
|
4803
4778
|
runId: call.run_id,
|
|
4804
4779
|
detail: "aggregate budget exhausted before the review step",
|
|
4805
4780
|
};
|
|
@@ -4808,9 +4783,28 @@ export class Orchestrator {
|
|
|
4808
4783
|
if (reviewers.length === 0)
|
|
4809
4784
|
return {
|
|
4810
4785
|
status: "skipped",
|
|
4786
|
+
terminalStatus: null,
|
|
4787
|
+
terminalSource: "review",
|
|
4788
|
+
evidenceRefs: [],
|
|
4811
4789
|
runId: call.run_id,
|
|
4812
4790
|
detail: "no doctor-OK reviewers available",
|
|
4813
4791
|
};
|
|
4792
|
+
const reviewLease = ledger.reserve({
|
|
4793
|
+
taskId: input.taskId ?? "orchestrate",
|
|
4794
|
+
attemptId: `review-${call.run_id}`,
|
|
4795
|
+
intent: "review",
|
|
4796
|
+
harnessId: "review-panel",
|
|
4797
|
+
cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
|
|
4798
|
+
});
|
|
4799
|
+
if (!reviewLease.granted)
|
|
4800
|
+
return {
|
|
4801
|
+
status: "skipped",
|
|
4802
|
+
terminalStatus: ledger.terminal(),
|
|
4803
|
+
terminalSource: "review",
|
|
4804
|
+
evidenceRefs: [],
|
|
4805
|
+
runId: call.run_id,
|
|
4806
|
+
detail: reviewLease.reason ?? "root paid budget refused the review step",
|
|
4807
|
+
};
|
|
4814
4808
|
const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
|
|
4815
4809
|
writeEvidencePacket(evidenceDir, {
|
|
4816
4810
|
userIntent: redactSecrets(input.prompt),
|
|
@@ -4829,7 +4823,13 @@ export class Orchestrator {
|
|
|
4829
4823
|
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
4830
4824
|
signal: input.signal,
|
|
4831
4825
|
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
4826
|
+
}).catch((error) => {
|
|
4827
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
|
|
4828
|
+
throw error;
|
|
4832
4829
|
});
|
|
4830
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(result.reviewSpendUsd, result.reviewSpendEstimated, "review-usage", [
|
|
4831
|
+
`orchestrate:review:${call.run_id}`,
|
|
4832
|
+
]));
|
|
4833
4833
|
const revalidated = await revalidateFindings(result.findings, {
|
|
4834
4834
|
candidateRoot: input.repoRoot,
|
|
4835
4835
|
evidenceDir,
|
|
@@ -4844,6 +4844,9 @@ export class Orchestrator {
|
|
|
4844
4844
|
const blockers = revalidated.filter((f) => isBlocking(f)).length;
|
|
4845
4845
|
return {
|
|
4846
4846
|
status: "done",
|
|
4847
|
+
terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
|
|
4848
|
+
terminalSource: "review",
|
|
4849
|
+
evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
|
|
4847
4850
|
runId: call.run_id,
|
|
4848
4851
|
detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
|
|
4849
4852
|
// Reviewer panels can spend real money on API-keyed routes; the
|
|
@@ -4853,7 +4856,13 @@ export class Orchestrator {
|
|
|
4853
4856
|
}
|
|
4854
4857
|
case "answer_question": {
|
|
4855
4858
|
// Delivery + registry-keying rationale owned by runSupport.
|
|
4856
|
-
|
|
4859
|
+
const answer = await deliverPlanAnswer(input, call);
|
|
4860
|
+
return {
|
|
4861
|
+
...answer,
|
|
4862
|
+
terminalStatus: answer.status === "done" ? "success" : null,
|
|
4863
|
+
terminalSource: "executor",
|
|
4864
|
+
evidenceRefs: [],
|
|
4865
|
+
};
|
|
4857
4866
|
}
|
|
4858
4867
|
default: {
|
|
4859
4868
|
// FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
|
|
@@ -4862,13 +4871,8 @@ export class Orchestrator {
|
|
|
4862
4871
|
}
|
|
4863
4872
|
}
|
|
4864
4873
|
}
|
|
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) {
|
|
4874
|
+
/** Execute an auto_full apply through the shared fresh-verification gate. */
|
|
4875
|
+
async executeApplyStep(input, call, log) {
|
|
4872
4876
|
const store = new ArtifactStore(input.repoRoot);
|
|
4873
4877
|
const sub = store.runPaths(call.run_id);
|
|
4874
4878
|
const patchPath = join(sub.finalDir, "patch.diff");
|
|
@@ -4877,25 +4881,29 @@ export class Orchestrator {
|
|
|
4877
4881
|
return {
|
|
4878
4882
|
ok: false,
|
|
4879
4883
|
runId: call.run_id,
|
|
4880
|
-
detail: `run ${call.run_id} has no patch.diff
|
|
4884
|
+
detail: `run ${call.run_id} has no patch.diff`,
|
|
4885
|
+
receipt: null,
|
|
4881
4886
|
};
|
|
4882
4887
|
if (containsSecretLikeToken(patchText))
|
|
4883
4888
|
return {
|
|
4884
4889
|
ok: false,
|
|
4885
4890
|
runId: call.run_id,
|
|
4886
4891
|
detail: "patch contains a secret-like token; refusing apply",
|
|
4892
|
+
receipt: null,
|
|
4887
4893
|
};
|
|
4888
4894
|
const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
|
|
4889
4895
|
const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
|
|
4896
|
+
const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
|
|
4890
4897
|
const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
|
|
4891
4898
|
const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
+
if (!taskContract.success)
|
|
4900
|
+
return {
|
|
4901
|
+
ok: false,
|
|
4902
|
+
runId: call.run_id,
|
|
4903
|
+
detail: "fresh verification contract is missing",
|
|
4904
|
+
receipt: null,
|
|
4905
|
+
};
|
|
4906
|
+
const applyGateInput = {
|
|
4899
4907
|
state: null,
|
|
4900
4908
|
decision: parsedDecision?.success ? parsedDecision.data : null,
|
|
4901
4909
|
workProduct: parsedWp?.success ? parsedWp.data : null,
|
|
@@ -4903,16 +4911,23 @@ export class Orchestrator {
|
|
|
4903
4911
|
originalRepoRoot: input.repoRoot,
|
|
4904
4912
|
targetRepoRoot: input.repoRoot,
|
|
4905
4913
|
operatorDecision: null,
|
|
4906
|
-
}
|
|
4914
|
+
};
|
|
4915
|
+
const gateError = validateApplyGate(applyGateInput);
|
|
4907
4916
|
if (gateError)
|
|
4908
|
-
return {
|
|
4909
|
-
|
|
4917
|
+
return {
|
|
4918
|
+
ok: false,
|
|
4919
|
+
runId: call.run_id,
|
|
4920
|
+
detail: `apply gate refused: ${gateError}`,
|
|
4921
|
+
receipt: null,
|
|
4922
|
+
};
|
|
4923
|
+
const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
|
|
4910
4924
|
return {
|
|
4911
4925
|
ok: delivered.applied,
|
|
4912
4926
|
runId: call.run_id,
|
|
4913
4927
|
detail: delivered.applied
|
|
4914
4928
|
? `applied (${call.mode})`
|
|
4915
4929
|
: `deliver failed: ${delivered.detail ?? "unknown"}`,
|
|
4930
|
+
receipt: delivered,
|
|
4916
4931
|
};
|
|
4917
4932
|
}
|
|
4918
4933
|
}
|