@claudexor/orchestrator 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attemptTelemetry.d.ts +9 -0
- package/dist/attemptTelemetry.d.ts.map +1 -1
- package/dist/attemptTelemetry.js +25 -0
- package/dist/attemptTelemetry.js.map +1 -1
- package/dist/attemptUsage.d.ts +19 -0
- package/dist/attemptUsage.d.ts.map +1 -0
- package/dist/attemptUsage.js +29 -0
- package/dist/attemptUsage.js.map +1 -0
- package/dist/candidateEvidence.d.ts +22 -0
- package/dist/candidateEvidence.d.ts.map +1 -0
- package/dist/candidateEvidence.js +40 -0
- package/dist/candidateEvidence.js.map +1 -0
- package/dist/candidateOutputs.d.ts +56 -0
- package/dist/candidateOutputs.d.ts.map +1 -0
- package/dist/candidateOutputs.js +209 -0
- package/dist/candidateOutputs.js.map +1 -0
- package/dist/continuity-facts.d.ts +37 -0
- package/dist/continuity-facts.d.ts.map +1 -0
- package/dist/continuity-facts.js +100 -0
- package/dist/continuity-facts.js.map +1 -0
- package/dist/continuity-summary.d.ts +45 -0
- package/dist/continuity-summary.d.ts.map +1 -0
- package/dist/continuity-summary.js +113 -0
- package/dist/continuity-summary.js.map +1 -0
- package/dist/continuity.d.ts +119 -0
- package/dist/continuity.d.ts.map +1 -0
- package/dist/continuity.js +220 -0
- package/dist/continuity.js.map +1 -0
- package/dist/contract-gates.d.ts +2 -3
- package/dist/contract-gates.d.ts.map +1 -1
- package/dist/contract-gates.js +2 -3
- package/dist/contract-gates.js.map +1 -1
- package/dist/council.d.ts +57 -0
- package/dist/council.d.ts.map +1 -0
- package/dist/council.js +98 -0
- package/dist/council.js.map +1 -0
- package/dist/credential-profiles.d.ts +37 -1
- package/dist/credential-profiles.d.ts.map +1 -1
- package/dist/credential-profiles.js +111 -3
- package/dist/credential-profiles.js.map +1 -1
- package/dist/diffReview.d.ts +3 -0
- package/dist/diffReview.d.ts.map +1 -1
- package/dist/diffReview.js +3 -0
- package/dist/diffReview.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestrator.d.ts +218 -62
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +1078 -1236
- package/dist/orchestrator.js.map +1 -1
- package/dist/planQuestions.d.ts +11 -0
- package/dist/planQuestions.d.ts.map +1 -0
- package/dist/planQuestions.js +119 -0
- package/dist/planQuestions.js.map +1 -0
- package/dist/planRun.d.ts +114 -0
- package/dist/planRun.d.ts.map +1 -0
- package/dist/planRun.js +346 -0
- package/dist/planRun.js.map +1 -0
- package/dist/runSupport.d.ts +31 -80
- package/dist/runSupport.d.ts.map +1 -1
- package/dist/runSupport.js +33 -106
- package/dist/runSupport.js.map +1 -1
- package/dist/runTerminals.d.ts.map +1 -1
- package/dist/runTerminals.js +18 -7
- package/dist/runTerminals.js.map +1 -1
- package/package.json +18 -19
- package/dist/orchestrateExecutor.d.ts +0 -43
- package/dist/orchestrateExecutor.d.ts.map +0 -1
- package/dist/orchestrateExecutor.js +0 -207
- package/dist/orchestrateExecutor.js.map +0 -1
- package/dist/orchestratePlanner.d.ts +0 -12
- package/dist/orchestratePlanner.d.ts.map +0 -1
- package/dist/orchestratePlanner.js +0 -91
- package/dist/orchestratePlanner.js.map +0 -1
- package/dist/outcomeReducer.d.ts +0 -25
- package/dist/outcomeReducer.d.ts.map +0 -1
- package/dist/outcomeReducer.js +0 -121
- package/dist/outcomeReducer.js.map +0 -1
package/dist/orchestrator.js
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, } from "./credential-profiles.js";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
|
|
3
3
|
import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
|
|
4
|
+
import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
|
|
5
|
+
import { processAttemptUsage } from "./attemptUsage.js";
|
|
6
|
+
import { toCandidateEvidence } from "./candidateEvidence.js";
|
|
4
7
|
import { capabilityIntents } from "@claudexor/gateway";
|
|
5
8
|
import { policyFindings } from "./policyFindings.js";
|
|
6
9
|
import { join } from "node:path";
|
|
7
|
-
import {
|
|
10
|
+
import { finalizePlanRun, runCouncilPlan, writePlanHarnessFailure, } from "./planRun.js";
|
|
11
|
+
import { HarnessRunSpec, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_ACCESS_CODE, FrozenTaskContractArtifact as TaskContractSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
|
|
8
12
|
import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
|
|
9
|
-
import {
|
|
10
|
-
import { AnswerAssembly, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
|
|
13
|
+
import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
|
|
11
14
|
import { assertRouteModelsAllowed } from "./modelGovernance.js";
|
|
12
15
|
import { RequestRequirementsResolver } from "./requestRequirements.js";
|
|
13
16
|
import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
|
|
14
17
|
import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
|
|
15
|
-
import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary,
|
|
18
|
+
import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
|
|
16
19
|
import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
|
|
17
20
|
import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
|
|
21
|
+
import { buildContinuation, } from "./continuity.js";
|
|
22
|
+
import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
|
|
21
23
|
import { runDiffReview } from "./diffReview.js";
|
|
22
24
|
import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
|
|
23
25
|
import { interactionChannelFor } from "./interaction.js";
|
|
@@ -26,34 +28,26 @@ import { ArtifactStore } from "@claudexor/artifact-store";
|
|
|
26
28
|
import { EventLog } from "@claudexor/event-log";
|
|
27
29
|
import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
|
|
28
30
|
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
29
|
-
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch,
|
|
31
|
+
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
|
|
30
32
|
import { HarnessGateway } from "@claudexor/gateway";
|
|
31
33
|
import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
|
|
32
34
|
import { arbitrate } from "@claudexor/arbitration";
|
|
33
35
|
import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
|
|
34
|
-
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement,
|
|
35
|
-
import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
36
|
+
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
|
|
37
|
+
import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLikeToken, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
38
|
+
/** A routed candidate adapter plus its manifest capabilities and user settings. */
|
|
39
|
+
/** The two access profiles that map to codex `danger-full-access` / an
|
|
40
|
+
* unsandboxed lane — the only ones under which a full-access-requiring MCP
|
|
41
|
+
* injection (the belt on codex) can reach the daemon. */
|
|
42
|
+
export function isFullAccess(access) {
|
|
43
|
+
return access === "full" || access === "external_sandbox_full";
|
|
44
|
+
}
|
|
36
45
|
const LABELS = "ABCDEFGHIJ".split("");
|
|
37
46
|
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
38
47
|
/** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
|
|
39
48
|
const MAX_PARALLEL_CANDIDATES = 4;
|
|
40
49
|
/** Default wait for one interactive answer before a benign decline. */
|
|
41
50
|
const DEFAULT_INTERACTION_TIMEOUT_MS = 900_000;
|
|
42
|
-
/**
|
|
43
|
-
* SAFETY INVARIANT 1 (asserted, not convention): a sub-run spawned by the
|
|
44
|
-
* orchestrate executor for a SAFE step (start_run/race) MUST run as an isolated
|
|
45
|
-
* ENVELOPE — never a live in-place turn on a thread. Throws loudly if a caller
|
|
46
|
-
* ever constructs a safe sub-run that could mutate the live tree.
|
|
47
|
-
*/
|
|
48
|
-
function assertEnvelopeSubRun(sub) {
|
|
49
|
-
if (sub.inPlace === true) {
|
|
50
|
-
throw new Error("orchestrate safe sub-run must be an isolated envelope (inPlace must be false), refusing live-tree mutation");
|
|
51
|
-
}
|
|
52
|
-
if (sub.threadId !== undefined || sub.executionRoot !== undefined) {
|
|
53
|
-
throw new Error("orchestrate safe sub-run must not bind a thread or in-place execution root (isolation envelope only)");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
/** Changed paths and +/- line counts parsed from a unified git diff. */
|
|
57
51
|
/** Run `work` over `items` with bounded concurrency, preserving item order via index. */
|
|
58
52
|
async function runBounded(items, limit, work) {
|
|
59
53
|
if (items.length === 0)
|
|
@@ -111,15 +105,6 @@ export class Orchestrator {
|
|
|
111
105
|
throw new Error(`unknown mode: ${String(resolved.mode)}`);
|
|
112
106
|
}
|
|
113
107
|
const mode = parsedMode.data;
|
|
114
|
-
// INV-023 at the ENGINE boundary too: maxToolCalls caps the orchestrate
|
|
115
|
-
// executor's plan steps — on any other mode it would be a silent no-op
|
|
116
|
-
// knob. The CLI and control API validate this already; a direct embedder
|
|
117
|
-
// must get the same loud refusal, not quiet acceptance.
|
|
118
|
-
if (resolved.maxToolCalls !== undefined &&
|
|
119
|
-
resolved.maxToolCalls !== null &&
|
|
120
|
-
mode !== "orchestrate") {
|
|
121
|
-
throw new Error(`maxToolCalls caps the orchestrate EXECUTOR's plan steps and only applies to mode=orchestrate (got mode=${mode}); drop the knob or switch modes`);
|
|
122
|
-
}
|
|
123
108
|
// denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
|
|
124
109
|
// only exists on envelope/isolated runs — an in-place run mutates the live
|
|
125
110
|
// tree directly, so the gate could not contain a violation. Refuse loudly
|
|
@@ -166,12 +151,11 @@ export class Orchestrator {
|
|
|
166
151
|
return guardAnnouncedRun(resolved.signal, (announce) => {
|
|
167
152
|
switch (mode) {
|
|
168
153
|
case "ask":
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
: this.runAudit(resolved, announce);
|
|
154
|
+
// `--deep-scan` widens the answer into the bounded multi-scout
|
|
155
|
+
// research sweep with synthesis (the old `audit --swarm`/`explore`).
|
|
156
|
+
return resolved.deepScan
|
|
157
|
+
? this.runDeepScan(resolved, announce)
|
|
158
|
+
: this.runAsk(resolved, announce);
|
|
175
159
|
case "agent":
|
|
176
160
|
// Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
|
|
177
161
|
// and `--attempts` select the convergence loop; `--n` selects the race
|
|
@@ -184,14 +168,6 @@ export class Orchestrator {
|
|
|
184
168
|
return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
|
|
185
169
|
case "plan":
|
|
186
170
|
return this.runPlan(resolved, announce);
|
|
187
|
-
case "orchestrate":
|
|
188
|
-
// Recursion guard: a sub-run spawned by the orchestrate executor carries
|
|
189
|
-
// orchestrateDepth>0 and must NOT itself orchestrate (no infinite planner
|
|
190
|
-
// recursion). Fail loudly rather than silently degrade.
|
|
191
|
-
if ((resolved.orchestrateDepth ?? 0) > 0) {
|
|
192
|
-
throw new Error("orchestrate-within-orchestrate is forbidden: a sub-run spawned by the orchestrate executor cannot itself orchestrate");
|
|
193
|
-
}
|
|
194
|
-
return this.runOrchestrate(resolved, announce);
|
|
195
171
|
}
|
|
196
172
|
});
|
|
197
173
|
}
|
|
@@ -231,10 +207,12 @@ export class Orchestrator {
|
|
|
231
207
|
safeMessage: message,
|
|
232
208
|
runDir: paths.root,
|
|
233
209
|
});
|
|
234
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
210
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: review preflight\n\n${message}\n`);
|
|
235
211
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
236
212
|
log.emit("run.failed", {
|
|
237
|
-
|
|
213
|
+
lifecycle: "failed",
|
|
214
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
215
|
+
reason: "harness_failed",
|
|
238
216
|
phase: "review_preflight",
|
|
239
217
|
error: message,
|
|
240
218
|
failure_ref: "final/failure.yaml",
|
|
@@ -244,7 +222,8 @@ export class Orchestrator {
|
|
|
244
222
|
runId,
|
|
245
223
|
taskId,
|
|
246
224
|
mode,
|
|
247
|
-
|
|
225
|
+
lifecycle: "failed",
|
|
226
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
248
227
|
winner: null,
|
|
249
228
|
runDir: paths.root,
|
|
250
229
|
summary: message,
|
|
@@ -308,11 +287,55 @@ export class Orchestrator {
|
|
|
308
287
|
credential_profile: profile,
|
|
309
288
|
};
|
|
310
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* The DURABLE per-lane read-only HOME env for a THREAD turn (INV-034), or
|
|
292
|
+
* null for a non-thread one-shot (which keeps the disposable route-context
|
|
293
|
+
* home). Anchored to the PROJECT partition (`input.repoRoot`), not the
|
|
294
|
+
* per-turn execution root, so the home is the SAME across turns of the same
|
|
295
|
+
* lane and the lifecycle owners (which key off `thread.repo.root`) reach it.
|
|
296
|
+
* Keyed by the run's REQUESTED credential profile — the same key the daemon's
|
|
297
|
+
* `resumeMap` lookup uses (INV-135), so record and resume land in one home.
|
|
298
|
+
*/
|
|
299
|
+
laneHomeEnvFor(input, harnessId) {
|
|
300
|
+
if (!input.threadId)
|
|
301
|
+
return null;
|
|
302
|
+
return new WorkspaceManager(input.repoRoot).laneHomeEnv(input.threadId, harnessId,
|
|
303
|
+
// The lane is keyed by the EFFECTIVE account (INV-135): an explicit pin,
|
|
304
|
+
// else null resolves the same home the recorded native session lives in.
|
|
305
|
+
this.effectiveProfileId(input, harnessId)).env;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The per-harness EFFECTIVE credential profile id (INV-135 accounts
|
|
309
|
+
* authority): an explicit per-run/per-thread pin wins; else null — POOL AUTO,
|
|
310
|
+
* the native/CLI login default subject (enabled profiles route only by
|
|
311
|
+
* explicit pin or quota rotation, never as a silent Active default).
|
|
312
|
+
*/
|
|
313
|
+
effectiveProfileId(input, _harnessId) {
|
|
314
|
+
return input.credentialProfileId ?? null;
|
|
315
|
+
}
|
|
316
|
+
/** Whether the native/CLI login is EXCLUDED from this harness's credential
|
|
317
|
+
* ladder (INV-135). When excluded, a harness with no effective profile has
|
|
318
|
+
* nothing routable and must refuse — never silently fall back into it. */
|
|
319
|
+
nativeCredentialsDisabled(repoRoot, harnessId) {
|
|
320
|
+
return (this.config(repoRoot)?.global.harnesses?.[harnessId]?.native_credentials_enabled === false);
|
|
321
|
+
}
|
|
311
322
|
resolveCredentialProfile(input, harnessId) {
|
|
312
|
-
|
|
323
|
+
const explicit = input.credentialProfileId ?? null;
|
|
324
|
+
const wanted = this.effectiveProfileId(input, harnessId);
|
|
325
|
+
if (!wanted)
|
|
313
326
|
return null;
|
|
314
327
|
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
315
|
-
|
|
328
|
+
try {
|
|
329
|
+
return resolveCredentialProfile(registry, wanted, harnessId);
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
// With Active removed, `wanted` is always the explicit pin; keep the
|
|
333
|
+
// fail-closed guard so any future non-pin source still refuses loudly.
|
|
334
|
+
if (!explicit) {
|
|
335
|
+
throw new Error(`harness "${harnessId}" credential profile "${wanted}" is unusable: ${err instanceof Error ? err.message : String(err)}`);
|
|
336
|
+
}
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
316
339
|
}
|
|
317
340
|
/** The typed effective auth route for a SELECTED credential profile
|
|
318
341
|
* (round-18 #2): adapters execute strictly by credential_kind, so routing,
|
|
@@ -334,29 +357,6 @@ export class Orchestrator {
|
|
|
334
357
|
const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
|
|
335
358
|
return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
|
|
336
359
|
}
|
|
337
|
-
/** null = no profile selected (default verdict stands); "available" = the
|
|
338
|
-
* profile's own probe admits the route; any other string = typed refusal. */
|
|
339
|
-
async profileAvailabilityOverride(input, harnessId) {
|
|
340
|
-
if (!input.credentialProfileId)
|
|
341
|
-
return null;
|
|
342
|
-
let profile;
|
|
343
|
-
try {
|
|
344
|
-
profile = this.resolveCredentialProfile(input, harnessId);
|
|
345
|
-
}
|
|
346
|
-
catch (err) {
|
|
347
|
-
return err instanceof Error ? err.message : String(err);
|
|
348
|
-
}
|
|
349
|
-
if (!profile)
|
|
350
|
-
return null;
|
|
351
|
-
const adapter = this.deps.registry.get(harnessId);
|
|
352
|
-
if (!adapter?.probeCredentialProfile) {
|
|
353
|
-
return `harness "${harnessId}" has no profile probe`;
|
|
354
|
-
}
|
|
355
|
-
const probe = await adapter.probeCredentialProfile(profile);
|
|
356
|
-
if (probe.availability === "available")
|
|
357
|
-
return "available";
|
|
358
|
-
return probe.detail ?? `${probe.availability}/${probe.verification}`;
|
|
359
|
-
}
|
|
360
360
|
preflightProfile(input, harnessId, log) {
|
|
361
361
|
const profile = this.resolveCredentialProfile(input, harnessId);
|
|
362
362
|
const policy = this.profilePolicy(input.repoRoot, harnessId);
|
|
@@ -371,13 +371,6 @@ export class Orchestrator {
|
|
|
371
371
|
}
|
|
372
372
|
return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
|
|
373
373
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Lift an adapter's auth-route override marker into the typed
|
|
376
|
-
* `route.fallback.auth_switched` run event (validated payload). An explicit
|
|
377
|
-
* subscription/api_key preference that could not be honored is never silent;
|
|
378
|
-
* neither is an `auto` choice that selects a smoke-proven paid route over an
|
|
379
|
-
* available native route.
|
|
380
|
-
*/
|
|
381
374
|
/**
|
|
382
375
|
* Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
|
|
383
376
|
* **capability-gate** to those that can actually produce work for `intent` (e.g. a
|
|
@@ -391,13 +384,7 @@ export class Orchestrator {
|
|
|
391
384
|
}
|
|
392
385
|
const cfg = this.config(input.repoRoot);
|
|
393
386
|
const configuredPool = cfg?.global.routing.eligible_harnesses;
|
|
394
|
-
const
|
|
395
|
-
const harnesses = input.harnesses ??
|
|
396
|
-
(configuredPool && configuredPool.length > 0
|
|
397
|
-
? configuredPool
|
|
398
|
-
: policy === "primary" && cfg?.global.routing.primary_harness
|
|
399
|
-
? [cfg.global.routing.primary_harness]
|
|
400
|
-
: undefined);
|
|
387
|
+
const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
|
|
401
388
|
const primaryHarness = input.primaryHarness ?? cfg?.global.routing.primary_harness ?? undefined;
|
|
402
389
|
if (primaryHarness &&
|
|
403
390
|
harnesses &&
|
|
@@ -449,7 +436,7 @@ export class Orchestrator {
|
|
|
449
436
|
externalContextPolicy: web,
|
|
450
437
|
};
|
|
451
438
|
}
|
|
452
|
-
async resolveCandidateAdapters(input, intent, ledger, routeContext) {
|
|
439
|
+
async resolveCandidateAdapters(input, intent, ledger, log, routeContext) {
|
|
453
440
|
let ids = input.harnesses;
|
|
454
441
|
const explicitPool = Boolean(ids && ids.length > 0);
|
|
455
442
|
const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
|
|
@@ -529,6 +516,18 @@ export class Orchestrator {
|
|
|
529
516
|
dropped.push(why);
|
|
530
517
|
continue;
|
|
531
518
|
}
|
|
519
|
+
// INV-135 accounts authority: with the native/CLI login excluded and no
|
|
520
|
+
// explicit pin, an unpinned run has nothing routable. Refuse an explicit
|
|
521
|
+
// request naming the setting; drop it from an auto pool — never silently
|
|
522
|
+
// fall back INTO the disabled login.
|
|
523
|
+
if (this.effectiveProfileId(input, id) === null &&
|
|
524
|
+
this.nativeCredentialsDisabled(input.repoRoot, id)) {
|
|
525
|
+
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
|
+
if (explicitPool)
|
|
527
|
+
throw new HarnessUnavailableError(why);
|
|
528
|
+
dropped.push(why);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
532
531
|
// W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
|
|
533
532
|
// resolved env/cwd its run will spawn with (see routeContext.ts).
|
|
534
533
|
let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
|
|
@@ -537,9 +536,7 @@ export class Orchestrator {
|
|
|
537
536
|
dropped.push(`${id} (unavailable)`);
|
|
538
537
|
continue;
|
|
539
538
|
}
|
|
540
|
-
// Doctor status is the readiness truth
|
|
541
|
-
// routes, and explicitly selecting an UNAVAILABLE harness fails loudly
|
|
542
|
-
// with the doctor's reasons. A DEGRADED harness (e.g. key present but
|
|
539
|
+
// Doctor status is the readiness truth. A DEGRADED harness (e.g. key present but
|
|
543
540
|
// unproven by isolated smoke) is admitted only by explicit user
|
|
544
541
|
// selection — degraded means usable-with-caveats, and the caveats are
|
|
545
542
|
// visible in doctor output and run events.
|
|
@@ -550,44 +547,47 @@ export class Orchestrator {
|
|
|
550
547
|
// the profile's transport, so the default store's state is not the
|
|
551
548
|
// routing truth). Capability/manifest gating above still applies.
|
|
552
549
|
let profileAdmitted = false;
|
|
553
|
-
|
|
554
|
-
|
|
550
|
+
const profileAdapter = this.deps.registry.get(id);
|
|
551
|
+
const profileVerdict = await selectedProfileAvailability({
|
|
552
|
+
registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
|
|
553
|
+
// The EFFECTIVE account (INV-135): an explicit pin is authenticated by ITS store.
|
|
554
|
+
profileId: this.effectiveProfileId(input, id),
|
|
555
|
+
harnessId: id,
|
|
556
|
+
probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
|
|
557
|
+
});
|
|
558
|
+
if (profileVerdict !== null) {
|
|
555
559
|
if (profileVerdict === "available") {
|
|
556
|
-
// The default-store failure may have zeroed enabled_intents; with
|
|
557
|
-
// the profile probe as the auth verdict, MANIFEST capabilities are
|
|
558
|
-
// the honest intent truth (degraded = usable-with-caveats).
|
|
559
|
-
status = {
|
|
560
|
-
...status,
|
|
561
|
-
status: "degraded",
|
|
562
|
-
enabledIntents: capabilityIntents(manifest.capabilities),
|
|
563
|
-
};
|
|
564
|
-
statusById.set(id, status);
|
|
565
560
|
profileAdmitted = true;
|
|
561
|
+
// A valid profile restores manifest intent truth when the default store failed.
|
|
562
|
+
if (status.status !== "ok") {
|
|
563
|
+
status = {
|
|
564
|
+
...status,
|
|
565
|
+
status: "degraded",
|
|
566
|
+
enabledIntents: capabilityIntents(manifest.capabilities),
|
|
567
|
+
};
|
|
568
|
+
statusById.set(id, status);
|
|
569
|
+
}
|
|
566
570
|
}
|
|
567
|
-
else
|
|
571
|
+
else {
|
|
568
572
|
const why = `${id} credential profile is not ready: ${profileVerdict}`;
|
|
569
573
|
if (explicitPool)
|
|
570
574
|
throw new HarnessUnavailableError(why);
|
|
571
575
|
dropped.push(why);
|
|
572
576
|
continue;
|
|
573
577
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
578
|
+
}
|
|
579
|
+
if (status.status === "unavailable" && !profileAdmitted) {
|
|
580
|
+
const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
|
|
581
|
+
if (explicitPool)
|
|
582
|
+
throw new HarnessUnavailableError(why);
|
|
583
|
+
dropped.push(why);
|
|
584
|
+
continue;
|
|
581
585
|
}
|
|
582
586
|
if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
|
|
583
587
|
dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
|
|
584
588
|
continue;
|
|
585
589
|
}
|
|
586
|
-
const readOnlyIntent = intent === "plan" ||
|
|
587
|
-
intent === "spec" ||
|
|
588
|
-
intent === "explain" ||
|
|
589
|
-
intent === "audit" ||
|
|
590
|
-
intent === "orchestrate";
|
|
590
|
+
const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
|
|
591
591
|
const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
|
|
592
592
|
? "readonly"
|
|
593
593
|
: (input.access ?? this.config(input.repoRoot).trust.access_default));
|
|
@@ -644,6 +644,8 @@ export class Orchestrator {
|
|
|
644
644
|
estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
|
|
645
645
|
supportsInteractive: manifest.capabilities.interactive,
|
|
646
646
|
supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
|
|
647
|
+
supportsMcpInjection: manifest.capability_profile.mcp_injection,
|
|
648
|
+
mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
|
|
647
649
|
implementationTransport: manifest.capabilities.implementation_transport,
|
|
648
650
|
settings: cfgEntry
|
|
649
651
|
? {
|
|
@@ -669,11 +671,33 @@ export class Orchestrator {
|
|
|
669
671
|
if (ordered.length === 0) {
|
|
670
672
|
throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
|
|
671
673
|
}
|
|
674
|
+
emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
|
|
672
675
|
const n = input.n ?? ordered.length;
|
|
673
676
|
const out = [];
|
|
674
677
|
for (let i = 0; i < n; i++)
|
|
675
678
|
out.push(ordered[i % ordered.length]);
|
|
676
679
|
this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
|
|
680
|
+
// Delegation belt (D32): agent-only, and only on a lane whose adapter can
|
|
681
|
+
// inject MCP servers. A requested delegate with NO injecting lane is a typed
|
|
682
|
+
// preflight refusal naming the harness(es) — never a silently dropped belt.
|
|
683
|
+
if (input.delegate === true && !out.some((lane) => lane.supportsMcpInjection)) {
|
|
684
|
+
const names = [...new Set(out.map((lane) => lane.adapter.id))].join(", ");
|
|
685
|
+
throw new HarnessUnavailableError(`--delegate requires a harness that can host the Claudexor delegation belt (capability_profile.mcp_injection); the routed harness(es) [${names}] cannot inject MCP servers — choose claude or codex, or drop --delegate`);
|
|
686
|
+
}
|
|
687
|
+
// A belt-injecting lane may still be UNABLE to reach the daemon at its
|
|
688
|
+
// access: codex's workspace-write seatbelt cancels the belt's daemon-crossing
|
|
689
|
+
// MCP call, so codex only hosts the belt at FULL access (same as its browser
|
|
690
|
+
// MCP). If EVERY injecting lane requires full access but runs below it, the
|
|
691
|
+
// belt would be injected only to be silently cancelled by the sandbox — the
|
|
692
|
+
// exact non-delegation this guard prevents. Refuse with the real remedy.
|
|
693
|
+
if (input.delegate === true) {
|
|
694
|
+
const injecting = out.filter((lane) => lane.supportsMcpInjection);
|
|
695
|
+
const canHostBelt = injecting.some((lane) => !lane.mcpInjectionRequiresFullAccess || isFullAccess(lane.adapterAccess));
|
|
696
|
+
if (!canHostBelt) {
|
|
697
|
+
const names = [...new Set(injecting.map((lane) => lane.adapter.id))].join(", ");
|
|
698
|
+
throw new HarnessUnavailableError(`--delegate needs a belt-hosting lane at full access: [${names}] can inject MCP servers but sandbox-cancel the delegation belt below full access (capability_profile.mcp_injection_requires_full_access) — re-run with --access full, or route a lane (e.g. claude) that hosts the belt at workspace_write`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
677
701
|
// outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
|
|
678
702
|
// natively constrain its final message would deliver best-effort text —
|
|
679
703
|
// that is a typed preflight refusal, never silent degradation. The
|
|
@@ -859,7 +883,7 @@ export class Orchestrator {
|
|
|
859
883
|
buildContract(input, taskId, mode) {
|
|
860
884
|
const resolvedCfg = this.config(input.repoRoot);
|
|
861
885
|
const cfg = resolvedCfg.project;
|
|
862
|
-
const readOnlyMode = mode === "ask" || mode === "plan"
|
|
886
|
+
const readOnlyMode = mode === "ask" || mode === "plan";
|
|
863
887
|
const requestedAccess = input.access ?? (readOnlyMode ? "readonly" : resolvedCfg.trust.access_default);
|
|
864
888
|
// Effective access is COMPUTED by the engine, never echoed from a client:
|
|
865
889
|
// read-only modes clamp to readonly regardless of the request.
|
|
@@ -880,63 +904,19 @@ export class Orchestrator {
|
|
|
880
904
|
{ code: TRUST_FULL_ACCESS_CODE, status: 403 });
|
|
881
905
|
}
|
|
882
906
|
const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
// empty and the interview pipeline dead in production.
|
|
887
|
-
let specFields = {};
|
|
888
|
-
let specTestCommands = [];
|
|
889
|
-
if (input.specPath) {
|
|
890
|
-
try {
|
|
891
|
-
const spec = SpecPackZ.parse(JSON.parse(readFileSync(input.specPath, "utf8")));
|
|
892
|
-
// Tamper fence (INV-081): the frozen spec's recorded hash must
|
|
893
|
-
// match what we just read — a spec.json edited AFTER freeze would
|
|
894
|
-
// otherwise silently rewrite success criteria/tests/protected paths
|
|
895
|
-
// while the contract records the stale hash as provenance.
|
|
896
|
-
if (input.specHash && hashJson(spec) !== input.specHash) {
|
|
897
|
-
throw new Error(`frozen SpecPack hash mismatch (expected ${input.specHash}, got ${hashJson(spec)}); the spec was modified after freeze — re-freeze it or drop --spec`);
|
|
898
|
-
}
|
|
899
|
-
const fromSpec = specPackToTaskContract(spec, {
|
|
900
|
-
repoRoot: input.repoRoot,
|
|
901
|
-
mode,
|
|
902
|
-
baseRef: input.baseRef,
|
|
903
|
-
paidBudget: input.paidBudget,
|
|
904
|
-
});
|
|
905
|
-
specFields = {
|
|
906
|
-
success_criteria: fromSpec.success_criteria,
|
|
907
|
-
non_goals: fromSpec.non_goals,
|
|
908
|
-
forbidden_approaches: fromSpec.forbidden_approaches,
|
|
909
|
-
decided_tradeoffs: fromSpec.decided_tradeoffs,
|
|
910
|
-
task_graph: fromSpec.task_graph,
|
|
911
|
-
constraints: fromSpec.constraints,
|
|
912
|
-
};
|
|
913
|
-
specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
|
|
914
|
-
program,
|
|
915
|
-
args,
|
|
916
|
-
...(cwd === undefined ? {} : { cwd }),
|
|
917
|
-
envAllowlist,
|
|
918
|
-
}));
|
|
919
|
-
}
|
|
920
|
-
catch (err) {
|
|
921
|
-
// An unreadable/unfrozen spec must fail the run loudly, never silently
|
|
922
|
-
// degrade into an unspecced contract.
|
|
923
|
-
throw new Error(`failed to resolve frozen SpecPack at ${input.specPath}: ${safeErrorMessage(err)}`);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
// Deterministic gate commands come from the frozen SpecPack, explicit run
|
|
927
|
-
// input, then versioned project config. Without these, gateSpecs is empty
|
|
928
|
-
// and convergence is review-only; with them, convergence is test-driven.
|
|
907
|
+
// Deterministic gate commands come from explicit run input, then versioned
|
|
908
|
+
// project config. Without these, gateSpecs is empty and convergence is
|
|
909
|
+
// review-only; with them, convergence is test-driven.
|
|
929
910
|
const resolvedGates = resolveContractGates({
|
|
930
911
|
repoRoot: input.repoRoot,
|
|
931
912
|
effectiveAccess,
|
|
932
913
|
config: cfg,
|
|
933
914
|
trustGrants: resolvedCfg.trust.test_command_grants,
|
|
934
|
-
specCommands: specTestCommands,
|
|
935
915
|
operatorCommands: input.tests ?? [],
|
|
936
916
|
projectCommands: cfg.tests?.commands ?? [],
|
|
937
917
|
});
|
|
938
918
|
const commands = resolvedGates.commands;
|
|
939
|
-
const protectedPaths = [
|
|
919
|
+
const protectedPaths = [];
|
|
940
920
|
const autoProtectedPaths = resolvedGates.autoProtectedPaths;
|
|
941
921
|
const protectedPathApprovals = [
|
|
942
922
|
...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
|
|
@@ -959,14 +939,6 @@ export class Orchestrator {
|
|
|
959
939
|
auth_preference: input.authPreference ?? "auto",
|
|
960
940
|
credential_profile_id: input.credentialProfileId ?? null,
|
|
961
941
|
max_turns: input.maxTurns ?? null,
|
|
962
|
-
spec: input.specId || input.specHash || input.specPath
|
|
963
|
-
? {
|
|
964
|
-
id: input.specId,
|
|
965
|
-
hash: input.specHash,
|
|
966
|
-
path: input.specPath,
|
|
967
|
-
}
|
|
968
|
-
: undefined,
|
|
969
|
-
...specFields,
|
|
970
942
|
constraints: {
|
|
971
943
|
protected_paths: protectedPaths,
|
|
972
944
|
deny_paths: [...new Set(input.denyPaths ?? [])],
|
|
@@ -1018,6 +990,39 @@ export class Orchestrator {
|
|
|
1018
990
|
* execution — owner Quiz-5a); reviewers and the auth smoke build their own
|
|
1019
991
|
* specs and never call this.
|
|
1020
992
|
*/
|
|
993
|
+
/** The extra MCP servers injected into one agent lane's sandbox. Today only
|
|
994
|
+
* the delegation belt (D32): present when `--delegate` is on, the daemon built
|
|
995
|
+
* a belt descriptor, the lane's adapter can inject MCP servers, and the lane is
|
|
996
|
+
* a WRITING agent intent (the delegator integrates results in its workspace;
|
|
997
|
+
* read lanes and reviewers have nothing to delegate). */
|
|
998
|
+
delegationBeltFor(input, intent, routed, resolvedBudget) {
|
|
999
|
+
if (!input?.delegate || !input.delegationBelt || !routed.supportsMcpInjection)
|
|
1000
|
+
return [];
|
|
1001
|
+
// A lane that sandbox-cancels the belt below full access (codex) must NOT
|
|
1002
|
+
// receive a belt it cannot use — that is the silent non-delegation. The
|
|
1003
|
+
// preflight already refused a run whose ONLY injecting lanes are such lanes
|
|
1004
|
+
// below full access; here we simply skip injecting into an individual lane
|
|
1005
|
+
// that cannot host it, so a mixed pool keeps the belt on the lanes that can.
|
|
1006
|
+
if (routed.mcpInjectionRequiresFullAccess && !isFullAccess(routed.adapterAccess))
|
|
1007
|
+
return [];
|
|
1008
|
+
const writingIntents = ["implement", "create_from_scratch", "repair"];
|
|
1009
|
+
if (!writingIntents.includes(intent))
|
|
1010
|
+
return [];
|
|
1011
|
+
// The CLI built the descriptor from the RAW request budget (undefined when
|
|
1012
|
+
// the caller relied on a config/dep default), which would leave the belt
|
|
1013
|
+
// unlimited while the real run is capped. Rebind the belt's parent-budget
|
|
1014
|
+
// env to the RESOLVED budget (resolvePaidBudget output) so sub-run draws are
|
|
1015
|
+
// bounded by the same headroom the parent run enforces — one budget owner.
|
|
1016
|
+
return [
|
|
1017
|
+
{
|
|
1018
|
+
...input.delegationBelt,
|
|
1019
|
+
env: {
|
|
1020
|
+
...input.delegationBelt.env,
|
|
1021
|
+
[DELEGATION_ENV.budget]: JSON.stringify(resolvedBudget),
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
];
|
|
1025
|
+
}
|
|
1021
1026
|
harnessSpecKnobs(contract, knobs, intent) {
|
|
1022
1027
|
return {
|
|
1023
1028
|
external_context_policy: knobs.webPolicy,
|
|
@@ -1031,11 +1036,10 @@ export class Orchestrator {
|
|
|
1031
1036
|
max_turns: knobs.maxTurns,
|
|
1032
1037
|
...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
|
|
1033
1038
|
// The user's answer contract rides every answer-producing lane INCLUDING
|
|
1034
|
-
// synthesis (its answer can become the final one)
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
...(intent !== "orchestrate" && contract.output_schema
|
|
1039
|
+
// synthesis (its answer can become the final one). The adapter gets the
|
|
1040
|
+
// vendor-STRICT transport form; the engine validator keeps the ORIGINAL
|
|
1041
|
+
// contract as the conformance authority.
|
|
1042
|
+
...(contract.output_schema
|
|
1039
1043
|
? { output_schema: strictifyOutputSchema(contract.output_schema) }
|
|
1040
1044
|
: {}),
|
|
1041
1045
|
};
|
|
@@ -1089,21 +1093,138 @@ export class Orchestrator {
|
|
|
1089
1093
|
ignored,
|
|
1090
1094
|
};
|
|
1091
1095
|
}
|
|
1092
|
-
/**
|
|
1093
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* Build the per-lane continuation packet for a thread turn (INV-137).
|
|
1098
|
+
* Resolves the lane (harness + effective profile), computes the delta since
|
|
1099
|
+
* the lane's checkpoint, reads prior outputs + the git anchor, and — for a
|
|
1100
|
+
* lane switch or gap — materializes `context/THREAD.md` and returns the
|
|
1101
|
+
* one-line prompt pointer. Emits `session.continuity` and stamps the turn.
|
|
1102
|
+
* Returns null (no packet, no pointer) for native resume, a fresh thread, or
|
|
1103
|
+
* a non-thread run. Never throws: continuity failure degrades to no packet.
|
|
1104
|
+
*/
|
|
1105
|
+
async resolveContinuity(runInput, harnessId, resolvedProfileId, nativeResumeAvailable, store, paths, repoRoot, log) {
|
|
1106
|
+
const ctx = runInput.threadContinuity;
|
|
1107
|
+
if (!runInput.threadId || !ctx)
|
|
1108
|
+
return null;
|
|
1109
|
+
try {
|
|
1110
|
+
const profileId = resolvedProfileId ?? ctx.profileId ?? null;
|
|
1111
|
+
const lane = { harness: harnessId, profileId };
|
|
1112
|
+
const checkpoint = ctx.laneCheckpoints.find((c) => c.harness === harnessId && (c.profileId ?? null) === profileId);
|
|
1113
|
+
const headTurnId = ctx.priorTurns.length
|
|
1114
|
+
? ctx.priorTurns[ctx.priorTurns.length - 1].id
|
|
1115
|
+
: null;
|
|
1116
|
+
const priorHeadOwner = headTurnId
|
|
1117
|
+
? ctx.laneCheckpoints.find((c) => c.turnId === headTurnId)
|
|
1118
|
+
: undefined;
|
|
1119
|
+
const priorHeadLane = priorHeadOwner
|
|
1120
|
+
? { harness: priorHeadOwner.harness, profileId: priorHeadOwner.profileId ?? null }
|
|
1121
|
+
: null;
|
|
1122
|
+
const priorTurns = ctx.priorTurns.map((t) => ({
|
|
1123
|
+
id: t.id,
|
|
1124
|
+
prompt: t.prompt,
|
|
1125
|
+
outputText: t.runId
|
|
1126
|
+
? (readTextSafe(join(store.runPaths(t.runId).finalDir, "answer.md")) ?? "")
|
|
1127
|
+
: "",
|
|
1128
|
+
}));
|
|
1129
|
+
const req = {
|
|
1130
|
+
lane,
|
|
1131
|
+
priorTurns,
|
|
1132
|
+
laneCheckpointTurnId: checkpoint?.turnId ?? null,
|
|
1133
|
+
nativeResumeAvailable,
|
|
1134
|
+
priorHeadLane,
|
|
1135
|
+
activePlan: activePlanPointer(ctx.priorTurns, store),
|
|
1136
|
+
anchor: await workspaceAnchor(repoRoot),
|
|
1137
|
+
};
|
|
1138
|
+
// V9c: when the packet would collapse an older prefix, replace the
|
|
1139
|
+
// mechanical one-liners with a cached (or freshly summarized) prose
|
|
1140
|
+
// summary. Same credential route + scoped lane home a real read-only
|
|
1141
|
+
// thread turn uses (INV-034/135). Best-effort in its OWN guard — a summary
|
|
1142
|
+
// failure keeps the full mechanical packet, never drops it.
|
|
1143
|
+
const sessionFields = this.sessionSpecFields(runInput, harnessId);
|
|
1144
|
+
req.cachedSummary = await resolveContinuitySummary({
|
|
1145
|
+
req,
|
|
1146
|
+
threadId: runInput.threadId,
|
|
1147
|
+
projectRoot: runInput.repoRoot,
|
|
1148
|
+
cwd: repoRoot,
|
|
1149
|
+
adapter: this.deps.registry.get(harnessId),
|
|
1150
|
+
credentialProfile: sessionFields.credential_profile,
|
|
1151
|
+
authPreference: sessionFields.auth_preference ?? "auto",
|
|
1152
|
+
laneEnv: this.laneHomeEnvFor(runInput, harnessId) ?? {},
|
|
1153
|
+
envInheritance: envInheritance(this.config(runInput.repoRoot)),
|
|
1154
|
+
signal: runInput.signal,
|
|
1155
|
+
});
|
|
1156
|
+
const result = buildContinuation(req);
|
|
1157
|
+
// Disclose on every lane and stamp the turn (INV-137: never silent).
|
|
1158
|
+
log?.emit("session.continuity", {
|
|
1159
|
+
thread_id: runInput.threadId,
|
|
1160
|
+
harness_id: harnessId,
|
|
1161
|
+
kind: result.disclosure.kind,
|
|
1162
|
+
packet_turns: result.disclosure.packetTurns,
|
|
1163
|
+
summarized: result.disclosure.summarized,
|
|
1164
|
+
lane_switched_from: result.disclosure.laneSwitchedFrom,
|
|
1165
|
+
});
|
|
1166
|
+
runInput.onContinuityResolved?.(ctx.turnId, result.disclosure);
|
|
1167
|
+
if (!result.packetMarkdown)
|
|
1168
|
+
return { pointerLine: null };
|
|
1169
|
+
const briefPath = join(paths.contextDir, "THREAD.md");
|
|
1170
|
+
store.writeText(briefPath, result.packetMarkdown);
|
|
1171
|
+
return {
|
|
1172
|
+
pointerLine: `Earlier conversation context for this thread is at: ${briefPath} — read it before answering.`,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
catch (err) {
|
|
1176
|
+
// Continuity is best-effort — a packet-build failure must never fail the
|
|
1177
|
+
// run — but it is NEVER silent (INV-137). Disclose the degradation: emit
|
|
1178
|
+
// the session.continuity event carrying the reason (so the failure is in
|
|
1179
|
+
// the run log), and stamp the turn as fresh — it honestly ran WITHOUT the
|
|
1180
|
+
// thread packet. Absent this, a summarization/anchor/read failure vanished.
|
|
1181
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1182
|
+
log?.emit("session.continuity", {
|
|
1183
|
+
thread_id: runInput.threadId,
|
|
1184
|
+
harness_id: harnessId,
|
|
1185
|
+
kind: "fresh",
|
|
1186
|
+
packet_turns: 0,
|
|
1187
|
+
summarized: false,
|
|
1188
|
+
lane_switched_from: null,
|
|
1189
|
+
degraded: true,
|
|
1190
|
+
reason,
|
|
1191
|
+
});
|
|
1192
|
+
runInput.onContinuityResolved?.(ctx.turnId, {
|
|
1193
|
+
kind: "fresh",
|
|
1194
|
+
packetTurns: 0,
|
|
1195
|
+
summarized: false,
|
|
1196
|
+
laneSwitchedFrom: null,
|
|
1197
|
+
});
|
|
1198
|
+
return { pointerLine: null };
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
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) {
|
|
1094
1202
|
const adapter = routed.adapter;
|
|
1095
1203
|
const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
|
|
1096
|
-
//
|
|
1097
|
-
// so their session ids are never retained after disposal.
|
|
1204
|
+
// Isolated scoped-home sessions are never retained after disposal.
|
|
1098
1205
|
const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
|
|
1099
1206
|
const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
|
|
1100
1207
|
const sessionFields = runInput ? this.sessionSpecFields(runInput, adapter.id, log) : undefined;
|
|
1208
|
+
// Continuity (INV-137): once the lane (harness + resolved profile) is known,
|
|
1209
|
+
// build the continuation packet, materialize context/THREAD.md, and point
|
|
1210
|
+
// the prompt at it — never embed the packet body in the prompt. Replaces the
|
|
1211
|
+
// old static session.rebound "not_portable" phrase with a real disclosure.
|
|
1212
|
+
const laneContinuity = runInput
|
|
1213
|
+
? 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
|
+
: null;
|
|
1215
|
+
const promptWithContinuity = laneContinuity?.pointerLine
|
|
1216
|
+
? `${prompt}\n\n${laneContinuity.pointerLine}`
|
|
1217
|
+
: prompt;
|
|
1101
1218
|
let spec = HarnessRunSpec.parse({
|
|
1102
1219
|
session_id: newId("ses"),
|
|
1103
1220
|
intent,
|
|
1104
|
-
prompt: promptWithProtectedPathConstraint(
|
|
1221
|
+
prompt: promptWithProtectedPathConstraint(promptWithContinuity, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
|
|
1105
1222
|
attachments: runInput?.attachments ?? [],
|
|
1106
|
-
browser: this.requestRequirements.browserSpec(routed.browserRequirement,
|
|
1223
|
+
browser: this.requestRequirements.browserSpec(routed.browserRequirement,
|
|
1224
|
+
// F4: browser-MCP screenshots land in the claudexor-owned
|
|
1225
|
+
// artifact dir inside the worktree — excluded from the diff, gallery-collected.
|
|
1226
|
+
join(envelope.worktree_path, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR)),
|
|
1227
|
+
extra_mcp_servers: this.delegationBeltFor(runInput, intent, routed, contract.budget.paid_budget),
|
|
1107
1228
|
cwd: envelope.worktree_path,
|
|
1108
1229
|
access: routed.adapterAccess,
|
|
1109
1230
|
...this.harnessSpecKnobs(contract, knobs, intent),
|
|
@@ -1123,16 +1244,6 @@ export class Orchestrator {
|
|
|
1123
1244
|
raw_context_packet: rawContextPacket,
|
|
1124
1245
|
stream_deltas: streamDeltas,
|
|
1125
1246
|
});
|
|
1126
|
-
if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
|
|
1127
|
-
log?.emit("session.rebound", SessionReboundLineageSchema.parse({
|
|
1128
|
-
thread_id: runInput.threadId,
|
|
1129
|
-
harness_id: adapter.id,
|
|
1130
|
-
from_native_session_id: sessionFields.resume_session_id,
|
|
1131
|
-
to_session_id: null,
|
|
1132
|
-
summary: "isolated envelope turn runs fresh: the native session is not portable into a scoped harness home; continuity rides on the thread prompt + repo state",
|
|
1133
|
-
reason: "not_portable",
|
|
1134
|
-
}));
|
|
1135
|
-
}
|
|
1136
1247
|
if (interaction)
|
|
1137
1248
|
spec.extra["interactionChannel"] = interaction;
|
|
1138
1249
|
const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
|
|
@@ -1163,6 +1274,7 @@ export class Orchestrator {
|
|
|
1163
1274
|
}
|
|
1164
1275
|
try {
|
|
1165
1276
|
for (let nativeTry = 0; !signal?.aborted; nativeTry += 1) {
|
|
1277
|
+
const clearFileBackedContext = stageFileBackedContext(envelope.worktree_path, fileBackedContext);
|
|
1166
1278
|
const runSpec = nativeTry === 0
|
|
1167
1279
|
? spec
|
|
1168
1280
|
: HarnessRunSpec.parse({ ...spec, session_id: newId("ses"), extra: { ...spec.extra } });
|
|
@@ -1238,31 +1350,23 @@ export class Orchestrator {
|
|
|
1238
1350
|
items: safeEv.plan_progress.items,
|
|
1239
1351
|
});
|
|
1240
1352
|
}
|
|
1241
|
-
if (safeEv.type === "usage"
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1353
|
+
if (safeEv.type === "usage") {
|
|
1354
|
+
const usage = processAttemptUsage({
|
|
1355
|
+
event: safeEv,
|
|
1356
|
+
telemetry,
|
|
1357
|
+
harnessId: adapter.id,
|
|
1358
|
+
attemptId,
|
|
1359
|
+
cost,
|
|
1360
|
+
costEstimated,
|
|
1361
|
+
emit: (type, payload) => log?.emit(type, payload),
|
|
1362
|
+
budgetGuard,
|
|
1363
|
+
cancel: () => void adapter.cancel?.(runSpec.session_id)?.catch(() => { }),
|
|
1251
1364
|
});
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const valuationOnly = safeEv.usage.estimated === true && telemetry.authMode === "local_session";
|
|
1256
|
-
if (!valuationOnly && budgetGuard?.(cost)) {
|
|
1365
|
+
cost = usage.cost;
|
|
1366
|
+
costEstimated = usage.costEstimated;
|
|
1367
|
+
if (usage.hardCapReached) {
|
|
1257
1368
|
harnessErrored = true;
|
|
1258
1369
|
errors.push("budget hard cap reached mid-attempt; stream aborted");
|
|
1259
|
-
log?.emit("budget.observation", {
|
|
1260
|
-
harness_id: adapter.id,
|
|
1261
|
-
attempt_id: attemptId,
|
|
1262
|
-
kind: "cooldown",
|
|
1263
|
-
detail: "hard cap mid-flight abort",
|
|
1264
|
-
});
|
|
1265
|
-
void adapter.cancel?.(runSpec.session_id)?.catch(() => { });
|
|
1266
1370
|
break;
|
|
1267
1371
|
}
|
|
1268
1372
|
}
|
|
@@ -1293,6 +1397,9 @@ export class Orchestrator {
|
|
|
1293
1397
|
harnessErrored = true;
|
|
1294
1398
|
errors.push(safeErrorMessage(err));
|
|
1295
1399
|
}
|
|
1400
|
+
finally {
|
|
1401
|
+
clearFileBackedContext();
|
|
1402
|
+
}
|
|
1296
1403
|
const transient = telemetry.transientFailures.at(-1) ?? null;
|
|
1297
1404
|
const sawTransient = telemetry.transientFailures.length > transientStart;
|
|
1298
1405
|
const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
|
|
@@ -1416,9 +1523,6 @@ export class Orchestrator {
|
|
|
1416
1523
|
// must carry it so the slot catch settles the TRUE cost, not 0.
|
|
1417
1524
|
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { costUsd: cost });
|
|
1418
1525
|
}
|
|
1419
|
-
store.writeText(join(attemptDir, "patch.diff"), diff);
|
|
1420
|
-
// Routing metrics (one owner in runSupport; clean attempts only —
|
|
1421
|
-
// auth-route evidence recorded regardless).
|
|
1422
1526
|
recordCleanAttemptMetrics(globalConfigDir(), adapter.id, {
|
|
1423
1527
|
costUsd: cost,
|
|
1424
1528
|
streamMs: attemptStreamEndedMs - attemptStartedMs,
|
|
@@ -1426,26 +1530,25 @@ export class Orchestrator {
|
|
|
1426
1530
|
aborted: signal?.aborted === true,
|
|
1427
1531
|
authMode: telemetry.authMode,
|
|
1428
1532
|
});
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1533
|
+
const producedFiles = writeCandidateAttemptArtifacts({
|
|
1534
|
+
store,
|
|
1535
|
+
attemptDir,
|
|
1536
|
+
worktreePath: envelope.worktree_path,
|
|
1537
|
+
diff,
|
|
1538
|
+
answerText,
|
|
1539
|
+
record: {
|
|
1540
|
+
attempt_id: attemptId,
|
|
1541
|
+
harness_id: adapter.id,
|
|
1542
|
+
label,
|
|
1543
|
+
cost_usd: cost,
|
|
1544
|
+
cost_estimated: costEstimated,
|
|
1545
|
+
errored,
|
|
1546
|
+
errors: errors.slice(0, 5),
|
|
1547
|
+
...telemetrySummary(telemetry),
|
|
1548
|
+
outcome: telemetry.outcome,
|
|
1549
|
+
gates: gates.map((g) => ({ id: g.id, status: g.status })),
|
|
1550
|
+
branch: envelope.branch_name,
|
|
1447
1551
|
},
|
|
1448
|
-
branch: envelope.branch_name,
|
|
1449
1552
|
});
|
|
1450
1553
|
return {
|
|
1451
1554
|
attemptId,
|
|
@@ -1455,6 +1558,7 @@ export class Orchestrator {
|
|
|
1455
1558
|
answerText,
|
|
1456
1559
|
reviewCwd: envelope.worktree_path,
|
|
1457
1560
|
baseSha: envelope.base_sha ?? undefined,
|
|
1561
|
+
producedFiles,
|
|
1458
1562
|
gates,
|
|
1459
1563
|
cost,
|
|
1460
1564
|
errored,
|
|
@@ -1463,51 +1567,6 @@ export class Orchestrator {
|
|
|
1463
1567
|
telemetry,
|
|
1464
1568
|
};
|
|
1465
1569
|
}
|
|
1466
|
-
toEvidence(run, contract, findings, finalReviewClean, reviewVerified = false) {
|
|
1467
|
-
const passed = gatesPassed(run.gates) && !run.errored;
|
|
1468
|
-
// Honest acceptance evidence: 0/0 when the contract has no success criteria
|
|
1469
|
-
// (no spec). The old code fabricated a 1/1 ("AC-implicit") cover, which made
|
|
1470
|
-
// arbitration report a vacuous "acceptance=100%" that just restated gates.
|
|
1471
|
-
const acTotal = contract.success_criteria.length;
|
|
1472
|
-
const acCovered = passed && contract.success_criteria.length > 0
|
|
1473
|
-
? contract.success_criteria.map((c) => c.id)
|
|
1474
|
-
: [];
|
|
1475
|
-
// Treat a harness error as a failed required gate so it cannot win arbitration.
|
|
1476
|
-
const gates = run.errored
|
|
1477
|
-
? [
|
|
1478
|
-
...run.gates,
|
|
1479
|
-
{
|
|
1480
|
-
id: "harness",
|
|
1481
|
-
command: "harness",
|
|
1482
|
-
exit_code: 1,
|
|
1483
|
-
status: "failed",
|
|
1484
|
-
duration_ms: 0,
|
|
1485
|
-
required: true,
|
|
1486
|
-
stdout_tail: null,
|
|
1487
|
-
stderr_tail: null,
|
|
1488
|
-
output_truncated: false,
|
|
1489
|
-
},
|
|
1490
|
-
]
|
|
1491
|
-
: run.gates;
|
|
1492
|
-
return {
|
|
1493
|
-
attemptId: run.attemptId,
|
|
1494
|
-
label: run.label,
|
|
1495
|
-
gates,
|
|
1496
|
-
acceptanceCovered: acCovered,
|
|
1497
|
-
acceptanceTotal: acTotal,
|
|
1498
|
-
findings,
|
|
1499
|
-
// Counted from the EVIDENCE gates (including the injected harness-failure
|
|
1500
|
-
// gate), so an errored candidate scores 0/1 — never a vacuous 0/0.
|
|
1501
|
-
testsPassed: gates.filter((g) => g.status === "passed").length,
|
|
1502
|
-
testsTotal: gates.length,
|
|
1503
|
-
finalReviewClean,
|
|
1504
|
-
reviewVerified,
|
|
1505
|
-
toolWarningsCount: run.telemetry.outcome?.toolWarningsCount ?? toolWarnings(run.telemetry).length,
|
|
1506
|
-
diffSize: run.diff.split("\n").length,
|
|
1507
|
-
diffBytes: Buffer.byteLength(run.diff, "utf8"),
|
|
1508
|
-
costUsd: run.cost,
|
|
1509
|
-
};
|
|
1510
|
-
}
|
|
1511
1570
|
interactionChannelFor(input, log, runId, taskId, attemptId, harnessId,
|
|
1512
1571
|
// REQUIRED (no default): every call site must state the routed manifest's
|
|
1513
1572
|
// `interactive` capability, or a future site would silently bypass the gate.
|
|
@@ -1552,10 +1611,12 @@ export class Orchestrator {
|
|
|
1552
1611
|
"Retry the run",
|
|
1553
1612
|
],
|
|
1554
1613
|
});
|
|
1555
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
1614
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: workspace\n\n${message}\n`);
|
|
1556
1615
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1557
1616
|
log.emit("run.failed", {
|
|
1558
|
-
|
|
1617
|
+
lifecycle: "failed",
|
|
1618
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1619
|
+
reason: "harness_failed",
|
|
1559
1620
|
phase: "workspace",
|
|
1560
1621
|
error: message,
|
|
1561
1622
|
failure_ref: "final/failure.yaml",
|
|
@@ -1563,6 +1624,37 @@ export class Orchestrator {
|
|
|
1563
1624
|
return message;
|
|
1564
1625
|
}
|
|
1565
1626
|
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Freeze-on-implement delivery (D17/D27): verify the frozen plan's hash and
|
|
1629
|
+
* materialize it as context/PLAN.md in the run artifact tree — OUTSIDE every
|
|
1630
|
+
* worktree, so it can never dirty a diff — then point the prompt at the
|
|
1631
|
+
* absolute path. A mismatched or unreadable plan fails LOUDLY before any
|
|
1632
|
+
* harness spawns (the tamper fence; retry replays planRef verbatim, so a
|
|
1633
|
+
* retried implement can never silently run without its plan).
|
|
1634
|
+
*/
|
|
1635
|
+
withPlanBrief(input, store, paths, log) {
|
|
1636
|
+
if (!input.planRef)
|
|
1637
|
+
return input;
|
|
1638
|
+
const text = readTextSafe(input.planRef.path);
|
|
1639
|
+
if (!text || !text.trim()) {
|
|
1640
|
+
throw new Error(`implement plan: the frozen plan at ${input.planRef.path} is missing or unreadable`);
|
|
1641
|
+
}
|
|
1642
|
+
const digest = sha256(text).replace(/^sha256:/, "");
|
|
1643
|
+
if (digest !== input.planRef.sha256) {
|
|
1644
|
+
throw new Error(`implement plan: plan hash mismatch (expected ${input.planRef.sha256}, got ${digest}) — the plan was modified after freeze; re-run Implement from the plan turn`);
|
|
1645
|
+
}
|
|
1646
|
+
const briefPath = join(paths.contextDir, "PLAN.md");
|
|
1647
|
+
store.writeText(briefPath, text);
|
|
1648
|
+
log.emit("plan.brief.materialized", {
|
|
1649
|
+
plan_run_id: input.planRef.runId,
|
|
1650
|
+
sha256: input.planRef.sha256,
|
|
1651
|
+
path: "context/PLAN.md",
|
|
1652
|
+
});
|
|
1653
|
+
return {
|
|
1654
|
+
...input,
|
|
1655
|
+
prompt: `${input.prompt}\n\nThe approved plan is at: ${briefPath} — read it before starting and re-read it as needed.`,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1566
1658
|
async runRace(input, mode, announce) {
|
|
1567
1659
|
const taskId = input.taskId ?? newId("task");
|
|
1568
1660
|
const runId = input.runId ?? newId("run");
|
|
@@ -1573,6 +1665,7 @@ export class Orchestrator {
|
|
|
1573
1665
|
const store = this.artifactStore(input);
|
|
1574
1666
|
const paths = store.createRun(runId);
|
|
1575
1667
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
1668
|
+
input = this.withPlanBrief(input, store, paths, log);
|
|
1576
1669
|
// The execution root is the tree the harness mutates: the project itself for
|
|
1577
1670
|
// in-place threads/ordinary runs, or the thread's persistent worktree for an
|
|
1578
1671
|
// isolated thread. Config/artifacts/contract stay anchored to repoRoot. Both
|
|
@@ -1606,7 +1699,8 @@ export class Orchestrator {
|
|
|
1606
1699
|
runId,
|
|
1607
1700
|
taskId,
|
|
1608
1701
|
mode,
|
|
1609
|
-
|
|
1702
|
+
lifecycle: "failed",
|
|
1703
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1610
1704
|
winner: null,
|
|
1611
1705
|
runDir: paths.root,
|
|
1612
1706
|
summary: gitPreconditionError,
|
|
@@ -1636,6 +1730,7 @@ export class Orchestrator {
|
|
|
1636
1730
|
});
|
|
1637
1731
|
let adapters;
|
|
1638
1732
|
try {
|
|
1733
|
+
// Best-of races the whole pool (no divergence `log`: the pool still runs).
|
|
1639
1734
|
adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger);
|
|
1640
1735
|
}
|
|
1641
1736
|
catch (err) {
|
|
@@ -1647,10 +1742,12 @@ export class Orchestrator {
|
|
|
1647
1742
|
safeMessage: message,
|
|
1648
1743
|
runDir: paths.root,
|
|
1649
1744
|
});
|
|
1650
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
1745
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
1651
1746
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1652
1747
|
log.emit("run.failed", {
|
|
1653
|
-
|
|
1748
|
+
lifecycle: "failed",
|
|
1749
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1750
|
+
reason: "harness_failed",
|
|
1654
1751
|
phase: "routing",
|
|
1655
1752
|
error: message,
|
|
1656
1753
|
failure_ref: "final/failure.yaml",
|
|
@@ -1659,7 +1756,8 @@ export class Orchestrator {
|
|
|
1659
1756
|
runId,
|
|
1660
1757
|
taskId,
|
|
1661
1758
|
mode,
|
|
1662
|
-
|
|
1759
|
+
lifecycle: "failed",
|
|
1760
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1663
1761
|
winner: null,
|
|
1664
1762
|
runDir: paths.root,
|
|
1665
1763
|
summary: message,
|
|
@@ -1789,7 +1887,7 @@ export class Orchestrator {
|
|
|
1789
1887
|
budgetStopped = true;
|
|
1790
1888
|
return true;
|
|
1791
1889
|
}, input, requestedSingleCandidate);
|
|
1792
|
-
ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
1890
|
+
ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
1793
1891
|
log.emit("harness.completed", {
|
|
1794
1892
|
harness_id: adapter.id,
|
|
1795
1893
|
attempt_id: slot.attemptId,
|
|
@@ -1881,29 +1979,34 @@ export class Orchestrator {
|
|
|
1881
1979
|
})), null), ledger.spend(), input.signal, store);
|
|
1882
1980
|
}
|
|
1883
1981
|
if (runs.length === 0) {
|
|
1884
|
-
const
|
|
1982
|
+
const budgetReason = ledger.terminal();
|
|
1983
|
+
const facts = makeOutcomeFacts("failed", {
|
|
1984
|
+
reason: budgetReason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
|
|
1985
|
+
noChanges: true,
|
|
1986
|
+
});
|
|
1885
1987
|
const why = budgetStopped
|
|
1886
1988
|
? "budget exhausted before any candidate run"
|
|
1887
1989
|
: "no candidates produced";
|
|
1888
1990
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1889
1991
|
winner: null,
|
|
1890
|
-
|
|
1891
|
-
outcome: "blocked",
|
|
1992
|
+
facts,
|
|
1892
1993
|
why_winner: why,
|
|
1893
1994
|
evidence_facts: ["no candidates were produced"],
|
|
1894
1995
|
apply_recommendation: "continue",
|
|
1895
1996
|
budget_summary: { spend_usd: ledger.spend(), estimated: false },
|
|
1896
1997
|
});
|
|
1897
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
1998
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: budget\n\n${why}\n`);
|
|
1898
1999
|
writeFailure(store, paths, {
|
|
1899
2000
|
phase: "budget",
|
|
1900
|
-
category: isBudgetTerminal(
|
|
2001
|
+
category: isBudgetTerminal(facts.reason) ? "budget" : "internal",
|
|
1901
2002
|
safeMessage: why,
|
|
1902
2003
|
runDir: paths.root,
|
|
1903
2004
|
});
|
|
1904
2005
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1905
2006
|
log.emit("run.failed", {
|
|
1906
|
-
|
|
2007
|
+
lifecycle: facts.lifecycle,
|
|
2008
|
+
facts,
|
|
2009
|
+
reason: facts.reason,
|
|
1907
2010
|
phase: "budget",
|
|
1908
2011
|
error: why,
|
|
1909
2012
|
failure_ref: "final/failure.yaml",
|
|
@@ -1912,7 +2015,8 @@ export class Orchestrator {
|
|
|
1912
2015
|
runId,
|
|
1913
2016
|
taskId,
|
|
1914
2017
|
mode,
|
|
1915
|
-
|
|
2018
|
+
lifecycle: facts.lifecycle,
|
|
2019
|
+
facts,
|
|
1916
2020
|
winner: null,
|
|
1917
2021
|
runDir: paths.root,
|
|
1918
2022
|
summary: why,
|
|
@@ -1933,11 +2037,10 @@ export class Orchestrator {
|
|
|
1933
2037
|
const rootCause = runs
|
|
1934
2038
|
.map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
|
|
1935
2039
|
.join("; ");
|
|
1936
|
-
const
|
|
2040
|
+
const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
|
|
1937
2041
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1938
2042
|
winner: null,
|
|
1939
|
-
|
|
1940
|
-
outcome: "blocked",
|
|
2043
|
+
facts,
|
|
1941
2044
|
why_winner: rootCause,
|
|
1942
2045
|
evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
|
|
1943
2046
|
apply_recommendation: "continue",
|
|
@@ -1948,7 +2051,7 @@ export class Orchestrator {
|
|
|
1948
2051
|
harnessId: r.harnessId,
|
|
1949
2052
|
telemetry: r.telemetry,
|
|
1950
2053
|
})), null);
|
|
1951
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
2054
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Phase: ${phase}\n\n${rootCause}\n`);
|
|
1952
2055
|
const existingEventRefs = runs
|
|
1953
2056
|
.map((r) => `attempts/${r.attemptId}/events.jsonl`)
|
|
1954
2057
|
.filter((rel) => existsSync(join(paths.root, rel)));
|
|
@@ -1967,7 +2070,9 @@ export class Orchestrator {
|
|
|
1967
2070
|
});
|
|
1968
2071
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1969
2072
|
log.emit("run.failed", {
|
|
1970
|
-
|
|
2073
|
+
lifecycle: facts.lifecycle,
|
|
2074
|
+
facts,
|
|
2075
|
+
reason: facts.reason,
|
|
1971
2076
|
phase,
|
|
1972
2077
|
error: rootCause,
|
|
1973
2078
|
failure_ref: "final/failure.yaml",
|
|
@@ -1976,7 +2081,8 @@ export class Orchestrator {
|
|
|
1976
2081
|
runId,
|
|
1977
2082
|
taskId,
|
|
1978
2083
|
mode,
|
|
1979
|
-
|
|
2084
|
+
lifecycle: facts.lifecycle,
|
|
2085
|
+
facts,
|
|
1980
2086
|
winner: null,
|
|
1981
2087
|
runDir: paths.root,
|
|
1982
2088
|
summary: rootCause,
|
|
@@ -2030,9 +2136,11 @@ export class Orchestrator {
|
|
|
2030
2136
|
let envelope;
|
|
2031
2137
|
try {
|
|
2032
2138
|
const plan = buildSynthesisPlan(evidences);
|
|
2033
|
-
const
|
|
2034
|
-
|
|
2035
|
-
.
|
|
2139
|
+
const synthesisInput = buildFileBackedSynthesisInput({
|
|
2140
|
+
instructions: plan.instructions,
|
|
2141
|
+
findings: plan.fixFindings,
|
|
2142
|
+
candidates: workingRuns,
|
|
2143
|
+
});
|
|
2036
2144
|
const synthAdapter = synthRouted.adapter;
|
|
2037
2145
|
// Disclose against the PER-ROUTE policy (per-harness web defaults
|
|
2038
2146
|
// included), exactly like the candidate slots do.
|
|
@@ -2045,13 +2153,12 @@ export class Orchestrator {
|
|
|
2045
2153
|
dirtyPolicy: "snapshot",
|
|
2046
2154
|
accessProfile: candidateAccess,
|
|
2047
2155
|
});
|
|
2048
|
-
const
|
|
2049
|
-
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthPrompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
2156
|
+
const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthesisInput.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
|
|
2050
2157
|
const safeEv = redactHarnessEvent(ev);
|
|
2051
2158
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
2052
2159
|
log.emit("harness.event", harnessEventPayload(synthAdapter.id, "synth", safeEv));
|
|
2053
|
-
}, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input);
|
|
2054
|
-
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
2160
|
+
}, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input, false, synthesisInput.content);
|
|
2161
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
2055
2162
|
reviewEnvelopes.push(envelope);
|
|
2056
2163
|
envelope = undefined;
|
|
2057
2164
|
try {
|
|
@@ -2105,7 +2212,7 @@ export class Orchestrator {
|
|
|
2105
2212
|
}
|
|
2106
2213
|
log.emit("arbitration.completed", {
|
|
2107
2214
|
winner: result.decision.winner,
|
|
2108
|
-
|
|
2215
|
+
lifecycle: result.decision.facts.lifecycle,
|
|
2109
2216
|
});
|
|
2110
2217
|
// Winner can only be a candidate that actually produced work; corpses are
|
|
2111
2218
|
// excluded from arbitration upstream and from the fallback here.
|
|
@@ -2118,9 +2225,18 @@ export class Orchestrator {
|
|
|
2118
2225
|
const actualReviewVerified = winnerRun
|
|
2119
2226
|
? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
|
|
2120
2227
|
: evidences.length > 0 && evidences.every((e) => e.reviewVerified);
|
|
2121
|
-
let
|
|
2122
|
-
|
|
2123
|
-
|
|
2228
|
+
let facts = result.decision.facts;
|
|
2229
|
+
// A reviewer NEEDS_HUMAN escalation forces the REVIEW axis to blocked (a
|
|
2230
|
+
// needs-decision terminal), unless the decision is already applyable-clean.
|
|
2231
|
+
if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
|
|
2232
|
+
facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
|
|
2233
|
+
}
|
|
2234
|
+
// A budget terminal turns a succeeded lifecycle into a failed one (D8): the
|
|
2235
|
+
// budget reason IS a RunReason.
|
|
2236
|
+
const budgetTerminal = ledger.terminal();
|
|
2237
|
+
if (facts.lifecycle === "succeeded" && budgetTerminal) {
|
|
2238
|
+
facts = makeOutcomeFacts("failed", { reason: budgetTerminal, noChanges: facts.noChanges });
|
|
2239
|
+
}
|
|
2124
2240
|
// FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
|
|
2125
2241
|
let finalVerify = null;
|
|
2126
2242
|
let finalVerifyFailed = false;
|
|
@@ -2134,26 +2250,37 @@ export class Orchestrator {
|
|
|
2134
2250
|
!inPlaceWinner &&
|
|
2135
2251
|
!deferredRaceVerify &&
|
|
2136
2252
|
winnerRun.diff.trim().length > 0 &&
|
|
2137
|
-
|
|
2253
|
+
facts.lifecycle === "succeeded" &&
|
|
2254
|
+
facts.review !== "blocked" &&
|
|
2138
2255
|
!input.signal?.aborted) {
|
|
2139
2256
|
finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
|
|
2140
2257
|
// Verify errors block like proven failures; accept_risk stays available.
|
|
2258
|
+
// A failed fresh verify lands on the CHECKS axis (a needs-decision block).
|
|
2141
2259
|
finalVerifyFailed = finalVerifyBlocks(finalVerify);
|
|
2142
2260
|
if (finalVerifyFailed)
|
|
2143
|
-
|
|
2261
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
2144
2262
|
}
|
|
2263
|
+
// A needs-decision terminal (review blocked or checks failed) overrides the
|
|
2264
|
+
// persisted green arbitration fields; otherwise the facts pass through.
|
|
2265
|
+
const needsDec = facts.review === "blocked" || facts.checks === "failed";
|
|
2145
2266
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
2146
2267
|
...result.decision,
|
|
2147
|
-
|
|
2148
|
-
...(status === "blocked"
|
|
2268
|
+
...(needsDec
|
|
2149
2269
|
? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
|
|
2150
|
-
: {}),
|
|
2270
|
+
: { facts }),
|
|
2151
2271
|
review_verified: actualReviewVerified,
|
|
2152
2272
|
final_verify: finalVerify,
|
|
2153
2273
|
});
|
|
2154
2274
|
store.writeYaml(join(paths.arbitrationDir, "pairwise.yaml"), result.pairwise);
|
|
2155
2275
|
const decisionPath = join(paths.arbitrationDir, "decision.yaml");
|
|
2156
2276
|
if (winnerRun) {
|
|
2277
|
+
for (const path of materializeWinnerOutputs({
|
|
2278
|
+
attemptDir: join(paths.attemptsDir, winnerRun.attemptId),
|
|
2279
|
+
runRoot: paths.root,
|
|
2280
|
+
paths: winnerRun.producedFiles ?? [],
|
|
2281
|
+
})) {
|
|
2282
|
+
log.emit("output.ready", { kind: "artifact", path });
|
|
2283
|
+
}
|
|
2157
2284
|
assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
|
|
2158
2285
|
const patchSha256 = sha256(winnerRun.diff);
|
|
2159
2286
|
store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
|
|
@@ -2186,8 +2313,9 @@ export class Orchestrator {
|
|
|
2186
2313
|
answerText: winnerAnswer,
|
|
2187
2314
|
});
|
|
2188
2315
|
}
|
|
2189
|
-
// Only a fully verified success may auto-adopt;
|
|
2190
|
-
|
|
2316
|
+
// Only a fully verified, applyable success may auto-adopt; a not-verified
|
|
2317
|
+
// or needs-decision terminal remains an inspectable artifact.
|
|
2318
|
+
const adoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
|
|
2191
2319
|
let adopted = null;
|
|
2192
2320
|
let applyState = "not_applied";
|
|
2193
2321
|
let postTurnSha = null;
|
|
@@ -2231,7 +2359,7 @@ export class Orchestrator {
|
|
|
2231
2359
|
adopted = false;
|
|
2232
2360
|
applyState = "not_applied";
|
|
2233
2361
|
deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
|
|
2234
|
-
|
|
2362
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
2235
2363
|
if (finalVerifyBlocks(finalVerify))
|
|
2236
2364
|
finalVerifyFailed = true;
|
|
2237
2365
|
log.emit("work_product.adopted", {
|
|
@@ -2245,7 +2373,7 @@ export class Orchestrator {
|
|
|
2245
2373
|
}
|
|
2246
2374
|
writeRaceDeliveryDecision(store, decisionPath, {
|
|
2247
2375
|
decision: result.decision,
|
|
2248
|
-
|
|
2376
|
+
facts,
|
|
2249
2377
|
reviewVerified: actualReviewVerified,
|
|
2250
2378
|
finalVerify,
|
|
2251
2379
|
deliveryFailureReason,
|
|
@@ -2266,8 +2394,9 @@ export class Orchestrator {
|
|
|
2266
2394
|
harness_id: winnerRun.harnessId,
|
|
2267
2395
|
synthesis: synth,
|
|
2268
2396
|
mode,
|
|
2269
|
-
// Artifact-only apply reads the same terminal
|
|
2270
|
-
|
|
2397
|
+
// Artifact-only apply reads the same terminal axes as the daemon (D8).
|
|
2398
|
+
lifecycle: facts.lifecycle,
|
|
2399
|
+
outcome_facts: facts,
|
|
2271
2400
|
review_verified: actualReviewVerified,
|
|
2272
2401
|
budget_stopped: budgetStopped,
|
|
2273
2402
|
patch_sha256: patchSha256,
|
|
@@ -2285,18 +2414,31 @@ export class Orchestrator {
|
|
|
2285
2414
|
revert_anchor_id: revertAnchorId,
|
|
2286
2415
|
},
|
|
2287
2416
|
});
|
|
2288
|
-
store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision,
|
|
2289
|
-
//
|
|
2417
|
+
store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, facts }, evidences, synth.reason, actualReviewVerified));
|
|
2418
|
+
// summary.md is a DIAGNOSTIC artifact only (V8/PLAN addendum 2): it no
|
|
2419
|
+
// longer carries primary-output authority. A clean applyable success or a
|
|
2420
|
+
// winner answer still marks it ready for legacy INV-116 ordering; any
|
|
2421
|
+
// other terminal is diagnostic context.
|
|
2290
2422
|
log.emit("output.ready", {
|
|
2291
2423
|
kind: "summary",
|
|
2292
2424
|
path: "final/summary.md",
|
|
2293
|
-
|
|
2425
|
+
state: (facts.lifecycle === "succeeded" &&
|
|
2426
|
+
facts.review === "approved" &&
|
|
2427
|
+
facts.checks !== "failed") ||
|
|
2428
|
+
winnerAnswer.length > 0
|
|
2429
|
+
? "ready"
|
|
2430
|
+
: "diagnostic",
|
|
2294
2431
|
});
|
|
2295
2432
|
}
|
|
2296
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({ attemptId: r.attemptId, harnessId: r.harnessId, telemetry: r.telemetry })), result.decision.
|
|
2433
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({ attemptId: r.attemptId, harnessId: r.harnessId, telemetry: r.telemetry })), result.decision.facts.lifecycle === "succeeded"
|
|
2297
2434
|
? result.decision.winner
|
|
2298
2435
|
: (winnerRun?.attemptId ?? null));
|
|
2299
|
-
|
|
2436
|
+
// A needs-decision terminal (review blocked or checks failed) OR a
|
|
2437
|
+
// non-succeeded lifecycle writes a failure record and fires
|
|
2438
|
+
// run.blocked/run.failed; a succeeded, non-needs-decision terminal
|
|
2439
|
+
// (applyable, no_changes, or not-verified) is an honest completion.
|
|
2440
|
+
const needsDecisionTerminal = facts.review === "blocked" || facts.checks === "failed";
|
|
2441
|
+
const isFailureTerminal = facts.lifecycle !== "succeeded" || needsDecisionTerminal;
|
|
2300
2442
|
if (deliveryFailureReason && !finalVerifyFailed) {
|
|
2301
2443
|
writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
|
|
2302
2444
|
}
|
|
@@ -2321,14 +2463,14 @@ export class Orchestrator {
|
|
|
2321
2463
|
],
|
|
2322
2464
|
});
|
|
2323
2465
|
}
|
|
2324
|
-
else if (
|
|
2466
|
+
else if (isFailureTerminal) {
|
|
2325
2467
|
writeFailure(store, paths, {
|
|
2326
2468
|
phase: needsHuman ? "review" : "arbitration",
|
|
2327
2469
|
category: needsHuman
|
|
2328
2470
|
? "policy"
|
|
2329
2471
|
: winnerRun?.errored
|
|
2330
2472
|
? "harness_error"
|
|
2331
|
-
: isBudgetTerminal(
|
|
2473
|
+
: isBudgetTerminal(facts.reason)
|
|
2332
2474
|
? "budget"
|
|
2333
2475
|
: "internal",
|
|
2334
2476
|
harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
|
|
@@ -2353,7 +2495,7 @@ export class Orchestrator {
|
|
|
2353
2495
|
],
|
|
2354
2496
|
});
|
|
2355
2497
|
if (!winnerRun) {
|
|
2356
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
2498
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: arbitration\n\n${result.decision.why_winner}\n`);
|
|
2357
2499
|
log.emit("output.ready", {
|
|
2358
2500
|
kind: "summary",
|
|
2359
2501
|
path: "final/summary.md",
|
|
@@ -2362,14 +2504,15 @@ export class Orchestrator {
|
|
|
2362
2504
|
}
|
|
2363
2505
|
}
|
|
2364
2506
|
log.emit("work_product.emitted", { winner: result.decision.winner });
|
|
2365
|
-
if (
|
|
2366
|
-
log.emit("run.completed", {
|
|
2507
|
+
if (!isFailureTerminal) {
|
|
2508
|
+
log.emit("run.completed", { lifecycle: facts.lifecycle, facts, reason: facts.reason });
|
|
2367
2509
|
}
|
|
2368
|
-
else if (
|
|
2369
|
-
//
|
|
2370
|
-
// phase "verification", not "review").
|
|
2510
|
+
else if (facts.lifecycle === "succeeded") {
|
|
2511
|
+
// needsDecision at terminal — the event's phase must agree with
|
|
2512
|
+
// failure.yaml (a verify block is phase "verification", not "review").
|
|
2371
2513
|
log.emit("run.blocked", {
|
|
2372
|
-
|
|
2514
|
+
lifecycle: facts.lifecycle,
|
|
2515
|
+
facts,
|
|
2373
2516
|
phase: deliveryFailureReason && !finalVerifyFailed
|
|
2374
2517
|
? "delivery"
|
|
2375
2518
|
: finalVerifyFailed
|
|
@@ -2379,13 +2522,20 @@ export class Orchestrator {
|
|
|
2379
2522
|
});
|
|
2380
2523
|
}
|
|
2381
2524
|
else {
|
|
2382
|
-
log.emit("run.failed", {
|
|
2525
|
+
log.emit("run.failed", {
|
|
2526
|
+
lifecycle: facts.lifecycle,
|
|
2527
|
+
facts,
|
|
2528
|
+
reason: facts.reason,
|
|
2529
|
+
phase: "arbitration",
|
|
2530
|
+
failure_ref: "final/failure.yaml",
|
|
2531
|
+
});
|
|
2383
2532
|
}
|
|
2384
2533
|
return {
|
|
2385
2534
|
runId,
|
|
2386
2535
|
taskId,
|
|
2387
2536
|
mode,
|
|
2388
|
-
|
|
2537
|
+
lifecycle: facts.lifecycle,
|
|
2538
|
+
facts,
|
|
2389
2539
|
winner: result.decision.winner,
|
|
2390
2540
|
runDir: paths.root,
|
|
2391
2541
|
summary: result.decision.why_winner,
|
|
@@ -2477,15 +2627,21 @@ export class Orchestrator {
|
|
|
2477
2627
|
distinctProviders: [],
|
|
2478
2628
|
reviewSpendUsd: 0,
|
|
2479
2629
|
reviewSpendEstimated: false,
|
|
2630
|
+
reviewCashUsd: 0,
|
|
2631
|
+
reviewValuationUsd: 0,
|
|
2632
|
+
reviewUnknownUsd: 0,
|
|
2480
2633
|
};
|
|
2481
2634
|
if (reviewLease?.granted) {
|
|
2482
|
-
ledger?.settle(reviewLease.lease?.lease_id ?? "",
|
|
2635
|
+
ledger?.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`attempt:${run.attemptId}`, "review:panel"], result.reviewUnknownUsd));
|
|
2483
2636
|
if ((result.reviewSpendUsd ?? 0) > 0) {
|
|
2484
2637
|
log.emit("budget.observation", {
|
|
2485
2638
|
harness_id: "review-panel",
|
|
2486
2639
|
attempt_id: run.attemptId,
|
|
2487
2640
|
kind: "spend",
|
|
2488
2641
|
usd: result.reviewSpendUsd,
|
|
2642
|
+
cash_usd: result.reviewCashUsd,
|
|
2643
|
+
valuation_usd: result.reviewValuationUsd,
|
|
2644
|
+
unknown_usd: result.reviewUnknownUsd,
|
|
2489
2645
|
estimated: result.reviewSpendEstimated === true,
|
|
2490
2646
|
});
|
|
2491
2647
|
}
|
|
@@ -2517,6 +2673,7 @@ export class Orchestrator {
|
|
|
2517
2673
|
store.writeYaml(join(paths.reviewsDir, `${run.attemptId}.yaml`), {
|
|
2518
2674
|
attempt_id: run.attemptId,
|
|
2519
2675
|
review_verified: candidateReviewVerified,
|
|
2676
|
+
final_review_clean: reviewClean,
|
|
2520
2677
|
cross_family_healthy: result.crossFamilyHealthy,
|
|
2521
2678
|
cross_family_verified: result.crossFamilyVerified,
|
|
2522
2679
|
healthy_providers: result.healthyProviders,
|
|
@@ -2532,7 +2689,7 @@ export class Orchestrator {
|
|
|
2532
2689
|
severity: f.severity,
|
|
2533
2690
|
status: f.status,
|
|
2534
2691
|
});
|
|
2535
|
-
evidences.push(
|
|
2692
|
+
evidences.push(toCandidateEvidence(run, contract, allFindings, reviewClean, candidateReviewVerified));
|
|
2536
2693
|
}
|
|
2537
2694
|
finally {
|
|
2538
2695
|
this.recordReviewEvidenceCleanup(store, join(paths.reviewsDir, `${run.attemptId}-evidence-cleanup.yaml`), run.attemptId, candidateEvidenceDir, candidateCwd);
|
|
@@ -2586,6 +2743,7 @@ export class Orchestrator {
|
|
|
2586
2743
|
const store = this.artifactStore(input);
|
|
2587
2744
|
const paths = store.createRun(runId);
|
|
2588
2745
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
2746
|
+
input = this.withPlanBrief(input, store, paths, log);
|
|
2589
2747
|
// The execution root is the tree the harness mutates (thread worktree for an
|
|
2590
2748
|
// isolated thread, else the project). The WorkspaceManager AND the git
|
|
2591
2749
|
// boundary must resolve against the SAME root — the race path does so via the
|
|
@@ -2618,7 +2776,8 @@ export class Orchestrator {
|
|
|
2618
2776
|
runId,
|
|
2619
2777
|
taskId,
|
|
2620
2778
|
mode,
|
|
2621
|
-
|
|
2779
|
+
lifecycle: "failed",
|
|
2780
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2622
2781
|
winner: null,
|
|
2623
2782
|
runDir: paths.root,
|
|
2624
2783
|
summary: gitPreconditionError,
|
|
@@ -2640,7 +2799,7 @@ export class Orchestrator {
|
|
|
2640
2799
|
// One envelope carried forward across attempts so the harness can repair its own work.
|
|
2641
2800
|
let adapterPool;
|
|
2642
2801
|
try {
|
|
2643
|
-
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
|
|
2802
|
+
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
|
|
2644
2803
|
this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
|
|
2645
2804
|
}
|
|
2646
2805
|
catch (err) {
|
|
@@ -2652,10 +2811,12 @@ export class Orchestrator {
|
|
|
2652
2811
|
safeMessage: message,
|
|
2653
2812
|
runDir: paths.root,
|
|
2654
2813
|
});
|
|
2655
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
2814
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
2656
2815
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2657
2816
|
log.emit("run.failed", {
|
|
2658
|
-
|
|
2817
|
+
lifecycle: "failed",
|
|
2818
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2819
|
+
reason: "harness_failed",
|
|
2659
2820
|
phase: "routing",
|
|
2660
2821
|
error: message,
|
|
2661
2822
|
failure_ref: "final/failure.yaml",
|
|
@@ -2665,7 +2826,8 @@ export class Orchestrator {
|
|
|
2665
2826
|
runId,
|
|
2666
2827
|
taskId,
|
|
2667
2828
|
mode,
|
|
2668
|
-
|
|
2829
|
+
lifecycle: "failed",
|
|
2830
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2669
2831
|
winner: null,
|
|
2670
2832
|
runDir: paths.root,
|
|
2671
2833
|
summary: message,
|
|
@@ -2689,10 +2851,12 @@ export class Orchestrator {
|
|
|
2689
2851
|
"Check harness doctor for reviewer readiness",
|
|
2690
2852
|
],
|
|
2691
2853
|
});
|
|
2692
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
2854
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: review preflight\n\n${message}\n`);
|
|
2693
2855
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2694
2856
|
log.emit("run.failed", {
|
|
2695
|
-
|
|
2857
|
+
lifecycle: "failed",
|
|
2858
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2859
|
+
reason: "harness_failed",
|
|
2696
2860
|
phase: "review",
|
|
2697
2861
|
error: message,
|
|
2698
2862
|
failure_ref: "final/failure.yaml",
|
|
@@ -2702,7 +2866,8 @@ export class Orchestrator {
|
|
|
2702
2866
|
runId,
|
|
2703
2867
|
taskId,
|
|
2704
2868
|
mode,
|
|
2705
|
-
|
|
2869
|
+
lifecycle: "failed",
|
|
2870
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2706
2871
|
winner: null,
|
|
2707
2872
|
runDir: paths.root,
|
|
2708
2873
|
summary: message,
|
|
@@ -2818,7 +2983,7 @@ export class Orchestrator {
|
|
|
2818
2983
|
ledger.updateHold(lease.lease?.lease_id ?? "", streamedUsd);
|
|
2819
2984
|
return ledger.tier() === "hard";
|
|
2820
2985
|
}, input, true);
|
|
2821
|
-
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
|
|
2986
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
|
|
2822
2987
|
log.emit("harness.completed", {
|
|
2823
2988
|
harness_id: adapter.id,
|
|
2824
2989
|
attempt_id: attemptId,
|
|
@@ -2906,15 +3071,21 @@ export class Orchestrator {
|
|
|
2906
3071
|
distinctProviders: [],
|
|
2907
3072
|
reviewSpendUsd: 0,
|
|
2908
3073
|
reviewSpendEstimated: false,
|
|
3074
|
+
reviewCashUsd: 0,
|
|
3075
|
+
reviewValuationUsd: 0,
|
|
3076
|
+
reviewUnknownUsd: 0,
|
|
2909
3077
|
};
|
|
2910
3078
|
if (reviewLease?.granted) {
|
|
2911
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "",
|
|
3079
|
+
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(reviewResult.reviewCashUsd, reviewResult.reviewValuationUsd, reviewResult.reviewSpendEstimated, [`attempt:${attemptId}`, "review:panel"], reviewResult.reviewUnknownUsd));
|
|
2912
3080
|
if ((reviewResult.reviewSpendUsd ?? 0) > 0) {
|
|
2913
3081
|
log.emit("budget.observation", {
|
|
2914
3082
|
harness_id: "review-panel",
|
|
2915
3083
|
attempt_id: attemptId,
|
|
2916
3084
|
kind: "spend",
|
|
2917
3085
|
usd: reviewResult.reviewSpendUsd,
|
|
3086
|
+
cash_usd: reviewResult.reviewCashUsd,
|
|
3087
|
+
valuation_usd: reviewResult.reviewValuationUsd,
|
|
3088
|
+
unknown_usd: reviewResult.reviewUnknownUsd,
|
|
2918
3089
|
estimated: reviewResult.reviewSpendEstimated === true,
|
|
2919
3090
|
});
|
|
2920
3091
|
if (reviewResult.reviewSpendEstimated === true)
|
|
@@ -2941,9 +3112,15 @@ export class Orchestrator {
|
|
|
2941
3112
|
const policy = policyFindings(run, actualReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
|
|
2942
3113
|
const allFindings = [...policy.findings, ...revalidated];
|
|
2943
3114
|
lastFindings = allFindings;
|
|
3115
|
+
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
3116
|
+
const finalReviewClean = reviewResult.crossFamilyHealthy &&
|
|
3117
|
+
reviewResult.crossFamilyVerified &&
|
|
3118
|
+
!inconclusive &&
|
|
3119
|
+
!allFindings.some((f) => isBlocking(f));
|
|
2944
3120
|
store.writeYaml(join(paths.reviewsDir, `${attemptId}.yaml`), {
|
|
2945
3121
|
attempt_id: attemptId,
|
|
2946
3122
|
review_verified: actualReviewVerified,
|
|
3123
|
+
final_review_clean: finalReviewClean,
|
|
2947
3124
|
cross_family_healthy: reviewResult.crossFamilyHealthy,
|
|
2948
3125
|
cross_family_verified: reviewResult.crossFamilyVerified,
|
|
2949
3126
|
healthy_providers: reviewResult.healthyProviders,
|
|
@@ -2953,11 +3130,6 @@ export class Orchestrator {
|
|
|
2953
3130
|
findings: allFindings,
|
|
2954
3131
|
route_proofs: reviewResult.routeProofs,
|
|
2955
3132
|
});
|
|
2956
|
-
const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
|
|
2957
|
-
const finalReviewClean = reviewResult.crossFamilyHealthy &&
|
|
2958
|
-
reviewResult.crossFamilyVerified &&
|
|
2959
|
-
!inconclusive &&
|
|
2960
|
-
!allFindings.some((f) => isBlocking(f));
|
|
2961
3133
|
lastFinalReviewClean = finalReviewClean;
|
|
2962
3134
|
// Measure diff stability instead of asserting it: the tree must not have
|
|
2963
3135
|
// changed between the candidate diff capture and the end of review.
|
|
@@ -3067,38 +3239,51 @@ export class Orchestrator {
|
|
|
3067
3239
|
if (envelope)
|
|
3068
3240
|
await wsm.dispose(envelope);
|
|
3069
3241
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
3242
|
+
// Base terminal AXES (D8) from the convergence loop outcome. Attempts-cap
|
|
3243
|
+
// exhaustion maps to budget_exhausted (an attempt budget); the give-up
|
|
3244
|
+
// states map to their matching RunReason.
|
|
3245
|
+
let facts = input.signal?.aborted
|
|
3246
|
+
? makeOutcomeFacts("cancelled", { reason: "user_cancelled" })
|
|
3072
3247
|
: converged
|
|
3073
|
-
? "
|
|
3248
|
+
? makeOutcomeFacts("succeeded")
|
|
3074
3249
|
: stuckNoProgress
|
|
3075
|
-
? "stuck_no_progress"
|
|
3250
|
+
? makeOutcomeFacts("failed", { reason: "stuck_no_progress" })
|
|
3076
3251
|
: exhausted
|
|
3077
|
-
? "
|
|
3078
|
-
: "not_converged";
|
|
3079
|
-
if (status === "success" && ledger.terminal() !== null)
|
|
3080
|
-
status = ledger.terminal();
|
|
3252
|
+
? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
|
|
3253
|
+
: makeOutcomeFacts("failed", { reason: "not_converged" });
|
|
3081
3254
|
let decision = null;
|
|
3082
3255
|
if (lastRun) {
|
|
3083
3256
|
const arb = arbitrate([
|
|
3084
|
-
|
|
3257
|
+
toCandidateEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
|
|
3085
3258
|
], {
|
|
3086
3259
|
spendUsd: ledger.spend(),
|
|
3087
3260
|
estimatedSpend: lastRun.costEstimated || reviewSpendEstimated,
|
|
3088
3261
|
});
|
|
3089
3262
|
decision = arb.decision;
|
|
3090
3263
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
3091
|
-
|
|
3092
|
-
|
|
3264
|
+
// A converged run adopts the arbitration axes (checks/review); an
|
|
3265
|
+
// otherwise not-converged loop that nonetheless produced an applyable
|
|
3266
|
+
// decision adopts it too.
|
|
3267
|
+
if (converged) {
|
|
3268
|
+
facts = decision.facts;
|
|
3093
3269
|
}
|
|
3094
|
-
else if (
|
|
3095
|
-
|
|
3270
|
+
else if (facts.reason === "not_converged" && decision.facts.lifecycle === "succeeded") {
|
|
3271
|
+
facts = decision.facts;
|
|
3096
3272
|
}
|
|
3097
3273
|
}
|
|
3098
|
-
// A
|
|
3274
|
+
// A budget terminal turns a succeeded lifecycle into a failed one (D8).
|
|
3275
|
+
const convBudgetTerminal = ledger.terminal();
|
|
3276
|
+
if (facts.lifecycle === "succeeded" && convBudgetTerminal) {
|
|
3277
|
+
facts = makeOutcomeFacts("failed", {
|
|
3278
|
+
reason: convBudgetTerminal,
|
|
3279
|
+
noChanges: facts.noChanges,
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
// A reviewer escalation to a human forces the REVIEW axis to blocked.
|
|
3099
3283
|
const needsHuman = lastFindings.some((f) => f.severity === "NEEDS_HUMAN" && isBlocking(f));
|
|
3100
|
-
if (needsHuman &&
|
|
3101
|
-
|
|
3284
|
+
if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
|
|
3285
|
+
facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
|
|
3286
|
+
}
|
|
3102
3287
|
// FinalVerifier (INV-115) applies to EVERY applyable envelope-mode patch,
|
|
3103
3288
|
// not only race winners: a convergence run's delivered patch must also
|
|
3104
3289
|
// survive a fresh tree at its own base + the deterministic gates there.
|
|
@@ -3109,20 +3294,22 @@ export class Orchestrator {
|
|
|
3109
3294
|
if (input.inPlace !== true &&
|
|
3110
3295
|
lastRun &&
|
|
3111
3296
|
lastRun.diff.trim().length > 0 &&
|
|
3112
|
-
|
|
3297
|
+
facts.lifecycle === "succeeded" &&
|
|
3298
|
+
facts.review !== "blocked" &&
|
|
3113
3299
|
!input.signal?.aborted) {
|
|
3114
3300
|
convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
|
|
3115
3301
|
if (finalVerifyBlocks(convFinalVerify))
|
|
3116
|
-
|
|
3302
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
3117
3303
|
}
|
|
3304
|
+
const convNeedsDecision = facts.review === "blocked" || facts.checks === "failed";
|
|
3118
3305
|
if (decision) {
|
|
3119
|
-
// Shared honesty owner (same as the race path): a
|
|
3306
|
+
// Shared honesty owner (same as the race path): a needs-decision terminal
|
|
3120
3307
|
// overrides the persisted decision; final_verify is recorded either way.
|
|
3121
3308
|
decision = {
|
|
3122
3309
|
...decision,
|
|
3123
|
-
...(
|
|
3310
|
+
...(convNeedsDecision
|
|
3124
3311
|
? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
|
|
3125
|
-
: {}),
|
|
3312
|
+
: { facts }),
|
|
3126
3313
|
final_verify: convFinalVerify,
|
|
3127
3314
|
};
|
|
3128
3315
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
@@ -3137,7 +3324,7 @@ export class Orchestrator {
|
|
|
3137
3324
|
// convergence run with inPlace mutated the live tree directly across its
|
|
3138
3325
|
// attempts, so it is "applied" even when review blocked (Revert offered).
|
|
3139
3326
|
const convHasDiff = lastRun.diff.trim().length > 0;
|
|
3140
|
-
const convAdoptable =
|
|
3327
|
+
const convAdoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
|
|
3141
3328
|
const convAdopted = input.inPlace === true && convHasDiff ? true : null;
|
|
3142
3329
|
const convApplyState = convAdopted === true
|
|
3143
3330
|
? convAdoptable
|
|
@@ -3157,7 +3344,8 @@ export class Orchestrator {
|
|
|
3157
3344
|
result_kind: "patch",
|
|
3158
3345
|
mode,
|
|
3159
3346
|
attempts: attempt,
|
|
3160
|
-
|
|
3347
|
+
lifecycle: facts.lifecycle,
|
|
3348
|
+
outcome_facts: facts,
|
|
3161
3349
|
review_verified: actualReviewVerified,
|
|
3162
3350
|
patch_sha256: patchSha256,
|
|
3163
3351
|
adopted: convAdopted,
|
|
@@ -3167,42 +3355,45 @@ export class Orchestrator {
|
|
|
3167
3355
|
revert_anchor_id: revertAnchorId,
|
|
3168
3356
|
},
|
|
3169
3357
|
});
|
|
3170
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
3358
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Attempts: ${attempt}\n- Winner: ${lastRun.attemptId}\n- Review verified (cross-family): ${actualReviewVerified}\n- Apply recommendation: ${decision?.apply_recommendation ?? "inspect"}${stuckNoProgressReason ? `\n- No-progress reason: ${stuckNoProgressReason}` : ""}\n`);
|
|
3171
3359
|
// Lifecycle invariant (all modes): output.ready precedes the terminal
|
|
3172
3360
|
// event so a client that applied the terminal event has the output.
|
|
3173
3361
|
log.emit("output.ready", {
|
|
3174
3362
|
kind: "summary",
|
|
3175
3363
|
path: "final/summary.md",
|
|
3176
|
-
...(
|
|
3364
|
+
...(convAdoptable ? {} : { state: "diagnostic" }),
|
|
3177
3365
|
});
|
|
3178
3366
|
}
|
|
3179
|
-
|
|
3367
|
+
// A needs-decision terminal (review blocked / checks failed) writes a
|
|
3368
|
+
// failure record + fires run.blocked even though the lifecycle succeeded.
|
|
3369
|
+
const convIsFailureTerminal = facts.lifecycle !== "succeeded" || convNeedsDecision;
|
|
3370
|
+
if (convIsFailureTerminal) {
|
|
3180
3371
|
writeFailure(store, paths, {
|
|
3181
|
-
phase: "convergence",
|
|
3182
|
-
category: isBudgetTerminal(
|
|
3372
|
+
phase: convNeedsDecision ? "review" : "convergence",
|
|
3373
|
+
category: isBudgetTerminal(facts.reason)
|
|
3183
3374
|
? "budget"
|
|
3184
|
-
:
|
|
3375
|
+
: facts.lifecycle === "cancelled"
|
|
3185
3376
|
? "cancelled"
|
|
3186
|
-
:
|
|
3377
|
+
: convNeedsDecision
|
|
3187
3378
|
? "policy"
|
|
3188
3379
|
: "internal",
|
|
3189
|
-
safeMessage:
|
|
3380
|
+
safeMessage: convNeedsDecision
|
|
3190
3381
|
? `review escalated to a human decision after ${attempt} attempt(s)`
|
|
3191
|
-
:
|
|
3382
|
+
: facts.reason === "stuck_no_progress"
|
|
3192
3383
|
? (stuckNoProgressReason ?? `stuck_no_progress after ${attempt} attempt(s)`)
|
|
3193
|
-
: `${
|
|
3384
|
+
: `${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""} after ${attempt} attempt(s)${lastDiffStable ? "" : " (diff changed after review; review is stale)"}`,
|
|
3194
3385
|
harnessId: lastRun?.harnessId,
|
|
3195
3386
|
attemptId: lastRun?.attemptId,
|
|
3196
3387
|
runDir: paths.root,
|
|
3197
|
-
nextActions:
|
|
3388
|
+
nextActions: facts.lifecycle === "cancelled"
|
|
3198
3389
|
? ["Retry if cancellation was accidental"]
|
|
3199
|
-
:
|
|
3390
|
+
: convNeedsDecision
|
|
3200
3391
|
? [
|
|
3201
3392
|
"Open the review queue",
|
|
3202
3393
|
"Decide the NEEDS_HUMAN findings",
|
|
3203
3394
|
"Re-run after the decision",
|
|
3204
3395
|
]
|
|
3205
|
-
:
|
|
3396
|
+
: facts.reason === "stuck_no_progress"
|
|
3206
3397
|
? [
|
|
3207
3398
|
"Inspect the stable patch",
|
|
3208
3399
|
"Inspect the failing gate output",
|
|
@@ -3215,7 +3406,7 @@ export class Orchestrator {
|
|
|
3215
3406
|
],
|
|
3216
3407
|
});
|
|
3217
3408
|
if (!lastRun) {
|
|
3218
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n-
|
|
3409
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Attempts: ${attempt}\n`);
|
|
3219
3410
|
log.emit("output.ready", {
|
|
3220
3411
|
kind: "summary",
|
|
3221
3412
|
path: "final/summary.md",
|
|
@@ -3224,13 +3415,18 @@ export class Orchestrator {
|
|
|
3224
3415
|
}
|
|
3225
3416
|
}
|
|
3226
3417
|
log.emit("work_product.emitted", { winner: lastRun?.attemptId ?? null });
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3418
|
+
if (!convIsFailureTerminal) {
|
|
3419
|
+
log.emit("run.completed", {
|
|
3420
|
+
lifecycle: facts.lifecycle,
|
|
3421
|
+
facts,
|
|
3422
|
+
reason: facts.reason,
|
|
3423
|
+
attempts: attempt,
|
|
3424
|
+
});
|
|
3230
3425
|
}
|
|
3231
|
-
else if (
|
|
3426
|
+
else if (facts.lifecycle === "succeeded") {
|
|
3232
3427
|
log.emit("run.blocked", {
|
|
3233
|
-
|
|
3428
|
+
lifecycle: facts.lifecycle,
|
|
3429
|
+
facts,
|
|
3234
3430
|
attempts: attempt,
|
|
3235
3431
|
phase: "review",
|
|
3236
3432
|
failure_ref: "final/failure.yaml",
|
|
@@ -3238,7 +3434,9 @@ export class Orchestrator {
|
|
|
3238
3434
|
}
|
|
3239
3435
|
else {
|
|
3240
3436
|
log.emit("run.failed", {
|
|
3241
|
-
|
|
3437
|
+
lifecycle: facts.lifecycle,
|
|
3438
|
+
facts,
|
|
3439
|
+
reason: facts.reason,
|
|
3242
3440
|
attempts: attempt,
|
|
3243
3441
|
phase: "convergence",
|
|
3244
3442
|
failure_ref: "final/failure.yaml",
|
|
@@ -3249,19 +3447,26 @@ export class Orchestrator {
|
|
|
3249
3447
|
runId,
|
|
3250
3448
|
taskId,
|
|
3251
3449
|
mode,
|
|
3252
|
-
|
|
3450
|
+
lifecycle: facts.lifecycle,
|
|
3451
|
+
facts,
|
|
3253
3452
|
winner: lastRun?.attemptId ?? null,
|
|
3254
3453
|
runDir: paths.root,
|
|
3255
3454
|
summary: converged
|
|
3256
3455
|
? `converged in ${attempt} attempt(s)`
|
|
3257
|
-
: `${
|
|
3456
|
+
: `${facts.lifecycle} after ${attempt} attempt(s)`,
|
|
3258
3457
|
candidates: lastRun
|
|
3259
|
-
? [
|
|
3458
|
+
? [
|
|
3459
|
+
{
|
|
3460
|
+
attemptId: lastRun.attemptId,
|
|
3461
|
+
harnessId: lastRun.harnessId,
|
|
3462
|
+
status: facts.lifecycle,
|
|
3463
|
+
},
|
|
3464
|
+
]
|
|
3260
3465
|
: [],
|
|
3261
3466
|
reviewVerified: actualReviewVerified,
|
|
3262
3467
|
};
|
|
3263
3468
|
}
|
|
3264
|
-
/** plan mode: multi-harness planning -> aggregate -> (optional) plan review ->
|
|
3469
|
+
/** plan mode: multi-harness planning -> aggregate -> (optional) plan review -> plan. Read-only. */
|
|
3265
3470
|
/**
|
|
3266
3471
|
* Wrap the user's goal in an explicit "plan, do not implement" instruction.
|
|
3267
3472
|
* Without this the raw prompt ("make a racing game") reaches the harness with
|
|
@@ -3280,10 +3485,222 @@ export class Orchestrator {
|
|
|
3280
3485
|
`1. Approach — 2-3 sentences on how you'd solve this.`,
|
|
3281
3486
|
`2. Steps — a numbered list; each step names the file(s) it touches and what changes.`,
|
|
3282
3487
|
`3. Risks & edge cases.`,
|
|
3283
|
-
`4.
|
|
3488
|
+
`4. End your response with a section titled exactly:`,
|
|
3489
|
+
``,
|
|
3490
|
+
`## Open Questions`,
|
|
3491
|
+
``,
|
|
3492
|
+
`List every decision the user must make before implementation, one per bullet, in EXACTLY this format:`,
|
|
3493
|
+
``,
|
|
3494
|
+
`- [single] <question> :: <option A> :: <option B>`,
|
|
3495
|
+
`- [multi] <question> :: <option A> :: <option B>`,
|
|
3496
|
+
`- [text] <question that has no good fixed options>`,
|
|
3497
|
+
``,
|
|
3498
|
+
`Rules: [single] = pick exactly one; [multi] = pick one or more; [text] = free-form (no "::" options). Ground every option in THIS repository. If nothing is ambiguous, write a single bullet: - (none)`,
|
|
3499
|
+
``,
|
|
3284
3500
|
`Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
|
|
3285
3501
|
].join("\n");
|
|
3286
3502
|
}
|
|
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
|
+
*/
|
|
3512
|
+
async runPlannerAttempt(args) {
|
|
3513
|
+
const { input, contract, taskId, runId, log, store, paths, ledger, routed, attemptId } = args;
|
|
3514
|
+
const adapter = routed.adapter;
|
|
3515
|
+
const lease = ledger.reserve({
|
|
3516
|
+
taskId,
|
|
3517
|
+
attemptId,
|
|
3518
|
+
intent: args.intent,
|
|
3519
|
+
harnessId: adapter.id,
|
|
3520
|
+
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3521
|
+
});
|
|
3522
|
+
if (!lease.granted) {
|
|
3523
|
+
log.emit("budget.lease.created", {
|
|
3524
|
+
granted: false,
|
|
3525
|
+
reason: lease.reason,
|
|
3526
|
+
attempt_id: attemptId,
|
|
3527
|
+
harness_id: adapter.id,
|
|
3528
|
+
});
|
|
3529
|
+
return {
|
|
3530
|
+
attemptId,
|
|
3531
|
+
harnessId: adapter.id,
|
|
3532
|
+
status: "failed",
|
|
3533
|
+
error: lease.reason ?? "budget lease denied",
|
|
3534
|
+
text: null,
|
|
3535
|
+
telemetry: null,
|
|
3536
|
+
budgetDenied: true,
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
|
|
3540
|
+
const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
|
|
3541
|
+
const planSessionFields = this.sessionSpecFields(input, adapter.id, log);
|
|
3542
|
+
// Continuity (INV-137): a thread PLAN turn is a chat turn — hydrate a
|
|
3543
|
+
// lane switch/gap with a packet and disclose it.
|
|
3544
|
+
const laneContinuity = args.laneRun
|
|
3545
|
+
? await this.resolveContinuity(input, adapter.id, planSessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, planSessionFields.resume_session_id !== null, store, paths, this.execRootOf(input), log)
|
|
3546
|
+
: null;
|
|
3547
|
+
const spec = HarnessRunSpec.parse({
|
|
3548
|
+
session_id: newId("ses"),
|
|
3549
|
+
intent: args.intent,
|
|
3550
|
+
prompt: laneContinuity?.pointerLine
|
|
3551
|
+
? `${args.promptBody}\n\n${laneContinuity.pointerLine}`
|
|
3552
|
+
: args.promptBody,
|
|
3553
|
+
cwd: this.execRootOf(input),
|
|
3554
|
+
access: "readonly",
|
|
3555
|
+
// Planners must SEE any image/file the user attached (e.g. "plan a fix for
|
|
3556
|
+
// what's in this screenshot"), not just agent/race runs.
|
|
3557
|
+
attachments: input.attachments ?? [],
|
|
3558
|
+
...planSessionFields,
|
|
3559
|
+
...this.harnessSpecKnobs(contract, knobs, args.intent),
|
|
3560
|
+
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3561
|
+
// A thread plan turn spawns in its DURABLE per-lane home so its native
|
|
3562
|
+
// session is reachable for resume next turn (INV-034); a non-thread
|
|
3563
|
+
// plan keeps the disposable route-context home.
|
|
3564
|
+
env: (args.laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? args.fallbackHome,
|
|
3565
|
+
});
|
|
3566
|
+
const plannerAbort = new AbortController();
|
|
3567
|
+
spec.extra["abortSignal"] = input.signal
|
|
3568
|
+
? AbortSignal.any([input.signal, plannerAbort.signal])
|
|
3569
|
+
: plannerAbort.signal;
|
|
3570
|
+
const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
|
|
3571
|
+
if (planInteraction)
|
|
3572
|
+
spec.extra["interactionChannel"] = planInteraction;
|
|
3573
|
+
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3574
|
+
const answer = new AnswerAssembly();
|
|
3575
|
+
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3576
|
+
knobs.webPolicy === "cached" ||
|
|
3577
|
+
knobs.webPolicy === "live", effectiveWeb, [],
|
|
3578
|
+
// Requested-model capture: a plan lane silently downgraded to another
|
|
3579
|
+
// model surfaces the mismatch in its route receipt, just like agent.
|
|
3580
|
+
knobs.model);
|
|
3581
|
+
const onAbort = () => {
|
|
3582
|
+
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3583
|
+
};
|
|
3584
|
+
if (input.signal) {
|
|
3585
|
+
if (input.signal.aborted)
|
|
3586
|
+
onAbort();
|
|
3587
|
+
else
|
|
3588
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3589
|
+
}
|
|
3590
|
+
let cost = 0;
|
|
3591
|
+
let costEstimated = false;
|
|
3592
|
+
let harnessError = null;
|
|
3593
|
+
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
3594
|
+
try {
|
|
3595
|
+
log.emit("harness.started", {
|
|
3596
|
+
harness_id: adapter.id,
|
|
3597
|
+
attempt_id: attemptId,
|
|
3598
|
+
external_context_policy: knobs.webPolicy,
|
|
3599
|
+
...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
|
|
3600
|
+
});
|
|
3601
|
+
if (!input.signal?.aborted) {
|
|
3602
|
+
const watchedPlan = withInactivityWatchdog(adapter.run(spec), {
|
|
3603
|
+
timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
|
|
3604
|
+
onTimeout: () => {
|
|
3605
|
+
plannerAbort.abort();
|
|
3606
|
+
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3607
|
+
},
|
|
3608
|
+
isSuspended: () => (planInteraction?.pendingCount?.() ?? 0) > 0,
|
|
3609
|
+
});
|
|
3610
|
+
for await (const ev of watchedPlan) {
|
|
3611
|
+
if (input.signal?.aborted)
|
|
3612
|
+
break;
|
|
3613
|
+
const safeEv = redactHarnessEvent(ev);
|
|
3614
|
+
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3615
|
+
// A thread PLAN turn IS a chat turn now (INV-034): its native
|
|
3616
|
+
// session lives in the DURABLE per-lane home, so record it for the
|
|
3617
|
+
// next lane turn's resume. Council members are distinct lanes.
|
|
3618
|
+
if (args.laneRun)
|
|
3619
|
+
observeNativeSessionEvent(input, adapter.id, safeEv);
|
|
3620
|
+
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
3621
|
+
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
3622
|
+
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
3623
|
+
observeAttemptTelemetry(telemetry, safeEv);
|
|
3624
|
+
if (safeEv.plan_progress) {
|
|
3625
|
+
log.emit("plan.progress", {
|
|
3626
|
+
attempt_id: attemptId,
|
|
3627
|
+
harness_id: adapter.id,
|
|
3628
|
+
items: safeEv.plan_progress.items,
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3631
|
+
// read-only routes burn quota too — same single owner as the agent loop.
|
|
3632
|
+
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
3633
|
+
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
3634
|
+
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
3635
|
+
cost += safeEv.usage.cost_usd;
|
|
3636
|
+
if (safeEv.usage.estimated)
|
|
3637
|
+
costEstimated = true;
|
|
3638
|
+
log.emit("budget.observation", {
|
|
3639
|
+
harness_id: adapter.id,
|
|
3640
|
+
attempt_id: attemptId,
|
|
3641
|
+
kind: "spend",
|
|
3642
|
+
usd: safeEv.usage.cost_usd,
|
|
3643
|
+
estimated: safeEv.usage.estimated === true,
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
// A TYPED final message wins verbatim over joined narration.
|
|
3647
|
+
answer.observe(safeEv);
|
|
3648
|
+
if (safeEv.type === "error")
|
|
3649
|
+
harnessError = safeEv.error ? redactSecrets(safeEv.error) : "harness emitted an error";
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
catch (err) {
|
|
3654
|
+
harnessError = safeErrorMessage(err);
|
|
3655
|
+
}
|
|
3656
|
+
finally {
|
|
3657
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
3658
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
|
|
3659
|
+
}
|
|
3660
|
+
const unrecovered = unrecoveredToolErrors(telemetry);
|
|
3661
|
+
const webBlocked = webUnsatisfied(telemetry);
|
|
3662
|
+
if (!harnessError && webBlocked) {
|
|
3663
|
+
harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
|
|
3664
|
+
}
|
|
3665
|
+
if (!harnessError && unrecovered.length > 0) {
|
|
3666
|
+
const first = unrecovered[0];
|
|
3667
|
+
harnessError = `${first.tool} failed without recovery: ${first.summary}`;
|
|
3668
|
+
}
|
|
3669
|
+
if (harnessError) {
|
|
3670
|
+
log.emit("harness.completed", {
|
|
3671
|
+
harness_id: adapter.id,
|
|
3672
|
+
attempt_id: attemptId,
|
|
3673
|
+
status: webBlocked ? "blocked" : "failed",
|
|
3674
|
+
error: harnessError,
|
|
3675
|
+
...telemetrySummary(telemetry),
|
|
3676
|
+
});
|
|
3677
|
+
return {
|
|
3678
|
+
attemptId,
|
|
3679
|
+
harnessId: adapter.id,
|
|
3680
|
+
status: webBlocked ? "blocked" : "failed",
|
|
3681
|
+
error: harnessError,
|
|
3682
|
+
text: null,
|
|
3683
|
+
telemetry,
|
|
3684
|
+
budgetDenied: false,
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
const text = answer.text() || "(no output)";
|
|
3688
|
+
log.emit("harness.completed", {
|
|
3689
|
+
harness_id: adapter.id,
|
|
3690
|
+
attempt_id: attemptId,
|
|
3691
|
+
status: "success",
|
|
3692
|
+
...telemetrySummary(telemetry),
|
|
3693
|
+
});
|
|
3694
|
+
return {
|
|
3695
|
+
attemptId,
|
|
3696
|
+
harnessId: adapter.id,
|
|
3697
|
+
status: "success",
|
|
3698
|
+
error: null,
|
|
3699
|
+
text,
|
|
3700
|
+
telemetry,
|
|
3701
|
+
budgetDenied: false,
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3287
3704
|
async runPlan(input, announce) {
|
|
3288
3705
|
const taskId = input.taskId ?? newId("task");
|
|
3289
3706
|
const runId = input.runId ?? newId("run");
|
|
@@ -3308,16 +3725,19 @@ export class Orchestrator {
|
|
|
3308
3725
|
});
|
|
3309
3726
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3310
3727
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3311
|
-
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
|
|
3312
|
-
if ("failed" in reviewersOutcome)
|
|
3313
|
-
return reviewersOutcome.failed;
|
|
3314
|
-
const reviewers = reviewersOutcome.reviewers;
|
|
3315
3728
|
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3316
|
-
// planner spawn consume the SAME scoped env (see routeContext.ts).
|
|
3729
|
+
// planner spawn consume the SAME scoped env (see routeContext.ts). The
|
|
3730
|
+
// probe home stays a disposable throwaway even for a thread lane turn (auth
|
|
3731
|
+
// truth is home-independent); only the planner spawn swaps in the durable
|
|
3732
|
+
// per-lane home below so its recorded native session survives.
|
|
3317
3733
|
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
3734
|
+
// A thread PLAN turn is a chat turn (INV-034): plan candidates are distinct
|
|
3735
|
+
// harnesses run sequentially, so each records its own lane's native session
|
|
3736
|
+
// and the next lane turn resumes it via `sessionSpecFields.resume_session_id`.
|
|
3737
|
+
const laneRun = Boolean(input.threadId);
|
|
3318
3738
|
let adapters;
|
|
3319
3739
|
try {
|
|
3320
|
-
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
|
|
3740
|
+
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
|
|
3321
3741
|
}
|
|
3322
3742
|
catch (err) {
|
|
3323
3743
|
roHome.dispose();
|
|
@@ -3329,10 +3749,12 @@ export class Orchestrator {
|
|
|
3329
3749
|
safeMessage: message,
|
|
3330
3750
|
runDir: paths.root,
|
|
3331
3751
|
});
|
|
3332
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n-
|
|
3752
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
3333
3753
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3334
3754
|
log.emit("run.failed", {
|
|
3335
|
-
|
|
3755
|
+
lifecycle: "failed",
|
|
3756
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3757
|
+
reason: "harness_failed",
|
|
3336
3758
|
phase: "routing",
|
|
3337
3759
|
error: message,
|
|
3338
3760
|
failure_ref: "final/failure.yaml",
|
|
@@ -3342,7 +3764,8 @@ export class Orchestrator {
|
|
|
3342
3764
|
runId,
|
|
3343
3765
|
taskId,
|
|
3344
3766
|
mode: "plan",
|
|
3345
|
-
|
|
3767
|
+
lifecycle: "failed",
|
|
3768
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3346
3769
|
winner: null,
|
|
3347
3770
|
runDir: paths.root,
|
|
3348
3771
|
summary: message,
|
|
@@ -3364,10 +3787,12 @@ export class Orchestrator {
|
|
|
3364
3787
|
safeMessage: message,
|
|
3365
3788
|
runDir: paths.root,
|
|
3366
3789
|
});
|
|
3367
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n-
|
|
3790
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: context\n\n${message}\n`);
|
|
3368
3791
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3369
3792
|
log.emit("run.failed", {
|
|
3370
|
-
|
|
3793
|
+
lifecycle: "failed",
|
|
3794
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3795
|
+
reason: "harness_failed",
|
|
3371
3796
|
phase: "context",
|
|
3372
3797
|
error: message,
|
|
3373
3798
|
failure_ref: "final/failure.yaml",
|
|
@@ -3377,188 +3802,106 @@ export class Orchestrator {
|
|
|
3377
3802
|
runId,
|
|
3378
3803
|
taskId,
|
|
3379
3804
|
mode: "plan",
|
|
3380
|
-
|
|
3805
|
+
lifecycle: "failed",
|
|
3806
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3381
3807
|
winner: null,
|
|
3382
3808
|
runDir: paths.root,
|
|
3383
3809
|
summary: `context failed: ${message}`,
|
|
3384
3810
|
candidates: [],
|
|
3385
3811
|
};
|
|
3386
3812
|
}
|
|
3813
|
+
// Council strategy (INV-031): N members draft in parallel, the primary
|
|
3814
|
+
// merges them into ONE plan + one question set. It owns roHome disposal.
|
|
3815
|
+
if (input.council) {
|
|
3816
|
+
return runCouncilPlan(this.planRunDeps(), {
|
|
3817
|
+
input,
|
|
3818
|
+
contract,
|
|
3819
|
+
taskId,
|
|
3820
|
+
runId,
|
|
3821
|
+
store,
|
|
3822
|
+
paths,
|
|
3823
|
+
log,
|
|
3824
|
+
ledger,
|
|
3825
|
+
adapters,
|
|
3826
|
+
roHome,
|
|
3827
|
+
contextSection,
|
|
3828
|
+
laneRun,
|
|
3829
|
+
});
|
|
3830
|
+
}
|
|
3387
3831
|
const plans = [];
|
|
3832
|
+
let fallbackFrom = null;
|
|
3388
3833
|
const planAttempts = [];
|
|
3389
3834
|
const attemptTelemetries = [];
|
|
3390
3835
|
try {
|
|
3391
3836
|
for (const [idx, routed] of adapters.entries()) {
|
|
3392
3837
|
if (input.signal?.aborted)
|
|
3393
3838
|
break;
|
|
3394
|
-
const adapter = routed.adapter;
|
|
3395
3839
|
const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
|
|
3396
|
-
const
|
|
3840
|
+
const outcome = await this.runPlannerAttempt({
|
|
3841
|
+
input,
|
|
3842
|
+
contract,
|
|
3397
3843
|
taskId,
|
|
3844
|
+
runId,
|
|
3845
|
+
log,
|
|
3846
|
+
store,
|
|
3847
|
+
paths,
|
|
3848
|
+
ledger,
|
|
3849
|
+
routed,
|
|
3398
3850
|
attemptId,
|
|
3851
|
+
laneRun,
|
|
3852
|
+
fallbackHome: roHome.env,
|
|
3853
|
+
promptBody: this.planPrompt(input.prompt) + contextSection,
|
|
3399
3854
|
intent: "plan",
|
|
3400
|
-
harnessId: adapter.id,
|
|
3401
|
-
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3402
3855
|
});
|
|
3403
|
-
if (
|
|
3404
|
-
log.emit("budget.lease.created", {
|
|
3405
|
-
granted: false,
|
|
3406
|
-
reason: lease.reason,
|
|
3407
|
-
attempt_id: attemptId,
|
|
3408
|
-
harness_id: adapter.id,
|
|
3409
|
-
});
|
|
3856
|
+
if (outcome.budgetDenied)
|
|
3410
3857
|
break;
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
intent: "plan",
|
|
3417
|
-
prompt: this.planPrompt(input.prompt) + contextSection + relayPriorPlansSection(plans),
|
|
3418
|
-
cwd: this.execRootOf(input),
|
|
3419
|
-
access: "readonly",
|
|
3420
|
-
// Planners must SEE any image/file the user attached (e.g. "plan a fix for
|
|
3421
|
-
// what's in this screenshot"), not just agent/race runs.
|
|
3422
|
-
attachments: input.attachments ?? [],
|
|
3423
|
-
...this.sessionSpecFields(input, adapter.id, log),
|
|
3424
|
-
...this.harnessSpecKnobs(contract, knobs, "plan"),
|
|
3425
|
-
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3426
|
-
env: roHome.env,
|
|
3427
|
-
});
|
|
3428
|
-
const plannerAbort = new AbortController();
|
|
3429
|
-
spec.extra["abortSignal"] = input.signal
|
|
3430
|
-
? AbortSignal.any([input.signal, plannerAbort.signal])
|
|
3431
|
-
: plannerAbort.signal;
|
|
3432
|
-
const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
|
|
3433
|
-
if (planInteraction)
|
|
3434
|
-
spec.extra["interactionChannel"] = planInteraction;
|
|
3435
|
-
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3436
|
-
const answer = new AnswerAssembly();
|
|
3437
|
-
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3438
|
-
knobs.webPolicy === "cached" ||
|
|
3439
|
-
knobs.webPolicy === "live", effectiveWeb, [],
|
|
3440
|
-
// Requested-model capture: a plan lane silently downgraded to another
|
|
3441
|
-
// model surfaces the mismatch in its route receipt, just like agent.
|
|
3442
|
-
knobs.model);
|
|
3443
|
-
const onAbort = () => {
|
|
3444
|
-
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3445
|
-
};
|
|
3446
|
-
if (input.signal) {
|
|
3447
|
-
if (input.signal.aborted)
|
|
3448
|
-
onAbort();
|
|
3449
|
-
else
|
|
3450
|
-
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3451
|
-
}
|
|
3452
|
-
let cost = 0;
|
|
3453
|
-
let costEstimated = false;
|
|
3454
|
-
let harnessError = null;
|
|
3455
|
-
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
3456
|
-
try {
|
|
3457
|
-
log.emit("harness.started", {
|
|
3458
|
-
harness_id: adapter.id,
|
|
3459
|
-
attempt_id: attemptId,
|
|
3460
|
-
external_context_policy: knobs.webPolicy,
|
|
3461
|
-
...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
|
|
3858
|
+
if (outcome.telemetry)
|
|
3859
|
+
attemptTelemetries.push({
|
|
3860
|
+
attemptId,
|
|
3861
|
+
harnessId: outcome.harnessId,
|
|
3862
|
+
telemetry: outcome.telemetry,
|
|
3462
3863
|
});
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3864
|
+
planAttempts.push({
|
|
3865
|
+
attemptId,
|
|
3866
|
+
harnessId: outcome.harnessId,
|
|
3867
|
+
status: outcome.status,
|
|
3868
|
+
error: outcome.error,
|
|
3869
|
+
});
|
|
3870
|
+
if (outcome.status !== "success") {
|
|
3871
|
+
const next = adapters[idx + 1];
|
|
3872
|
+
if (next && !input.signal?.aborted) {
|
|
3873
|
+
fallbackFrom = outcome.harnessId;
|
|
3874
|
+
log.emit("route.fallback.started", {
|
|
3875
|
+
from_harness: outcome.harnessId,
|
|
3876
|
+
to_harness: next.adapter.id,
|
|
3877
|
+
attempt_id: attemptId,
|
|
3878
|
+
reason: "planner_failed",
|
|
3471
3879
|
});
|
|
3472
|
-
for await (const ev of watchedPlan) {
|
|
3473
|
-
if (input.signal?.aborted)
|
|
3474
|
-
break;
|
|
3475
|
-
const safeEv = redactHarnessEvent(ev);
|
|
3476
|
-
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3477
|
-
// NOT observed for resume: a read-only planner is not a chat turn,
|
|
3478
|
-
// and attaching its session id would poison thread continuity (and
|
|
3479
|
-
// race parallel planner/reviewer sessions), regardless of whether
|
|
3480
|
-
// the vendor stored that session in the scoped or native store.
|
|
3481
|
-
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
3482
|
-
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
3483
|
-
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
3484
|
-
observeAttemptTelemetry(telemetry, safeEv);
|
|
3485
|
-
if (safeEv.plan_progress) {
|
|
3486
|
-
log.emit("plan.progress", {
|
|
3487
|
-
attempt_id: attemptId,
|
|
3488
|
-
harness_id: adapter.id,
|
|
3489
|
-
items: safeEv.plan_progress.items,
|
|
3490
|
-
});
|
|
3491
|
-
}
|
|
3492
|
-
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
3493
|
-
// the loudest) — same single owner as the agent loop.
|
|
3494
|
-
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
3495
|
-
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
3496
|
-
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
3497
|
-
cost += safeEv.usage.cost_usd;
|
|
3498
|
-
if (safeEv.usage.estimated)
|
|
3499
|
-
costEstimated = true;
|
|
3500
|
-
log.emit("budget.observation", {
|
|
3501
|
-
harness_id: adapter.id,
|
|
3502
|
-
attempt_id: attemptId,
|
|
3503
|
-
kind: "spend",
|
|
3504
|
-
usd: safeEv.usage.cost_usd,
|
|
3505
|
-
estimated: safeEv.usage.estimated === true,
|
|
3506
|
-
});
|
|
3507
|
-
}
|
|
3508
|
-
// A TYPED final message wins verbatim over joined narration.
|
|
3509
|
-
answer.observe(safeEv);
|
|
3510
|
-
if (safeEv.type === "error")
|
|
3511
|
-
harnessError = safeEv.error
|
|
3512
|
-
? redactSecrets(safeEv.error)
|
|
3513
|
-
: "harness emitted an error";
|
|
3514
|
-
}
|
|
3515
3880
|
}
|
|
3881
|
+
else if (fallbackFrom || next === undefined) {
|
|
3882
|
+
log.emit("route.fallback.exhausted", {
|
|
3883
|
+
harness_id: outcome.harnessId,
|
|
3884
|
+
attempt_id: attemptId,
|
|
3885
|
+
reason: "planner_failed",
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
continue;
|
|
3516
3889
|
}
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
}
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
const webBlocked = webUnsatisfied(telemetry);
|
|
3527
|
-
if (!harnessError && webBlocked) {
|
|
3528
|
-
harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
|
|
3529
|
-
}
|
|
3530
|
-
if (!harnessError && unrecovered.length > 0) {
|
|
3531
|
-
const first = unrecovered[0];
|
|
3532
|
-
harnessError = `${first.tool} failed without recovery: ${first.summary}`;
|
|
3533
|
-
}
|
|
3534
|
-
if (harnessError) {
|
|
3535
|
-
// One failed planner does not abort a multi-harness plan; the run fails
|
|
3536
|
-
// only when EVERY planner fails (parity with explore).
|
|
3537
|
-
log.emit("harness.completed", {
|
|
3538
|
-
harness_id: adapter.id,
|
|
3890
|
+
const text = outcome.text ?? "(no output)";
|
|
3891
|
+
plans.push({ id: outcome.harnessId, text });
|
|
3892
|
+
store.writeText(join(paths.root, "plans", `${outcome.harnessId}.md`), redactSecrets(text) + "\n");
|
|
3893
|
+
// Solo planning (D31): the FIRST successful planner is the plan; later
|
|
3894
|
+
// pool members are a sequential fallback chain (ask parity), not
|
|
3895
|
+
// parallel co-authors. Council re-enables the multi-draft round.
|
|
3896
|
+
if (fallbackFrom) {
|
|
3897
|
+
log.emit("route.fallback.completed", {
|
|
3898
|
+
harness_id: outcome.harnessId,
|
|
3539
3899
|
attempt_id: attemptId,
|
|
3540
|
-
status:
|
|
3541
|
-
|
|
3542
|
-
...telemetrySummary(telemetry),
|
|
3900
|
+
status: "success",
|
|
3901
|
+
reason: "planner_failed",
|
|
3543
3902
|
});
|
|
3544
|
-
planAttempts.push({
|
|
3545
|
-
attemptId,
|
|
3546
|
-
harnessId: adapter.id,
|
|
3547
|
-
status: webBlocked ? "blocked" : "failed",
|
|
3548
|
-
error: harnessError,
|
|
3549
|
-
});
|
|
3550
|
-
continue;
|
|
3551
3903
|
}
|
|
3552
|
-
|
|
3553
|
-
log.emit("harness.completed", {
|
|
3554
|
-
harness_id: adapter.id,
|
|
3555
|
-
attempt_id: attemptId,
|
|
3556
|
-
status: "success",
|
|
3557
|
-
...telemetrySummary(telemetry),
|
|
3558
|
-
});
|
|
3559
|
-
planAttempts.push({ attemptId, harnessId: adapter.id, status: "success", error: null });
|
|
3560
|
-
plans.push({ id: adapter.id, text });
|
|
3561
|
-
store.writeText(join(paths.root, "plans", `${adapter.id}.md`), redactSecrets(text) + "\n");
|
|
3904
|
+
break;
|
|
3562
3905
|
}
|
|
3563
3906
|
}
|
|
3564
3907
|
finally {
|
|
@@ -3573,116 +3916,18 @@ export class Orchestrator {
|
|
|
3573
3916
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3574
3917
|
}
|
|
3575
3918
|
if (plans.length === 0) {
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
.join("\n") || "all planners failed";
|
|
3580
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null);
|
|
3581
|
-
store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${message}\n`);
|
|
3582
|
-
writeFailure(store, paths, {
|
|
3583
|
-
phase: "harness",
|
|
3584
|
-
category: blocked ? "policy" : "harness_error",
|
|
3585
|
-
safeMessage: message,
|
|
3586
|
-
eventRefs: planAttempts.map((p) => `attempts/${p.attemptId}/events.jsonl`),
|
|
3587
|
-
runDir: paths.root,
|
|
3588
|
-
nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
|
|
3589
|
-
});
|
|
3590
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: ${blocked ? "blocked" : "failed"}\n\n${message}\n`);
|
|
3591
|
-
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3592
|
-
if (blocked)
|
|
3593
|
-
log.emit("run.blocked", {
|
|
3594
|
-
status: "blocked",
|
|
3595
|
-
phase: "harness",
|
|
3596
|
-
error: message,
|
|
3597
|
-
failure_ref: "final/failure.yaml",
|
|
3598
|
-
});
|
|
3599
|
-
else
|
|
3600
|
-
log.emit("run.failed", {
|
|
3601
|
-
status: "failed",
|
|
3602
|
-
phase: "harness",
|
|
3603
|
-
error: message,
|
|
3604
|
-
failure_ref: "final/failure.yaml",
|
|
3605
|
-
});
|
|
3606
|
-
return {
|
|
3607
|
-
spendUsd: ledger.spend(),
|
|
3608
|
-
runId,
|
|
3609
|
-
taskId,
|
|
3610
|
-
mode: "plan",
|
|
3611
|
-
status: blocked ? "blocked" : "failed",
|
|
3612
|
-
winner: null,
|
|
3613
|
-
runDir: paths.root,
|
|
3614
|
-
summary: message,
|
|
3615
|
-
candidates: planAttempts.map((p) => ({
|
|
3616
|
-
attemptId: p.attemptId,
|
|
3617
|
-
harnessId: p.harnessId,
|
|
3618
|
-
status: p.status,
|
|
3619
|
-
})),
|
|
3620
|
-
};
|
|
3621
|
-
}
|
|
3622
|
-
let ambiguities = [];
|
|
3623
|
-
let reviewFindings = [];
|
|
3624
|
-
if (reviewers.length > 0 && plans.length > 0) {
|
|
3625
|
-
const reviewDir = join(paths.root, "review-evidence");
|
|
3626
|
-
const planEvidence = plans.map((p) => `## Plan from ${p.id}\n${p.text}`).join("\n\n");
|
|
3627
|
-
const planReviewDiff = "(plan review — no code diff)\n";
|
|
3628
|
-
writeEvidencePacket(reviewDir, {
|
|
3629
|
-
userIntent: redactSecrets(input.prompt),
|
|
3630
|
-
planAccepted: planEvidence,
|
|
3631
|
-
diff: planReviewDiff,
|
|
3632
|
-
tests: renderTestsEvidence(contract),
|
|
3633
|
-
});
|
|
3634
|
-
// Reserve BEFORE spending: a hard budget tier must stop the paid plan
|
|
3635
|
-
// review from starting, not account for it after the fact.
|
|
3636
|
-
const lease = ledger.reserve({
|
|
3919
|
+
return writePlanHarnessFailure(this.planRunDeps(), {
|
|
3920
|
+
input,
|
|
3921
|
+
contract,
|
|
3637
3922
|
taskId,
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
diff: planReviewDiff,
|
|
3647
|
-
evidenceDir: reviewDir,
|
|
3648
|
-
artifactsDir: join(paths.reviewsDir, "plan-reviewers"),
|
|
3649
|
-
cwd: this.execRootOf(input),
|
|
3650
|
-
reviewers,
|
|
3651
|
-
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
3652
|
-
signal: input.signal,
|
|
3653
|
-
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
3654
|
-
});
|
|
3655
|
-
reviewFindings = await revalidateFindings(res.findings, {
|
|
3656
|
-
candidateRoot: this.execRootOf(input),
|
|
3657
|
-
evidenceDir: reviewDir,
|
|
3658
|
-
});
|
|
3659
|
-
ambiguities = reviewFindings.filter((f) => f.category === "spec_gap" || f.severity === "NEEDS_HUMAN");
|
|
3660
|
-
store.writeYaml(join(paths.reviewsDir, "plan-review.yaml"), {
|
|
3661
|
-
findings: reviewFindings,
|
|
3662
|
-
route_proofs: res.routeProofs,
|
|
3663
|
-
reviewer_requests: res.reviewerRequests,
|
|
3664
|
-
});
|
|
3665
|
-
ledger.settle(lease.lease?.lease_id ?? "", usageCostSettlement(res.reviewSpendUsd, res.reviewSpendEstimated, "review-usage", [
|
|
3666
|
-
"attempt:plan-review",
|
|
3667
|
-
"review:panel",
|
|
3668
|
-
]));
|
|
3669
|
-
if ((res.reviewSpendUsd ?? 0) > 0) {
|
|
3670
|
-
log.emit("budget.observation", {
|
|
3671
|
-
harness_id: "review-panel",
|
|
3672
|
-
kind: "spend",
|
|
3673
|
-
usd: res.reviewSpendUsd,
|
|
3674
|
-
estimated: res.reviewSpendEstimated,
|
|
3675
|
-
});
|
|
3676
|
-
}
|
|
3677
|
-
}
|
|
3678
|
-
else {
|
|
3679
|
-
log.emit("budget.lease.created", {
|
|
3680
|
-
granted: false,
|
|
3681
|
-
reason: lease.reason,
|
|
3682
|
-
attempt_id: "plan-review",
|
|
3683
|
-
harness_id: "review-panel",
|
|
3684
|
-
});
|
|
3685
|
-
}
|
|
3923
|
+
runId,
|
|
3924
|
+
store,
|
|
3925
|
+
paths,
|
|
3926
|
+
log,
|
|
3927
|
+
ledger,
|
|
3928
|
+
planAttempts,
|
|
3929
|
+
attemptTelemetries,
|
|
3930
|
+
}, "all planners failed");
|
|
3686
3931
|
}
|
|
3687
3932
|
if (input.signal?.aborted) {
|
|
3688
3933
|
return cancelledResult(log, runId, taskId, "plan", paths.root, planAttempts.map((p) => ({
|
|
@@ -3691,132 +3936,69 @@ export class Orchestrator {
|
|
|
3691
3936
|
status: p.status,
|
|
3692
3937
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3693
3938
|
}
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
`## Goal`,
|
|
3708
|
-
redactSecrets(input.prompt),
|
|
3709
|
-
"",
|
|
3710
|
-
`## Plan${plans.length > 1 ? "s" : ""} (${plans.length}/${planAttempts.length} planner${planAttempts.length === 1 ? "" : "s"})`,
|
|
3711
|
-
...plans.map((p) => `\n### Plan — ${p.id}\n${redactSecrets(p.text)}`),
|
|
3712
|
-
...(reviewFindings.length > 0
|
|
3713
|
-
? [
|
|
3714
|
-
"",
|
|
3715
|
-
"## Review findings",
|
|
3716
|
-
...reviewFindings.map((f) => `- ${sevMark[f.severity] ?? f.severity}: ${redactSecrets(f.claim)}`),
|
|
3717
|
-
]
|
|
3718
|
-
: []),
|
|
3719
|
-
...(ambiguities.length > 0
|
|
3720
|
-
? ["", "## Open questions", ...ambiguities.map((a) => `- ${redactSecrets(a.claim)}`)]
|
|
3721
|
-
: []),
|
|
3722
|
-
...(failedPlanners.length > 0
|
|
3723
|
-
? [
|
|
3724
|
-
"",
|
|
3725
|
-
"## Planner omissions",
|
|
3726
|
-
...failedPlanners.map((p) => `- ${p.attemptId} / ${p.harnessId} ${p.status}: ${p.error}`),
|
|
3727
|
-
]
|
|
3728
|
-
: []),
|
|
3729
|
-
"",
|
|
3730
|
-
].join("\n");
|
|
3731
|
-
store.writeText(join(paths.finalDir, "plan.md"), planDoc + "\n");
|
|
3732
|
-
// A plan is a delivered work product (a report), even with risks — parity
|
|
3733
|
-
// with the other read-only modes (removes the "only successful mode with no
|
|
3734
|
-
// work_product" anomaly). result_kind=plan tells surfaces NO files changed.
|
|
3735
|
-
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
3736
|
-
id: newId("wp"),
|
|
3737
|
-
kind: "report",
|
|
3738
|
-
source_task_id: taskId,
|
|
3739
|
-
producer_attempt_id: planAttempts.find((p) => p.status === "success")?.attemptId ?? null,
|
|
3740
|
-
meta: {
|
|
3741
|
-
mode: "plan",
|
|
3742
|
-
result_kind: "plan",
|
|
3743
|
-
planners: plans.length,
|
|
3744
|
-
diffstat: { files: 0, additions: 0, deletions: 0 },
|
|
3745
|
-
blockers: blockingFindings.length,
|
|
3746
|
-
adopted: null,
|
|
3747
|
-
},
|
|
3939
|
+
return finalizePlanRun(this.planRunDeps(), {
|
|
3940
|
+
input,
|
|
3941
|
+
contract,
|
|
3942
|
+
taskId,
|
|
3943
|
+
runId,
|
|
3944
|
+
store,
|
|
3945
|
+
paths,
|
|
3946
|
+
log,
|
|
3947
|
+
ledger,
|
|
3948
|
+
plans,
|
|
3949
|
+
planAttempts,
|
|
3950
|
+
attemptTelemetries,
|
|
3951
|
+
council: null,
|
|
3748
3952
|
});
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3953
|
+
}
|
|
3954
|
+
/** Bind the few orchestrator methods planRun.ts needs (the rest of its
|
|
3955
|
+
* collaborators are module-level imports). Kept as a factory so each call
|
|
3956
|
+
* gets correctly-bound `this` without leaking the whole orchestrator. */
|
|
3957
|
+
planRunDeps() {
|
|
3754
3958
|
return {
|
|
3755
|
-
|
|
3756
|
-
runId,
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
status: "success",
|
|
3760
|
-
winner: null,
|
|
3761
|
-
runDir: paths.root,
|
|
3762
|
-
summary: `Plan from ${plans.length} planner(s); ${blockingFindings.length} blocker(s), ${ambiguities.length} open question(s).`,
|
|
3763
|
-
candidates: planAttempts.map((p) => ({
|
|
3764
|
-
attemptId: p.attemptId,
|
|
3765
|
-
harnessId: p.harnessId,
|
|
3766
|
-
status: p.status,
|
|
3767
|
-
})),
|
|
3959
|
+
runPlannerAttempt: (a) => this.runPlannerAttempt(a),
|
|
3960
|
+
writeRunTelemetry: (store, paths, contract, runId, taskId, mode, attempts, finalAttemptId) => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId),
|
|
3961
|
+
execRootOf: (input) => this.execRootOf(input),
|
|
3962
|
+
planPrompt: (goal) => this.planPrompt(goal),
|
|
3768
3963
|
};
|
|
3769
3964
|
}
|
|
3770
3965
|
/** ask: one selected harness answers read-only questions; no patch/apply controls. */
|
|
3771
3966
|
async runAsk(input, announce) {
|
|
3772
3967
|
return this.runReadOnlyReport(input, {
|
|
3773
3968
|
mode: "ask",
|
|
3774
|
-
|
|
3969
|
+
deepScan: false,
|
|
3775
3970
|
intent: "explain",
|
|
3776
3971
|
title: "Answer",
|
|
3777
3972
|
artifactName: "answer.md",
|
|
3778
3973
|
defaultPrompt: "Answer the user's question.",
|
|
3779
3974
|
}, announce);
|
|
3780
3975
|
}
|
|
3781
|
-
/**
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
mode: "audit",
|
|
3785
|
-
swarm: true,
|
|
3786
|
-
intent: "audit",
|
|
3787
|
-
title: "Explore synthesis",
|
|
3788
|
-
artifactName: "explore.md",
|
|
3789
|
-
defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
|
|
3790
|
-
}, announce);
|
|
3791
|
-
}
|
|
3792
|
-
/** audit: single read-only audit/map report. */
|
|
3793
|
-
async runAudit(input, announce) {
|
|
3976
|
+
/** ask --deep-scan: bounded multi-scout research sweep with synthesis
|
|
3977
|
+
* (the old `audit --swarm` / `explore`). */
|
|
3978
|
+
async runDeepScan(input, announce) {
|
|
3794
3979
|
return this.runReadOnlyReport(input, {
|
|
3795
|
-
mode: "
|
|
3796
|
-
|
|
3980
|
+
mode: "ask",
|
|
3981
|
+
deepScan: true,
|
|
3797
3982
|
intent: "audit",
|
|
3798
|
-
title: "
|
|
3983
|
+
title: "Deep scan synthesis",
|
|
3799
3984
|
artifactName: "report.md",
|
|
3800
|
-
defaultPrompt: "
|
|
3985
|
+
defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
|
|
3801
3986
|
}, announce);
|
|
3802
3987
|
}
|
|
3803
3988
|
resolvePaidBudget(inputBudget, cfg) {
|
|
3804
3989
|
return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
|
|
3805
3990
|
}
|
|
3806
|
-
rootLedger(
|
|
3807
|
-
//
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
3810
|
-
//
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
valuation_usd: valuationUsd,
|
|
3818
|
-
}),
|
|
3819
|
-
});
|
|
3991
|
+
rootLedger(_input, contract, log) {
|
|
3992
|
+
// The root ledger discloses into THIS run's log: the ledger is the one
|
|
3993
|
+
// owner of the cash fact (subscription-entitled work settles to 0 there),
|
|
3994
|
+
// and the UI renders `budget.cash` verbatim — never inferring money from
|
|
3995
|
+
// route labels (W4.3 sol #15).
|
|
3996
|
+
const ledger = new BudgetLedger(contract.budget.paid_budget, undefined, {
|
|
3997
|
+
onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
|
|
3998
|
+
cash_spend_usd: cashSpendUsd,
|
|
3999
|
+
valuation_usd: valuationUsd,
|
|
4000
|
+
}),
|
|
4001
|
+
});
|
|
3820
4002
|
for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
|
|
3821
4003
|
ledger.observeQuotaSnapshot(snapshot);
|
|
3822
4004
|
}
|
|
@@ -3835,55 +4017,12 @@ export class Orchestrator {
|
|
|
3835
4017
|
? "metered"
|
|
3836
4018
|
: "unknown";
|
|
3837
4019
|
}
|
|
3838
|
-
async runOrchestrate(input, announce) {
|
|
3839
|
-
// "Doctor-verified" must mean status ok — degraded key-present routes are
|
|
3840
|
-
// excluded from the pool the planner plans over (readiness honesty).
|
|
3841
|
-
const pool = await this.gateway.doctorOkReal({ cwd: input.repoRoot }, "orchestrate");
|
|
3842
|
-
const crossFamily = pool.length >= 2;
|
|
3843
|
-
const goal = input.prompt || "Plan the next move for this repository.";
|
|
3844
|
-
// The typed orchestration contract is a REAL persisted artifact (producer
|
|
3845
|
-
// here, consumers: the planner prompt below + the plan validator).
|
|
3846
|
-
// Autonomy is producer-supplied (control-api/CLI -> daemon -> RunInput);
|
|
3847
|
-
// the executor below is its consumer. Default `suggest` (plan-only) preserves
|
|
3848
|
-
// the read-only contract when no autonomy is requested.
|
|
3849
|
-
const autonomy = input.autonomy ?? "suggest";
|
|
3850
|
-
const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
|
|
3851
|
-
const orchestrateContract = OrchestrateContractSchema.parse({
|
|
3852
|
-
thread_id: input.threadId ?? newId("th"),
|
|
3853
|
-
goal,
|
|
3854
|
-
budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
|
|
3855
|
-
autonomy,
|
|
3856
|
-
});
|
|
3857
|
-
const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
|
|
3858
|
-
return this.runReadOnlyReport(
|
|
3859
|
-
// The executed pool is pinned to the PLANNED pool (no double doctor
|
|
3860
|
-
// resolution drift between the prompt's claims and the actual route).
|
|
3861
|
-
// The planner must NOT resume or overwrite the thread's conversational
|
|
3862
|
-
// session — it speaks its own tool-belt framing, not the user's chat.
|
|
3863
|
-
{
|
|
3864
|
-
...input,
|
|
3865
|
-
resumeSessions: undefined,
|
|
3866
|
-
onSessionObserved: undefined,
|
|
3867
|
-
harnesses: input.harnesses ?? (pool.length > 0 ? pool : undefined),
|
|
3868
|
-
prompt: plannerPrompt,
|
|
3869
|
-
}, {
|
|
3870
|
-
mode: "orchestrate",
|
|
3871
|
-
swarm: false,
|
|
3872
|
-
intent: "orchestrate",
|
|
3873
|
-
title: "Orchestration plan",
|
|
3874
|
-
artifactName: "orchestration.md",
|
|
3875
|
-
defaultPrompt: plannerPrompt,
|
|
3876
|
-
contractIntent: goal,
|
|
3877
|
-
orchestrateContract,
|
|
3878
|
-
}, announce);
|
|
3879
|
-
}
|
|
3880
4020
|
async runReadOnlyReport(input, opts, announce) {
|
|
3881
4021
|
const taskId = input.taskId ?? newId("task");
|
|
3882
4022
|
const runId = input.runId ?? newId("run");
|
|
3883
4023
|
const prompt = input.prompt || opts.defaultPrompt;
|
|
3884
4024
|
// Contract validation BEFORE the run is announced (see runRace). The
|
|
3885
|
-
// recorded user intent is the CALLER's goal
|
|
3886
|
-
// prompt (orchestrate wraps the goal in a planner prompt).
|
|
4025
|
+
// recorded user intent is the CALLER's goal.
|
|
3887
4026
|
const contract = this.buildContract({ ...input, prompt: opts.contractIntent ?? prompt }, taskId, opts.mode);
|
|
3888
4027
|
const store = this.artifactStore(input);
|
|
3889
4028
|
const paths = store.createRun(runId);
|
|
@@ -3903,61 +4042,32 @@ export class Orchestrator {
|
|
|
3903
4042
|
});
|
|
3904
4043
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3905
4044
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
// Lazy ContextPack: explore/audit attach the compact scope atlas; ask stays bare.
|
|
3910
|
-
let contextSection = "";
|
|
3911
|
-
if (opts.mode !== "ask") {
|
|
3912
|
-
try {
|
|
3913
|
-
contextSection = await this.lazyContextSection(input, contract, store, paths, log);
|
|
3914
|
-
}
|
|
3915
|
-
catch (err) {
|
|
3916
|
-
const message = safeErrorMessage(err);
|
|
3917
|
-
store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
|
|
3918
|
-
writeFailure(store, paths, {
|
|
3919
|
-
phase: "context",
|
|
3920
|
-
category: "project",
|
|
3921
|
-
safeMessage: message,
|
|
3922
|
-
runDir: paths.root,
|
|
3923
|
-
});
|
|
3924
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: failed\n- Phase: context\n\n${message}\n`);
|
|
3925
|
-
log.emit("output.ready", {
|
|
3926
|
-
kind: "summary",
|
|
3927
|
-
path: "final/summary.md",
|
|
3928
|
-
state: "diagnostic",
|
|
3929
|
-
});
|
|
3930
|
-
log.emit("run.failed", {
|
|
3931
|
-
status: "failed",
|
|
3932
|
-
phase: "context",
|
|
3933
|
-
error: message,
|
|
3934
|
-
failure_ref: "final/failure.yaml",
|
|
3935
|
-
});
|
|
3936
|
-
return {
|
|
3937
|
-
runId,
|
|
3938
|
-
taskId,
|
|
3939
|
-
mode: opts.mode,
|
|
3940
|
-
status: "failed",
|
|
3941
|
-
winner: null,
|
|
3942
|
-
runDir: paths.root,
|
|
3943
|
-
summary: `context failed: ${message}`,
|
|
3944
|
-
candidates: [],
|
|
3945
|
-
};
|
|
3946
|
-
}
|
|
3947
|
-
}
|
|
4045
|
+
// The ask/deep-scan report stays bare (its scouts read the tree themselves);
|
|
4046
|
+
// no lazy ContextPack section is attached here.
|
|
4047
|
+
const contextSection = "";
|
|
3948
4048
|
const externalContextPolicy = contract.external_context.policy;
|
|
3949
|
-
const width = opts.
|
|
4049
|
+
const width = opts.deepScan
|
|
3950
4050
|
? Math.min(Math.max(input.n ?? 4, 1), 8)
|
|
3951
4051
|
: externalContextPolicy === "off"
|
|
3952
4052
|
? 1
|
|
3953
4053
|
: Math.min(Math.max(input.n ?? 2, 1), 3);
|
|
3954
4054
|
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3955
4055
|
// read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
|
|
4056
|
+
// The point-probe home is a disposable throwaway even for a thread lane
|
|
4057
|
+
// turn: readiness auth truth is home-INDEPENDENT (credentials come from the
|
|
4058
|
+
// profile/keychain/default store, never the scoped home), so the probe and
|
|
4059
|
+
// the run share the same auth source; only the ACTUAL spawn swaps in the
|
|
4060
|
+
// durable per-lane home below so the recorded native session survives.
|
|
3956
4061
|
const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
|
|
4062
|
+
// A thread ASK turn is a chat turn: its native session is recorded per lane
|
|
4063
|
+
// and the next lane turn resumes it (INV-034). Deep-scan (multi-scout
|
|
4064
|
+
// research) and orchestrate (tool-belt planner, not the user's chat) are
|
|
4065
|
+
// NOT lane chat turns — they keep the disposable home and record nothing.
|
|
4066
|
+
const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
|
|
3957
4067
|
let adapters;
|
|
3958
4068
|
try {
|
|
3959
|
-
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
|
|
3960
|
-
if (!opts.
|
|
4069
|
+
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome);
|
|
4070
|
+
if (!opts.deepScan) {
|
|
3961
4071
|
const seen = new Set();
|
|
3962
4072
|
adapters = adapters.filter((routed) => {
|
|
3963
4073
|
if (seen.has(routed.adapter.id))
|
|
@@ -3977,10 +4087,12 @@ export class Orchestrator {
|
|
|
3977
4087
|
safeMessage: message,
|
|
3978
4088
|
runDir: paths.root,
|
|
3979
4089
|
});
|
|
3980
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n-
|
|
4090
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
|
|
3981
4091
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3982
4092
|
log.emit("run.failed", {
|
|
3983
|
-
|
|
4093
|
+
lifecycle: "failed",
|
|
4094
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
4095
|
+
reason: "harness_failed",
|
|
3984
4096
|
phase: "routing",
|
|
3985
4097
|
error: message,
|
|
3986
4098
|
failure_ref: "final/failure.yaml",
|
|
@@ -3989,7 +4101,8 @@ export class Orchestrator {
|
|
|
3989
4101
|
runId,
|
|
3990
4102
|
taskId,
|
|
3991
4103
|
mode: opts.mode,
|
|
3992
|
-
|
|
4104
|
+
lifecycle: "failed",
|
|
4105
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3993
4106
|
winner: null,
|
|
3994
4107
|
runDir: paths.root,
|
|
3995
4108
|
summary: message,
|
|
@@ -4030,17 +4143,26 @@ export class Orchestrator {
|
|
|
4030
4143
|
}
|
|
4031
4144
|
const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
|
|
4032
4145
|
const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
|
|
4033
|
-
const explorerPrompt = (opts.
|
|
4146
|
+
const explorerPrompt = (opts.deepScan
|
|
4034
4147
|
? `${prompt}\n\nExplorer ${idx + 1}/${adapters.length}: focus on a distinct slice. Emit evidence-cited findings, explicit unknowns/omissions, and follow-up questions. Do not edit files.`
|
|
4035
4148
|
: prompt) + contextSection;
|
|
4036
4149
|
const sessionFields = this.sessionSpecFields(input, adapter.id, log);
|
|
4037
4150
|
const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
|
|
4038
4151
|
if (grantResume)
|
|
4039
4152
|
resumeGranted.add(adapter.id);
|
|
4153
|
+
// Continuity (INV-137): a thread ASK turn is a chat turn — hydrate a lane
|
|
4154
|
+
// switch/gap with a packet and disclose it. Gated on laneRun (deep-scan
|
|
4155
|
+
// scouts are excluded from laneRun); native resume is available only when
|
|
4156
|
+
// this slot was granted the lane's recorded session.
|
|
4157
|
+
const laneContinuity = laneRun
|
|
4158
|
+
? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, store, paths, this.execRootOf(input), log)
|
|
4159
|
+
: null;
|
|
4040
4160
|
let spec = HarnessRunSpec.parse({
|
|
4041
4161
|
session_id: newId("ses"),
|
|
4042
4162
|
intent: opts.intent,
|
|
4043
|
-
prompt:
|
|
4163
|
+
prompt: laneContinuity?.pointerLine
|
|
4164
|
+
? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
|
|
4165
|
+
: explorerPrompt,
|
|
4044
4166
|
cwd: this.execRootOf(input),
|
|
4045
4167
|
access: "readonly",
|
|
4046
4168
|
// ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
|
|
@@ -4052,19 +4174,10 @@ export class Orchestrator {
|
|
|
4052
4174
|
resume_session_id: grantResume ? sessionFields.resume_session_id : null,
|
|
4053
4175
|
...this.harnessSpecKnobs(contract, knobs, opts.intent),
|
|
4054
4176
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
4055
|
-
|
|
4056
|
-
//
|
|
4057
|
-
//
|
|
4058
|
-
|
|
4059
|
-
// gated off when this spec will ride the INTERACTIVE stream-json
|
|
4060
|
-
// transport (an interaction channel will be offered): --json-schema x
|
|
4061
|
-
// interactive is an unverified vendor combination — fenced parsing
|
|
4062
|
-
// carries those runs until it is live-verified.
|
|
4063
|
-
...(opts.intent === "orchestrate" &&
|
|
4064
|
-
routed.supportsJsonSchemaOutput &&
|
|
4065
|
-
!(Boolean(input.onInteraction) && routed.supportsInteractive)
|
|
4066
|
-
? { output_schema: orchestratePlanJsonSchema() }
|
|
4067
|
-
: {}),
|
|
4177
|
+
// A thread lane turn spawns in its DURABLE per-lane home so the native
|
|
4178
|
+
// session it records is reachable for resume next turn; everything else
|
|
4179
|
+
// uses the disposable route-context home.
|
|
4180
|
+
env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
|
|
4068
4181
|
});
|
|
4069
4182
|
const reportAbort = new AbortController();
|
|
4070
4183
|
spec.extra["abortSignal"] = input.signal
|
|
@@ -4131,9 +4244,14 @@ export class Orchestrator {
|
|
|
4131
4244
|
break;
|
|
4132
4245
|
const safeEv = redactHarnessEvent(ev);
|
|
4133
4246
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
4134
|
-
//
|
|
4135
|
-
//
|
|
4136
|
-
//
|
|
4247
|
+
// A thread ASK turn IS a chat turn now (INV-034): its native
|
|
4248
|
+
// session lives in the DURABLE per-lane home, so record it for the
|
|
4249
|
+
// next lane turn's resume. The read-only fallback chain is
|
|
4250
|
+
// sequential (never the parallel deep-scan swarm, which is
|
|
4251
|
+
// excluded from `laneRun`), so recordSession's upsert keeps the
|
|
4252
|
+
// latest lane session without a race.
|
|
4253
|
+
if (laneRun)
|
|
4254
|
+
observeNativeSessionEvent(input, adapter.id, safeEv);
|
|
4137
4255
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
4138
4256
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
4139
4257
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
@@ -4226,7 +4344,7 @@ export class Orchestrator {
|
|
|
4226
4344
|
}
|
|
4227
4345
|
finally {
|
|
4228
4346
|
input.signal?.removeEventListener("abort", onAbort);
|
|
4229
|
-
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode));
|
|
4347
|
+
ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
|
|
4230
4348
|
}
|
|
4231
4349
|
if (harnessError && telemetry.transientFailures.length > 0) {
|
|
4232
4350
|
log.emit("route.transient.exhausted", {
|
|
@@ -4269,7 +4387,7 @@ export class Orchestrator {
|
|
|
4269
4387
|
error: harnessError,
|
|
4270
4388
|
telemetry,
|
|
4271
4389
|
});
|
|
4272
|
-
if (opts.
|
|
4390
|
+
if (opts.deepScan) {
|
|
4273
4391
|
store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
|
|
4274
4392
|
}
|
|
4275
4393
|
return;
|
|
@@ -4288,7 +4406,7 @@ export class Orchestrator {
|
|
|
4288
4406
|
error: null,
|
|
4289
4407
|
telemetry,
|
|
4290
4408
|
});
|
|
4291
|
-
if (opts.
|
|
4409
|
+
if (opts.deepScan) {
|
|
4292
4410
|
const warningNote = toolWarnings(telemetry).length
|
|
4293
4411
|
? `\n\n> Tool warnings: ${toolWarnings(telemetry)
|
|
4294
4412
|
.map((e) => `${e.tool}: ${e.summary}`)
|
|
@@ -4298,7 +4416,7 @@ export class Orchestrator {
|
|
|
4298
4416
|
}
|
|
4299
4417
|
};
|
|
4300
4418
|
try {
|
|
4301
|
-
if (opts.
|
|
4419
|
+
if (opts.deepScan) {
|
|
4302
4420
|
// Explorer swarm runs in parallel (bounded), mirroring parallel candidates.
|
|
4303
4421
|
await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), runReadonlyAttempt);
|
|
4304
4422
|
}
|
|
@@ -4388,7 +4506,7 @@ export class Orchestrator {
|
|
|
4388
4506
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
4389
4507
|
}
|
|
4390
4508
|
const succeededReadonly = attempts.filter((a) => a.status === "success");
|
|
4391
|
-
if (!opts.
|
|
4509
|
+
if (!opts.deepScan && succeededReadonly.length === 0) {
|
|
4392
4510
|
const last = attempts[attempts.length - 1];
|
|
4393
4511
|
const webBlocked = attempts.some((a) => a.status === "blocked");
|
|
4394
4512
|
const singleError = last?.error ?? (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
|
|
@@ -4422,16 +4540,17 @@ export class Orchestrator {
|
|
|
4422
4540
|
runDir: paths.root,
|
|
4423
4541
|
nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
|
|
4424
4542
|
});
|
|
4425
|
-
const
|
|
4426
|
-
? "blocked"
|
|
4543
|
+
const terminalFacts = webBlocked
|
|
4544
|
+
? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
|
|
4427
4545
|
: budgetStopped && attempts.length === 0
|
|
4428
|
-
? "
|
|
4429
|
-
: "failed";
|
|
4430
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${last?.harnessId ?? "none"}\n-
|
|
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`);
|
|
4431
4549
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
4432
|
-
if (
|
|
4550
|
+
if (terminalFacts.lifecycle === "succeeded") {
|
|
4433
4551
|
log.emit("run.blocked", {
|
|
4434
|
-
|
|
4552
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4553
|
+
facts: terminalFacts,
|
|
4435
4554
|
harness_id: last?.harnessId,
|
|
4436
4555
|
error: singleError,
|
|
4437
4556
|
failure_ref: "final/failure.yaml",
|
|
@@ -4439,7 +4558,9 @@ export class Orchestrator {
|
|
|
4439
4558
|
}
|
|
4440
4559
|
else {
|
|
4441
4560
|
log.emit("run.failed", {
|
|
4442
|
-
|
|
4561
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4562
|
+
facts: terminalFacts,
|
|
4563
|
+
reason: terminalFacts.reason,
|
|
4443
4564
|
harness_id: last?.harnessId,
|
|
4444
4565
|
error: singleError,
|
|
4445
4566
|
failure_ref: "final/failure.yaml",
|
|
@@ -4450,7 +4571,8 @@ export class Orchestrator {
|
|
|
4450
4571
|
runId,
|
|
4451
4572
|
taskId,
|
|
4452
4573
|
mode: opts.mode,
|
|
4453
|
-
|
|
4574
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4575
|
+
facts: terminalFacts,
|
|
4454
4576
|
winner: null,
|
|
4455
4577
|
runDir: paths.root,
|
|
4456
4578
|
summary: singleError,
|
|
@@ -4462,7 +4584,7 @@ export class Orchestrator {
|
|
|
4462
4584
|
};
|
|
4463
4585
|
}
|
|
4464
4586
|
const succeeded = succeededReadonly;
|
|
4465
|
-
if (opts.
|
|
4587
|
+
if (opts.deepScan && succeeded.length === 0) {
|
|
4466
4588
|
const message = attempts
|
|
4467
4589
|
.map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
|
|
4468
4590
|
.join("\n");
|
|
@@ -4481,18 +4603,24 @@ export class Orchestrator {
|
|
|
4481
4603
|
"Retry after setup",
|
|
4482
4604
|
],
|
|
4483
4605
|
});
|
|
4484
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n-
|
|
4606
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${blocked ? "succeeded (needs review)" : "failed"}\n\n${message}\n`);
|
|
4485
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" });
|
|
4486
4611
|
if (blocked)
|
|
4487
4612
|
log.emit("run.blocked", {
|
|
4488
|
-
|
|
4613
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4614
|
+
facts: scanFailFacts,
|
|
4489
4615
|
phase: "harness",
|
|
4490
4616
|
error: message,
|
|
4491
4617
|
failure_ref: "final/failure.yaml",
|
|
4492
4618
|
});
|
|
4493
4619
|
else
|
|
4494
4620
|
log.emit("run.failed", {
|
|
4495
|
-
|
|
4621
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4622
|
+
facts: scanFailFacts,
|
|
4623
|
+
reason: scanFailFacts.reason,
|
|
4496
4624
|
phase: "harness",
|
|
4497
4625
|
error: message,
|
|
4498
4626
|
failure_ref: "final/failure.yaml",
|
|
@@ -4502,7 +4630,8 @@ export class Orchestrator {
|
|
|
4502
4630
|
runId,
|
|
4503
4631
|
taskId,
|
|
4504
4632
|
mode: opts.mode,
|
|
4505
|
-
|
|
4633
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4634
|
+
facts: scanFailFacts,
|
|
4506
4635
|
winner: null,
|
|
4507
4636
|
runDir: paths.root,
|
|
4508
4637
|
summary: message,
|
|
@@ -4514,7 +4643,7 @@ export class Orchestrator {
|
|
|
4514
4643
|
};
|
|
4515
4644
|
}
|
|
4516
4645
|
const unsuccessful = attempts.filter((a) => a.status !== "success");
|
|
4517
|
-
const report = opts.
|
|
4646
|
+
const report = opts.deepScan
|
|
4518
4647
|
? [
|
|
4519
4648
|
`Explorers succeeded: ${succeeded.length}/${attempts.length}.`,
|
|
4520
4649
|
"",
|
|
@@ -4552,36 +4681,12 @@ export class Orchestrator {
|
|
|
4552
4681
|
answerText: succeeded[0]?.report ?? "",
|
|
4553
4682
|
});
|
|
4554
4683
|
}
|
|
4555
|
-
|
|
4556
|
-
// the required fenced JSON block, validate it against the tool belt, and
|
|
4557
|
-
// persist final/orchestration.yaml; a missing/invalid block is disclosed in
|
|
4558
|
-
// the summary and events (suggest autonomy: the plan is the work product).
|
|
4559
|
-
let typedPlanNote = "";
|
|
4560
|
-
let orchestratePlan = null;
|
|
4561
|
-
if (opts.mode === "orchestrate") {
|
|
4562
|
-
const extracted = extractOrchestratePlan(report);
|
|
4563
|
-
if (extracted.plan) {
|
|
4564
|
-
orchestratePlan = extracted.plan;
|
|
4565
|
-
store.writeYaml(join(paths.finalDir, "orchestration.yaml"), extracted.plan);
|
|
4566
|
-
log.emit("output.ready", { kind: "report", path: "final/orchestration.yaml" });
|
|
4567
|
-
typedPlanNote = `\n- Typed plan: final/orchestration.yaml (${extracted.plan.tool_calls.length} tool call(s))`;
|
|
4568
|
-
}
|
|
4569
|
-
else {
|
|
4570
|
-
store.writeText(join(paths.finalDir, "orchestration_parse_error.md"), `# Typed plan missing\n\n${extracted.error}\n`);
|
|
4571
|
-
log.emit("output.ready", {
|
|
4572
|
-
kind: "report",
|
|
4573
|
-
path: "final/orchestration_parse_error.md",
|
|
4574
|
-
state: "diagnostic",
|
|
4575
|
-
});
|
|
4576
|
-
typedPlanNote = `\n- Typed plan: MISSING (${extracted.error}); the markdown plan above is the only artifact`;
|
|
4577
|
-
}
|
|
4578
|
-
}
|
|
4579
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.swarm ? null : (succeeded[0]?.attemptId ?? null));
|
|
4684
|
+
this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null));
|
|
4580
4685
|
log.emit("output.ready", {
|
|
4581
4686
|
kind: opts.mode === "ask" ? "answer" : "report",
|
|
4582
4687
|
path: `final/${opts.artifactName}`,
|
|
4583
4688
|
});
|
|
4584
|
-
if (opts.
|
|
4689
|
+
if (opts.deepScan) {
|
|
4585
4690
|
store.writeYaml(join(paths.finalDir, "explore-findings.yaml"), {
|
|
4586
4691
|
mode: "explore",
|
|
4587
4692
|
width,
|
|
@@ -4603,99 +4708,60 @@ export class Orchestrator {
|
|
|
4603
4708
|
});
|
|
4604
4709
|
store.writeText(join(paths.finalDir, "omissions.md"), `# Omissions\n\n${unsuccessful.map((a) => `- ${a.attemptId} / ${a.harnessId} (${a.status}): ${a.error}`).join("\n") || "- None recorded by the runner. Synthesis claims still require evidence checks."}\n`);
|
|
4605
4710
|
}
|
|
4606
|
-
//
|
|
4607
|
-
//
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
|
|
4613
|
-
let terminal = "success";
|
|
4614
|
-
let orchestrateReadOnly = true;
|
|
4615
|
-
let orchestrateReceiptRefs = [];
|
|
4616
|
-
if (ledger.terminal() !== null)
|
|
4617
|
-
terminal = ledger.terminal();
|
|
4618
|
-
// orchestrate's contract output IS the typed plan. If the planner failed to
|
|
4619
|
-
// produce a valid one, the run is NOT a clean success — disclose it honestly
|
|
4620
|
-
// (the markdown plan stays as a diagnostic artifact) rather than reporting
|
|
4621
|
-
// success alongside an orchestration_parse_error.md.
|
|
4622
|
-
if (opts.mode === "orchestrate" && !orchestratePlan)
|
|
4623
|
-
terminal = "not_converged";
|
|
4624
|
-
if (opts.mode === "orchestrate" && autonomy !== "suggest" && orchestratePlan) {
|
|
4625
|
-
// Thread the GENERATED runId onto input so the executor's answer_question
|
|
4626
|
-
// step keys the interaction registry by this orchestrate run's id (callers
|
|
4627
|
-
// often invoke run() without a preassigned runId).
|
|
4628
|
-
const executionInput = { ...input, runId };
|
|
4629
|
-
const exec = await executeOrchestratePlan({
|
|
4630
|
-
plan: orchestratePlan,
|
|
4631
|
-
autonomy,
|
|
4632
|
-
maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
|
|
4633
|
-
signal: input.signal,
|
|
4634
|
-
store,
|
|
4635
|
-
paths,
|
|
4636
|
-
log,
|
|
4637
|
-
ledger,
|
|
4638
|
-
executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
|
|
4639
|
-
executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
|
|
4640
|
-
});
|
|
4641
|
-
terminal = exec.terminal;
|
|
4642
|
-
orchestrateReadOnly = exec.readOnly;
|
|
4643
|
-
orchestrateReceiptRefs = exec.receiptRefs;
|
|
4644
|
-
typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
|
|
4645
|
-
}
|
|
4711
|
+
// A read-only report (ask / deep-scan) has no live-tree work; the only
|
|
4712
|
+
// non-clean terminal is an aggregate paid-budget stop.
|
|
4713
|
+
let terminalFacts = makeOutcomeFacts("succeeded");
|
|
4714
|
+
const reportBudgetTerminal = ledger.terminal();
|
|
4715
|
+
if (reportBudgetTerminal)
|
|
4716
|
+
terminalFacts = makeOutcomeFacts("failed", { reason: reportBudgetTerminal });
|
|
4646
4717
|
const harnessLabel = attempts
|
|
4647
4718
|
.map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
|
|
4648
4719
|
.join(", ");
|
|
4649
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harnesses: ${harnessLabel}\n-
|
|
4720
|
+
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harnesses: ${harnessLabel}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${report}\n`);
|
|
4650
4721
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
4651
4722
|
id: newId("wp"),
|
|
4652
4723
|
kind: "report",
|
|
4653
4724
|
source_task_id: taskId,
|
|
4654
4725
|
producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
|
|
4655
|
-
files: Object.fromEntries([
|
|
4656
|
-
[opts.artifactName, join(paths.finalDir, opts.artifactName)],
|
|
4657
|
-
...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
|
|
4658
|
-
]),
|
|
4726
|
+
files: Object.fromEntries([[opts.artifactName, join(paths.finalDir, opts.artifactName)]]),
|
|
4659
4727
|
meta: {
|
|
4660
4728
|
harnesses: attempts.map((a) => a.harnessId),
|
|
4661
4729
|
mode: opts.mode,
|
|
4662
4730
|
intent: opts.intent,
|
|
4663
|
-
read_only:
|
|
4731
|
+
read_only: true,
|
|
4664
4732
|
},
|
|
4665
4733
|
});
|
|
4666
4734
|
log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4735
|
+
if (terminalFacts.lifecycle === "failed") {
|
|
4736
|
+
writeFailure(store, paths, {
|
|
4737
|
+
phase: "executor",
|
|
4738
|
+
category: "budget",
|
|
4739
|
+
safeMessage: `read-only report ended ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason.replaceAll("_", " ")})` : ""}`,
|
|
4740
|
+
runDir: paths.root,
|
|
4741
|
+
nextActions: ["Inspect the report artifacts", "Adjust the budget and retry"],
|
|
4674
4742
|
});
|
|
4675
|
-
}
|
|
4676
|
-
else if (orchestrateFailure) {
|
|
4677
|
-
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4678
4743
|
log.emit("run.failed", {
|
|
4679
|
-
|
|
4680
|
-
|
|
4744
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4745
|
+
facts: terminalFacts,
|
|
4746
|
+
reason: terminalFacts.reason,
|
|
4747
|
+
phase: "executor",
|
|
4681
4748
|
failure_ref: "final/failure.yaml",
|
|
4682
4749
|
});
|
|
4683
4750
|
}
|
|
4684
|
-
else if (terminal === "cancelled") {
|
|
4685
|
-
// Cancel is failure-shaped (parity with every other mode's cancel
|
|
4686
|
-
// terminal): tailers waiting for run.completed-as-success must not
|
|
4687
|
-
// mistake an operator abort for a clean report.
|
|
4688
|
-
log.emit("run.failed", { status: terminal });
|
|
4689
|
-
}
|
|
4690
4751
|
else {
|
|
4691
|
-
log.emit("run.completed", {
|
|
4752
|
+
log.emit("run.completed", {
|
|
4753
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4754
|
+
facts: terminalFacts,
|
|
4755
|
+
reason: terminalFacts.reason,
|
|
4756
|
+
});
|
|
4692
4757
|
}
|
|
4693
4758
|
return {
|
|
4694
4759
|
spendUsd: ledger.spend(),
|
|
4695
4760
|
runId,
|
|
4696
4761
|
taskId,
|
|
4697
4762
|
mode: opts.mode,
|
|
4698
|
-
|
|
4763
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4764
|
+
facts: terminalFacts,
|
|
4699
4765
|
winner: null,
|
|
4700
4766
|
runDir: paths.root,
|
|
4701
4767
|
summary: redactSecrets(report).slice(0, 400),
|
|
@@ -4706,230 +4772,6 @@ export class Orchestrator {
|
|
|
4706
4772
|
})),
|
|
4707
4773
|
};
|
|
4708
4774
|
}
|
|
4709
|
-
/**
|
|
4710
|
-
* Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
|
|
4711
|
-
* (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
|
|
4712
|
-
* answer delivery that never mutate the live tree.
|
|
4713
|
-
*/
|
|
4714
|
-
async executeSafeStep(input, call, log, store, paths, ledger) {
|
|
4715
|
-
switch (call.tool) {
|
|
4716
|
-
case "start_run":
|
|
4717
|
-
case "race": {
|
|
4718
|
-
// Isolated envelope sub-run (construction owned by runSupport);
|
|
4719
|
-
// recursion guard via orchestrateDepth+1, may NOT orchestrate.
|
|
4720
|
-
const subInput = {
|
|
4721
|
-
...buildEnvelopeSubInput(input, call, ledger),
|
|
4722
|
-
};
|
|
4723
|
-
// SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
|
|
4724
|
-
// isolated envelope — never a live in-place turn.
|
|
4725
|
-
assertEnvelopeSubRun(subInput);
|
|
4726
|
-
log.emit("orchestrate.subrun.started", {
|
|
4727
|
-
tool: call.tool,
|
|
4728
|
-
mode: subInput.mode,
|
|
4729
|
-
n: subInput.n ?? null,
|
|
4730
|
-
});
|
|
4731
|
-
const res = await this.run(subInput);
|
|
4732
|
-
return {
|
|
4733
|
-
status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
|
|
4734
|
-
terminalStatus: res.status,
|
|
4735
|
-
terminalSource: "subrun",
|
|
4736
|
-
evidenceRefs: [`run:${res.runId}`],
|
|
4737
|
-
runId: res.runId,
|
|
4738
|
-
spendUsd: res.spendUsd ?? null,
|
|
4739
|
-
detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
|
|
4740
|
-
};
|
|
4741
|
-
}
|
|
4742
|
-
case "status": {
|
|
4743
|
-
// Pure read of the referenced run's decision/work_product artifacts.
|
|
4744
|
-
const read = readRunStatus(input.repoRoot, call.run_id);
|
|
4745
|
-
return {
|
|
4746
|
-
status: read ? "done" : "skipped",
|
|
4747
|
-
terminalStatus: read?.status ?? null,
|
|
4748
|
-
terminalSource: "subrun",
|
|
4749
|
-
evidenceRefs: read?.evidenceRefs ?? [],
|
|
4750
|
-
runId: call.run_id,
|
|
4751
|
-
detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4752
|
-
};
|
|
4753
|
-
}
|
|
4754
|
-
case "review": {
|
|
4755
|
-
// Read-only review over the referenced run's recorded patch diff. The
|
|
4756
|
-
// step ACTUALLY runs the reviewer panel (evidence beats summaries — a
|
|
4757
|
-
// "done" review must mean a review happened), persists its artifacts, and
|
|
4758
|
-
// reports the real outcome; eligibility alone is never reported as done.
|
|
4759
|
-
const diff = readRunPatch(input.repoRoot, call.run_id);
|
|
4760
|
-
if (diff === null)
|
|
4761
|
-
return {
|
|
4762
|
-
status: "skipped",
|
|
4763
|
-
terminalStatus: null,
|
|
4764
|
-
terminalSource: "review",
|
|
4765
|
-
evidenceRefs: [],
|
|
4766
|
-
runId: call.run_id,
|
|
4767
|
-
detail: `run ${call.run_id} has no patch.diff to review`,
|
|
4768
|
-
};
|
|
4769
|
-
// Aggregate honesty: reviewer panels spend real money on
|
|
4770
|
-
// API-keyed routes and the spend is charged AFTER the fact — with no
|
|
4771
|
-
// remaining headroom the review must not start at all.
|
|
4772
|
-
if (ledger.terminal() !== null) {
|
|
4773
|
-
return {
|
|
4774
|
-
status: "skipped",
|
|
4775
|
-
terminalStatus: null,
|
|
4776
|
-
terminalSource: "review",
|
|
4777
|
-
evidenceRefs: [],
|
|
4778
|
-
runId: call.run_id,
|
|
4779
|
-
detail: "aggregate budget exhausted before the review step",
|
|
4780
|
-
};
|
|
4781
|
-
}
|
|
4782
|
-
const reviewers = await this.resolveReviewers(input.repoRoot, input.authPreference);
|
|
4783
|
-
if (reviewers.length === 0)
|
|
4784
|
-
return {
|
|
4785
|
-
status: "skipped",
|
|
4786
|
-
terminalStatus: null,
|
|
4787
|
-
terminalSource: "review",
|
|
4788
|
-
evidenceRefs: [],
|
|
4789
|
-
runId: call.run_id,
|
|
4790
|
-
detail: "no doctor-OK reviewers available",
|
|
4791
|
-
};
|
|
4792
|
-
const reviewLease = ledger.reserve({
|
|
4793
|
-
taskId: input.taskId ?? "orchestrate",
|
|
4794
|
-
attemptId: `review-${call.run_id}`,
|
|
4795
|
-
intent: "review",
|
|
4796
|
-
harnessId: "review-panel",
|
|
4797
|
-
cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
|
|
4798
|
-
});
|
|
4799
|
-
if (!reviewLease.granted)
|
|
4800
|
-
return {
|
|
4801
|
-
status: "skipped",
|
|
4802
|
-
terminalStatus: ledger.terminal(),
|
|
4803
|
-
terminalSource: "review",
|
|
4804
|
-
evidenceRefs: [],
|
|
4805
|
-
runId: call.run_id,
|
|
4806
|
-
detail: reviewLease.reason ?? "root paid budget refused the review step",
|
|
4807
|
-
};
|
|
4808
|
-
const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
|
|
4809
|
-
writeEvidencePacket(evidenceDir, {
|
|
4810
|
-
userIntent: redactSecrets(input.prompt),
|
|
4811
|
-
planAccepted: `orchestrate review tool requested a read-only review of run ${call.run_id}.`,
|
|
4812
|
-
diff,
|
|
4813
|
-
tests: input.tests?.join("\n") || "(no test commands configured)",
|
|
4814
|
-
decidedTradeoffs: "This review is scoped to the referenced run patch and must use typed reviewer artifacts, not summary-only evidence.",
|
|
4815
|
-
});
|
|
4816
|
-
const result = await this.reviewScoped({
|
|
4817
|
-
candidateLabel: `Run ${call.run_id}`,
|
|
4818
|
-
diff,
|
|
4819
|
-
evidenceDir,
|
|
4820
|
-
artifactsDir: join(paths.reviewsDir, `orchestrate-${call.run_id}`),
|
|
4821
|
-
cwd: input.repoRoot,
|
|
4822
|
-
reviewers,
|
|
4823
|
-
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
4824
|
-
signal: input.signal,
|
|
4825
|
-
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
4826
|
-
}).catch((error) => {
|
|
4827
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
|
|
4828
|
-
throw error;
|
|
4829
|
-
});
|
|
4830
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(result.reviewSpendUsd, result.reviewSpendEstimated, "review-usage", [
|
|
4831
|
-
`orchestrate:review:${call.run_id}`,
|
|
4832
|
-
]));
|
|
4833
|
-
const revalidated = await revalidateFindings(result.findings, {
|
|
4834
|
-
candidateRoot: input.repoRoot,
|
|
4835
|
-
evidenceDir,
|
|
4836
|
-
});
|
|
4837
|
-
store.writeYaml(join(paths.reviewsDir, `orchestrate-${call.run_id}.yaml`), {
|
|
4838
|
-
target_run_id: call.run_id,
|
|
4839
|
-
cross_family_healthy: result.crossFamilyHealthy,
|
|
4840
|
-
cross_family_verified: result.crossFamilyVerified,
|
|
4841
|
-
findings: revalidated,
|
|
4842
|
-
route_proofs: result.routeProofs,
|
|
4843
|
-
});
|
|
4844
|
-
const blockers = revalidated.filter((f) => isBlocking(f)).length;
|
|
4845
|
-
return {
|
|
4846
|
-
status: "done",
|
|
4847
|
-
terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
|
|
4848
|
-
terminalSource: "review",
|
|
4849
|
-
evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
|
|
4850
|
-
runId: call.run_id,
|
|
4851
|
-
detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
|
|
4852
|
-
// Reviewer panels can spend real money on API-keyed routes; the
|
|
4853
|
-
// aggregate cap must charge it like any other step.
|
|
4854
|
-
spendUsd: result.reviewSpendUsd ?? null,
|
|
4855
|
-
};
|
|
4856
|
-
}
|
|
4857
|
-
case "answer_question": {
|
|
4858
|
-
// Delivery + registry-keying rationale owned by runSupport.
|
|
4859
|
-
const answer = await deliverPlanAnswer(input, call);
|
|
4860
|
-
return {
|
|
4861
|
-
...answer,
|
|
4862
|
-
terminalStatus: answer.status === "done" ? "success" : null,
|
|
4863
|
-
terminalSource: "executor",
|
|
4864
|
-
evidenceRefs: [],
|
|
4865
|
-
};
|
|
4866
|
-
}
|
|
4867
|
-
default: {
|
|
4868
|
-
// FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
|
|
4869
|
-
// the caller routes risky steps to executeApplyStep / the auto_safe block.
|
|
4870
|
-
throw new Error(`executeSafeStep refused a non-safe tool '${call.tool}' (risky tools must not run as safe steps)`);
|
|
4871
|
-
}
|
|
4872
|
-
}
|
|
4873
|
-
}
|
|
4874
|
-
/** Execute an auto_full apply through the shared fresh-verification gate. */
|
|
4875
|
-
async executeApplyStep(input, call, log) {
|
|
4876
|
-
const store = new ArtifactStore(input.repoRoot);
|
|
4877
|
-
const sub = store.runPaths(call.run_id);
|
|
4878
|
-
const patchPath = join(sub.finalDir, "patch.diff");
|
|
4879
|
-
const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : null;
|
|
4880
|
-
if (patchText === null)
|
|
4881
|
-
return {
|
|
4882
|
-
ok: false,
|
|
4883
|
-
runId: call.run_id,
|
|
4884
|
-
detail: `run ${call.run_id} has no patch.diff`,
|
|
4885
|
-
receipt: null,
|
|
4886
|
-
};
|
|
4887
|
-
if (containsSecretLikeToken(patchText))
|
|
4888
|
-
return {
|
|
4889
|
-
ok: false,
|
|
4890
|
-
runId: call.run_id,
|
|
4891
|
-
detail: "patch contains a secret-like token; refusing apply",
|
|
4892
|
-
receipt: null,
|
|
4893
|
-
};
|
|
4894
|
-
const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
|
|
4895
|
-
const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
|
|
4896
|
-
const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
|
|
4897
|
-
const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
|
|
4898
|
-
const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
|
|
4899
|
-
if (!taskContract.success)
|
|
4900
|
-
return {
|
|
4901
|
-
ok: false,
|
|
4902
|
-
runId: call.run_id,
|
|
4903
|
-
detail: "fresh verification contract is missing",
|
|
4904
|
-
receipt: null,
|
|
4905
|
-
};
|
|
4906
|
-
const applyGateInput = {
|
|
4907
|
-
state: null,
|
|
4908
|
-
decision: parsedDecision?.success ? parsedDecision.data : null,
|
|
4909
|
-
workProduct: parsedWp?.success ? parsedWp.data : null,
|
|
4910
|
-
patch: patchText,
|
|
4911
|
-
originalRepoRoot: input.repoRoot,
|
|
4912
|
-
targetRepoRoot: input.repoRoot,
|
|
4913
|
-
operatorDecision: null,
|
|
4914
|
-
};
|
|
4915
|
-
const gateError = validateApplyGate(applyGateInput);
|
|
4916
|
-
if (gateError)
|
|
4917
|
-
return {
|
|
4918
|
-
ok: false,
|
|
4919
|
-
runId: call.run_id,
|
|
4920
|
-
detail: `apply gate refused: ${gateError}`,
|
|
4921
|
-
receipt: null,
|
|
4922
|
-
};
|
|
4923
|
-
const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
|
|
4924
|
-
return {
|
|
4925
|
-
ok: delivered.applied,
|
|
4926
|
-
runId: call.run_id,
|
|
4927
|
-
detail: delivered.applied
|
|
4928
|
-
? `applied (${call.mode})`
|
|
4929
|
-
: `deliver failed: ${delivered.detail ?? "unknown"}`,
|
|
4930
|
-
receipt: delivered,
|
|
4931
|
-
};
|
|
4932
|
-
}
|
|
4933
4775
|
}
|
|
4934
4776
|
function assertNoSecretLikeTokens(label, text) {
|
|
4935
4777
|
if (containsSecretLikeToken(text)) {
|