@claudexor/orchestrator 3.0.4 → 3.1.1
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/attemptFinalize.d.ts +200 -0
- package/dist/attemptFinalize.d.ts.map +1 -0
- package/dist/attemptFinalize.js +412 -0
- package/dist/attemptFinalize.js.map +1 -0
- package/dist/attemptTelemetry.d.ts +88 -7
- package/dist/attemptTelemetry.d.ts.map +1 -1
- package/dist/attemptTelemetry.js +276 -14
- package/dist/attemptTelemetry.js.map +1 -1
- package/dist/budgetFailure.d.ts +70 -0
- package/dist/budgetFailure.d.ts.map +1 -0
- package/dist/budgetFailure.js +114 -0
- package/dist/budgetFailure.js.map +1 -0
- package/dist/candidateEvidence.d.ts +55 -1
- package/dist/candidateEvidence.d.ts.map +1 -1
- package/dist/candidateEvidence.js +72 -0
- package/dist/candidateEvidence.js.map +1 -1
- package/dist/continuation.d.ts +77 -0
- package/dist/continuation.d.ts.map +1 -0
- package/dist/continuation.js +92 -0
- package/dist/continuation.js.map +1 -0
- package/dist/deepScanReducer.d.ts +148 -0
- package/dist/deepScanReducer.d.ts.map +1 -0
- package/dist/deepScanReducer.js +348 -0
- package/dist/deepScanReducer.js.map +1 -0
- package/dist/effortGovernance.d.ts +49 -0
- package/dist/effortGovernance.d.ts.map +1 -0
- package/dist/effortGovernance.js +35 -0
- package/dist/effortGovernance.js.map +1 -0
- package/dist/harnessFailure.d.ts +27 -0
- package/dist/harnessFailure.d.ts.map +1 -0
- package/dist/harnessFailure.js +61 -0
- package/dist/harnessFailure.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator.d.ts +103 -16
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +1245 -327
- package/dist/orchestrator.js.map +1 -1
- package/dist/planQuestions.d.ts.map +1 -1
- package/dist/planQuestions.js +105 -50
- package/dist/planQuestions.js.map +1 -1
- package/dist/planRun.d.ts +15 -7
- package/dist/planRun.d.ts.map +1 -1
- package/dist/planRun.js +88 -42
- package/dist/planRun.js.map +1 -1
- package/dist/planTerminal.d.ts +36 -0
- package/dist/planTerminal.d.ts.map +1 -0
- package/dist/planTerminal.js +63 -0
- package/dist/planTerminal.js.map +1 -0
- package/dist/reviewerPanel.d.ts +4 -0
- package/dist/reviewerPanel.d.ts.map +1 -1
- package/dist/reviewerPanel.js +54 -9
- package/dist/reviewerPanel.js.map +1 -1
- package/dist/revisePrompt.d.ts +17 -0
- package/dist/revisePrompt.d.ts.map +1 -0
- package/dist/revisePrompt.js +15 -0
- package/dist/revisePrompt.js.map +1 -0
- package/dist/runSupport.d.ts +67 -2
- package/dist/runSupport.d.ts.map +1 -1
- package/dist/runSupport.js +86 -6
- package/dist/runSupport.js.map +1 -1
- package/dist/runTelemetryWriter.d.ts +6 -1
- package/dist/runTelemetryWriter.d.ts.map +1 -1
- package/dist/runTelemetryWriter.js +2 -0
- package/dist/runTelemetryWriter.js.map +1 -1
- package/dist/runTerminals.d.ts +15 -4
- package/dist/runTerminals.d.ts.map +1 -1
- package/dist/runTerminals.js +16 -1
- package/dist/runTerminals.js.map +1 -1
- package/dist/structuredOutput.d.ts +27 -0
- package/dist/structuredOutput.d.ts.map +1 -1
- package/dist/structuredOutput.js +65 -7
- package/dist/structuredOutput.js.map +1 -1
- package/dist/transientClassify.d.ts +59 -0
- package/dist/transientClassify.d.ts.map +1 -0
- package/dist/transientClassify.js +109 -0
- package/dist/transientClassify.js.map +1 -0
- package/dist/write-isolation.d.ts +11 -0
- package/dist/write-isolation.d.ts.map +1 -0
- package/dist/write-isolation.js +13 -0
- package/dist/write-isolation.js.map +1 -0
- package/package.json +17 -17
package/dist/orchestrator.js
CHANGED
|
@@ -3,7 +3,7 @@ import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefault
|
|
|
3
3
|
import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
|
|
4
4
|
import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
|
|
5
5
|
import { processAttemptUsage } from "./attemptUsage.js";
|
|
6
|
-
import { toCandidateEvidence } from "./candidateEvidence.js";
|
|
6
|
+
import { candidateRoster, convergenceOutcomeFacts, isWorkingCandidate, partitionCandidates, toCandidateEvidence, } from "./candidateEvidence.js";
|
|
7
7
|
import { capabilityIntents } from "@claudexor/gateway";
|
|
8
8
|
import { policyFindings } from "./policyFindings.js";
|
|
9
9
|
import { join } from "node:path";
|
|
@@ -12,29 +12,37 @@ import { HarnessRunSpec, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_
|
|
|
12
12
|
import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
|
|
13
13
|
import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
|
|
14
14
|
import { assertRouteModelsAllowed } from "./modelGovernance.js";
|
|
15
|
+
import { governRouteEffort } from "./effortGovernance.js";
|
|
15
16
|
import { RequestRequirementsResolver } from "./requestRequirements.js";
|
|
17
|
+
import { buildRevisePrompt } from "./revisePrompt.js";
|
|
16
18
|
import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
|
|
19
|
+
import { budgetFailureRecord, classifyBudgetFailure } from "./budgetFailure.js";
|
|
17
20
|
import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
|
|
18
|
-
import { transientRetryDelayMs,
|
|
21
|
+
import { transientRetryDelayMs, promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
|
|
19
22
|
import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
|
|
20
23
|
import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
|
|
21
24
|
import { buildContinuation, } from "./continuity.js";
|
|
22
25
|
import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
|
|
23
26
|
import { runDiffReview } from "./diffReview.js";
|
|
24
|
-
import {
|
|
27
|
+
import { rawScoutBundle, resolveDeepScanSynthesis, } from "./deepScanReducer.js";
|
|
28
|
+
import { classifyAdapterThrow, createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
|
|
29
|
+
import { dominantHarnessFailureCategory, harnessFailureNextActions } from "./harnessFailure.js";
|
|
30
|
+
import { finalizeAttempt, readOnlyNoSuccessTerminal, resolveWorkReportEnvelope, unrecoveredToolErrorFailure, unwrapWorkReportEnvelope, webEvidenceFailure, } from "./attemptFinalize.js";
|
|
31
|
+
import { buildContinuationPacket, decideContinuation, synthesizeContinuationRequest, } from "./continuation.js";
|
|
25
32
|
import { interactionChannelFor } from "./interaction.js";
|
|
26
33
|
import { gateSpecsFromContract, renderTestsEvidence, resolveContractGates, } from "./contract-gates.js";
|
|
27
34
|
import { ArtifactStore } from "@claudexor/artifact-store";
|
|
28
35
|
import { EventLog } from "@claudexor/event-log";
|
|
29
36
|
import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
|
|
30
|
-
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
37
|
+
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureClaudeBridge, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
31
38
|
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
|
|
32
39
|
import { HarnessGateway } from "@claudexor/gateway";
|
|
33
40
|
import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
|
|
34
41
|
import { arbitrate } from "@claudexor/arbitration";
|
|
35
42
|
import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
|
|
36
|
-
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
|
|
43
|
+
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, explainRanking, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
|
|
37
44
|
import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLikeToken, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
45
|
+
import { assertWriteIsolation } from "./write-isolation.js";
|
|
38
46
|
/** A routed candidate adapter plus its manifest capabilities and user settings. */
|
|
39
47
|
/** The two access profiles that map to codex `danger-full-access` / an
|
|
40
48
|
* unsandboxed lane — the only ones under which a full-access-requiring MCP
|
|
@@ -42,6 +50,25 @@ import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLik
|
|
|
42
50
|
export function isFullAccess(access) {
|
|
43
51
|
return access === "full" || access === "external_sandbox_full";
|
|
44
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A routing preflight refusal (`RoutingPreflightError`: quality routing with no
|
|
55
|
+
* comparable user-declared tier for the intent) is a CONFIGURATION error, not a
|
|
56
|
+
* harness-availability problem (A-1/D-9/#22). Classifying it as
|
|
57
|
+
* `harness_unavailable` sent the operator to re-auth or wait for a harness; the
|
|
58
|
+
* real fix is to configure a tier or change the routing goal. Detected by the
|
|
59
|
+
* typed `code` (robust across duplicate `@claudexor/budget` package copies) so
|
|
60
|
+
* EVERY strategy's routing catch (ask/agent/plan/deep-scan/council) classifies
|
|
61
|
+
* it identically. Returns the failure category + matching remediation.
|
|
62
|
+
*/
|
|
63
|
+
export function routingFailureClassification(err) {
|
|
64
|
+
const isPreflightRefusal = !!err &&
|
|
65
|
+
typeof err === "object" &&
|
|
66
|
+
err.code === "routing_preflight_refused";
|
|
67
|
+
if (isPreflightRefusal) {
|
|
68
|
+
return { category: "config_error", nextActions: harnessFailureNextActions("config_error") };
|
|
69
|
+
}
|
|
70
|
+
return { category: "harness_unavailable" };
|
|
71
|
+
}
|
|
45
72
|
const LABELS = "ABCDEFGHIJ".split("");
|
|
46
73
|
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
47
74
|
/** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
|
|
@@ -68,6 +95,11 @@ export class Orchestrator {
|
|
|
68
95
|
deps;
|
|
69
96
|
gateway;
|
|
70
97
|
requestRequirements = new RequestRequirementsResolver();
|
|
98
|
+
/** QA-034: the typed routing rationale computed ONCE at pool ordering, keyed
|
|
99
|
+
* by run id so the terminal telemetry writer can record it as run evidence
|
|
100
|
+
* (RunTelemetry.routing_rationale). Cleared when the run's telemetry is
|
|
101
|
+
* written. Absent for runs with an explicit single-harness pool (no ranking). */
|
|
102
|
+
routingRationaleByRun = new Map();
|
|
71
103
|
/** Per-attempt cap on forwarded live delta chunks (W-C4 flood guard, sol
|
|
72
104
|
* #10): past this the deltas are dropped and the cutoff is disclosed once;
|
|
73
105
|
* the complete message always still lands. */
|
|
@@ -105,19 +137,21 @@ export class Orchestrator {
|
|
|
105
137
|
throw new Error(`unknown mode: ${String(resolved.mode)}`);
|
|
106
138
|
}
|
|
107
139
|
const mode = parsedMode.data;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
const projectProtectedPaths = mode === "agent" ? this.projectConfig(resolved.repoRoot).constraints.protected_paths : [];
|
|
141
|
+
assertWriteIsolation({
|
|
142
|
+
mode,
|
|
143
|
+
protectedPaths: projectProtectedPaths,
|
|
144
|
+
denyPaths: resolved.denyPaths,
|
|
145
|
+
inPlace: resolved.inPlace,
|
|
146
|
+
repoRoot: resolved.repoRoot,
|
|
147
|
+
executionRoot: this.execRootOf(resolved),
|
|
148
|
+
});
|
|
115
149
|
// outputSchema constrains the run's final ANSWER. It is honored exactly
|
|
116
150
|
// where a final answer is delivered (agent race incl. synthesis, and ask);
|
|
117
151
|
// every other strategy refuses loudly rather than carrying a contract the
|
|
118
152
|
// engine would not validate (INV-023). The schema itself is normalized for
|
|
119
153
|
// the native structured-output routes here at the boundary — unsupported
|
|
120
|
-
// shapes ($ref, non-object root) are a typed refusal, not a mid-run 400.
|
|
154
|
+
// shapes (external/cyclic $ref, non-object root) are a typed refusal, not a mid-run 400.
|
|
121
155
|
if (resolved.outputSchema !== undefined && resolved.outputSchema !== null) {
|
|
122
156
|
if (mode !== "agent" && mode !== "ask") {
|
|
123
157
|
throw new Error(`outputSchema constrains the final answer and applies to agent/ask runs (got mode=${mode}); drop the schema or switch modes`);
|
|
@@ -128,8 +162,8 @@ export class Orchestrator {
|
|
|
128
162
|
// Shape-refuse unsupported schemas, then PROVE it compiles under the same
|
|
129
163
|
// ajv the engine validator uses — a malformed schema is a preflight
|
|
130
164
|
// refusal here (before any run dir), never a mid-run validator crash. The
|
|
131
|
-
// contract keeps the ORIGINAL (conformance authority);
|
|
132
|
-
// transport-only
|
|
165
|
+
// contract keeps the ORIGINAL (conformance authority); local-ref inlining
|
|
166
|
+
// and strictification are transport-only transforms in harnessSpecKnobs.
|
|
133
167
|
resolved.outputSchema = normalizeUserOutputSchema(resolved.outputSchema);
|
|
134
168
|
assertOutputSchemaCompiles(resolved.outputSchema);
|
|
135
169
|
}
|
|
@@ -169,9 +203,13 @@ export class Orchestrator {
|
|
|
169
203
|
case "plan":
|
|
170
204
|
return this.runPlan(resolved, announce);
|
|
171
205
|
}
|
|
172
|
-
}
|
|
206
|
+
},
|
|
207
|
+
// Single per-run terminalization hook: release the routing-rationale map
|
|
208
|
+
// entry on EVERY terminal (incl. a run that died before its telemetry
|
|
209
|
+
// writer ran, which is the leak this closes).
|
|
210
|
+
(runId) => this.routingRationaleByRun.delete(runId));
|
|
173
211
|
}
|
|
174
|
-
async resolveReviewers(cwd, runAuthPreference) {
|
|
212
|
+
async resolveReviewers(cwd, runAuthPreference, onIgnoredSetting) {
|
|
175
213
|
if (this.deps.reviewers)
|
|
176
214
|
return this.deps.reviewers;
|
|
177
215
|
if (this.deps.reviewerPanel && this.deps.reviewerPanel.length > 0) {
|
|
@@ -182,10 +220,8 @@ export class Orchestrator {
|
|
|
182
220
|
registry: this.deps.registry,
|
|
183
221
|
harnessSettings: this.config(cwd)?.global.harnesses ?? {},
|
|
184
222
|
authPreferenceFor: (id) => this.authPreferenceForHarness(cwd, id, runAuthPreference),
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
reviewerEfforts: this.deps.reviewerEfforts,
|
|
188
|
-
});
|
|
223
|
+
onIgnoredSetting,
|
|
224
|
+
}, { reviewerModels: this.deps.reviewerModels, reviewerEfforts: this.deps.reviewerEfforts });
|
|
189
225
|
}
|
|
190
226
|
/**
|
|
191
227
|
* Resolve reviewers INSIDE a strategy, after run-dir creation: an explicit
|
|
@@ -196,7 +232,9 @@ export class Orchestrator {
|
|
|
196
232
|
*/
|
|
197
233
|
async resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, mode) {
|
|
198
234
|
try {
|
|
199
|
-
|
|
235
|
+
// Auto-panel dropped knobs (reviewerEfforts) → ignored-settings channel (QA-070):
|
|
236
|
+
const warn = (d) => void log.emit("review.preflight", { ignored_settings: [d] });
|
|
237
|
+
return { reviewers: await this.resolveReviewers(input.repoRoot, input.authPreference, warn) };
|
|
200
238
|
}
|
|
201
239
|
catch (err) {
|
|
202
240
|
const message = safeErrorMessage(err);
|
|
@@ -385,25 +423,38 @@ export class Orchestrator {
|
|
|
385
423
|
const cfg = this.config(input.repoRoot);
|
|
386
424
|
const configuredPool = cfg?.global.routing.eligible_harnesses;
|
|
387
425
|
const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
|
|
388
|
-
|
|
426
|
+
// GH #25 precedence: an explicit --primary-harness wins and is validated
|
|
427
|
+
// against the pool; else a single-item explicit pool infers itself as
|
|
428
|
+
// primary (shipped in #34); else the configured default primary applies.
|
|
429
|
+
const explicitPrimary = input.primaryHarness;
|
|
430
|
+
const configPrimary = cfg?.global.routing.primary_harness;
|
|
431
|
+
const primaryHarness = explicitPrimary ??
|
|
432
|
+
(input.harnesses?.length === 1 ? input.harnesses[0] : undefined) ??
|
|
433
|
+
configPrimary ??
|
|
434
|
+
undefined;
|
|
389
435
|
if (primaryHarness &&
|
|
390
436
|
harnesses &&
|
|
391
437
|
harnesses.length > 0 &&
|
|
392
438
|
!harnesses.includes(primaryHarness)) {
|
|
393
|
-
|
|
439
|
+
if (explicitPrimary) {
|
|
440
|
+
// An explicit primary must be a member of the eligible pool (authoritative).
|
|
441
|
+
throw new Error(`primary harness '${explicitPrimary}' is not in the eligible harness pool (${harnesses.join(", ")}); ` +
|
|
442
|
+
`pass --primary-harness as one of [${harnesses.join(", ")}], or add '${explicitPrimary}' to --harness`);
|
|
443
|
+
}
|
|
444
|
+
// GH #25 remainder: a MULTI-harness pool whose CONFIGURED default primary
|
|
445
|
+
// is absent, with no --primary-harness pinned, is ambiguous — the engine
|
|
446
|
+
// must not silently reroute. Refuse with a structured, copy-pasteable fix
|
|
447
|
+
// naming the pool, the missing primary, and the exact flag to add.
|
|
448
|
+
throw new HarnessUnavailableError(`ambiguous primary harness: the configured default primary '${primaryHarness}' is not in the selected pool [${harnesses.join(", ")}], ` +
|
|
449
|
+
`and no --primary-harness was given. Pin one explicitly, e.g. \`--primary-harness ${harnesses[0]}\` ` +
|
|
450
|
+
`(or another of [${harnesses.join(", ")}]).`);
|
|
394
451
|
}
|
|
395
452
|
if (input.web && input.externalContextPolicy && input.web !== input.externalContextPolicy) {
|
|
396
453
|
throw new Error(`contradictory web policy: web='${input.web}' vs externalContextPolicy='${input.externalContextPolicy}' (pass one, or equal values)`);
|
|
397
454
|
}
|
|
398
455
|
const web = input.web ?? input.externalContextPolicy ?? "auto";
|
|
399
|
-
// INV-103: model
|
|
400
|
-
//
|
|
401
|
-
// never the whole pool (the old global fallback poisoned every pool
|
|
402
|
-
// member with one vendor's model id). Specific beats general: an explicit
|
|
403
|
-
// per-harness map entry wins over the scalar.
|
|
404
|
-
//
|
|
405
|
-
// Map KEYS fail loudly (INV-021): a typo'd harness id ("claud") must
|
|
406
|
-
// never silently no-op into "the run used defaults and nothing said why".
|
|
456
|
+
// INV-103: scalar `model` expands only to the resolved primary, never the pool;
|
|
457
|
+
// an explicit per-harness map wins. Unknown map keys fail loudly (INV-021).
|
|
407
458
|
const knownHarnessIds = new Set(this.deps.registry.keys());
|
|
408
459
|
for (const key of Object.keys(input.models ?? {})) {
|
|
409
460
|
if (!knownHarnessIds.has(key)) {
|
|
@@ -421,6 +472,19 @@ export class Orchestrator {
|
|
|
421
472
|
}
|
|
422
473
|
models[scalarTarget] ??= input.model;
|
|
423
474
|
}
|
|
475
|
+
// QA-035: FREEZE the config-derived per-harness default_model into the
|
|
476
|
+
// resolved model map at initial normalization, exactly like an explicit
|
|
477
|
+
// input. Without this the TaskContract records `routing_models: {}` and an
|
|
478
|
+
// Exact Retry re-resolves the model against CURRENT settings — silently
|
|
479
|
+
// changing the route after a settings edit. A per-turn/scalar value already
|
|
480
|
+
// set wins (??=). Only a known resolved pool can be frozen here; a pure
|
|
481
|
+
// auto pool's lanes are not yet known (documented seam).
|
|
482
|
+
const harnessCfg = cfg?.global.harnesses ?? {};
|
|
483
|
+
for (const hid of harnesses ?? []) {
|
|
484
|
+
const def = harnessCfg[hid]?.default_model;
|
|
485
|
+
if (def)
|
|
486
|
+
models[hid] ??= def;
|
|
487
|
+
}
|
|
424
488
|
return {
|
|
425
489
|
...input,
|
|
426
490
|
harnesses,
|
|
@@ -436,7 +500,16 @@ export class Orchestrator {
|
|
|
436
500
|
externalContextPolicy: web,
|
|
437
501
|
};
|
|
438
502
|
}
|
|
439
|
-
async resolveCandidateAdapters(input, intent, ledger, log, routeContext
|
|
503
|
+
async resolveCandidateAdapters(input, intent, ledger, log, routeContext,
|
|
504
|
+
/** QA-034: when provided, the pool-ordering rationale is recorded under this
|
|
505
|
+
* run id so the terminal telemetry writer can persist it. */
|
|
506
|
+
runId,
|
|
507
|
+
/** Deep-scan opts in: multi-scout coverage repeats a surviving harness to
|
|
508
|
+
* reach the requested width (distinct SLICES, not distinct harnesses), so a
|
|
509
|
+
* dropped lane must not clamp the scout count. Best-of leaves this false —
|
|
510
|
+
* its width is distinct-harness diversity, so a dropped lane clamps rather
|
|
511
|
+
* than self-races (QA-043). */
|
|
512
|
+
allowDuplicateFill = false) {
|
|
440
513
|
let ids = input.harnesses;
|
|
441
514
|
const explicitPool = Boolean(ids && ids.length > 0);
|
|
442
515
|
const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
|
|
@@ -492,6 +565,21 @@ export class Orchestrator {
|
|
|
492
565
|
const policy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
493
566
|
const pool = [];
|
|
494
567
|
const dropped = [];
|
|
568
|
+
// Structured requested-vs-effective route receipt (QA-043): every auto-pool
|
|
569
|
+
// drop is recorded with its typed STAGE so the disclosure preserves the
|
|
570
|
+
// real cause instead of collapsing to one reason.
|
|
571
|
+
const droppedLanes = [];
|
|
572
|
+
// The ONE explicit-lane admission gate shared by every drop site (QA-043 /
|
|
573
|
+
// QA-047 meta-move): an EXPLICITLY selected lane that becomes ineligible is
|
|
574
|
+
// a loud typed refusal naming the lane + reason — never a silent
|
|
575
|
+
// substitution or self-race duplication; an AUTO lane is dropped with a
|
|
576
|
+
// typed omission recorded for the degradation receipt.
|
|
577
|
+
const dropLane = (harnessId, stage, detail) => {
|
|
578
|
+
if (explicitPool)
|
|
579
|
+
throw new HarnessUnavailableError(detail);
|
|
580
|
+
dropped.push(detail);
|
|
581
|
+
droppedLanes.push({ harnessId, stage, detail });
|
|
582
|
+
};
|
|
495
583
|
for (const id of ids) {
|
|
496
584
|
const adapter = this.deps.registry.get(id);
|
|
497
585
|
if (!adapter) {
|
|
@@ -502,7 +590,7 @@ export class Orchestrator {
|
|
|
502
590
|
const known = [...this.deps.registry.keys()].sort().join(", ");
|
|
503
591
|
throw new HarnessUnavailableError(`unknown harness '${id}' (registered: ${known}); run \`claudexor harness list --all\``);
|
|
504
592
|
}
|
|
505
|
-
|
|
593
|
+
dropLane(id, "discovery", `${id} (not registered)`);
|
|
506
594
|
continue;
|
|
507
595
|
}
|
|
508
596
|
// Per-harness settings: a user-disabled harness never routes. Explicit
|
|
@@ -511,9 +599,7 @@ export class Orchestrator {
|
|
|
511
599
|
const cfgEntry = harnessSettings[id];
|
|
512
600
|
if (cfgEntry && cfgEntry.enabled === false) {
|
|
513
601
|
const why = `${id} is disabled in settings (harnesses.${id}.enabled=false)`;
|
|
514
|
-
|
|
515
|
-
throw new HarnessUnavailableError(why);
|
|
516
|
-
dropped.push(why);
|
|
602
|
+
dropLane(id, "settings", why);
|
|
517
603
|
continue;
|
|
518
604
|
}
|
|
519
605
|
// INV-135 accounts authority: with the native/CLI login excluded and no
|
|
@@ -523,9 +609,7 @@ export class Orchestrator {
|
|
|
523
609
|
if (this.effectiveProfileId(input, id) === null &&
|
|
524
610
|
this.nativeCredentialsDisabled(input.repoRoot, id)) {
|
|
525
611
|
const why = `${id} has no routable credential: the CLI login is disabled (harnesses.${id}.native_credentials_enabled=false) and no account is pinned (--profile)`;
|
|
526
|
-
|
|
527
|
-
throw new HarnessUnavailableError(why);
|
|
528
|
-
dropped.push(why);
|
|
612
|
+
dropLane(id, "credential", why);
|
|
529
613
|
continue;
|
|
530
614
|
}
|
|
531
615
|
// W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
|
|
@@ -533,7 +617,12 @@ export class Orchestrator {
|
|
|
533
617
|
let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
|
|
534
618
|
const manifest = status?.manifest ?? null;
|
|
535
619
|
if (!status || !manifest) {
|
|
536
|
-
|
|
620
|
+
// QA-047: an explicit member with no doctor manifest (absent binary /
|
|
621
|
+
// unconfigured provider) is unavailable — it must fail LOUDLY for an
|
|
622
|
+
// explicit pool (naming the real doctor reasons), not vanish before the
|
|
623
|
+
// later explicit-status guard because a healthier lane survived.
|
|
624
|
+
const reasons = status?.reasons?.length ? `: ${status.reasons.join("; ")}` : "";
|
|
625
|
+
dropLane(id, "doctor", `${id} is unavailable${reasons || " (no manifest / not ready)"}`);
|
|
537
626
|
continue;
|
|
538
627
|
}
|
|
539
628
|
// Doctor status is the readiness truth. A DEGRADED harness (e.g. key present but
|
|
@@ -570,21 +659,17 @@ export class Orchestrator {
|
|
|
570
659
|
}
|
|
571
660
|
else {
|
|
572
661
|
const why = `${id} credential profile is not ready: ${profileVerdict}`;
|
|
573
|
-
|
|
574
|
-
throw new HarnessUnavailableError(why);
|
|
575
|
-
dropped.push(why);
|
|
662
|
+
dropLane(id, "credential", why);
|
|
576
663
|
continue;
|
|
577
664
|
}
|
|
578
665
|
}
|
|
579
666
|
if (status.status === "unavailable" && !profileAdmitted) {
|
|
580
667
|
const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
|
|
581
|
-
|
|
582
|
-
throw new HarnessUnavailableError(why);
|
|
583
|
-
dropped.push(why);
|
|
668
|
+
dropLane(id, "doctor", why);
|
|
584
669
|
continue;
|
|
585
670
|
}
|
|
586
671
|
if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
|
|
587
|
-
|
|
672
|
+
dropLane(id, "doctor", `${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
|
|
588
673
|
continue;
|
|
589
674
|
}
|
|
590
675
|
const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
|
|
@@ -607,16 +692,12 @@ export class Orchestrator {
|
|
|
607
692
|
(routeWebRequired && (webSupport === "none" || webSupport === "uncontrolled"));
|
|
608
693
|
if (webIncompatible) {
|
|
609
694
|
const why = `${id} cannot enforce web policy '${routePolicy}' (manifest web_policy=${webSupport}); choose a web-capable/enforceable harness or change --web to a compatible policy`;
|
|
610
|
-
|
|
611
|
-
throw new HarnessUnavailableError(why);
|
|
612
|
-
dropped.push(why);
|
|
695
|
+
dropLane(id, "web", why);
|
|
613
696
|
continue;
|
|
614
697
|
}
|
|
615
698
|
const attachmentRefusal = this.requestRequirements.attachmentRefusal(id, input.attachments ?? [], manifest.capability_profile.attachment_inputs);
|
|
616
699
|
if (attachmentRefusal) {
|
|
617
|
-
|
|
618
|
-
throw new HarnessUnavailableError(attachmentRefusal);
|
|
619
|
-
dropped.push(attachmentRefusal);
|
|
700
|
+
dropLane(id, "attachment", attachmentRefusal);
|
|
620
701
|
continue;
|
|
621
702
|
}
|
|
622
703
|
const reason = status.reasons.length > 0 ? `: ${status.reasons.join("; ")}` : "";
|
|
@@ -642,8 +723,11 @@ export class Orchestrator {
|
|
|
642
723
|
// the default store's sources apply only to profile-less runs.
|
|
643
724
|
authRouteEstimate: this.profileAuthRoute(input, id) ??
|
|
644
725
|
estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
|
|
726
|
+
supportsSynthesize: manifest.capabilities.synthesize,
|
|
645
727
|
supportsInteractive: manifest.capabilities.interactive,
|
|
646
728
|
supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
|
|
729
|
+
workReportTransport: manifest.capabilities.work_report_transport,
|
|
730
|
+
structuredOutputChannel: manifest.capabilities.structured_output_channel,
|
|
647
731
|
supportsMcpInjection: manifest.capability_profile.mcp_injection,
|
|
648
732
|
mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
|
|
649
733
|
implementationTransport: manifest.capabilities.implementation_transport,
|
|
@@ -661,21 +745,51 @@ export class Orchestrator {
|
|
|
661
745
|
: null,
|
|
662
746
|
});
|
|
663
747
|
}
|
|
664
|
-
else
|
|
665
|
-
|
|
748
|
+
else {
|
|
749
|
+
// QA-043: an intent- or access-incompatible lane. For an EXPLICIT pool
|
|
750
|
+
// this is a loud refusal (dropLane throws) naming the lane and the
|
|
751
|
+
// exact capability gap — never a silent omission that a surviving lane
|
|
752
|
+
// then masks by modulo self-duplication. The typed stage distinguishes
|
|
753
|
+
// an access refusal from a capability one so the disclosure is honest.
|
|
754
|
+
dropLane(id, accessSupported ? "capability" : "access", `${id} (${accessSupported ? `cannot ${intent}${reason}` : `cannot enforce ${requiredAccess}`})`);
|
|
755
|
+
}
|
|
666
756
|
}
|
|
667
757
|
if (pool.length === 0) {
|
|
668
758
|
throw new HarnessUnavailableError(`no harness can perform '${intent}' for this mode${dropped.length ? ` (skipped: ${dropped.join(", ")})` : ""}`);
|
|
669
759
|
}
|
|
670
|
-
const ordered = this.orderPool(pool, input, intent, statusById, ledger);
|
|
760
|
+
const ordered = this.orderPool(pool, input, intent, statusById, ledger, runId);
|
|
671
761
|
if (ordered.length === 0) {
|
|
672
762
|
throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
|
|
673
763
|
}
|
|
674
764
|
emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
|
|
675
765
|
const n = input.n ?? ordered.length;
|
|
676
766
|
const out = [];
|
|
677
|
-
|
|
678
|
-
|
|
767
|
+
if (droppedLanes.length > 0 && !allowDuplicateFill) {
|
|
768
|
+
// QA-043: lanes were dropped from an AUTO best-of pool (an explicit pool
|
|
769
|
+
// would have thrown at the drop). NEVER refill a dropped lane's slot by
|
|
770
|
+
// duplicating a surviving harness — that manufactures a self-race that
|
|
771
|
+
// masks the omission. Clamp to distinct survivors and disclose below.
|
|
772
|
+
// (Deep-scan sets allowDuplicateFill: its width is scout coverage, not
|
|
773
|
+
// harness diversity, so a dropped lane must not cut the scout count.)
|
|
774
|
+
for (let i = 0; i < Math.min(n, ordered.length); i++)
|
|
775
|
+
out.push(ordered[i]);
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
// No lane was dropped: a pool smaller than `n` is an intentional
|
|
779
|
+
// best-of-N on the available harness(es) (e.g. explicit `--harness codex
|
|
780
|
+
// -n 3`), so the historical width fill is preserved.
|
|
781
|
+
for (let i = 0; i < n; i++)
|
|
782
|
+
out.push(ordered[i % ordered.length]);
|
|
783
|
+
}
|
|
784
|
+
// Disclose an auto-pool omission / width clamp once, with the
|
|
785
|
+
// requested-vs-effective route receipt (never silent — QA-043).
|
|
786
|
+
emitPoolDegraded(log, {
|
|
787
|
+
requestedHarnesses: ids,
|
|
788
|
+
effectiveHarnesses: [...new Set(out.map((lane) => lane.adapter.id))],
|
|
789
|
+
requestedN: n,
|
|
790
|
+
effectiveN: out.length,
|
|
791
|
+
droppedLanes,
|
|
792
|
+
});
|
|
679
793
|
this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
|
|
680
794
|
// Delegation belt (D32): agent-only, and only on a lane whose adapter can
|
|
681
795
|
// inject MCP servers. A requested delegate with NO injecting lane is a typed
|
|
@@ -730,7 +844,7 @@ export class Orchestrator {
|
|
|
730
844
|
* explicit user pool keeps the user's order; an explicit primary harness is
|
|
731
845
|
* always pinned first. Cross-family diversity is encouraged for later slots.
|
|
732
846
|
*/
|
|
733
|
-
orderPool(pool, input, intent, statusById, ledger) {
|
|
847
|
+
orderPool(pool, input, intent, statusById, ledger, runId) {
|
|
734
848
|
let ordered = pool;
|
|
735
849
|
if (pool.length > 0) {
|
|
736
850
|
const routeLedger = ledger ?? new BudgetLedger();
|
|
@@ -740,7 +854,8 @@ export class Orchestrator {
|
|
|
740
854
|
// Settled cost is evidence for economy routing, never a provider quality prior.
|
|
741
855
|
const metrics = loadHarnessMetrics(globalConfigDir());
|
|
742
856
|
const remaining = pool.map((r) => {
|
|
743
|
-
const
|
|
857
|
+
const status = statusById.get(r.adapter.id);
|
|
858
|
+
const authModes = status?.manifest?.auth_modes ?? [];
|
|
744
859
|
const metric = metrics[r.adapter.id];
|
|
745
860
|
// Auth mode for routing: prefer the ROUTE EVIDENCE from the
|
|
746
861
|
// last settled attempt (adapter-disclosed, persisted in metrics) over
|
|
@@ -773,13 +888,23 @@ export class Orchestrator {
|
|
|
773
888
|
catch {
|
|
774
889
|
credentialSubjectId = undefined;
|
|
775
890
|
}
|
|
891
|
+
// QA-034: the typed auth-route evidence (doctor source verification x the
|
|
892
|
+
// resolved route) is AUTHORITATIVE for billing knowledge in the router —
|
|
893
|
+
// a VERIFIED native route proves subscription_entitlement, so it survives
|
|
894
|
+
// paid_fallback:never and ranks with a real economy tuple instead of
|
|
895
|
+
// reading as unknown/paid. Absent (unknown route) falls back to the
|
|
896
|
+
// metric-derived billingKnowledge below.
|
|
897
|
+
const authRoute = this.authRouteEvidenceFor(authMode, status?.authSources ?? []);
|
|
776
898
|
return {
|
|
777
899
|
harnessId: r.adapter.id,
|
|
778
900
|
available: true,
|
|
779
901
|
model: input.models?.[r.adapter.id] ??
|
|
780
902
|
config.harnesses[r.adapter.id]?.default_model ??
|
|
781
903
|
undefined,
|
|
782
|
-
effort: input.
|
|
904
|
+
effort: input.efforts?.[r.adapter.id] ??
|
|
905
|
+
input.effort ??
|
|
906
|
+
config.harnesses[r.adapter.id]?.effort ??
|
|
907
|
+
undefined,
|
|
783
908
|
billingKnowledge: authMode === "api_key" ? "metered" : "unknown",
|
|
784
909
|
incrementalCostUsd: authMode === "api_key" ? (metric?.avg_cost_usd ?? null) : null,
|
|
785
910
|
credentialRoute: authMode === "api_key"
|
|
@@ -787,18 +912,25 @@ export class Orchestrator {
|
|
|
787
912
|
: authMode === "local_session"
|
|
788
913
|
? "vendor_native"
|
|
789
914
|
: undefined,
|
|
915
|
+
...(authRoute ? { authRoute } : {}),
|
|
790
916
|
credentialSubjectId,
|
|
791
917
|
};
|
|
792
918
|
});
|
|
793
|
-
const
|
|
919
|
+
const routeCtx = {
|
|
794
920
|
goal,
|
|
795
921
|
paidFallback: config.routing.paid_fallback,
|
|
796
922
|
intent,
|
|
797
923
|
qualityTiers: config.routing.quality_tiers,
|
|
798
924
|
ledger: routeLedger,
|
|
799
|
-
|
|
925
|
+
now: Date.now(), // ONE instant for the sort AND the rationale below
|
|
926
|
+
};
|
|
927
|
+
const ranked = rankHarnesses(remaining, routeCtx)
|
|
800
928
|
.map((candidate) => byId.get(candidate.harnessId))
|
|
801
929
|
.filter((candidate) => Boolean(candidate));
|
|
930
|
+
// QA-034: the rationale is run evidence recorded ONCE at pool ordering,
|
|
931
|
+
// pinned to routeCtx.now so it cannot disagree with the order just taken.
|
|
932
|
+
if (runId)
|
|
933
|
+
this.routingRationaleByRun.set(runId, explainRanking(remaining, routeCtx));
|
|
802
934
|
ordered = ranked;
|
|
803
935
|
}
|
|
804
936
|
if (input.primaryHarness) {
|
|
@@ -808,6 +940,30 @@ export class Orchestrator {
|
|
|
808
940
|
}
|
|
809
941
|
return ordered;
|
|
810
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Typed auth-route evidence for one candidate (QA-034): the concrete
|
|
945
|
+
* credential route the resolved auth mode maps to, plus the doctor's
|
|
946
|
+
* verification for the source that route runs under. `local_session` →
|
|
947
|
+
* vendor_native + the native/OAuth source verification; `api_key` →
|
|
948
|
+
* managed_api_key + the key source verification. Unknown route → no evidence
|
|
949
|
+
* (the router keeps its conservative metric-derived billing). Verification is
|
|
950
|
+
* the source's typed verdict — never inferred from mere availability.
|
|
951
|
+
*/
|
|
952
|
+
authRouteEvidenceFor(authMode, sources) {
|
|
953
|
+
const usable = (s) => s.availability === "available" && s.verification !== "failed";
|
|
954
|
+
if (authMode === "local_session") {
|
|
955
|
+
const native = sources.find((s) => usable(s) && (s.source === "native_session" || s.source === "oauth_token_env"));
|
|
956
|
+
return { route: "vendor_native", verification: native?.verification ?? "not_run" };
|
|
957
|
+
}
|
|
958
|
+
if (authMode === "api_key") {
|
|
959
|
+
const key = sources.find((s) => usable(s) &&
|
|
960
|
+
(s.source === "api_key_env" ||
|
|
961
|
+
s.source === "api_key_flag" ||
|
|
962
|
+
s.source === "provider_auth_file"));
|
|
963
|
+
return { route: "managed_api_key", verification: key?.verification ?? "not_run" };
|
|
964
|
+
}
|
|
965
|
+
return undefined;
|
|
966
|
+
}
|
|
811
967
|
/**
|
|
812
968
|
* Lazy ContextPack: built ONLY for the read-only report modes
|
|
813
969
|
* (explore/plan/readonly_audit) that consume it. Persisted to
|
|
@@ -916,7 +1072,7 @@ export class Orchestrator {
|
|
|
916
1072
|
projectCommands: cfg.tests?.commands ?? [],
|
|
917
1073
|
});
|
|
918
1074
|
const commands = resolvedGates.commands;
|
|
919
|
-
const protectedPaths = [];
|
|
1075
|
+
const protectedPaths = [...new Set(cfg.constraints.protected_paths)];
|
|
920
1076
|
const autoProtectedPaths = resolvedGates.autoProtectedPaths;
|
|
921
1077
|
const protectedPathApprovals = [
|
|
922
1078
|
...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
|
|
@@ -973,6 +1129,22 @@ export class Orchestrator {
|
|
|
973
1129
|
// primary by resolveRunInput). The contract is what route spec building
|
|
974
1130
|
// reads — there is no run-global model (INV-103).
|
|
975
1131
|
routing_models: input.models ?? {},
|
|
1132
|
+
// QA-035: freeze the RESOLVED reasoning-effort per known lane so Exact
|
|
1133
|
+
// Retry replays it instead of re-resolving current settings. Precedence
|
|
1134
|
+
// (specific beats general): the harness-scoped `efforts` map entry, then a
|
|
1135
|
+
// per-turn scalar `input.effort`, then the harness settings default — the
|
|
1136
|
+
// same map that Exact Retry replays so a NON-PRIMARY lane keeps its own
|
|
1137
|
+
// frozen effort (QA-035 completeness). Only known-pool lanes are frozen
|
|
1138
|
+
// here (a pure auto pool's lanes resolve later — documented seam).
|
|
1139
|
+
routing_efforts: Object.fromEntries([...new Set([...(input.harnesses ?? []), ...Object.keys(input.efforts ?? {})])]
|
|
1140
|
+
.map((hid) => [
|
|
1141
|
+
hid,
|
|
1142
|
+
input.efforts?.[hid] ??
|
|
1143
|
+
input.effort ??
|
|
1144
|
+
resolvedCfg.global.harnesses?.[hid]?.effort ??
|
|
1145
|
+
null,
|
|
1146
|
+
])
|
|
1147
|
+
.filter((entry) => entry[1] !== null)),
|
|
976
1148
|
});
|
|
977
1149
|
}
|
|
978
1150
|
/**
|
|
@@ -1044,6 +1216,41 @@ export class Orchestrator {
|
|
|
1044
1216
|
: {}),
|
|
1045
1217
|
};
|
|
1046
1218
|
}
|
|
1219
|
+
/**
|
|
1220
|
+
* D-16: the WorkReport transport envelope for one route. Called at every
|
|
1221
|
+
* task-producing spec-build site AFTER harnessSpecKnobs so it OVERRIDES the
|
|
1222
|
+
* plain caller-schema transport with the compiled `{work_report, output}`
|
|
1223
|
+
* envelope on capable routes. The returned `mode` is retained by the caller
|
|
1224
|
+
* and handed to `unwrapWorkReportEnvelope` when the answer is finalized.
|
|
1225
|
+
*/
|
|
1226
|
+
workReportEnvelopeFor(routed, contract, interactive) {
|
|
1227
|
+
return resolveWorkReportEnvelope({
|
|
1228
|
+
transport: routed.workReportTransport,
|
|
1229
|
+
channel: routed.structuredOutputChannel,
|
|
1230
|
+
supportsJsonSchemaOutput: routed.supportsJsonSchemaOutput,
|
|
1231
|
+
interactive,
|
|
1232
|
+
callerSchema: contract.output_schema ?? null,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* D-16: apply the resolved WorkReport transport to a built spec — set the
|
|
1237
|
+
* envelope output_schema (constrained/side_tool routes) and APPEND the fenced
|
|
1238
|
+
* envelope instruction (validated routes, e.g. cursor). Mutates the spec in
|
|
1239
|
+
* place and returns the mode the answer unwrap consumes. Called at every
|
|
1240
|
+
* task-producing spec-build site so the transport is never wired one-off.
|
|
1241
|
+
*/
|
|
1242
|
+
applyWorkEnvelope(spec, workEnvelope) {
|
|
1243
|
+
if (workEnvelope.outputSchema !== undefined)
|
|
1244
|
+
spec.output_schema = workEnvelope.outputSchema;
|
|
1245
|
+
const instruction = workEnvelope.mode.instruction;
|
|
1246
|
+
if (instruction) {
|
|
1247
|
+
spec.instructions =
|
|
1248
|
+
spec.instructions && spec.instructions.trim()
|
|
1249
|
+
? `${spec.instructions}\n\n${instruction}`
|
|
1250
|
+
: instruction;
|
|
1251
|
+
}
|
|
1252
|
+
return workEnvelope.mode;
|
|
1253
|
+
}
|
|
1047
1254
|
routeSpecKnobs(routed, contract, overrideModel, effortHint) {
|
|
1048
1255
|
const s = routed.settings;
|
|
1049
1256
|
const contractPolicy = contract.external_context.policy;
|
|
@@ -1076,13 +1283,16 @@ export class Orchestrator {
|
|
|
1076
1283
|
// per-harness map, which beats the per-harness settings default. There is
|
|
1077
1284
|
// no run-global model.
|
|
1078
1285
|
const model = overrideModel ?? contract.routing_models[routed.adapter.id] ?? s?.defaultModel ?? null;
|
|
1079
|
-
// Effort disclosure (INV-105)
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1286
|
+
// Effort disclosure (INV-105) against the harness's advertised ladder. This
|
|
1287
|
+
// gate only DISCLOSES an unplaceable level; the clamp belongs to the adapter,
|
|
1288
|
+
// which resolves against the catalog for the profile env the child runs in
|
|
1289
|
+
// (the manifest here is the DEFAULT account's — see effortGovernance.ts). The
|
|
1290
|
+
// contract's FROZEN per-lane effort (QA-035) wins so Exact Retry replays it
|
|
1291
|
+
// without re-reading settings; `effortHint`/settings apply only to an unfrozen lane.
|
|
1292
|
+
const governed = governRouteEffort(contract.routing_efforts[routed.adapter.id] ?? effortHint ?? s?.effort ?? null, { id: routed.adapter.id, ...routed });
|
|
1293
|
+
const effort = governed.effort;
|
|
1294
|
+
if (governed.ignored)
|
|
1295
|
+
ignored.push(governed.ignored);
|
|
1086
1296
|
return {
|
|
1087
1297
|
model,
|
|
1088
1298
|
effort,
|
|
@@ -1198,7 +1408,11 @@ export class Orchestrator {
|
|
|
1198
1408
|
return { pointerLine: null };
|
|
1199
1409
|
}
|
|
1200
1410
|
}
|
|
1201
|
-
async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput, streamDeltas = false, fileBackedContext
|
|
1411
|
+
async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput, streamDeltas = false, fileBackedContext,
|
|
1412
|
+
/** D-16d: when set, the mechanical continuation checkpoint pointer for a
|
|
1413
|
+
* one-shot fresh-session continuation — appended to the prompt so the model
|
|
1414
|
+
* (and the offline fake) re-grounds in the exhausted attempt's partial work. */
|
|
1415
|
+
continuationPointer) {
|
|
1202
1416
|
const adapter = routed.adapter;
|
|
1203
1417
|
const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
|
|
1204
1418
|
// Isolated scoped-home sessions are never retained after disposal.
|
|
@@ -1212,13 +1426,14 @@ export class Orchestrator {
|
|
|
1212
1426
|
const laneContinuity = runInput
|
|
1213
1427
|
? await this.resolveContinuity(runInput, adapter.id, sessionFields?.credential_profile?.profile_id ?? runInput.credentialProfileId ?? null, inPlaceEnvelope && !!sessionFields?.resume_session_id, store, paths, envelope.repo_root, log)
|
|
1214
1428
|
: null;
|
|
1215
|
-
const promptWithContinuity = laneContinuity?.pointerLine
|
|
1216
|
-
? `${prompt}\n\n${laneContinuity.pointerLine}`
|
|
1217
|
-
: prompt;
|
|
1218
1429
|
let spec = HarnessRunSpec.parse({
|
|
1219
1430
|
session_id: newId("ses"),
|
|
1220
1431
|
intent,
|
|
1221
|
-
|
|
1432
|
+
// Engine-derived read-only prompt constraints: protected/auto-protected
|
|
1433
|
+
// paths PLUS the exact typed gate argv the run will execute (QA-022 FIX B).
|
|
1434
|
+
prompt: promptWithEngineConstraints([prompt, laneContinuity?.pointerLine, continuationPointer]
|
|
1435
|
+
.filter((s) => typeof s === "string" && s.length > 0)
|
|
1436
|
+
.join("\n\n"), contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.tests.commands),
|
|
1222
1437
|
attachments: runInput?.attachments ?? [],
|
|
1223
1438
|
browser: this.requestRequirements.browserSpec(routed.browserRequirement,
|
|
1224
1439
|
// F4: browser-MCP screenshots land in the claudexor-owned
|
|
@@ -1246,6 +1461,10 @@ export class Orchestrator {
|
|
|
1246
1461
|
});
|
|
1247
1462
|
if (interaction)
|
|
1248
1463
|
spec.extra["interactionChannel"] = interaction;
|
|
1464
|
+
// D-16: compile the WorkReport envelope onto the spec (overriding the plain
|
|
1465
|
+
// caller-schema transport) and keep the mode for the answer unwrap.
|
|
1466
|
+
const workEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(interaction));
|
|
1467
|
+
const workReportMode = this.applyWorkEnvelope(spec, workEnvelope);
|
|
1249
1468
|
const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
|
|
1250
1469
|
const attemptStartedMs = Date.now();
|
|
1251
1470
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
@@ -1256,12 +1475,25 @@ export class Orchestrator {
|
|
|
1256
1475
|
// W-C4 delta flood budget (per attempt): counts forwarded delta chunks.
|
|
1257
1476
|
let deltaCount = 0;
|
|
1258
1477
|
let deltaCutoffDisclosed = false;
|
|
1478
|
+
// QA-024: emit the belt-failure disclosure event at most once per attempt.
|
|
1479
|
+
let beltFailureDisclosed = false;
|
|
1259
1480
|
const errors = [];
|
|
1260
1481
|
const answer = new AnswerAssembly();
|
|
1261
1482
|
const retryPolicy = transientRetryPolicy(this.config(contract.repo.root));
|
|
1483
|
+
// QA-024: the delegation belt is the ONLY engine-owned extra MCP server
|
|
1484
|
+
// injected into an agent lane (the browser MCP rides its own field), so its
|
|
1485
|
+
// presence in the spec marks the belt requested-and-injected for THIS
|
|
1486
|
+
// attempt. A mixed pool leaves it off lanes that cannot host it, so this is
|
|
1487
|
+
// per-attempt truth, not the run-wide --delegate flag.
|
|
1488
|
+
const beltServerName = spec.extra_mcp_servers?.[0]?.name ?? null;
|
|
1489
|
+
// QA-040: the browser MCP is injected under the fixed `browser` namespace
|
|
1490
|
+
// (codex `mcp_servers.browser.*`, claude `mcp__browser__*`). Its presence in
|
|
1491
|
+
// the spec marks the browser armed for THIS attempt — the telemetry fold
|
|
1492
|
+
// then recognizes browser tool calls as trusted live-web evidence.
|
|
1493
|
+
const browserServerName = spec.browser ? "browser" : null;
|
|
1262
1494
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
1263
1495
|
knobs.webPolicy === "cached" ||
|
|
1264
|
-
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model);
|
|
1496
|
+
knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model, beltServerName, browserServerName);
|
|
1265
1497
|
let activeSessionId = spec.session_id;
|
|
1266
1498
|
const onAbort = () => {
|
|
1267
1499
|
void adapter.cancel?.(activeSessionId)?.catch(() => { });
|
|
@@ -1341,6 +1573,23 @@ export class Orchestrator {
|
|
|
1341
1573
|
observeNativeSessionEvent(runInput, adapter.id, safeEv);
|
|
1342
1574
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
1343
1575
|
observeAttemptTelemetry(telemetry, safeEv);
|
|
1576
|
+
// QA-024: the injected delegation belt's MCP server reported `failed`
|
|
1577
|
+
// to start. Disclose it ONCE as a typed run event the moment the
|
|
1578
|
+
// `started` frame reveals it — the harness is about to run without
|
|
1579
|
+
// `mcp__<belt>__*` tools and may degrade to its own native subagent.
|
|
1580
|
+
// The terminal outcome axis (delegationBeltUnavailable) reflects it
|
|
1581
|
+
// too; this event makes the failure visible while the run is live.
|
|
1582
|
+
if (telemetry.delegationBelt.requested &&
|
|
1583
|
+
telemetry.delegationBelt.failed &&
|
|
1584
|
+
!beltFailureDisclosed) {
|
|
1585
|
+
beltFailureDisclosed = true;
|
|
1586
|
+
log?.emit("delegation.belt.unavailable", {
|
|
1587
|
+
attempt_id: attemptId,
|
|
1588
|
+
harness_id: adapter.id,
|
|
1589
|
+
server_name: telemetry.delegationBelt.serverName,
|
|
1590
|
+
reason: "mcp_server_failed_to_start",
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1344
1593
|
// Live plan checklist: forward the adapter's typed plan
|
|
1345
1594
|
// progress as a run event (LAST WINS; the UI renders the latest).
|
|
1346
1595
|
if (safeEv.plan_progress) {
|
|
@@ -1393,15 +1642,21 @@ export class Orchestrator {
|
|
|
1393
1642
|
}
|
|
1394
1643
|
catch (err) {
|
|
1395
1644
|
// A throwing adapter must not lose the cost already streamed: record the
|
|
1396
|
-
// error here and let the caller settle the REAL accumulated spend.
|
|
1645
|
+
// error here and let the caller settle the REAL accumulated spend. #31:
|
|
1646
|
+
// classify the throw (watchdog timeout vs process crash) so the retry
|
|
1647
|
+
// gate and required-actions read a typed category, not a bare boolean.
|
|
1397
1648
|
harnessErrored = true;
|
|
1398
1649
|
errors.push(safeErrorMessage(err));
|
|
1650
|
+
telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
|
|
1399
1651
|
}
|
|
1400
1652
|
finally {
|
|
1401
1653
|
clearFileBackedContext();
|
|
1402
1654
|
}
|
|
1403
|
-
const
|
|
1404
|
-
const
|
|
1655
|
+
const newTransients = telemetry.transientFailures.slice(transientStart);
|
|
1656
|
+
const transient = newTransients.at(-1) ?? null;
|
|
1657
|
+
// #31: the centralized retry gate reads the classified `retryable`, not a
|
|
1658
|
+
// bare "saw any transient" boolean.
|
|
1659
|
+
const sawRetryable = newTransients.some((f) => f.retryable);
|
|
1405
1660
|
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
1406
1661
|
const currentDiff = await wsm.diff(envelope);
|
|
1407
1662
|
const currentAnswer = answer.text();
|
|
@@ -1432,7 +1687,7 @@ export class Orchestrator {
|
|
|
1432
1687
|
}
|
|
1433
1688
|
}
|
|
1434
1689
|
if (!harnessErrored ||
|
|
1435
|
-
!
|
|
1690
|
+
!sawRetryable ||
|
|
1436
1691
|
!deliverableEmpty ||
|
|
1437
1692
|
nativeTry >= retryPolicy.maxRetries ||
|
|
1438
1693
|
signal?.aborted)
|
|
@@ -1443,6 +1698,7 @@ export class Orchestrator {
|
|
|
1443
1698
|
harness_id: adapter.id,
|
|
1444
1699
|
attempt_id: attemptId,
|
|
1445
1700
|
kind: transient?.kind ?? "unknown",
|
|
1701
|
+
category: transient?.category ?? "unknown_harness_error",
|
|
1446
1702
|
native_try: nativeTry + 1,
|
|
1447
1703
|
});
|
|
1448
1704
|
log?.emit("route.transient.retry_scheduled", {
|
|
@@ -1463,16 +1719,23 @@ export class Orchestrator {
|
|
|
1463
1719
|
log?.emit("route.transient.exhausted", {
|
|
1464
1720
|
harness_id: adapter.id,
|
|
1465
1721
|
attempt_id: attemptId,
|
|
1722
|
+
category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
|
|
1466
1723
|
retries: retryPolicy.maxRetries,
|
|
1467
1724
|
});
|
|
1468
1725
|
}
|
|
1469
1726
|
const attemptStreamEndedMs = Date.now();
|
|
1470
1727
|
if (webUnsatisfied(telemetry)) {
|
|
1471
|
-
errors.push(
|
|
1728
|
+
errors.push(webEvidenceFailure(telemetry.web));
|
|
1472
1729
|
}
|
|
1473
1730
|
const diff = await wsm.diff(envelope);
|
|
1474
|
-
|
|
1475
|
-
const
|
|
1731
|
+
// D-16: un-nest {work_report, output} so answer.md persists the OUTPUT, not the envelope.
|
|
1732
|
+
const unwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", workReportMode, {
|
|
1733
|
+
sideToolReport: telemetry.sideToolWorkReport ?? undefined,
|
|
1734
|
+
});
|
|
1735
|
+
// X119: persist the VERBATIM redacted bytes; trim ONLY for the emptiness check.
|
|
1736
|
+
const redacted = redactSecrets(unwrapped.deliverable);
|
|
1737
|
+
const answerText = redacted.trim().length > 0 ? redacted : undefined;
|
|
1738
|
+
const deliverableEvidence = diff.trim().length > 0 || Boolean(answerText);
|
|
1476
1739
|
// Cancelled attempts skip gates entirely: the operator asked to
|
|
1477
1740
|
// stop NOW; running a 600s-per-gate suite after the abort delays the ack
|
|
1478
1741
|
// and burns compute on a result nobody will adopt. Diff/attempt.yaml
|
|
@@ -1507,12 +1770,28 @@ export class Orchestrator {
|
|
|
1507
1770
|
});
|
|
1508
1771
|
}
|
|
1509
1772
|
const webBlocked = webUnsatisfied(telemetry);
|
|
1773
|
+
// D-16 unified finalizer: fold the WorkReport / context signals into the
|
|
1774
|
+
// deliverable + work_state. A broken contract on a constrained route
|
|
1775
|
+
// elevates harnessErrored (never a prose success).
|
|
1776
|
+
const finalized = finalizeAttempt({
|
|
1777
|
+
deliverableEvidence,
|
|
1778
|
+
harnessErrored,
|
|
1779
|
+
workReport: unwrapped.workReport,
|
|
1780
|
+
workReportSource: unwrapped.source,
|
|
1781
|
+
workReportViolation: unwrapped.contractViolation,
|
|
1782
|
+
contextTerminalExhausted: telemetry.contextExhausted,
|
|
1783
|
+
});
|
|
1784
|
+
harnessErrored = finalized.harnessErrored;
|
|
1785
|
+
if (finalized.outcomeClass === "contract_failure" && unwrapped.contractViolation)
|
|
1786
|
+
errors.push(`work_report contract: ${unwrapped.contractViolation}`);
|
|
1787
|
+
const deliverablePresent = finalized.deliverablePresent;
|
|
1510
1788
|
const errored = harnessErrored || webBlocked;
|
|
1511
1789
|
setAttemptOutcome(telemetry, {
|
|
1512
1790
|
deliverablePresent,
|
|
1513
1791
|
gatesPassed: gates.length > 0 ? gatesPassed(gates) : null,
|
|
1514
1792
|
harnessErrored,
|
|
1515
1793
|
webRequiredUnsatisfied: webBlocked,
|
|
1794
|
+
workState: finalized.workState,
|
|
1516
1795
|
});
|
|
1517
1796
|
const attemptDir = join(paths.attemptsDir, attemptId);
|
|
1518
1797
|
try {
|
|
@@ -1565,6 +1844,7 @@ export class Orchestrator {
|
|
|
1565
1844
|
costEstimated,
|
|
1566
1845
|
errors: errors.slice(0, 8),
|
|
1567
1846
|
telemetry,
|
|
1847
|
+
outcomeClass: finalized.outcomeClass,
|
|
1568
1848
|
};
|
|
1569
1849
|
}
|
|
1570
1850
|
interactionChannelFor(input, log, runId, taskId, attemptId, harnessId,
|
|
@@ -1624,6 +1904,40 @@ export class Orchestrator {
|
|
|
1624
1904
|
return message;
|
|
1625
1905
|
}
|
|
1626
1906
|
}
|
|
1907
|
+
/**
|
|
1908
|
+
* D-14 layer 3 (AGENTS.md unification, INV-113): the ONE new live-tree write.
|
|
1909
|
+
* When the PROJECT root has `AGENTS.md` and no `CLAUDE.md`, drop a thin
|
|
1910
|
+
* `CLAUDE.md` (`@AGENTS.md` import + Claudexor ownership marker) so a Claude
|
|
1911
|
+
* Code route reads the same instruction file codex/cursor read natively.
|
|
1912
|
+
*
|
|
1913
|
+
* Fenced exactly where the automatic git-init boundary is: read-only modes
|
|
1914
|
+
* never reach this run-prep stage; `--in-place` stateful targets are left
|
|
1915
|
+
* untouched; the write targets the PROJECT root (`repoRoot`), never a worktree
|
|
1916
|
+
* envelope. The workspace helper adds exclusive-create + no-follow +
|
|
1917
|
+
* idempotency, so a hand-written or symlinked `CLAUDE.md` is never overwritten
|
|
1918
|
+
* and a concurrent/second prep is a no-op. Announced via a typed
|
|
1919
|
+
* `project.claude_bridge.created` event on an actual create only — the git-init
|
|
1920
|
+
* pattern. A bridge is a convenience, not a precondition: any failure is
|
|
1921
|
+
* swallowed so it can never fail an otherwise-valid write run.
|
|
1922
|
+
*/
|
|
1923
|
+
ensureClaudeBridgeForRun(repoRoot, inPlace, log) {
|
|
1924
|
+
if (repoRoot === NO_PROJECT_ROOT || inPlace)
|
|
1925
|
+
return;
|
|
1926
|
+
let result;
|
|
1927
|
+
try {
|
|
1928
|
+
result = ensureClaudeBridge(repoRoot);
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
if (result.created) {
|
|
1934
|
+
log.emit("project.claude_bridge.created", {
|
|
1935
|
+
project_root: repoRoot,
|
|
1936
|
+
path: "CLAUDE.md",
|
|
1937
|
+
source: "AGENTS.md",
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1627
1941
|
/**
|
|
1628
1942
|
* Freeze-on-implement delivery (D17/D27): verify the frozen plan's hash and
|
|
1629
1943
|
* materialize it as context/PLAN.md in the run artifact tree — OUTSIDE every
|
|
@@ -1707,6 +2021,9 @@ export class Orchestrator {
|
|
|
1707
2021
|
candidates: [],
|
|
1708
2022
|
};
|
|
1709
2023
|
}
|
|
2024
|
+
// Same run-prep stage as the git boundary: if the PROJECT root uses AGENTS.md
|
|
2025
|
+
// with no CLAUDE.md, bridge it so a Claude Code candidate reads it (INV-113).
|
|
2026
|
+
this.ensureClaudeBridgeForRun(input.repoRoot, input.inPlace === true, log);
|
|
1710
2027
|
// Pre-turn snapshot of the live tree for in-place runs: the revert restore
|
|
1711
2028
|
// target (server-owned revertInPlace). A snapshot failure must never fail the
|
|
1712
2029
|
// run — revert is simply unavailable then.
|
|
@@ -1730,17 +2047,23 @@ export class Orchestrator {
|
|
|
1730
2047
|
});
|
|
1731
2048
|
let adapters;
|
|
1732
2049
|
try {
|
|
1733
|
-
// Best-of races the whole pool
|
|
1734
|
-
|
|
2050
|
+
// Best-of races the whole pool. The `log` is passed so an AUTO pool that
|
|
2051
|
+
// drops a lane / clamps width discloses `route.pool.degraded` (QA-043) —
|
|
2052
|
+
// the resolver never refills a dropped slot with a duplicate harness.
|
|
2053
|
+
adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger, log, undefined, runId);
|
|
1735
2054
|
}
|
|
1736
2055
|
catch (err) {
|
|
1737
2056
|
const message = safeErrorMessage(err);
|
|
1738
2057
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
2058
|
+
// A routing preflight refusal is a config_error, not harness_unavailable
|
|
2059
|
+
// (A-1/#22): classify identically across every strategy's routing catch.
|
|
2060
|
+
const routingFailure = routingFailureClassification(err);
|
|
1739
2061
|
writeFailure(store, paths, {
|
|
1740
2062
|
phase: "routing",
|
|
1741
|
-
category:
|
|
2063
|
+
category: routingFailure.category,
|
|
1742
2064
|
safeMessage: message,
|
|
1743
2065
|
runDir: paths.root,
|
|
2066
|
+
...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
|
|
1744
2067
|
});
|
|
1745
2068
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
1746
2069
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
@@ -1777,6 +2100,9 @@ export class Orchestrator {
|
|
|
1777
2100
|
};
|
|
1778
2101
|
const candidateAccess = contract.access.effective_profile;
|
|
1779
2102
|
let budgetStopped = false;
|
|
2103
|
+
// QA-050: keep the ledger's typed denial so the zero-candidate terminal
|
|
2104
|
+
// emits budget remediation (not an empty/auth action list).
|
|
2105
|
+
let budgetDenial = null;
|
|
1780
2106
|
let softWarned = false;
|
|
1781
2107
|
const requestedSingleCandidate = adapters.length === 1;
|
|
1782
2108
|
const slots = [];
|
|
@@ -1801,6 +2127,12 @@ export class Orchestrator {
|
|
|
1801
2127
|
// already granted; only a tripped hard cap stops everything.
|
|
1802
2128
|
if (lease.denied !== "estimate_headroom")
|
|
1803
2129
|
budgetStopped = true;
|
|
2130
|
+
budgetDenial ??= {
|
|
2131
|
+
code: lease.denied ?? "hard_cap",
|
|
2132
|
+
reason: lease.reason ?? "budget lease denied",
|
|
2133
|
+
harnessId: routed.adapter.id,
|
|
2134
|
+
attemptId,
|
|
2135
|
+
};
|
|
1804
2136
|
break; // do not spawn more paid work
|
|
1805
2137
|
}
|
|
1806
2138
|
slots.push({
|
|
@@ -1811,6 +2143,11 @@ export class Orchestrator {
|
|
|
1811
2143
|
});
|
|
1812
2144
|
}
|
|
1813
2145
|
const runsBySlot = new Array(slots.length);
|
|
2146
|
+
// D-16d: one-shot continuation budget shared across the concurrent candidate
|
|
2147
|
+
// slots (parity with the read-only chain's single counter). Claimed
|
|
2148
|
+
// synchronously (check-then-increment with no await between), so two slots
|
|
2149
|
+
// that both exhaust cannot both consume the single continuation.
|
|
2150
|
+
let candidateContinuationCount = 0;
|
|
1814
2151
|
const runSlot = async (slot, slotIdx) => {
|
|
1815
2152
|
if (input.signal?.aborted) {
|
|
1816
2153
|
ledger.cancel(slot.leaseId);
|
|
@@ -1891,11 +2228,109 @@ export class Orchestrator {
|
|
|
1891
2228
|
log.emit("harness.completed", {
|
|
1892
2229
|
harness_id: adapter.id,
|
|
1893
2230
|
attempt_id: slot.attemptId,
|
|
1894
|
-
|
|
2231
|
+
// QA-027: never claim `success` over an attempt the operator/deadline
|
|
2232
|
+
// cut short. An abort makes the run non-successful; the top-level
|
|
2233
|
+
// status axis must say `cancelled` (the nested outcome axis already
|
|
2234
|
+
// rides in telemetrySummary), not launder a torn-off stream as clean.
|
|
2235
|
+
status: input.signal?.aborted ? "cancelled" : run.errored ? "failed" : "success",
|
|
1895
2236
|
cost_usd: run.cost,
|
|
1896
2237
|
...telemetrySummary(run.telemetry),
|
|
1897
2238
|
});
|
|
1898
|
-
|
|
2239
|
+
// D-16d one-shot continuation for an ENVELOPED candidate (parity with the
|
|
2240
|
+
// read-only loop, which had the ONLY continuation wiring). An eligible
|
|
2241
|
+
// terminal context exhaustion (repeated_refill, no completed report) gets
|
|
2242
|
+
// ONE fresh-session re-run in the SAME envelope, re-grounded by a
|
|
2243
|
+
// mechanical checkpoint packet; the exhausted candidate is superseded ONLY
|
|
2244
|
+
// after the continuation completes. In-place candidates are excluded (a
|
|
2245
|
+
// fresh session cannot safely resume mutation of the live tree).
|
|
2246
|
+
let effectiveRun = run;
|
|
2247
|
+
const envInPlace = envelope.worktree_path === envelope.repo_root;
|
|
2248
|
+
if (!run.errored && !input.signal?.aborted && candidateContinuationCount === 0) {
|
|
2249
|
+
const contDecision = decideContinuation({
|
|
2250
|
+
contextExhausted: run.telemetry.contextExhausted,
|
|
2251
|
+
contextExhaustedCause: run.telemetry.contextExhaustedCause,
|
|
2252
|
+
workStateCompleted: run.telemetry.outcome?.workState?.state === "completed",
|
|
2253
|
+
continuationCount: candidateContinuationCount,
|
|
2254
|
+
runKind: envInPlace ? "in_place" : "enveloped",
|
|
2255
|
+
});
|
|
2256
|
+
if (contDecision.eligible) {
|
|
2257
|
+
const contAttemptId = `${slot.attemptId}c`;
|
|
2258
|
+
const packet = buildContinuationPacket(synthesizeContinuationRequest({
|
|
2259
|
+
harness: adapter.id,
|
|
2260
|
+
profileId: input.credentialProfileId ?? null,
|
|
2261
|
+
priorPrompt: input.prompt,
|
|
2262
|
+
priorOutput: run.answerText ?? run.diff ?? "",
|
|
2263
|
+
}));
|
|
2264
|
+
// Reserve the continuation lease BEFORE any disclosure: a denied lease
|
|
2265
|
+
// must never emit run.continuation (which claims a continuation
|
|
2266
|
+
// launched) and must not consume the one-shot with no attempt. Grant ->
|
|
2267
|
+
// claim + disclose + run; refusal -> typed run.continuation.denied.
|
|
2268
|
+
const contLease = ledger.reserve({
|
|
2269
|
+
taskId,
|
|
2270
|
+
attemptId: contAttemptId,
|
|
2271
|
+
intent: this.candidateIntent(input),
|
|
2272
|
+
harnessId: adapter.id,
|
|
2273
|
+
cost: attemptCostEvidence(adapter.id, contAttemptId, this.estimateUsdFloor(input.repoRoot), this.routeBillingKnowledge(input, adapter.id)),
|
|
2274
|
+
});
|
|
2275
|
+
if (contLease.granted) {
|
|
2276
|
+
candidateContinuationCount += 1; // claim the one-shot only once it launches
|
|
2277
|
+
log.emit("run.continuation", {
|
|
2278
|
+
from_attempt: run.attemptId,
|
|
2279
|
+
cause: run.telemetry.contextExhaustedCause,
|
|
2280
|
+
continuation_count: candidateContinuationCount,
|
|
2281
|
+
packet_turns: packet.continuity.disclosure.packetTurns,
|
|
2282
|
+
});
|
|
2283
|
+
const contLeaseId = contLease.lease?.lease_id ?? "";
|
|
2284
|
+
try {
|
|
2285
|
+
const contRun = await this.runCandidateInEnvelope(slot.routed, envelope, contAttemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
2286
|
+
const safeEv = redactHarnessEvent(ev);
|
|
2287
|
+
safeInvoke(input.onHarnessEvent, safeEv);
|
|
2288
|
+
log.emit("harness.event", harnessEventPayload(adapter.id, contAttemptId, safeEv));
|
|
2289
|
+
}, input.signal, downgradeModel ?? undefined, input.effort, this.candidateIntent(input), log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, contAttemptId, adapter.id, slot.routed.supportsInteractive), (streamedUsd) => {
|
|
2290
|
+
ledger.updateHold(contLeaseId, streamedUsd);
|
|
2291
|
+
if (ledger.tier() !== "hard")
|
|
2292
|
+
return false;
|
|
2293
|
+
budgetStopped = true;
|
|
2294
|
+
return true;
|
|
2295
|
+
}, input, requestedSingleCandidate, undefined, packet.pointerLine ?? undefined);
|
|
2296
|
+
ledger.settle(contLeaseId, attemptUsageCostSettlement(contRun.cost, contRun.costEstimated, contRun.attemptId, contRun.harnessId, contRun.telemetry.authMode, contRun.telemetry.usageCost));
|
|
2297
|
+
log.emit("harness.completed", {
|
|
2298
|
+
harness_id: adapter.id,
|
|
2299
|
+
attempt_id: contAttemptId,
|
|
2300
|
+
status: input.signal?.aborted
|
|
2301
|
+
? "cancelled"
|
|
2302
|
+
: contRun.errored
|
|
2303
|
+
? "failed"
|
|
2304
|
+
: "success",
|
|
2305
|
+
cost_usd: contRun.cost,
|
|
2306
|
+
...telemetrySummary(contRun.telemetry),
|
|
2307
|
+
});
|
|
2308
|
+
// Supersede the exhausted candidate ONLY after the continuation
|
|
2309
|
+
// actually completes cleanly (never over a torn-off/aborted stream).
|
|
2310
|
+
if (!contRun.errored && !input.signal?.aborted)
|
|
2311
|
+
effectiveRun = contRun;
|
|
2312
|
+
}
|
|
2313
|
+
catch (err) {
|
|
2314
|
+
ledger.settle(contLeaseId, unknownCostSettlement("continuation-error", 0));
|
|
2315
|
+
log.emit("harness.completed", {
|
|
2316
|
+
harness_id: adapter.id,
|
|
2317
|
+
attempt_id: contAttemptId,
|
|
2318
|
+
status: "failed",
|
|
2319
|
+
error: safeErrorMessage(err),
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
else {
|
|
2324
|
+
ledger.cancel(contLease.lease?.lease_id ?? "");
|
|
2325
|
+
log.emit("run.continuation.denied", {
|
|
2326
|
+
from_attempt: run.attemptId,
|
|
2327
|
+
cause: run.telemetry.contextExhaustedCause,
|
|
2328
|
+
reason: contLease.reason ?? contLease.denied ?? "budget lease denied",
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
runsBySlot[slotIdx] = effectiveRun;
|
|
1899
2334
|
reviewEnvelopes.push(envelope);
|
|
1900
2335
|
envelope = undefined;
|
|
1901
2336
|
}
|
|
@@ -1972,42 +2407,54 @@ export class Orchestrator {
|
|
|
1972
2407
|
}
|
|
1973
2408
|
if (input.signal?.aborted) {
|
|
1974
2409
|
await disposeReviewEnvelopes();
|
|
1975
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.
|
|
1976
|
-
attemptId: r.attemptId,
|
|
1977
|
-
harnessId: r.harnessId,
|
|
1978
|
-
telemetry: r.telemetry,
|
|
1979
|
-
})), null), ledger.spend(), input.signal, store);
|
|
2410
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
|
|
1980
2411
|
}
|
|
1981
2412
|
if (runs.length === 0) {
|
|
1982
2413
|
const budgetReason = ledger.terminal();
|
|
2414
|
+
// QA-050: when the zero-candidate cause is a budget refusal, the shared
|
|
2415
|
+
// classifier owns the typed code, the refused route/slot, and actionable
|
|
2416
|
+
// budget remediation (previously an empty nextActions array).
|
|
2417
|
+
const agentBudgetMapping = budgetStopped || budgetReason
|
|
2418
|
+
? classifyBudgetFailure({ denial: budgetDenial, terminal: budgetReason })
|
|
2419
|
+
: null;
|
|
1983
2420
|
const facts = makeOutcomeFacts("failed", {
|
|
1984
|
-
reason:
|
|
2421
|
+
reason: agentBudgetMapping?.reason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
|
|
1985
2422
|
noChanges: true,
|
|
1986
2423
|
});
|
|
1987
|
-
const why =
|
|
1988
|
-
? "budget exhausted before any candidate run"
|
|
1989
|
-
: "no candidates produced";
|
|
2424
|
+
const why = agentBudgetMapping?.safeMessage ?? "no candidates produced";
|
|
1990
2425
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1991
2426
|
winner: null,
|
|
1992
2427
|
facts,
|
|
1993
2428
|
why_winner: why,
|
|
1994
2429
|
evidence_facts: ["no candidates were produced"],
|
|
1995
2430
|
apply_recommendation: "continue",
|
|
1996
|
-
budget_summary: {
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
safeMessage: why,
|
|
2003
|
-
runDir: paths.root,
|
|
2431
|
+
budget_summary: {
|
|
2432
|
+
spend_usd: ledger.spend(),
|
|
2433
|
+
estimated: false,
|
|
2434
|
+
cash_usd: ledger.spend(),
|
|
2435
|
+
valuation_usd: ledger.valuation(),
|
|
2436
|
+
},
|
|
2004
2437
|
});
|
|
2438
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: ${agentBudgetMapping ? "budget" : "executor"}\n\n${why}\n`);
|
|
2439
|
+
if (agentBudgetMapping) {
|
|
2440
|
+
writeFailure(store, paths, budgetFailureRecord(agentBudgetMapping, { runDir: paths.root }));
|
|
2441
|
+
}
|
|
2442
|
+
else {
|
|
2443
|
+
writeFailure(store, paths, {
|
|
2444
|
+
phase: "executor",
|
|
2445
|
+
category: "internal",
|
|
2446
|
+
safeMessage: why,
|
|
2447
|
+
runDir: paths.root,
|
|
2448
|
+
nextActions: ["Open diagnostics", "Retry the run"],
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2005
2451
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2006
2452
|
log.emit("run.failed", {
|
|
2007
2453
|
lifecycle: facts.lifecycle,
|
|
2008
2454
|
facts,
|
|
2009
2455
|
reason: facts.reason,
|
|
2010
|
-
phase: "budget",
|
|
2456
|
+
phase: agentBudgetMapping ? "budget" : "executor",
|
|
2457
|
+
...(agentBudgetMapping?.harnessId ? { harness_id: agentBudgetMapping.harnessId } : {}),
|
|
2011
2458
|
error: why,
|
|
2012
2459
|
failure_ref: "final/failure.yaml",
|
|
2013
2460
|
});
|
|
@@ -2029,32 +2476,34 @@ export class Orchestrator {
|
|
|
2029
2476
|
// producing anything are corpses: reviewing "(empty diff)" spends real
|
|
2030
2477
|
// reviewer money on nothing and buries the root cause behind an
|
|
2031
2478
|
// arbitration scoring string.
|
|
2032
|
-
const workingRuns = runs
|
|
2479
|
+
const workingRuns = partitionCandidates(runs).working;
|
|
2033
2480
|
if (workingRuns.length === 0) {
|
|
2034
2481
|
await disposeReviewEnvelopes();
|
|
2035
2482
|
const first = runs[0];
|
|
2036
2483
|
const phase = first.infraPhase ?? "harness";
|
|
2037
|
-
const rootCause = runs
|
|
2038
|
-
.map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
|
|
2039
|
-
.join("; ");
|
|
2040
|
-
const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
|
|
2484
|
+
const { facts, why: rootCause } = partitionCandidates(runs);
|
|
2041
2485
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
2042
2486
|
winner: null,
|
|
2043
2487
|
facts,
|
|
2044
2488
|
why_winner: rootCause,
|
|
2045
2489
|
evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
|
|
2046
2490
|
apply_recommendation: "continue",
|
|
2047
|
-
budget_summary: {
|
|
2491
|
+
budget_summary: {
|
|
2492
|
+
spend_usd: ledger.spend(),
|
|
2493
|
+
estimated: false,
|
|
2494
|
+
cash_usd: ledger.spend(),
|
|
2495
|
+
valuation_usd: ledger.valuation(),
|
|
2496
|
+
},
|
|
2048
2497
|
});
|
|
2049
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs
|
|
2050
|
-
attemptId: r.attemptId,
|
|
2051
|
-
harnessId: r.harnessId,
|
|
2052
|
-
telemetry: r.telemetry,
|
|
2053
|
-
})), null);
|
|
2498
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null);
|
|
2054
2499
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Phase: ${phase}\n\n${rootCause}\n`);
|
|
2055
2500
|
const existingEventRefs = runs
|
|
2056
2501
|
.map((r) => `attempts/${r.attemptId}/events.jsonl`)
|
|
2057
2502
|
.filter((rel) => existsSync(join(paths.root, rel)));
|
|
2503
|
+
// #31: auth guidance only on a classified auth failure; every other
|
|
2504
|
+
// harness cause (timeout, rate limit, crash, config) gets remediation that
|
|
2505
|
+
// fits it, instead of a doomed "Check harness authentication".
|
|
2506
|
+
const harnessCategory = dominantHarnessFailureCategory(first.telemetry.transientFailures);
|
|
2058
2507
|
writeFailure(store, paths, {
|
|
2059
2508
|
phase,
|
|
2060
2509
|
category: phase === "workspace" ? "project" : "harness_error",
|
|
@@ -2066,7 +2515,7 @@ export class Orchestrator {
|
|
|
2066
2515
|
runDir: paths.root,
|
|
2067
2516
|
nextActions: phase === "workspace"
|
|
2068
2517
|
? ["Check the project folder", "Open diagnostics", "Retry the run"]
|
|
2069
|
-
:
|
|
2518
|
+
: harnessFailureNextActions(harnessCategory),
|
|
2070
2519
|
});
|
|
2071
2520
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2072
2521
|
log.emit("run.failed", {
|
|
@@ -2094,7 +2543,31 @@ export class Orchestrator {
|
|
|
2094
2543
|
spendUsd: ledger.spend(),
|
|
2095
2544
|
};
|
|
2096
2545
|
}
|
|
2097
|
-
|
|
2546
|
+
// QA-025: only announce that review STARTED when the panel will actually
|
|
2547
|
+
// run. A candidate that changed no files is skipped inside reviewRuns; a
|
|
2548
|
+
// start event before that check falsely claims a paid review began (and its
|
|
2549
|
+
// `review_verified` payload was the PRELIMINARY route-family count, not any
|
|
2550
|
+
// real verification). Compute the reviewable set first and emit a typed
|
|
2551
|
+
// `review.skipped` when nothing is reviewable, so every start has a matching
|
|
2552
|
+
// terminal and the no-diff path records `not_run` consistently.
|
|
2553
|
+
const reviewableRuns = workingRuns.filter((r) => r.diff.trim().length > 0);
|
|
2554
|
+
const configuredFamilies = new Set(reviewers.map((r) => r.providerFamily)).size;
|
|
2555
|
+
if (reviewableRuns.length === 0 || reviewers.length === 0) {
|
|
2556
|
+
log.emit("review.skipped", {
|
|
2557
|
+
reason: reviewers.length === 0 ? "no_reviewers" : "no_changes",
|
|
2558
|
+
reviewable_candidates: reviewableRuns.length,
|
|
2559
|
+
configured_reviewers: reviewers.length,
|
|
2560
|
+
configured_provider_families: configuredFamilies,
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
log.emit("review.started", {
|
|
2565
|
+
reviewers: reviewers.length,
|
|
2566
|
+
reviewable_candidates: reviewableRuns.length,
|
|
2567
|
+
configured_provider_families: configuredFamilies,
|
|
2568
|
+
cross_family_route_eligible: reviewVerified,
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2098
2571
|
let evidences;
|
|
2099
2572
|
try {
|
|
2100
2573
|
// reviewRuns internally SKIPS the paid reviewer call for empty-diff
|
|
@@ -2113,11 +2586,7 @@ export class Orchestrator {
|
|
|
2113
2586
|
await disposeReviewEnvelopes();
|
|
2114
2587
|
}
|
|
2115
2588
|
if (input.signal?.aborted) {
|
|
2116
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.
|
|
2117
|
-
attemptId: r.attemptId,
|
|
2118
|
-
harnessId: r.harnessId,
|
|
2119
|
-
telemetry: r.telemetry,
|
|
2120
|
-
})), null), ledger.spend(), input.signal, store);
|
|
2589
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
|
|
2121
2590
|
}
|
|
2122
2591
|
// Synthesis: if worthwhile, run a synthesizer as a NEW, re-checked candidate.
|
|
2123
2592
|
const synth = decideSynthesis(evidences, input.synthesis ?? "auto");
|
|
@@ -2161,22 +2630,22 @@ export class Orchestrator {
|
|
|
2161
2630
|
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
2162
2631
|
reviewEnvelopes.push(envelope);
|
|
2163
2632
|
envelope = undefined;
|
|
2633
|
+
// D-16 r8: only a WORKING synth is reviewed/adopted (same veto owner
|
|
2634
|
+
// as the race lane); `runs` still records it for telemetry.
|
|
2635
|
+
runs.push(run);
|
|
2164
2636
|
try {
|
|
2165
|
-
|
|
2166
|
-
|
|
2637
|
+
if (isWorkingCandidate(run)) {
|
|
2638
|
+
const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
|
|
2639
|
+
evidences.push(...synthEvidence);
|
|
2640
|
+
workingRuns.push(run);
|
|
2641
|
+
}
|
|
2167
2642
|
if (input.signal?.aborted) {
|
|
2168
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.
|
|
2169
|
-
attemptId: r.attemptId,
|
|
2170
|
-
harnessId: r.harnessId,
|
|
2171
|
-
telemetry: r.telemetry,
|
|
2172
|
-
})), null), ledger.spend(), input.signal, store);
|
|
2643
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
|
|
2173
2644
|
}
|
|
2174
2645
|
}
|
|
2175
2646
|
finally {
|
|
2176
2647
|
await disposeReviewEnvelopes();
|
|
2177
2648
|
}
|
|
2178
|
-
runs.push(run);
|
|
2179
|
-
workingRuns.push(run);
|
|
2180
2649
|
}
|
|
2181
2650
|
catch (err) {
|
|
2182
2651
|
ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("synthesis-error"));
|
|
@@ -2193,17 +2662,17 @@ export class Orchestrator {
|
|
|
2193
2662
|
}
|
|
2194
2663
|
}
|
|
2195
2664
|
if (input.signal?.aborted) {
|
|
2196
|
-
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.
|
|
2197
|
-
attemptId: r.attemptId,
|
|
2198
|
-
harnessId: r.harnessId,
|
|
2199
|
-
telemetry: r.telemetry,
|
|
2200
|
-
})), null), ledger.spend(), input.signal, store);
|
|
2665
|
+
return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
|
|
2201
2666
|
}
|
|
2202
2667
|
let result;
|
|
2203
2668
|
try {
|
|
2204
2669
|
result = arbitrate(evidences, {
|
|
2205
2670
|
spendUsd: ledger.spend(),
|
|
2206
2671
|
estimatedSpend: runs.some((r) => r.costEstimated),
|
|
2672
|
+
// QA-010b: carry the settled cash + subscription-valuation totals
|
|
2673
|
+
// (reviewer panel included) onto the decision record.
|
|
2674
|
+
cashUsd: ledger.spend(),
|
|
2675
|
+
valuationUsd: ledger.valuation(),
|
|
2207
2676
|
});
|
|
2208
2677
|
}
|
|
2209
2678
|
catch (err) {
|
|
@@ -2213,6 +2682,11 @@ export class Orchestrator {
|
|
|
2213
2682
|
log.emit("arbitration.completed", {
|
|
2214
2683
|
winner: result.decision.winner,
|
|
2215
2684
|
lifecycle: result.decision.facts.lifecycle,
|
|
2685
|
+
// QA-028: surface the axis that actually separated the winner from the
|
|
2686
|
+
// runner-up (null on an exact tie) so live surfaces can explain the pick.
|
|
2687
|
+
...(result.decision.decisive_axis
|
|
2688
|
+
? { decisive_axis: result.decision.decisive_axis.key }
|
|
2689
|
+
: {}),
|
|
2216
2690
|
});
|
|
2217
2691
|
// Winner can only be a candidate that actually produced work; corpses are
|
|
2218
2692
|
// excluded from arbitration upstream and from the fallback here.
|
|
@@ -2266,7 +2740,7 @@ export class Orchestrator {
|
|
|
2266
2740
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
2267
2741
|
...result.decision,
|
|
2268
2742
|
...(needsDec
|
|
2269
|
-
? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
|
|
2743
|
+
? blockedDecisionOverride(result.decision.evidence_facts, facts, finalVerify)
|
|
2270
2744
|
: { facts }),
|
|
2271
2745
|
review_verified: actualReviewVerified,
|
|
2272
2746
|
final_verify: finalVerify,
|
|
@@ -2291,7 +2765,7 @@ export class Orchestrator {
|
|
|
2291
2765
|
? winnerEvidence.findings.filter((f) => isBlocking(f)).length
|
|
2292
2766
|
: 0;
|
|
2293
2767
|
// Prose from an empty-diff winner is an answer, never a patch.
|
|
2294
|
-
const winnerAnswer = winnerRun.answerText
|
|
2768
|
+
const winnerAnswer = winnerRun.answerText ?? "";
|
|
2295
2769
|
const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
|
|
2296
2770
|
// The winner's final MESSAGE is the human-facing answer and materializes
|
|
2297
2771
|
// for diff-ful runs too: the chat renders final/answer.md (the projection
|
|
@@ -2430,7 +2904,7 @@ export class Orchestrator {
|
|
|
2430
2904
|
: "diagnostic",
|
|
2431
2905
|
});
|
|
2432
2906
|
}
|
|
2433
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs
|
|
2907
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), result.decision.facts.lifecycle === "succeeded"
|
|
2434
2908
|
? result.decision.winner
|
|
2435
2909
|
: (winnerRun?.attemptId ?? null));
|
|
2436
2910
|
// A needs-decision terminal (review blocked or checks failed) OR a
|
|
@@ -2464,9 +2938,16 @@ export class Orchestrator {
|
|
|
2464
2938
|
});
|
|
2465
2939
|
}
|
|
2466
2940
|
else if (isFailureTerminal) {
|
|
2941
|
+
// QA-010c: a reviewer-accepted BLOCK/FIX_FIRST (or a failed deterministic
|
|
2942
|
+
// gate) that BLOCKS the run is an operator-decision terminal, not an
|
|
2943
|
+
// internal engine error. Only a genuinely unexpected non-decision terminal
|
|
2944
|
+
// stays `internal`. A review/checks block is `policy` (the acceptance
|
|
2945
|
+
// path), phase `review`, and its remedy names the decision, not "retry
|
|
2946
|
+
// with a different harness".
|
|
2947
|
+
const decisionBlock = needsHuman || needsDecisionTerminal;
|
|
2467
2948
|
writeFailure(store, paths, {
|
|
2468
|
-
phase:
|
|
2469
|
-
category:
|
|
2949
|
+
phase: decisionBlock ? "review" : "arbitration",
|
|
2950
|
+
category: decisionBlock
|
|
2470
2951
|
? "policy"
|
|
2471
2952
|
: winnerRun?.errored
|
|
2472
2953
|
? "harness_error"
|
|
@@ -2477,16 +2958,20 @@ export class Orchestrator {
|
|
|
2477
2958
|
attemptId: winnerRun?.errored ? winnerRun.attemptId : undefined,
|
|
2478
2959
|
safeMessage: needsHuman
|
|
2479
2960
|
? `review escalated to a human decision: ${result.decision.why_winner}`
|
|
2480
|
-
:
|
|
2961
|
+
: needsDecisionTerminal
|
|
2962
|
+
? `review blocked before apply: ${result.decision.why_winner}`
|
|
2963
|
+
: result.decision.why_winner,
|
|
2481
2964
|
rawDetailRef: winnerRun?.errored
|
|
2482
2965
|
? `attempts/${winnerRun.attemptId}/attempt.yaml`
|
|
2483
2966
|
: undefined,
|
|
2484
2967
|
runDir: paths.root,
|
|
2485
|
-
nextActions:
|
|
2968
|
+
nextActions: decisionBlock
|
|
2486
2969
|
? [
|
|
2487
|
-
"
|
|
2488
|
-
"
|
|
2489
|
-
|
|
2970
|
+
"Review the blocking findings on the run's turn",
|
|
2971
|
+
"Accept the risk to apply this exact patch, or discard the change",
|
|
2972
|
+
...(facts.checks === "failed"
|
|
2973
|
+
? ["Configure/approve the deterministic test command, then re-run"]
|
|
2974
|
+
: []),
|
|
2490
2975
|
]
|
|
2491
2976
|
: [
|
|
2492
2977
|
"Open diagnostics",
|
|
@@ -2550,7 +3035,11 @@ export class Orchestrator {
|
|
|
2550
3035
|
};
|
|
2551
3036
|
}
|
|
2552
3037
|
/** Single-owner telemetry artifact (final/telemetry.yaml); surfaces project it, never recompute. */
|
|
2553
|
-
writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId) {
|
|
3038
|
+
writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId, deepScanSynthesis) {
|
|
3039
|
+
// QA-034: attach the routing rationale recorded at pool ordering (if this
|
|
3040
|
+
// run computed one), then clear it — telemetry is written once at terminal.
|
|
3041
|
+
const routingRationale = this.routingRationaleByRun.get(runId) ?? null;
|
|
3042
|
+
this.routingRationaleByRun.delete(runId);
|
|
2554
3043
|
writeRunTelemetryArtifact({
|
|
2555
3044
|
store,
|
|
2556
3045
|
finalDir: paths.finalDir,
|
|
@@ -2560,6 +3049,8 @@ export class Orchestrator {
|
|
|
2560
3049
|
mode,
|
|
2561
3050
|
attempts,
|
|
2562
3051
|
finalAttemptId,
|
|
3052
|
+
routingRationale,
|
|
3053
|
+
deepScanSynthesis: deepScanSynthesis ?? null,
|
|
2563
3054
|
resolveAuthPreference: (harnessId) => this.authPreferenceForHarness(contract.repo.root, harnessId, contract.auth_preference),
|
|
2564
3055
|
});
|
|
2565
3056
|
}
|
|
@@ -2784,6 +3275,11 @@ export class Orchestrator {
|
|
|
2784
3275
|
candidates: [],
|
|
2785
3276
|
};
|
|
2786
3277
|
}
|
|
3278
|
+
// Same run-prep stage as the git boundary (and the same `!inPlace`
|
|
3279
|
+
// exclusion — we are inside that branch, so inPlace is false here): bridge
|
|
3280
|
+
// an AGENTS.md-only PROJECT root so a Claude Code convergence attempt reads
|
|
3281
|
+
// it (INV-113).
|
|
3282
|
+
this.ensureClaudeBridgeForRun(input.repoRoot, false, log);
|
|
2787
3283
|
}
|
|
2788
3284
|
const reviewDir = join(paths.root, "review-evidence");
|
|
2789
3285
|
writeEvidencePacket(reviewDir, {
|
|
@@ -2799,17 +3295,21 @@ export class Orchestrator {
|
|
|
2799
3295
|
// One envelope carried forward across attempts so the harness can repair its own work.
|
|
2800
3296
|
let adapterPool;
|
|
2801
3297
|
try {
|
|
2802
|
-
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
|
|
3298
|
+
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log, undefined, runId);
|
|
2803
3299
|
this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
|
|
2804
3300
|
}
|
|
2805
3301
|
catch (err) {
|
|
2806
3302
|
const message = safeErrorMessage(err);
|
|
2807
3303
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
3304
|
+
// A routing preflight refusal is a config_error, not harness_unavailable
|
|
3305
|
+
// (A-1/#22): classify identically across every strategy's routing catch.
|
|
3306
|
+
const routingFailure = routingFailureClassification(err);
|
|
2808
3307
|
writeFailure(store, paths, {
|
|
2809
3308
|
phase: "routing",
|
|
2810
|
-
category:
|
|
3309
|
+
category: routingFailure.category,
|
|
2811
3310
|
safeMessage: message,
|
|
2812
3311
|
runDir: paths.root,
|
|
3312
|
+
...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
|
|
2813
3313
|
});
|
|
2814
3314
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
2815
3315
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
@@ -2881,6 +3381,7 @@ export class Orchestrator {
|
|
|
2881
3381
|
let attempt = 0;
|
|
2882
3382
|
let converged = false;
|
|
2883
3383
|
let exhausted = false;
|
|
3384
|
+
let interrupted = false; // D-16 r8: terminalizes the run interrupted
|
|
2884
3385
|
let lastFindings = [];
|
|
2885
3386
|
let lastRun = null;
|
|
2886
3387
|
let actualReviewVerified = false;
|
|
@@ -2939,7 +3440,7 @@ export class Orchestrator {
|
|
|
2939
3440
|
: "";
|
|
2940
3441
|
const prompt = attempt === 1
|
|
2941
3442
|
? input.prompt
|
|
2942
|
-
:
|
|
3443
|
+
: buildRevisePrompt(input.prompt, lastFindings, runtimeErrors);
|
|
2943
3444
|
// Loop detection (budget router): the 3rd identical repair prompt means
|
|
2944
3445
|
// findings/errors are not changing — stop burning paid attempts.
|
|
2945
3446
|
const fingerprint = promptFingerprint(prompt);
|
|
@@ -2987,7 +3488,8 @@ export class Orchestrator {
|
|
|
2987
3488
|
log.emit("harness.completed", {
|
|
2988
3489
|
harness_id: adapter.id,
|
|
2989
3490
|
attempt_id: attemptId,
|
|
2990
|
-
|
|
3491
|
+
// QA-027: an aborted attempt is `cancelled`, never a clean `success`.
|
|
3492
|
+
status: input.signal?.aborted ? "cancelled" : run.errored ? "failed" : "success",
|
|
2991
3493
|
cost_usd: run.cost,
|
|
2992
3494
|
...telemetrySummary(run.telemetry),
|
|
2993
3495
|
});
|
|
@@ -3017,6 +3519,12 @@ export class Orchestrator {
|
|
|
3017
3519
|
}
|
|
3018
3520
|
lastRun = run;
|
|
3019
3521
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry: run.telemetry });
|
|
3522
|
+
// D-16 r8: interrupted (errored===false) would CONVERGE a partial diff
|
|
3523
|
+
// as clean — break BEFORE review; a harness error still gate-retries.
|
|
3524
|
+
if (run.outcomeClass === "interrupted") {
|
|
3525
|
+
interrupted = true;
|
|
3526
|
+
break;
|
|
3527
|
+
}
|
|
3020
3528
|
// Post-mutation fence for in-place: snapshot the live tree NOW (after the
|
|
3021
3529
|
// harness mutated it, before this attempt's review). The last attempt's
|
|
3022
3530
|
// value is the revert target persisted into work_product.yaml.
|
|
@@ -3242,22 +3750,41 @@ export class Orchestrator {
|
|
|
3242
3750
|
// Base terminal AXES (D8) from the convergence loop outcome. Attempts-cap
|
|
3243
3751
|
// exhaustion maps to budget_exhausted (an attempt budget); the give-up
|
|
3244
3752
|
// states map to their matching RunReason.
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3753
|
+
//
|
|
3754
|
+
// QA-041 terminal-causality precedence: convergence used to hard-code EVERY
|
|
3755
|
+
// aborted signal to `user_cancelled`, which (a) fabricated an operator action
|
|
3756
|
+
// that never happened when the maxSeconds wall-clock deadline fired, and
|
|
3757
|
+
// (b) discarded an already-proven `stuck_no_progress` terminal. The typed
|
|
3758
|
+
// abort reason (`wall_clock_exceeded` from the deadline controller, carried
|
|
3759
|
+
// on `input.signal.reason` via AbortSignal.any) is now read at the source,
|
|
3760
|
+
// and an established semantic terminal (stuck_no_progress) wins over the
|
|
3761
|
+
// abort so the deadline that only ended a redundant post-proof panel does
|
|
3762
|
+
// not overwrite the actionable no-progress reason. `user_cancelled` is
|
|
3763
|
+
// emitted ONLY for a real control cancel (no typed deadline reason).
|
|
3764
|
+
const convAbortReason = typeof input.signal?.reason === "string" && input.signal.reason
|
|
3765
|
+
? input.signal.reason
|
|
3766
|
+
: undefined;
|
|
3767
|
+
const convCancelFacts = () => makeOutcomeFacts("cancelled", {
|
|
3768
|
+
reason: convAbortReason === "wall_clock_exceeded" ? "wall_clock_exceeded" : "user_cancelled",
|
|
3769
|
+
});
|
|
3770
|
+
let facts = convergenceOutcomeFacts({
|
|
3771
|
+
converged,
|
|
3772
|
+
interrupted,
|
|
3773
|
+
stuckNoProgress,
|
|
3774
|
+
aborted: Boolean(input.signal?.aborted),
|
|
3775
|
+
exhausted,
|
|
3776
|
+
}, convCancelFacts);
|
|
3254
3777
|
let decision = null;
|
|
3255
|
-
|
|
3778
|
+
// D-16 r8: interrupted is never arbitrated; the partial stays diagnostic.
|
|
3779
|
+
if (lastRun && !interrupted) {
|
|
3256
3780
|
const arb = arbitrate([
|
|
3257
3781
|
toCandidateEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
|
|
3258
3782
|
], {
|
|
3259
3783
|
spendUsd: ledger.spend(),
|
|
3260
3784
|
estimatedSpend: lastRun.costEstimated || reviewSpendEstimated,
|
|
3785
|
+
// QA-010b: settled cash + valuation (reviewer panel included).
|
|
3786
|
+
cashUsd: ledger.spend(),
|
|
3787
|
+
valuationUsd: ledger.valuation(),
|
|
3261
3788
|
});
|
|
3262
3789
|
decision = arb.decision;
|
|
3263
3790
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
@@ -3308,15 +3835,18 @@ export class Orchestrator {
|
|
|
3308
3835
|
decision = {
|
|
3309
3836
|
...decision,
|
|
3310
3837
|
...(convNeedsDecision
|
|
3311
|
-
? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
|
|
3838
|
+
? blockedDecisionOverride(decision.evidence_facts, facts, convFinalVerify)
|
|
3312
3839
|
: { facts }),
|
|
3313
3840
|
final_verify: convFinalVerify,
|
|
3314
3841
|
};
|
|
3315
3842
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
3316
3843
|
}
|
|
3317
3844
|
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, attemptTelemetries, lastRun?.attemptId ?? null);
|
|
3318
|
-
// Deliver the converged/last work to final/ so `apply` and `inspect` can
|
|
3319
|
-
|
|
3845
|
+
// Deliver the converged/last work to final/ so `apply` and `inspect` can
|
|
3846
|
+
// use it. D-16 r8: an INTERRUPTED envelope run delivers no applyable
|
|
3847
|
+
// work_product (its partial patch.diff stays diagnostic via attempts/);
|
|
3848
|
+
// in-place keeps the product so the honest Revert offer survives.
|
|
3849
|
+
if (lastRun && (!interrupted || input.inPlace === true)) {
|
|
3320
3850
|
assertNoSecretLikeTokens("final patch diff", lastRun.diff);
|
|
3321
3851
|
const patchSha256 = sha256(lastRun.diff);
|
|
3322
3852
|
store.writeText(join(paths.finalDir, "patch.diff"), lastRun.diff);
|
|
@@ -3386,12 +3916,16 @@ export class Orchestrator {
|
|
|
3386
3916
|
attemptId: lastRun?.attemptId,
|
|
3387
3917
|
runDir: paths.root,
|
|
3388
3918
|
nextActions: facts.lifecycle === "cancelled"
|
|
3389
|
-
?
|
|
3919
|
+
? facts.reason === "wall_clock_exceeded"
|
|
3920
|
+
? [
|
|
3921
|
+
"Inspect the partial work kept from before the deadline",
|
|
3922
|
+
"Increase --max-seconds or narrow the scope, then re-run",
|
|
3923
|
+
]
|
|
3924
|
+
: ["Retry if cancellation was accidental"]
|
|
3390
3925
|
: convNeedsDecision
|
|
3391
3926
|
? [
|
|
3392
|
-
"
|
|
3393
|
-
"
|
|
3394
|
-
"Re-run after the decision",
|
|
3927
|
+
"Review the blocking findings on the run's turn",
|
|
3928
|
+
"Accept the risk to apply this exact patch, or discard the change",
|
|
3395
3929
|
]
|
|
3396
3930
|
: facts.reason === "stuck_no_progress"
|
|
3397
3931
|
? [
|
|
@@ -3405,8 +3939,12 @@ export class Orchestrator {
|
|
|
3405
3939
|
"Retry with more attempts or a narrower prompt",
|
|
3406
3940
|
],
|
|
3407
3941
|
});
|
|
3408
|
-
|
|
3409
|
-
|
|
3942
|
+
// D-16 r8/r9: an INTERRUPTED envelope run still gets its diagnostic
|
|
3943
|
+
// summary + output.ready (only patch/work_product are withheld) — the
|
|
3944
|
+
// ARCHITECTURE event contract guarantees output.ready precedes the
|
|
3945
|
+
// terminal in every mode.
|
|
3946
|
+
if (!lastRun || (interrupted && input.inPlace !== true)) {
|
|
3947
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Attempts: ${attempt}\n`);
|
|
3410
3948
|
log.emit("output.ready", {
|
|
3411
3949
|
kind: "summary",
|
|
3412
3950
|
path: "final/summary.md",
|
|
@@ -3414,7 +3952,10 @@ export class Orchestrator {
|
|
|
3414
3952
|
});
|
|
3415
3953
|
}
|
|
3416
3954
|
}
|
|
3417
|
-
|
|
3955
|
+
// work_product.emitted only when a product was actually written (r9).
|
|
3956
|
+
if (lastRun && (!interrupted || input.inPlace === true)) {
|
|
3957
|
+
log.emit("work_product.emitted", { winner: lastRun.attemptId });
|
|
3958
|
+
}
|
|
3418
3959
|
if (!convIsFailureTerminal) {
|
|
3419
3960
|
log.emit("run.completed", {
|
|
3420
3961
|
lifecycle: facts.lifecycle,
|
|
@@ -3440,6 +3981,11 @@ export class Orchestrator {
|
|
|
3440
3981
|
attempts: attempt,
|
|
3441
3982
|
phase: "convergence",
|
|
3442
3983
|
failure_ref: "final/failure.yaml",
|
|
3984
|
+
// QA-041: surface the typed deadline reason on the terminal event so the
|
|
3985
|
+
// daemon job result / Control API can distinguish it from a user cancel.
|
|
3986
|
+
...(facts.lifecycle === "cancelled" && convAbortReason
|
|
3987
|
+
? { cancel_reason: convAbortReason }
|
|
3988
|
+
: {}),
|
|
3443
3989
|
});
|
|
3444
3990
|
}
|
|
3445
3991
|
return {
|
|
@@ -3451,6 +3997,11 @@ export class Orchestrator {
|
|
|
3451
3997
|
facts,
|
|
3452
3998
|
winner: lastRun?.attemptId ?? null,
|
|
3453
3999
|
runDir: paths.root,
|
|
4000
|
+
// QA-041: carry the typed deadline reason on the result so daemon/Control
|
|
4001
|
+
// API/CLI never falsely attribute a maxSeconds deadline to the user.
|
|
4002
|
+
...(facts.lifecycle === "cancelled" && convAbortReason
|
|
4003
|
+
? { cancelReason: convAbortReason }
|
|
4004
|
+
: {}),
|
|
3454
4005
|
summary: converged
|
|
3455
4006
|
? `converged in ${attempt} attempt(s)`
|
|
3456
4007
|
: `${facts.lifecycle} after ${attempt} attempt(s)`,
|
|
@@ -3500,15 +4051,7 @@ export class Orchestrator {
|
|
|
3500
4051
|
`Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
|
|
3501
4052
|
].join("\n");
|
|
3502
4053
|
}
|
|
3503
|
-
/**
|
|
3504
|
-
* Run ONE planner spawn (native plan mode, read-only) end to end: budget
|
|
3505
|
-
* lease, spec build, continuity hydration, event streaming, telemetry, and
|
|
3506
|
-
* settle. Shared by the solo plan loop (each pool member is a sequential
|
|
3507
|
-
* fallback) and the Council strategy (each member is a parallel draft, then
|
|
3508
|
-
* one merge iteration on the primary — same machinery, different prompt +
|
|
3509
|
-
* intent). The caller owns bookkeeping that differs per path: which artifact
|
|
3510
|
-
* a success writes to, fallback disclosure, and accumulation.
|
|
3511
|
-
*/
|
|
4054
|
+
/** One read-only planner spawn shared by solo fallback, Council drafts, and merge. */
|
|
3512
4055
|
async runPlannerAttempt(args) {
|
|
3513
4056
|
const { input, contract, taskId, runId, log, store, paths, ledger, routed, attemptId } = args;
|
|
3514
4057
|
const adapter = routed.adapter;
|
|
@@ -3523,6 +4066,7 @@ export class Orchestrator {
|
|
|
3523
4066
|
log.emit("budget.lease.created", {
|
|
3524
4067
|
granted: false,
|
|
3525
4068
|
reason: lease.reason,
|
|
4069
|
+
denied: lease.denied,
|
|
3526
4070
|
attempt_id: attemptId,
|
|
3527
4071
|
harness_id: adapter.id,
|
|
3528
4072
|
});
|
|
@@ -3530,10 +4074,17 @@ export class Orchestrator {
|
|
|
3530
4074
|
attemptId,
|
|
3531
4075
|
harnessId: adapter.id,
|
|
3532
4076
|
status: "failed",
|
|
4077
|
+
outcomeClass: "clean", // never spawned: refused pre-flight by the budget gate
|
|
3533
4078
|
error: lease.reason ?? "budget lease denied",
|
|
3534
4079
|
text: null,
|
|
3535
4080
|
telemetry: null,
|
|
3536
4081
|
budgetDenied: true,
|
|
4082
|
+
budgetDenial: {
|
|
4083
|
+
code: lease.denied ?? "hard_cap",
|
|
4084
|
+
reason: lease.reason ?? "budget lease denied",
|
|
4085
|
+
harnessId: adapter.id,
|
|
4086
|
+
attemptId,
|
|
4087
|
+
},
|
|
3537
4088
|
};
|
|
3538
4089
|
}
|
|
3539
4090
|
const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
|
|
@@ -3570,6 +4121,10 @@ export class Orchestrator {
|
|
|
3570
4121
|
const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
|
|
3571
4122
|
if (planInteraction)
|
|
3572
4123
|
spec.extra["interactionChannel"] = planInteraction;
|
|
4124
|
+
// D-16: compile the WorkReport envelope for the plan lane (require plan text
|
|
4125
|
+
// below folds the deliverable; the veto rides work_state).
|
|
4126
|
+
const planWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(planInteraction));
|
|
4127
|
+
const planWorkMode = this.applyWorkEnvelope(spec, planWorkEnvelope);
|
|
3573
4128
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3574
4129
|
const answer = new AnswerAssembly();
|
|
3575
4130
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
@@ -3657,34 +4212,74 @@ export class Orchestrator {
|
|
|
3657
4212
|
input.signal?.removeEventListener("abort", onAbort);
|
|
3658
4213
|
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
|
|
3659
4214
|
}
|
|
4215
|
+
// D-16: unwrap and require PLAN TEXT — a plan with no text is not delivered.
|
|
4216
|
+
// The unwrap runs BEFORE the error axes: the deliverable it yields is what
|
|
4217
|
+
// decides whether an unrecovered tool error is fatal (explorer parity).
|
|
4218
|
+
const planUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", planWorkMode, {
|
|
4219
|
+
sideToolReport: telemetry.sideToolWorkReport ?? undefined,
|
|
4220
|
+
});
|
|
4221
|
+
const planText = redactSecrets(planUnwrapped.deliverable).trim();
|
|
3660
4222
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
3661
4223
|
const webBlocked = webUnsatisfied(telemetry);
|
|
3662
4224
|
if (!harnessError && webBlocked) {
|
|
3663
|
-
harnessError =
|
|
4225
|
+
harnessError = webEvidenceFailure(telemetry.web);
|
|
3664
4226
|
}
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
4227
|
+
// INV-043/INV-044, explorer parity: a DELIVERED plan keeps an unrecovered
|
|
4228
|
+
// non-web tool error as warning evidence instead of discarding the plan (see
|
|
4229
|
+
// the helper). Web keeps its hard gate above; the finalizer outranks both.
|
|
4230
|
+
harnessError ??= unrecoveredToolErrorFailure(unrecovered, planText.length > 0);
|
|
4231
|
+
const planFinalized = finalizeAttempt({
|
|
4232
|
+
deliverableEvidence: planText.length > 0,
|
|
4233
|
+
harnessErrored: harnessError !== null && !webBlocked,
|
|
4234
|
+
workReport: planUnwrapped.workReport,
|
|
4235
|
+
workReportSource: planUnwrapped.source,
|
|
4236
|
+
workReportViolation: planUnwrapped.contractViolation,
|
|
4237
|
+
contextTerminalExhausted: telemetry.contextExhausted,
|
|
4238
|
+
});
|
|
4239
|
+
// A broken WorkReport contract is a hard failure only when the finalizer
|
|
4240
|
+
// ranked it so (a terminal context exhaustion outranks it).
|
|
4241
|
+
if (!harnessError && planFinalized.outcomeClass === "contract_failure") {
|
|
4242
|
+
harnessError = `work_report contract: ${planUnwrapped.contractViolation}`;
|
|
3668
4243
|
}
|
|
3669
|
-
|
|
4244
|
+
// D-16 r9: an interrupted (context-exhausted) planner is NEVER a clean
|
|
4245
|
+
// plan — partial text must not become final/plan.md as success. A VETO
|
|
4246
|
+
// (needs_input/incomplete work_state) is DIFFERENT by the sealed D-16
|
|
4247
|
+
// contract (X35, INV-116 canaries): the plan still delivers, lifecycle
|
|
4248
|
+
// succeeded, and the work_state veto rides the OUTCOME (non-zero exit) —
|
|
4249
|
+
// it must not be laundered into a harness failure either direction.
|
|
4250
|
+
if (!harnessError && planFinalized.outcomeClass === "interrupted") {
|
|
4251
|
+
harnessError = "context capacity exhausted before the plan completed";
|
|
4252
|
+
}
|
|
4253
|
+
const attemptError = harnessError ??
|
|
4254
|
+
(planFinalized.deliverablePresent ? null : "planner produced no plan text") ??
|
|
4255
|
+
(input.signal?.aborted ? "planner cancelled" : null);
|
|
4256
|
+
setAttemptOutcome(telemetry, {
|
|
4257
|
+
deliverablePresent: planFinalized.deliverablePresent,
|
|
4258
|
+
gatesPassed: null,
|
|
4259
|
+
harnessErrored: (harnessError !== null && !webBlocked) || planFinalized.harnessErrored,
|
|
4260
|
+
webRequiredUnsatisfied: webBlocked,
|
|
4261
|
+
workState: planFinalized.workState,
|
|
4262
|
+
});
|
|
4263
|
+
if (attemptError) {
|
|
3670
4264
|
log.emit("harness.completed", {
|
|
3671
4265
|
harness_id: adapter.id,
|
|
3672
4266
|
attempt_id: attemptId,
|
|
3673
4267
|
status: webBlocked ? "blocked" : "failed",
|
|
3674
|
-
error:
|
|
4268
|
+
error: attemptError,
|
|
3675
4269
|
...telemetrySummary(telemetry),
|
|
3676
4270
|
});
|
|
3677
4271
|
return {
|
|
3678
4272
|
attemptId,
|
|
3679
4273
|
harnessId: adapter.id,
|
|
3680
4274
|
status: webBlocked ? "blocked" : "failed",
|
|
3681
|
-
|
|
4275
|
+
outcomeClass: planFinalized.outcomeClass,
|
|
4276
|
+
error: attemptError,
|
|
3682
4277
|
text: null,
|
|
3683
4278
|
telemetry,
|
|
3684
4279
|
budgetDenied: false,
|
|
3685
4280
|
};
|
|
3686
4281
|
}
|
|
3687
|
-
const text =
|
|
4282
|
+
const text = planText || "(no output)";
|
|
3688
4283
|
log.emit("harness.completed", {
|
|
3689
4284
|
harness_id: adapter.id,
|
|
3690
4285
|
attempt_id: attemptId,
|
|
@@ -3695,6 +4290,7 @@ export class Orchestrator {
|
|
|
3695
4290
|
attemptId,
|
|
3696
4291
|
harnessId: adapter.id,
|
|
3697
4292
|
status: "success",
|
|
4293
|
+
outcomeClass: planFinalized.outcomeClass,
|
|
3698
4294
|
error: null,
|
|
3699
4295
|
text,
|
|
3700
4296
|
telemetry,
|
|
@@ -3737,17 +4333,21 @@ export class Orchestrator {
|
|
|
3737
4333
|
const laneRun = Boolean(input.threadId);
|
|
3738
4334
|
let adapters;
|
|
3739
4335
|
try {
|
|
3740
|
-
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
|
|
4336
|
+
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome, runId);
|
|
3741
4337
|
}
|
|
3742
4338
|
catch (err) {
|
|
3743
4339
|
roHome.dispose();
|
|
3744
4340
|
const message = safeErrorMessage(err);
|
|
3745
4341
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
4342
|
+
// A routing preflight refusal is a config_error, not harness_unavailable
|
|
4343
|
+
// (A-1/#22): classify identically across every strategy's routing catch.
|
|
4344
|
+
const routingFailure = routingFailureClassification(err);
|
|
3746
4345
|
writeFailure(store, paths, {
|
|
3747
4346
|
phase: "routing",
|
|
3748
|
-
category:
|
|
4347
|
+
category: routingFailure.category,
|
|
3749
4348
|
safeMessage: message,
|
|
3750
4349
|
runDir: paths.root,
|
|
4350
|
+
...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
|
|
3751
4351
|
});
|
|
3752
4352
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
3753
4353
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
@@ -3832,6 +4432,9 @@ export class Orchestrator {
|
|
|
3832
4432
|
let fallbackFrom = null;
|
|
3833
4433
|
const planAttempts = [];
|
|
3834
4434
|
const attemptTelemetries = [];
|
|
4435
|
+
// QA-050: the ledger's typed denial when a planner slot was refused
|
|
4436
|
+
// pre-spawn, so the terminal is a budget failure (not "all planners failed").
|
|
4437
|
+
let planBudgetDenial = null;
|
|
3835
4438
|
try {
|
|
3836
4439
|
for (const [idx, routed] of adapters.entries()) {
|
|
3837
4440
|
if (input.signal?.aborted)
|
|
@@ -3853,8 +4456,20 @@ export class Orchestrator {
|
|
|
3853
4456
|
promptBody: this.planPrompt(input.prompt) + contextSection,
|
|
3854
4457
|
intent: "plan",
|
|
3855
4458
|
});
|
|
3856
|
-
if (outcome.budgetDenied)
|
|
4459
|
+
if (outcome.budgetDenied) {
|
|
4460
|
+
// QA-050: retain the denied planner slot before breaking so the
|
|
4461
|
+
// terminal names the refused route and does not read "all planners
|
|
4462
|
+
// failed"; capture the typed denial for the budget classifier.
|
|
4463
|
+
planBudgetDenial ??= outcome.budgetDenial ?? null;
|
|
4464
|
+
planAttempts.push({
|
|
4465
|
+
attemptId,
|
|
4466
|
+
harnessId: outcome.harnessId,
|
|
4467
|
+
status: outcome.status,
|
|
4468
|
+
outcomeClass: outcome.outcomeClass,
|
|
4469
|
+
error: outcome.error,
|
|
4470
|
+
});
|
|
3857
4471
|
break;
|
|
4472
|
+
}
|
|
3858
4473
|
if (outcome.telemetry)
|
|
3859
4474
|
attemptTelemetries.push({
|
|
3860
4475
|
attemptId,
|
|
@@ -3865,6 +4480,7 @@ export class Orchestrator {
|
|
|
3865
4480
|
attemptId,
|
|
3866
4481
|
harnessId: outcome.harnessId,
|
|
3867
4482
|
status: outcome.status,
|
|
4483
|
+
outcomeClass: outcome.outcomeClass,
|
|
3868
4484
|
error: outcome.error,
|
|
3869
4485
|
});
|
|
3870
4486
|
if (outcome.status !== "success") {
|
|
@@ -3878,7 +4494,7 @@ export class Orchestrator {
|
|
|
3878
4494
|
reason: "planner_failed",
|
|
3879
4495
|
});
|
|
3880
4496
|
}
|
|
3881
|
-
else if (fallbackFrom || next === undefined) {
|
|
4497
|
+
else if (!input.signal?.aborted && (fallbackFrom || next === undefined)) {
|
|
3882
4498
|
log.emit("route.fallback.exhausted", {
|
|
3883
4499
|
harness_id: outcome.harnessId,
|
|
3884
4500
|
attempt_id: attemptId,
|
|
@@ -3927,6 +4543,7 @@ export class Orchestrator {
|
|
|
3927
4543
|
ledger,
|
|
3928
4544
|
planAttempts,
|
|
3929
4545
|
attemptTelemetries,
|
|
4546
|
+
budgetDenial: planBudgetDenial,
|
|
3930
4547
|
}, "all planners failed");
|
|
3931
4548
|
}
|
|
3932
4549
|
if (input.signal?.aborted) {
|
|
@@ -4017,6 +4634,58 @@ export class Orchestrator {
|
|
|
4017
4634
|
? "metered"
|
|
4018
4635
|
: "unknown";
|
|
4019
4636
|
}
|
|
4637
|
+
/**
|
|
4638
|
+
* #27 / D-6: build the engine-side deps closure for the deep-scan bounded
|
|
4639
|
+
* synthesis reducer (packages/orchestrator/src/deepScanReducer.ts owns the
|
|
4640
|
+
* spawn/stream/settle machinery). The closure keeps the private
|
|
4641
|
+
* route/session/knob machinery HERE and hands the module only finished public
|
|
4642
|
+
* types (a `HarnessRunSpec`, cost evidence, a disposable home).
|
|
4643
|
+
*/
|
|
4644
|
+
deepScanReducerDeps(input, contract, log) {
|
|
4645
|
+
return {
|
|
4646
|
+
newReadOnlyHome: () => resolveReadOnlyRouteContext(this.execRootOf(input)),
|
|
4647
|
+
costEvidence: (harnessId, attemptId) =>
|
|
4648
|
+
// The reducer admits under a finite estimate floor (mirror of the n>1
|
|
4649
|
+
// scout reserve) so a subscription route is not refused for lacking a
|
|
4650
|
+
// cash quote.
|
|
4651
|
+
attemptCostEvidence(harnessId, attemptId, this.estimateUsdFloor(input.repoRoot), this.routeBillingKnowledge(input, harnessId)),
|
|
4652
|
+
buildSpec: (routed, homeEnv, prompt, attemptId) => {
|
|
4653
|
+
const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
|
|
4654
|
+
const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
|
|
4655
|
+
const sessionFields = this.sessionSpecFields(input, routed.adapter.id, log);
|
|
4656
|
+
const spec = HarnessRunSpec.parse({
|
|
4657
|
+
session_id: newId("ses"),
|
|
4658
|
+
intent: "synthesize",
|
|
4659
|
+
prompt,
|
|
4660
|
+
cwd: this.execRootOf(input),
|
|
4661
|
+
access: "readonly",
|
|
4662
|
+
attachments: [],
|
|
4663
|
+
auth_preference: sessionFields.auth_preference,
|
|
4664
|
+
credential_profile: sessionFields.credential_profile,
|
|
4665
|
+
// A FRESH session — the reducer never resumes a scout's conversation.
|
|
4666
|
+
resume_session_id: null,
|
|
4667
|
+
...this.harnessSpecKnobs(contract, knobs, "synthesize"),
|
|
4668
|
+
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
4669
|
+
env: homeEnv,
|
|
4670
|
+
});
|
|
4671
|
+
// D-16: compile the WorkReport transport onto the reducer spec (the
|
|
4672
|
+
// reducer is non-interactive) so its output is unwrapped + finalized
|
|
4673
|
+
// through the shared attempt contract, not a fourth deliverable predicate.
|
|
4674
|
+
const workReportMode = this.applyWorkEnvelope(spec, this.workReportEnvelopeFor(routed, contract, false));
|
|
4675
|
+
return {
|
|
4676
|
+
spec,
|
|
4677
|
+
webPolicy: knobs.webPolicy,
|
|
4678
|
+
effectiveWeb,
|
|
4679
|
+
model: knobs.model,
|
|
4680
|
+
workReportMode,
|
|
4681
|
+
};
|
|
4682
|
+
},
|
|
4683
|
+
hardTimeoutMs: reviewerTimeoutMs(this.config(input.repoRoot)),
|
|
4684
|
+
inactivityTimeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
|
|
4685
|
+
webRequired: contract.external_context.web_required,
|
|
4686
|
+
quotaEventSink: this.deps.quotaEventSink,
|
|
4687
|
+
};
|
|
4688
|
+
}
|
|
4020
4689
|
async runReadOnlyReport(input, opts, announce) {
|
|
4021
4690
|
const taskId = input.taskId ?? newId("task");
|
|
4022
4691
|
const runId = input.runId ?? newId("run");
|
|
@@ -4066,7 +4735,10 @@ export class Orchestrator {
|
|
|
4066
4735
|
const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
|
|
4067
4736
|
let adapters;
|
|
4068
4737
|
try {
|
|
4069
|
-
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome
|
|
4738
|
+
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome, runId,
|
|
4739
|
+
// Deep-scan repeats a surviving harness to reach scout width; a dropped
|
|
4740
|
+
// lane must not clamp coverage (QA-043 clamp is best-of-only).
|
|
4741
|
+
opts.deepScan === true);
|
|
4070
4742
|
if (!opts.deepScan) {
|
|
4071
4743
|
const seen = new Set();
|
|
4072
4744
|
adapters = adapters.filter((routed) => {
|
|
@@ -4081,11 +4753,15 @@ export class Orchestrator {
|
|
|
4081
4753
|
roHome.dispose();
|
|
4082
4754
|
const message = safeErrorMessage(err);
|
|
4083
4755
|
store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
|
|
4756
|
+
// A routing preflight refusal is a config_error, not harness_unavailable
|
|
4757
|
+
// (A-1/#22): classify identically across every strategy's routing catch.
|
|
4758
|
+
const routingFailure = routingFailureClassification(err);
|
|
4084
4759
|
writeFailure(store, paths, {
|
|
4085
4760
|
phase: "routing",
|
|
4086
|
-
category:
|
|
4761
|
+
category: routingFailure.category,
|
|
4087
4762
|
safeMessage: message,
|
|
4088
4763
|
runDir: paths.root,
|
|
4764
|
+
...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
|
|
4089
4765
|
});
|
|
4090
4766
|
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
4091
4767
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
@@ -4113,34 +4789,101 @@ export class Orchestrator {
|
|
|
4113
4789
|
const attemptTelemetries = [];
|
|
4114
4790
|
let fallbackOpen = false;
|
|
4115
4791
|
let budgetStopped = false;
|
|
4792
|
+
// QA-050: keep the ledger's TYPED denial (not just a boolean) so the
|
|
4793
|
+
// terminal names the budget sub-code, the refused route/slot, and budget
|
|
4794
|
+
// remediation instead of a harness auth/setup template. First denial wins —
|
|
4795
|
+
// it is the decisive pre-spawn refusal.
|
|
4796
|
+
let budgetDenial = null;
|
|
4116
4797
|
// in a swarm the same harness appears in several slots; resuming the
|
|
4117
4798
|
// ONE native session id from all of them races the vendor's session store
|
|
4118
4799
|
// (and is semantically wrong — N explorers continuing one conversation).
|
|
4119
4800
|
// Grant resume to the first slot of each harness only; the rest run fresh.
|
|
4120
4801
|
const resumeGranted = new Set();
|
|
4121
|
-
|
|
4802
|
+
// QA-019 disclosure: a scout the budget gate refused before spawn is
|
|
4803
|
+
// recorded as a failed attempt with a placeholder telemetry and a
|
|
4804
|
+
// budget_denied marker. It enters the denominator (honest 1/2), omissions,
|
|
4805
|
+
// and telemetry.yaml, and the marker lets the all-denied terminal still
|
|
4806
|
+
// route through the QA-050 budget classifier (never harness_error).
|
|
4807
|
+
const recordBudgetDeniedScout = (harnessId, attemptId, reason) => {
|
|
4808
|
+
const telemetry = createAttemptTelemetry(contract.external_context.policy, contract.external_context.web_required, contract.external_context.effective_mode);
|
|
4809
|
+
const error = `budget denied before spawn: ${reason}`;
|
|
4810
|
+
setAttemptOutcome(telemetry, {
|
|
4811
|
+
deliverablePresent: false,
|
|
4812
|
+
gatesPassed: null,
|
|
4813
|
+
harnessErrored: false,
|
|
4814
|
+
webRequiredUnsatisfied: false,
|
|
4815
|
+
});
|
|
4816
|
+
attempts.push({
|
|
4817
|
+
attemptId,
|
|
4818
|
+
harnessId,
|
|
4819
|
+
status: "failed",
|
|
4820
|
+
report: "",
|
|
4821
|
+
error,
|
|
4822
|
+
telemetry,
|
|
4823
|
+
budgetDenied: true,
|
|
4824
|
+
});
|
|
4825
|
+
attemptTelemetries.push({ attemptId, harnessId, telemetry });
|
|
4826
|
+
if (opts.deepScan) {
|
|
4827
|
+
store.writeText(join(paths.findingsDir, `${attemptId}-budget-denied.md`), `# Explorer ${attemptId} not started\n\n${error}\n`);
|
|
4828
|
+
}
|
|
4829
|
+
};
|
|
4830
|
+
const runReadonlyAttempt = async (routed, idx, modelOverride,
|
|
4831
|
+
// D-16d: a one-shot continuation re-run injects its checkpoint packet
|
|
4832
|
+
// pointer here; the attempt runs a FRESH session (resume is never granted
|
|
4833
|
+
// to a same-adapter follow-up slot) and is tagged `-cont`.
|
|
4834
|
+
continuationPointer,
|
|
4835
|
+
// D-16d: fired exactly once AFTER the budget lease is granted and BEFORE the
|
|
4836
|
+
// attempt streams — the continuation caller emits run.continuation here so
|
|
4837
|
+
// the disclosure never precedes (or outlives) a denied lease. The result
|
|
4838
|
+
// carries the denial reason so a refusal discloses run.continuation.denied.
|
|
4839
|
+
onLaunch) => {
|
|
4122
4840
|
const adapter = routed.adapter;
|
|
4123
|
-
const attemptId =
|
|
4124
|
-
? `a${String(idx + 1).padStart(2, "0")}-
|
|
4125
|
-
:
|
|
4841
|
+
const attemptId = continuationPointer
|
|
4842
|
+
? `a${String(idx + 1).padStart(2, "0")}-cont`
|
|
4843
|
+
: modelOverride
|
|
4844
|
+
? `a${String(idx + 1).padStart(2, "0")}-fb`
|
|
4845
|
+
: `a${String(idx + 1).padStart(2, "0")}`;
|
|
4126
4846
|
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
4127
4847
|
const lease = ledger.reserve({
|
|
4128
4848
|
taskId,
|
|
4129
4849
|
attemptId,
|
|
4130
4850
|
intent: opts.intent,
|
|
4131
4851
|
harnessId: adapter.id,
|
|
4132
|
-
|
|
4852
|
+
// QA-019: an n>1 deep-scan scout admits under a FINITE estimate floor
|
|
4853
|
+
// (mirror of the candidate loop): the first scout reserves without a
|
|
4854
|
+
// floor, but later scouts pass the repo's usd floor so a subscription
|
|
4855
|
+
// swarm is not refused for lacking a per-attempt cash quote under a cap.
|
|
4856
|
+
cost: attemptCostEvidence(adapter.id, attemptId, opts.deepScan && idx > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
4133
4857
|
});
|
|
4134
4858
|
if (!lease.granted) {
|
|
4135
4859
|
log.emit("budget.lease.created", {
|
|
4136
4860
|
granted: false,
|
|
4137
4861
|
reason: lease.reason,
|
|
4862
|
+
denied: lease.denied,
|
|
4138
4863
|
attempt_id: attemptId,
|
|
4139
4864
|
harness_id: adapter.id,
|
|
4140
4865
|
});
|
|
4141
4866
|
budgetStopped = true;
|
|
4142
|
-
|
|
4867
|
+
budgetDenial ??= {
|
|
4868
|
+
code: lease.denied ?? "hard_cap",
|
|
4869
|
+
reason: lease.reason ?? "budget lease denied",
|
|
4870
|
+
harnessId: adapter.id,
|
|
4871
|
+
attemptId,
|
|
4872
|
+
};
|
|
4873
|
+
// QA-019 disclosure: a still-denied deep-scan scout must not vanish from
|
|
4874
|
+
// the denominator. Record a placeholder failed attempt with a
|
|
4875
|
+
// budget_denied marker so the explore-findings map counts it (1/2, not
|
|
4876
|
+
// 1/1), omissions and telemetry record the denial, and the all-denied
|
|
4877
|
+
// terminal still routes through the QA-050 budget classifier. The
|
|
4878
|
+
// sequential ask/audit path has no denominator — a denial there stays a
|
|
4879
|
+
// pure budget stop (no phantom failed attempt), preserving its terminal.
|
|
4880
|
+
if (opts.deepScan) {
|
|
4881
|
+
recordBudgetDeniedScout(adapter.id, attemptId, lease.reason ?? "budget lease denied");
|
|
4882
|
+
}
|
|
4883
|
+
return { status: "budget_denied", reason: lease.reason ?? "budget lease denied" };
|
|
4143
4884
|
}
|
|
4885
|
+
// Lease granted: the attempt is now committed to run — disclose the launch.
|
|
4886
|
+
onLaunch?.();
|
|
4144
4887
|
const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
|
|
4145
4888
|
const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
|
|
4146
4889
|
const explorerPrompt = (opts.deepScan
|
|
@@ -4157,12 +4900,15 @@ export class Orchestrator {
|
|
|
4157
4900
|
const laneContinuity = laneRun
|
|
4158
4901
|
? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, store, paths, this.execRootOf(input), log)
|
|
4159
4902
|
: null;
|
|
4903
|
+
// D-16d: the continuation packet pointer rides after the lane pointer so
|
|
4904
|
+
// the fresh session is re-grounded in the exhausted attempt's work.
|
|
4905
|
+
const promptWithPointers = [explorerPrompt, laneContinuity?.pointerLine, continuationPointer]
|
|
4906
|
+
.filter((p) => Boolean(p))
|
|
4907
|
+
.join("\n\n");
|
|
4160
4908
|
let spec = HarnessRunSpec.parse({
|
|
4161
4909
|
session_id: newId("ses"),
|
|
4162
4910
|
intent: opts.intent,
|
|
4163
|
-
prompt:
|
|
4164
|
-
? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
|
|
4165
|
-
: explorerPrompt,
|
|
4911
|
+
prompt: promptWithPointers,
|
|
4166
4912
|
cwd: this.execRootOf(input),
|
|
4167
4913
|
access: "readonly",
|
|
4168
4914
|
// ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
|
|
@@ -4186,6 +4932,9 @@ export class Orchestrator {
|
|
|
4186
4932
|
const reportInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
|
|
4187
4933
|
if (reportInteraction)
|
|
4188
4934
|
spec.extra["interactionChannel"] = reportInteraction;
|
|
4935
|
+
// D-16: compile the WorkReport envelope for the read-only lane.
|
|
4936
|
+
const readonlyWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(reportInteraction));
|
|
4937
|
+
const readonlyWorkMode = this.applyWorkEnvelope(spec, readonlyWorkEnvelope);
|
|
4189
4938
|
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
4190
4939
|
const answer = new AnswerAssembly();
|
|
4191
4940
|
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
@@ -4289,9 +5038,13 @@ export class Orchestrator {
|
|
|
4289
5038
|
}
|
|
4290
5039
|
catch (err) {
|
|
4291
5040
|
harnessError = safeErrorMessage(err);
|
|
5041
|
+
// #31: classify the throw so the retry gate and required-actions read
|
|
5042
|
+
// a typed category (watchdog timeout vs process crash).
|
|
5043
|
+
telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
|
|
4292
5044
|
}
|
|
4293
|
-
const
|
|
4294
|
-
const
|
|
5045
|
+
const newTransients = telemetry.transientFailures.slice(transientStart);
|
|
5046
|
+
const transient = newTransients.at(-1) ?? null;
|
|
5047
|
+
const sawRetryable = newTransients.some((f) => f.retryable);
|
|
4295
5048
|
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
4296
5049
|
const reportSoFar = answer.text();
|
|
4297
5050
|
// W5.4 reactive failover, READ-ONLY lane (same contract as the
|
|
@@ -4319,7 +5072,7 @@ export class Orchestrator {
|
|
|
4319
5072
|
}
|
|
4320
5073
|
}
|
|
4321
5074
|
if (!harnessError ||
|
|
4322
|
-
!
|
|
5075
|
+
!sawRetryable ||
|
|
4323
5076
|
reportSoFar.length > 0 ||
|
|
4324
5077
|
nativeTry >= retryPolicy.maxRetries ||
|
|
4325
5078
|
input.signal?.aborted)
|
|
@@ -4330,6 +5083,7 @@ export class Orchestrator {
|
|
|
4330
5083
|
harness_id: adapter.id,
|
|
4331
5084
|
attempt_id: attemptId,
|
|
4332
5085
|
kind: transient?.kind ?? "unknown",
|
|
5086
|
+
category: transient?.category ?? "unknown_harness_error",
|
|
4333
5087
|
native_try: nativeTry + 1,
|
|
4334
5088
|
});
|
|
4335
5089
|
log.emit("route.transient.retry_scheduled", {
|
|
@@ -4350,26 +5104,43 @@ export class Orchestrator {
|
|
|
4350
5104
|
log.emit("route.transient.exhausted", {
|
|
4351
5105
|
harness_id: adapter.id,
|
|
4352
5106
|
attempt_id: attemptId,
|
|
5107
|
+
category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
|
|
4353
5108
|
retries: retryPolicy.maxRetries,
|
|
4354
5109
|
});
|
|
4355
5110
|
}
|
|
4356
5111
|
attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
|
|
4357
|
-
|
|
5112
|
+
// D-16: un-nest the {work_report, output} envelope; the OUTPUT is the report.
|
|
5113
|
+
const roUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", readonlyWorkMode, {
|
|
5114
|
+
sideToolReport: telemetry.sideToolWorkReport ?? undefined,
|
|
5115
|
+
});
|
|
5116
|
+
const report = redactSecrets(roUnwrapped.deliverable);
|
|
4358
5117
|
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
4359
5118
|
const webBlocked = webUnsatisfied(telemetry);
|
|
4360
|
-
const
|
|
5119
|
+
const reportPresent = report.length > 0;
|
|
4361
5120
|
if (!harnessError && webBlocked) {
|
|
4362
|
-
harnessError =
|
|
5121
|
+
harnessError = webEvidenceFailure(telemetry.web);
|
|
4363
5122
|
}
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
5123
|
+
harnessError ??= unrecoveredToolErrorFailure(unrecovered, reportPresent);
|
|
5124
|
+
const roFinalized = finalizeAttempt({
|
|
5125
|
+
deliverableEvidence: reportPresent,
|
|
5126
|
+
harnessErrored: harnessError !== null && !webBlocked,
|
|
5127
|
+
workReport: roUnwrapped.workReport,
|
|
5128
|
+
workReportSource: roUnwrapped.source,
|
|
5129
|
+
workReportViolation: roUnwrapped.contractViolation,
|
|
5130
|
+
contextTerminalExhausted: telemetry.contextExhausted,
|
|
5131
|
+
});
|
|
5132
|
+
// A broken WorkReport contract is a hard failure ONLY when the finalizer
|
|
5133
|
+
// ranked it so — a concurrent terminal context exhaustion outranks it
|
|
5134
|
+
// (interrupted, not a contract failure). Let the finalizer own precedence.
|
|
5135
|
+
if (!harnessError && roFinalized.outcomeClass === "contract_failure") {
|
|
5136
|
+
harnessError = `work_report contract: ${roUnwrapped.contractViolation}`;
|
|
4367
5137
|
}
|
|
4368
5138
|
setAttemptOutcome(telemetry, {
|
|
4369
|
-
deliverablePresent,
|
|
5139
|
+
deliverablePresent: roFinalized.deliverablePresent,
|
|
4370
5140
|
gatesPassed: null,
|
|
4371
5141
|
harnessErrored: harnessError !== null && !webBlocked,
|
|
4372
5142
|
webRequiredUnsatisfied: webBlocked,
|
|
5143
|
+
workState: roFinalized.workState,
|
|
4373
5144
|
});
|
|
4374
5145
|
if (harnessError) {
|
|
4375
5146
|
log.emit("harness.completed", {
|
|
@@ -4390,21 +5161,25 @@ export class Orchestrator {
|
|
|
4390
5161
|
if (opts.deepScan) {
|
|
4391
5162
|
store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
|
|
4392
5163
|
}
|
|
4393
|
-
return;
|
|
5164
|
+
return { status: "launched" };
|
|
4394
5165
|
}
|
|
5166
|
+
// D-16 r8: an interrupted scout is a FAILED omission, never reducer input;
|
|
5167
|
+
// the sequential ask/audit winner folds at its own terminal (stays success).
|
|
5168
|
+
const scoutInterrupted = opts.deepScan && roFinalized.outcomeClass === "interrupted";
|
|
4395
5169
|
log.emit("harness.completed", {
|
|
4396
5170
|
harness_id: adapter.id,
|
|
4397
5171
|
attempt_id: attemptId,
|
|
4398
|
-
status: "success",
|
|
5172
|
+
status: scoutInterrupted ? "interrupted" : "success",
|
|
4399
5173
|
...telemetrySummary(telemetry),
|
|
4400
5174
|
});
|
|
4401
5175
|
attempts.push({
|
|
4402
5176
|
attemptId,
|
|
4403
5177
|
harnessId: adapter.id,
|
|
4404
|
-
status: "success",
|
|
5178
|
+
status: scoutInterrupted ? "failed" : "success",
|
|
4405
5179
|
report: report || "(no output)",
|
|
4406
|
-
error: null,
|
|
5180
|
+
error: scoutInterrupted ? "context capacity exhausted before the scout completed" : null,
|
|
4407
5181
|
telemetry,
|
|
5182
|
+
...(scoutInterrupted ? { interrupted: true } : {}),
|
|
4408
5183
|
});
|
|
4409
5184
|
if (opts.deepScan) {
|
|
4410
5185
|
const warningNote = toolWarnings(telemetry).length
|
|
@@ -4412,22 +5187,82 @@ export class Orchestrator {
|
|
|
4412
5187
|
.map((e) => `${e.tool}: ${e.summary}`)
|
|
4413
5188
|
.join("; ")}\n`
|
|
4414
5189
|
: "";
|
|
4415
|
-
|
|
5190
|
+
const scoutTag = scoutInterrupted
|
|
5191
|
+
? " interrupted (context capacity exhausted)"
|
|
5192
|
+
: ` (${adapter.id})`;
|
|
5193
|
+
store.writeText(join(paths.findingsDir, `${attemptId}${scoutInterrupted ? "-interrupted" : ""}.md`), `# Explorer ${attemptId}${scoutTag}\n\n${report || "(no output)"}${warningNote}\n`);
|
|
4416
5194
|
}
|
|
5195
|
+
return { status: "launched" };
|
|
4417
5196
|
};
|
|
4418
5197
|
try {
|
|
4419
5198
|
if (opts.deepScan) {
|
|
4420
|
-
// Explorer swarm runs in parallel (bounded), mirroring parallel
|
|
4421
|
-
|
|
5199
|
+
// Explorer swarm runs in parallel (bounded), mirroring parallel
|
|
5200
|
+
// candidates. The swarm has no continuation lane, so the launched/denied
|
|
5201
|
+
// return is unused here.
|
|
5202
|
+
await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), async (routed, idx) => {
|
|
5203
|
+
await runReadonlyAttempt(routed, idx);
|
|
5204
|
+
});
|
|
4422
5205
|
}
|
|
4423
5206
|
else {
|
|
4424
5207
|
// ask/audit: sequential fallback chain — first success wins; a blocked
|
|
4425
5208
|
// attempt opens a fallback arc to the next eligible harness.
|
|
5209
|
+
let continuationCount = 0; // D-16d: one-shot budget across the chain
|
|
4426
5210
|
for (const [idx, routed] of adapters.entries()) {
|
|
4427
5211
|
if (input.signal?.aborted)
|
|
4428
5212
|
break;
|
|
4429
5213
|
await runReadonlyAttempt(routed, idx);
|
|
4430
5214
|
let last = attempts[attempts.length - 1];
|
|
5215
|
+
// D-16d one-shot continuation: an ELIGIBLE terminal context exhaustion
|
|
5216
|
+
// (repeated_refill, no completed report) gets ONE fresh-session re-run,
|
|
5217
|
+
// re-grounded by a mechanical checkpoint packet. On completion the
|
|
5218
|
+
// exhausted attempt is superseded so the continuation wins the terminal.
|
|
5219
|
+
if (last?.status === "success" && continuationCount === 0 && !budgetStopped) {
|
|
5220
|
+
const decision = decideContinuation({
|
|
5221
|
+
contextExhausted: last.telemetry.contextExhausted,
|
|
5222
|
+
contextExhaustedCause: last.telemetry.contextExhaustedCause,
|
|
5223
|
+
workStateCompleted: last.telemetry.outcome?.workState?.state === "completed",
|
|
5224
|
+
continuationCount,
|
|
5225
|
+
runKind: "read_only",
|
|
5226
|
+
});
|
|
5227
|
+
if (decision.eligible) {
|
|
5228
|
+
const exhausted = last;
|
|
5229
|
+
const packet = buildContinuationPacket(synthesizeContinuationRequest({
|
|
5230
|
+
harness: exhausted.harnessId,
|
|
5231
|
+
profileId: input.credentialProfileId ?? null,
|
|
5232
|
+
priorPrompt: prompt,
|
|
5233
|
+
priorOutput: exhausted.report,
|
|
5234
|
+
}));
|
|
5235
|
+
// The continuation lease is reserved INSIDE runReadonlyAttempt; emit
|
|
5236
|
+
// run.continuation via onLaunch (fires only AFTER the grant, before the
|
|
5237
|
+
// stream) so a denied lease never leaves a false "launched" disclosure
|
|
5238
|
+
// nor consumes the one-shot. A refusal emits run.continuation.denied.
|
|
5239
|
+
const outcome = await runReadonlyAttempt(routed, idx, undefined, packet.pointerLine ?? undefined, () => {
|
|
5240
|
+
continuationCount += 1;
|
|
5241
|
+
log.emit("run.continuation", {
|
|
5242
|
+
from_attempt: exhausted.attemptId,
|
|
5243
|
+
cause: last.telemetry.contextExhaustedCause,
|
|
5244
|
+
continuation_count: continuationCount,
|
|
5245
|
+
packet_turns: packet.continuity.disclosure.packetTurns,
|
|
5246
|
+
});
|
|
5247
|
+
});
|
|
5248
|
+
if (outcome.status === "budget_denied") {
|
|
5249
|
+
log.emit("run.continuation.denied", {
|
|
5250
|
+
from_attempt: exhausted.attemptId,
|
|
5251
|
+
cause: last.telemetry.contextExhaustedCause,
|
|
5252
|
+
reason: outcome.reason,
|
|
5253
|
+
});
|
|
5254
|
+
}
|
|
5255
|
+
else {
|
|
5256
|
+
const cont = attempts[attempts.length - 1];
|
|
5257
|
+
if (cont && cont !== exhausted && cont.status === "success") {
|
|
5258
|
+
exhausted.status = "failed";
|
|
5259
|
+
exhausted.error =
|
|
5260
|
+
exhausted.error ?? "superseded by one-shot continuation (context exhausted)";
|
|
5261
|
+
last = cont;
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
4431
5266
|
// Per-harness fallback_model: one same-harness retry on FAILURE (not
|
|
4432
5267
|
// policy blocks) before falling through to the next harness.
|
|
4433
5268
|
const fallbackModel = routed.settings?.fallbackModel;
|
|
@@ -4498,18 +5333,27 @@ export class Orchestrator {
|
|
|
4498
5333
|
// injected API-route state. Vendor-owned native credentials were not copied.
|
|
4499
5334
|
roHome.dispose();
|
|
4500
5335
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
5336
|
+
const candidateSummaries = attempts.map((a) => ({
|
|
5337
|
+
attemptId: a.attemptId,
|
|
5338
|
+
harnessId: a.harnessId,
|
|
5339
|
+
status: a.status,
|
|
5340
|
+
}));
|
|
5341
|
+
const cancelledTerminal = () => cancelledResult(log, runId, taskId, opts.mode, paths.root, candidateSummaries, () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
5342
|
+
if (input.signal?.aborted)
|
|
5343
|
+
return cancelledTerminal();
|
|
4508
5344
|
const succeededReadonly = attempts.filter((a) => a.status === "success");
|
|
4509
5345
|
if (!opts.deepScan && succeededReadonly.length === 0) {
|
|
4510
5346
|
const last = attempts[attempts.length - 1];
|
|
4511
5347
|
const webBlocked = attempts.some((a) => a.status === "blocked");
|
|
4512
|
-
|
|
5348
|
+
// QA-050: a budget refusal is a BUDGET failure, not a harness one — route
|
|
5349
|
+
// it through the shared classifier so phase/category/code/route and the
|
|
5350
|
+
// remediation are budget-typed (never auth/setup) across every mode.
|
|
5351
|
+
const budgetMapping = budgetStopped && !webBlocked
|
|
5352
|
+
? classifyBudgetFailure({ denial: budgetDenial, terminal: ledger.terminal() })
|
|
5353
|
+
: null;
|
|
5354
|
+
const singleError = budgetMapping?.safeMessage ??
|
|
5355
|
+
last?.error ??
|
|
5356
|
+
(budgetStopped ? "budget exhausted before any attempt" : "harness failed");
|
|
4513
5357
|
if (fallbackOpen || webBlocked) {
|
|
4514
5358
|
log.emit("route.fallback.exhausted", {
|
|
4515
5359
|
harness_id: last?.harnessId ?? null,
|
|
@@ -4529,29 +5373,47 @@ export class Orchestrator {
|
|
|
4529
5373
|
});
|
|
4530
5374
|
}
|
|
4531
5375
|
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null);
|
|
4532
|
-
store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${singleError}\n`);
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
5376
|
+
store.writeText(join(paths.contextDir, "context_error.md"), `# ${budgetMapping ? "Budget Denied" : "Harness Error"}\n\n${singleError}\n`);
|
|
5377
|
+
const roEventRefs = attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`);
|
|
5378
|
+
if (budgetMapping) {
|
|
5379
|
+
writeFailure(store, paths, budgetFailureRecord(budgetMapping, { eventRefs: roEventRefs, runDir: paths.root }));
|
|
5380
|
+
}
|
|
5381
|
+
else {
|
|
5382
|
+
// #31: classify the harness cause across the read-only attempts so auth
|
|
5383
|
+
// guidance appears only on a real auth failure.
|
|
5384
|
+
const roCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
|
|
5385
|
+
writeFailure(store, paths, {
|
|
5386
|
+
phase: "harness",
|
|
5387
|
+
category: webBlocked ? "policy" : "harness_error",
|
|
5388
|
+
harnessId: last?.harnessId,
|
|
5389
|
+
attemptId: last?.attemptId,
|
|
5390
|
+
safeMessage: singleError,
|
|
5391
|
+
eventRefs: roEventRefs,
|
|
5392
|
+
runDir: paths.root,
|
|
5393
|
+
nextActions: harnessFailureNextActions(roCategory),
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
// QA-036: re-check the DELIVERABLE through the shared finalizer helper —
|
|
5397
|
+
// a blocked Ask that produced NO answer can no longer read as a succeeded
|
|
5398
|
+
// "Needs review" run (exit 0); it is an honest failure (exit 1).
|
|
5399
|
+
const roTerminal = readOnlyNoSuccessTerminal({
|
|
5400
|
+
webBlocked,
|
|
5401
|
+
hasDeliverable: partialReport.trim().length > 0,
|
|
5402
|
+
budgetStopped,
|
|
5403
|
+
attemptsCount: attempts.length,
|
|
5404
|
+
});
|
|
5405
|
+
const terminalFacts = makeOutcomeFacts(roTerminal.lifecycle, {
|
|
5406
|
+
...(roTerminal.review ? { review: roTerminal.review } : {}),
|
|
5407
|
+
reason: roTerminal.reason,
|
|
4542
5408
|
});
|
|
4543
|
-
const
|
|
4544
|
-
|
|
4545
|
-
: budgetStopped && attempts.length === 0
|
|
4546
|
-
? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
|
|
4547
|
-
: makeOutcomeFacts("failed", { reason: "harness_failed" });
|
|
4548
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${last?.harnessId ?? "none"}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${singleError}\n`);
|
|
5409
|
+
const terminalHarnessId = budgetMapping?.harnessId ?? last?.harnessId;
|
|
5410
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${terminalHarnessId ?? "none"}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${singleError}\n`);
|
|
4549
5411
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
4550
5412
|
if (terminalFacts.lifecycle === "succeeded") {
|
|
4551
5413
|
log.emit("run.blocked", {
|
|
4552
5414
|
lifecycle: terminalFacts.lifecycle,
|
|
4553
5415
|
facts: terminalFacts,
|
|
4554
|
-
harness_id:
|
|
5416
|
+
harness_id: terminalHarnessId,
|
|
4555
5417
|
error: singleError,
|
|
4556
5418
|
failure_ref: "final/failure.yaml",
|
|
4557
5419
|
});
|
|
@@ -4561,7 +5423,8 @@ export class Orchestrator {
|
|
|
4561
5423
|
lifecycle: terminalFacts.lifecycle,
|
|
4562
5424
|
facts: terminalFacts,
|
|
4563
5425
|
reason: terminalFacts.reason,
|
|
4564
|
-
|
|
5426
|
+
phase: budgetMapping?.phase,
|
|
5427
|
+
harness_id: terminalHarnessId,
|
|
4565
5428
|
error: singleError,
|
|
4566
5429
|
failure_ref: "final/failure.yaml",
|
|
4567
5430
|
});
|
|
@@ -4585,46 +5448,62 @@ export class Orchestrator {
|
|
|
4585
5448
|
}
|
|
4586
5449
|
const succeeded = succeededReadonly;
|
|
4587
5450
|
if (opts.deepScan && succeeded.length === 0) {
|
|
4588
|
-
const message = attempts
|
|
4589
|
-
.map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
|
|
4590
|
-
.join("\n");
|
|
4591
5451
|
const blocked = attempts.some((a) => a.status === "blocked");
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
});
|
|
4606
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${blocked ? "succeeded (needs review)" : "failed"}\n\n${message}\n`);
|
|
4607
|
-
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
4608
|
-
const scanFailFacts = blocked
|
|
4609
|
-
? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
|
|
4610
|
-
: makeOutcomeFacts("failed", { reason: "harness_failed" });
|
|
4611
|
-
if (blocked)
|
|
4612
|
-
log.emit("run.blocked", {
|
|
4613
|
-
lifecycle: scanFailFacts.lifecycle,
|
|
4614
|
-
facts: scanFailFacts,
|
|
4615
|
-
phase: "harness",
|
|
4616
|
-
error: message,
|
|
4617
|
-
failure_ref: "final/failure.yaml",
|
|
5452
|
+
// QA-050/QA-019: an all-denied scan (finite-zero, or every scout refused
|
|
5453
|
+
// before spawn) is a BUDGET failure, not harness_error — route it through
|
|
5454
|
+
// the shared classifier. Only a pure-denial scan (no scout actually errored
|
|
5455
|
+
// in the harness) qualifies, so a real explorer failure is never masked.
|
|
5456
|
+
const scanBudgetMapping = budgetStopped && !blocked && attempts.every((a) => a.budgetDenied === true)
|
|
5457
|
+
? classifyBudgetFailure({ denial: budgetDenial, terminal: ledger.terminal() })
|
|
5458
|
+
: null;
|
|
5459
|
+
// D-16 r8: ALL scouts out of context → the aggregate is interrupted.
|
|
5460
|
+
const allInterrupted = attempts.length > 0 && attempts.every((a) => a.interrupted === true);
|
|
5461
|
+
const scanFailFacts = allInterrupted
|
|
5462
|
+
? makeOutcomeFacts("interrupted", { reason: "context_capacity_exhausted" })
|
|
5463
|
+
: makeOutcomeFacts("failed", {
|
|
5464
|
+
reason: scanBudgetMapping ? scanBudgetMapping.reason : "harness_failed",
|
|
4618
5465
|
});
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
5466
|
+
const message = scanBudgetMapping
|
|
5467
|
+
? scanBudgetMapping.safeMessage
|
|
5468
|
+
: attempts.map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`).join("\n");
|
|
5469
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null);
|
|
5470
|
+
if (scanBudgetMapping) {
|
|
5471
|
+
writeFailure(store, paths, budgetFailureRecord(scanBudgetMapping, {
|
|
5472
|
+
eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
|
|
5473
|
+
runDir: paths.root,
|
|
5474
|
+
}));
|
|
5475
|
+
}
|
|
5476
|
+
else {
|
|
5477
|
+
// #31: classify the scout failures; keep the scan-specific width hint but
|
|
5478
|
+
// drop the unconditional auth line unless the cause was a real auth failure.
|
|
5479
|
+
const scanCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
|
|
5480
|
+
writeFailure(store, paths, {
|
|
4624
5481
|
phase: "harness",
|
|
4625
|
-
|
|
4626
|
-
|
|
5482
|
+
category: blocked ? "policy" : "harness_error",
|
|
5483
|
+
safeMessage: message || "all explorers failed",
|
|
5484
|
+
eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
|
|
5485
|
+
runDir: paths.root,
|
|
5486
|
+
nextActions: [
|
|
5487
|
+
...harnessFailureNextActions(scanCategory).filter((a) => !a.startsWith("Retry")),
|
|
5488
|
+
"Reduce explore width",
|
|
5489
|
+
"Retry after setup",
|
|
5490
|
+
],
|
|
4627
5491
|
});
|
|
5492
|
+
}
|
|
5493
|
+
// QA-036: with ZERO successful explorers there is no synthesizable
|
|
5494
|
+
// deliverable, so a blocked scan can no longer read as a succeeded
|
|
5495
|
+
// "needs review" run (exit 0). An empty scan is a failure whether the
|
|
5496
|
+
// explorers were blocked or errored.
|
|
5497
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${scanFailFacts.lifecycle}${scanFailFacts.reason ? ` (${scanFailFacts.reason})` : ""}\n\n${message}\n`);
|
|
5498
|
+
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
5499
|
+
log.emit("run.failed", {
|
|
5500
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
5501
|
+
facts: scanFailFacts,
|
|
5502
|
+
reason: scanFailFacts.reason,
|
|
5503
|
+
phase: scanBudgetMapping ? scanBudgetMapping.phase : "harness",
|
|
5504
|
+
error: message,
|
|
5505
|
+
failure_ref: "final/failure.yaml",
|
|
5506
|
+
});
|
|
4628
5507
|
return {
|
|
4629
5508
|
spendUsd: ledger.spend(),
|
|
4630
5509
|
runId,
|
|
@@ -4643,45 +5522,54 @@ export class Orchestrator {
|
|
|
4643
5522
|
};
|
|
4644
5523
|
}
|
|
4645
5524
|
const unsuccessful = attempts.filter((a) => a.status !== "success");
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
5525
|
+
// #27 / D-6: a multi-scout deep scan runs ONE bounded synthesis reducer over
|
|
5526
|
+
// the raw scout reports so the final artifact is a real merge, not a
|
|
5527
|
+
// concatenation. A single report (width-1) needs no merge; a failed/denied
|
|
5528
|
+
// reducer degrades to an HONEST raw scout bundle, never a fake synthesis. The
|
|
5529
|
+
// whole decision + reducer spawn lives in deepScanReducer.ts (its owner).
|
|
5530
|
+
let deepScanSynthesis = null;
|
|
5531
|
+
let reducedReport = null;
|
|
5532
|
+
if (opts.deepScan) {
|
|
5533
|
+
({ deepScanSynthesis, reducedReport } = await resolveDeepScanSynthesis(this.deepScanReducerDeps(input, contract, log), {
|
|
5534
|
+
succeeded,
|
|
5535
|
+
adapters,
|
|
5536
|
+
budgetStopped,
|
|
5537
|
+
aborted: Boolean(input.signal?.aborted),
|
|
5538
|
+
taskId,
|
|
5539
|
+
goal: prompt,
|
|
5540
|
+
findingsDir: paths.findingsDir,
|
|
5541
|
+
ledger,
|
|
5542
|
+
log,
|
|
5543
|
+
paths,
|
|
5544
|
+
signal: input.signal,
|
|
5545
|
+
onHarnessEvent: input.onHarnessEvent,
|
|
5546
|
+
attemptTelemetries,
|
|
5547
|
+
}));
|
|
5548
|
+
}
|
|
5549
|
+
// INV-116: a cancel that landed WHILE the bounded reducer ran (scouts done,
|
|
5550
|
+
// synthesis in flight) is a cancelled terminal — never a laundered success.
|
|
5551
|
+
if (input.signal?.aborted)
|
|
5552
|
+
return cancelledTerminal();
|
|
5553
|
+
const report = !opts.deepScan
|
|
5554
|
+
? (succeeded[0]?.report ?? "(no output)")
|
|
5555
|
+
: reducedReport !== null
|
|
5556
|
+
? reducedReport
|
|
5557
|
+
: rawScoutBundle({ succeeded, unsuccessful, status: deepScanSynthesis });
|
|
4671
5558
|
store.writeText(join(paths.finalDir, opts.artifactName), `# ${opts.title}\n\n${report}\n`);
|
|
4672
5559
|
// ask is the only read-only strategy that can carry a structured-output
|
|
4673
|
-
// contract (the boundary refuses the rest); validate the
|
|
4674
|
-
//
|
|
5560
|
+
// contract (the boundary refuses the rest); validate the FINAL aggregate
|
|
5561
|
+
// (the reduced synthesis, or the honest bundle for a degraded scan) — never
|
|
5562
|
+
// the first scout's raw report — and never the titled artifact wrapper.
|
|
4675
5563
|
if (opts.mode === "ask" && contract.output_schema) {
|
|
4676
5564
|
finalizeStructuredOutput({
|
|
4677
5565
|
store,
|
|
4678
5566
|
finalDir: paths.finalDir,
|
|
4679
5567
|
log,
|
|
4680
5568
|
schema: contract.output_schema,
|
|
4681
|
-
answerText: succeeded[0]?.report ?? "",
|
|
5569
|
+
answerText: opts.deepScan ? report : (succeeded[0]?.report ?? ""),
|
|
4682
5570
|
});
|
|
4683
5571
|
}
|
|
4684
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null));
|
|
5572
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null), deepScanSynthesis);
|
|
4685
5573
|
log.emit("output.ready", {
|
|
4686
5574
|
kind: opts.mode === "ask" ? "answer" : "report",
|
|
4687
5575
|
path: `final/${opts.artifactName}`,
|
|
@@ -4712,8 +5600,31 @@ export class Orchestrator {
|
|
|
4712
5600
|
// non-clean terminal is an aggregate paid-budget stop.
|
|
4713
5601
|
let terminalFacts = makeOutcomeFacts("succeeded");
|
|
4714
5602
|
const reportBudgetTerminal = ledger.terminal();
|
|
4715
|
-
if (reportBudgetTerminal)
|
|
5603
|
+
if (reportBudgetTerminal) {
|
|
4716
5604
|
terminalFacts = makeOutcomeFacts("failed", { reason: reportBudgetTerminal });
|
|
5605
|
+
}
|
|
5606
|
+
else if (!opts.deepScan) {
|
|
5607
|
+
// D-16: fold the winning read-only attempt's work_state into the terminal.
|
|
5608
|
+
// A terminal context exhaustion with no completed report ⇒ interrupted;
|
|
5609
|
+
// a needs_input/incomplete report ⇒ a succeeded run whose work_state
|
|
5610
|
+
// vetoes applyability and a clean exit (INV-116). answer.md was already
|
|
5611
|
+
// persisted from the unwrapped OUTPUT.
|
|
5612
|
+
const winnerTelemetry = succeeded[0]?.telemetry;
|
|
5613
|
+
const winnerWorkState = winnerTelemetry?.outcome?.workState;
|
|
5614
|
+
if (winnerTelemetry?.contextExhausted && winnerWorkState?.state !== "completed") {
|
|
5615
|
+
terminalFacts = makeOutcomeFacts("interrupted", { reason: "context_capacity_exhausted" });
|
|
5616
|
+
}
|
|
5617
|
+
else if (winnerWorkState?.state === "needs_input" ||
|
|
5618
|
+
winnerWorkState?.state === "incomplete") {
|
|
5619
|
+
terminalFacts = makeOutcomeFacts("succeeded", {
|
|
5620
|
+
reason: winnerWorkState.state === "needs_input" ? "input_required" : "work_incomplete",
|
|
5621
|
+
work_state: winnerWorkState,
|
|
5622
|
+
});
|
|
5623
|
+
}
|
|
5624
|
+
else if (winnerWorkState) {
|
|
5625
|
+
terminalFacts = makeOutcomeFacts("succeeded", { work_state: winnerWorkState });
|
|
5626
|
+
}
|
|
5627
|
+
}
|
|
4717
5628
|
const harnessLabel = attempts
|
|
4718
5629
|
.map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
|
|
4719
5630
|
.join(", ");
|
|
@@ -4732,13 +5643,17 @@ export class Orchestrator {
|
|
|
4732
5643
|
},
|
|
4733
5644
|
});
|
|
4734
5645
|
log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
|
|
4735
|
-
|
|
5646
|
+
const workVetoed = terminalFacts.work_state?.state === "needs_input" ||
|
|
5647
|
+
terminalFacts.work_state?.state === "incomplete";
|
|
5648
|
+
if (terminalFacts.lifecycle !== "succeeded") {
|
|
4736
5649
|
writeFailure(store, paths, {
|
|
4737
5650
|
phase: "executor",
|
|
4738
|
-
category: "budget",
|
|
5651
|
+
category: terminalFacts.reason === "context_capacity_exhausted" ? "harness_error" : "budget",
|
|
4739
5652
|
safeMessage: `read-only report ended ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason.replaceAll("_", " ")})` : ""}`,
|
|
4740
5653
|
runDir: paths.root,
|
|
4741
|
-
nextActions:
|
|
5654
|
+
nextActions: terminalFacts.reason === "context_capacity_exhausted"
|
|
5655
|
+
? ["Inspect the partial report", "Re-run with a narrower scope"]
|
|
5656
|
+
: ["Inspect the report artifacts", "Adjust the budget and retry"],
|
|
4742
5657
|
});
|
|
4743
5658
|
log.emit("run.failed", {
|
|
4744
5659
|
lifecycle: terminalFacts.lifecycle,
|
|
@@ -4748,6 +5663,16 @@ export class Orchestrator {
|
|
|
4748
5663
|
failure_ref: "final/failure.yaml",
|
|
4749
5664
|
});
|
|
4750
5665
|
}
|
|
5666
|
+
else if (workVetoed) {
|
|
5667
|
+
// D-16: a succeeded lifecycle whose work_state vetoes is a needs-me
|
|
5668
|
+
// terminal — run.blocked (not run.completed); the outcome-aware exit
|
|
5669
|
+
// projection returns non-zero from the same facts.
|
|
5670
|
+
log.emit("run.blocked", {
|
|
5671
|
+
lifecycle: terminalFacts.lifecycle,
|
|
5672
|
+
facts: terminalFacts,
|
|
5673
|
+
reason: terminalFacts.reason,
|
|
5674
|
+
});
|
|
5675
|
+
}
|
|
4751
5676
|
else {
|
|
4752
5677
|
log.emit("run.completed", {
|
|
4753
5678
|
lifecycle: terminalFacts.lifecycle,
|
|
@@ -4765,11 +5690,7 @@ export class Orchestrator {
|
|
|
4765
5690
|
winner: null,
|
|
4766
5691
|
runDir: paths.root,
|
|
4767
5692
|
summary: redactSecrets(report).slice(0, 400),
|
|
4768
|
-
candidates:
|
|
4769
|
-
attemptId: a.attemptId,
|
|
4770
|
-
harnessId: a.harnessId,
|
|
4771
|
-
status: a.status,
|
|
4772
|
-
})),
|
|
5693
|
+
candidates: candidateSummaries,
|
|
4773
5694
|
};
|
|
4774
5695
|
}
|
|
4775
5696
|
}
|
|
@@ -4778,7 +5699,4 @@ function assertNoSecretLikeTokens(label, text) {
|
|
|
4778
5699
|
throw new Error(`${label} contains secret-like token; refusing to persist artifact`);
|
|
4779
5700
|
}
|
|
4780
5701
|
}
|
|
4781
|
-
function safeErrorMessage(err) {
|
|
4782
|
-
return redactSecrets(err instanceof Error ? err.message : String(err));
|
|
4783
|
-
}
|
|
4784
5702
|
//# sourceMappingURL=orchestrator.js.map
|