@claudexor/orchestrator 2.1.3 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/candidateEvidence.d.ts.map +1 -1
- package/dist/candidateEvidence.js +4 -6
- package/dist/candidateEvidence.js.map +1 -1
- package/dist/candidateOutputs.d.ts +13 -0
- package/dist/candidateOutputs.d.ts.map +1 -1
- package/dist/candidateOutputs.js +71 -8
- package/dist/candidateOutputs.js.map +1 -1
- 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 +26 -0
- package/dist/credential-profiles.d.ts.map +1 -1
- package/dist/credential-profiles.js +21 -0
- package/dist/credential-profiles.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 -57
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +968 -1077
- 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,4 +1,4 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
|
|
3
3
|
import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
|
|
4
4
|
import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
|
|
@@ -7,20 +7,19 @@ import { toCandidateEvidence } from "./candidateEvidence.js";
|
|
|
7
7
|
import { capabilityIntents } from "@claudexor/gateway";
|
|
8
8
|
import { policyFindings } from "./policyFindings.js";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
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";
|
|
11
12
|
import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
|
|
12
|
-
import {
|
|
13
|
-
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";
|
|
14
14
|
import { assertRouteModelsAllowed } from "./modelGovernance.js";
|
|
15
15
|
import { RequestRequirementsResolver } from "./requestRequirements.js";
|
|
16
16
|
import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
|
|
17
17
|
import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
|
|
18
|
-
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";
|
|
19
19
|
import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
|
|
20
20
|
import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
|
|
21
|
+
import { buildContinuation, } from "./continuity.js";
|
|
22
|
+
import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
|
|
24
23
|
import { runDiffReview } from "./diffReview.js";
|
|
25
24
|
import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
|
|
26
25
|
import { interactionChannelFor } from "./interaction.js";
|
|
@@ -29,34 +28,26 @@ import { ArtifactStore } from "@claudexor/artifact-store";
|
|
|
29
28
|
import { EventLog } from "@claudexor/event-log";
|
|
30
29
|
import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
|
|
31
30
|
import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
|
|
32
|
-
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch,
|
|
31
|
+
import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
|
|
33
32
|
import { HarnessGateway } from "@claudexor/gateway";
|
|
34
33
|
import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
|
|
35
34
|
import { arbitrate } from "@claudexor/arbitration";
|
|
36
35
|
import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
|
|
37
36
|
import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
|
|
38
|
-
import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
|
|
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
|
+
}
|
|
39
45
|
const LABELS = "ABCDEFGHIJ".split("");
|
|
40
46
|
const NO_PROJECT_ROOT = noProjectRepoRoot();
|
|
41
47
|
/** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
|
|
42
48
|
const MAX_PARALLEL_CANDIDATES = 4;
|
|
43
49
|
/** Default wait for one interactive answer before a benign decline. */
|
|
44
50
|
const DEFAULT_INTERACTION_TIMEOUT_MS = 900_000;
|
|
45
|
-
/**
|
|
46
|
-
* SAFETY INVARIANT 1 (asserted, not convention): a sub-run spawned by the
|
|
47
|
-
* orchestrate executor for a SAFE step (start_run/race) MUST run as an isolated
|
|
48
|
-
* ENVELOPE — never a live in-place turn on a thread. Throws loudly if a caller
|
|
49
|
-
* ever constructs a safe sub-run that could mutate the live tree.
|
|
50
|
-
*/
|
|
51
|
-
function assertEnvelopeSubRun(sub) {
|
|
52
|
-
if (sub.inPlace === true) {
|
|
53
|
-
throw new Error("orchestrate safe sub-run must be an isolated envelope (inPlace must be false), refusing live-tree mutation");
|
|
54
|
-
}
|
|
55
|
-
if (sub.threadId !== undefined || sub.executionRoot !== undefined) {
|
|
56
|
-
throw new Error("orchestrate safe sub-run must not bind a thread or in-place execution root (isolation envelope only)");
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
/** Changed paths and +/- line counts parsed from a unified git diff. */
|
|
60
51
|
/** Run `work` over `items` with bounded concurrency, preserving item order via index. */
|
|
61
52
|
async function runBounded(items, limit, work) {
|
|
62
53
|
if (items.length === 0)
|
|
@@ -114,15 +105,6 @@ export class Orchestrator {
|
|
|
114
105
|
throw new Error(`unknown mode: ${String(resolved.mode)}`);
|
|
115
106
|
}
|
|
116
107
|
const mode = parsedMode.data;
|
|
117
|
-
// INV-023 at the ENGINE boundary too: maxToolCalls caps the orchestrate
|
|
118
|
-
// executor's plan steps — on any other mode it would be a silent no-op
|
|
119
|
-
// knob. The CLI and control API validate this already; a direct embedder
|
|
120
|
-
// must get the same loud refusal, not quiet acceptance.
|
|
121
|
-
if (resolved.maxToolCalls !== undefined &&
|
|
122
|
-
resolved.maxToolCalls !== null &&
|
|
123
|
-
mode !== "orchestrate") {
|
|
124
|
-
throw new Error(`maxToolCalls caps the orchestrate EXECUTOR's plan steps and only applies to mode=orchestrate (got mode=${mode}); drop the knob or switch modes`);
|
|
125
|
-
}
|
|
126
108
|
// denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
|
|
127
109
|
// only exists on envelope/isolated runs — an in-place run mutates the live
|
|
128
110
|
// tree directly, so the gate could not contain a violation. Refuse loudly
|
|
@@ -169,12 +151,11 @@ export class Orchestrator {
|
|
|
169
151
|
return guardAnnouncedRun(resolved.signal, (announce) => {
|
|
170
152
|
switch (mode) {
|
|
171
153
|
case "ask":
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
: 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);
|
|
178
159
|
case "agent":
|
|
179
160
|
// Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
|
|
180
161
|
// and `--attempts` select the convergence loop; `--n` selects the race
|
|
@@ -187,14 +168,6 @@ export class Orchestrator {
|
|
|
187
168
|
return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
|
|
188
169
|
case "plan":
|
|
189
170
|
return this.runPlan(resolved, announce);
|
|
190
|
-
case "orchestrate":
|
|
191
|
-
// Recursion guard: a sub-run spawned by the orchestrate executor carries
|
|
192
|
-
// orchestrateDepth>0 and must NOT itself orchestrate (no infinite planner
|
|
193
|
-
// recursion). Fail loudly rather than silently degrade.
|
|
194
|
-
if ((resolved.orchestrateDepth ?? 0) > 0) {
|
|
195
|
-
throw new Error("orchestrate-within-orchestrate is forbidden: a sub-run spawned by the orchestrate executor cannot itself orchestrate");
|
|
196
|
-
}
|
|
197
|
-
return this.runOrchestrate(resolved, announce);
|
|
198
171
|
}
|
|
199
172
|
});
|
|
200
173
|
}
|
|
@@ -234,10 +207,12 @@ export class Orchestrator {
|
|
|
234
207
|
safeMessage: message,
|
|
235
208
|
runDir: paths.root,
|
|
236
209
|
});
|
|
237
|
-
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`);
|
|
238
211
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
239
212
|
log.emit("run.failed", {
|
|
240
|
-
|
|
213
|
+
lifecycle: "failed",
|
|
214
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
215
|
+
reason: "harness_failed",
|
|
241
216
|
phase: "review_preflight",
|
|
242
217
|
error: message,
|
|
243
218
|
failure_ref: "final/failure.yaml",
|
|
@@ -247,7 +222,8 @@ export class Orchestrator {
|
|
|
247
222
|
runId,
|
|
248
223
|
taskId,
|
|
249
224
|
mode,
|
|
250
|
-
|
|
225
|
+
lifecycle: "failed",
|
|
226
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
251
227
|
winner: null,
|
|
252
228
|
runDir: paths.root,
|
|
253
229
|
summary: message,
|
|
@@ -311,11 +287,55 @@ export class Orchestrator {
|
|
|
311
287
|
credential_profile: profile,
|
|
312
288
|
};
|
|
313
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
|
+
}
|
|
314
322
|
resolveCredentialProfile(input, harnessId) {
|
|
315
|
-
|
|
323
|
+
const explicit = input.credentialProfileId ?? null;
|
|
324
|
+
const wanted = this.effectiveProfileId(input, harnessId);
|
|
325
|
+
if (!wanted)
|
|
316
326
|
return null;
|
|
317
327
|
const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
|
|
318
|
-
|
|
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
|
+
}
|
|
319
339
|
}
|
|
320
340
|
/** The typed effective auth route for a SELECTED credential profile
|
|
321
341
|
* (round-18 #2): adapters execute strictly by credential_kind, so routing,
|
|
@@ -351,13 +371,6 @@ export class Orchestrator {
|
|
|
351
371
|
}
|
|
352
372
|
return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
|
|
353
373
|
}
|
|
354
|
-
/**
|
|
355
|
-
* Lift an adapter's auth-route override marker into the typed
|
|
356
|
-
* `route.fallback.auth_switched` run event (validated payload). An explicit
|
|
357
|
-
* subscription/api_key preference that could not be honored is never silent;
|
|
358
|
-
* neither is an `auto` choice that selects a smoke-proven paid route over an
|
|
359
|
-
* available native route.
|
|
360
|
-
*/
|
|
361
374
|
/**
|
|
362
375
|
* Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
|
|
363
376
|
* **capability-gate** to those that can actually produce work for `intent` (e.g. a
|
|
@@ -371,13 +384,7 @@ export class Orchestrator {
|
|
|
371
384
|
}
|
|
372
385
|
const cfg = this.config(input.repoRoot);
|
|
373
386
|
const configuredPool = cfg?.global.routing.eligible_harnesses;
|
|
374
|
-
const
|
|
375
|
-
const harnesses = input.harnesses ??
|
|
376
|
-
(configuredPool && configuredPool.length > 0
|
|
377
|
-
? configuredPool
|
|
378
|
-
: policy === "primary" && cfg?.global.routing.primary_harness
|
|
379
|
-
? [cfg.global.routing.primary_harness]
|
|
380
|
-
: undefined);
|
|
387
|
+
const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
|
|
381
388
|
const primaryHarness = input.primaryHarness ?? cfg?.global.routing.primary_harness ?? undefined;
|
|
382
389
|
if (primaryHarness &&
|
|
383
390
|
harnesses &&
|
|
@@ -429,7 +436,7 @@ export class Orchestrator {
|
|
|
429
436
|
externalContextPolicy: web,
|
|
430
437
|
};
|
|
431
438
|
}
|
|
432
|
-
async resolveCandidateAdapters(input, intent, ledger, routeContext) {
|
|
439
|
+
async resolveCandidateAdapters(input, intent, ledger, log, routeContext) {
|
|
433
440
|
let ids = input.harnesses;
|
|
434
441
|
const explicitPool = Boolean(ids && ids.length > 0);
|
|
435
442
|
const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
|
|
@@ -509,6 +516,18 @@ export class Orchestrator {
|
|
|
509
516
|
dropped.push(why);
|
|
510
517
|
continue;
|
|
511
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
|
+
}
|
|
512
531
|
// W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
|
|
513
532
|
// resolved env/cwd its run will spawn with (see routeContext.ts).
|
|
514
533
|
let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
|
|
@@ -531,7 +550,8 @@ export class Orchestrator {
|
|
|
531
550
|
const profileAdapter = this.deps.registry.get(id);
|
|
532
551
|
const profileVerdict = await selectedProfileAvailability({
|
|
533
552
|
registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
|
|
534
|
-
|
|
553
|
+
// The EFFECTIVE account (INV-135): an explicit pin is authenticated by ITS store.
|
|
554
|
+
profileId: this.effectiveProfileId(input, id),
|
|
535
555
|
harnessId: id,
|
|
536
556
|
probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
|
|
537
557
|
});
|
|
@@ -567,11 +587,7 @@ export class Orchestrator {
|
|
|
567
587
|
dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
|
|
568
588
|
continue;
|
|
569
589
|
}
|
|
570
|
-
const readOnlyIntent = intent === "plan" ||
|
|
571
|
-
intent === "spec" ||
|
|
572
|
-
intent === "explain" ||
|
|
573
|
-
intent === "audit" ||
|
|
574
|
-
intent === "orchestrate";
|
|
590
|
+
const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
|
|
575
591
|
const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
|
|
576
592
|
? "readonly"
|
|
577
593
|
: (input.access ?? this.config(input.repoRoot).trust.access_default));
|
|
@@ -628,6 +644,8 @@ export class Orchestrator {
|
|
|
628
644
|
estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
|
|
629
645
|
supportsInteractive: manifest.capabilities.interactive,
|
|
630
646
|
supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
|
|
647
|
+
supportsMcpInjection: manifest.capability_profile.mcp_injection,
|
|
648
|
+
mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
|
|
631
649
|
implementationTransport: manifest.capabilities.implementation_transport,
|
|
632
650
|
settings: cfgEntry
|
|
633
651
|
? {
|
|
@@ -653,11 +671,33 @@ export class Orchestrator {
|
|
|
653
671
|
if (ordered.length === 0) {
|
|
654
672
|
throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
|
|
655
673
|
}
|
|
674
|
+
emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
|
|
656
675
|
const n = input.n ?? ordered.length;
|
|
657
676
|
const out = [];
|
|
658
677
|
for (let i = 0; i < n; i++)
|
|
659
678
|
out.push(ordered[i % ordered.length]);
|
|
660
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
|
+
}
|
|
661
701
|
// outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
|
|
662
702
|
// natively constrain its final message would deliver best-effort text —
|
|
663
703
|
// that is a typed preflight refusal, never silent degradation. The
|
|
@@ -843,7 +883,7 @@ export class Orchestrator {
|
|
|
843
883
|
buildContract(input, taskId, mode) {
|
|
844
884
|
const resolvedCfg = this.config(input.repoRoot);
|
|
845
885
|
const cfg = resolvedCfg.project;
|
|
846
|
-
const readOnlyMode = mode === "ask" || mode === "plan"
|
|
886
|
+
const readOnlyMode = mode === "ask" || mode === "plan";
|
|
847
887
|
const requestedAccess = input.access ?? (readOnlyMode ? "readonly" : resolvedCfg.trust.access_default);
|
|
848
888
|
// Effective access is COMPUTED by the engine, never echoed from a client:
|
|
849
889
|
// read-only modes clamp to readonly regardless of the request.
|
|
@@ -864,63 +904,19 @@ export class Orchestrator {
|
|
|
864
904
|
{ code: TRUST_FULL_ACCESS_CODE, status: 403 });
|
|
865
905
|
}
|
|
866
906
|
const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
// empty and the interview pipeline dead in production.
|
|
871
|
-
let specFields = {};
|
|
872
|
-
let specTestCommands = [];
|
|
873
|
-
if (input.specPath) {
|
|
874
|
-
try {
|
|
875
|
-
const spec = SpecPackZ.parse(JSON.parse(readFileSync(input.specPath, "utf8")));
|
|
876
|
-
// Tamper fence (INV-081): the frozen spec's recorded hash must
|
|
877
|
-
// match what we just read — a spec.json edited AFTER freeze would
|
|
878
|
-
// otherwise silently rewrite success criteria/tests/protected paths
|
|
879
|
-
// while the contract records the stale hash as provenance.
|
|
880
|
-
if (input.specHash && hashJson(spec) !== input.specHash) {
|
|
881
|
-
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`);
|
|
882
|
-
}
|
|
883
|
-
const fromSpec = specPackToTaskContract(spec, {
|
|
884
|
-
repoRoot: input.repoRoot,
|
|
885
|
-
mode,
|
|
886
|
-
baseRef: input.baseRef,
|
|
887
|
-
paidBudget: input.paidBudget,
|
|
888
|
-
});
|
|
889
|
-
specFields = {
|
|
890
|
-
success_criteria: fromSpec.success_criteria,
|
|
891
|
-
non_goals: fromSpec.non_goals,
|
|
892
|
-
forbidden_approaches: fromSpec.forbidden_approaches,
|
|
893
|
-
decided_tradeoffs: fromSpec.decided_tradeoffs,
|
|
894
|
-
task_graph: fromSpec.task_graph,
|
|
895
|
-
constraints: fromSpec.constraints,
|
|
896
|
-
};
|
|
897
|
-
specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
|
|
898
|
-
program,
|
|
899
|
-
args,
|
|
900
|
-
...(cwd === undefined ? {} : { cwd }),
|
|
901
|
-
envAllowlist,
|
|
902
|
-
}));
|
|
903
|
-
}
|
|
904
|
-
catch (err) {
|
|
905
|
-
// An unreadable/unfrozen spec must fail the run loudly, never silently
|
|
906
|
-
// degrade into an unspecced contract.
|
|
907
|
-
throw new Error(`failed to resolve frozen SpecPack at ${input.specPath}: ${safeErrorMessage(err)}`);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
// Deterministic gate commands come from the frozen SpecPack, explicit run
|
|
911
|
-
// input, then versioned project config. Without these, gateSpecs is empty
|
|
912
|
-
// 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.
|
|
913
910
|
const resolvedGates = resolveContractGates({
|
|
914
911
|
repoRoot: input.repoRoot,
|
|
915
912
|
effectiveAccess,
|
|
916
913
|
config: cfg,
|
|
917
914
|
trustGrants: resolvedCfg.trust.test_command_grants,
|
|
918
|
-
specCommands: specTestCommands,
|
|
919
915
|
operatorCommands: input.tests ?? [],
|
|
920
916
|
projectCommands: cfg.tests?.commands ?? [],
|
|
921
917
|
});
|
|
922
918
|
const commands = resolvedGates.commands;
|
|
923
|
-
const protectedPaths = [
|
|
919
|
+
const protectedPaths = [];
|
|
924
920
|
const autoProtectedPaths = resolvedGates.autoProtectedPaths;
|
|
925
921
|
const protectedPathApprovals = [
|
|
926
922
|
...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
|
|
@@ -943,14 +939,6 @@ export class Orchestrator {
|
|
|
943
939
|
auth_preference: input.authPreference ?? "auto",
|
|
944
940
|
credential_profile_id: input.credentialProfileId ?? null,
|
|
945
941
|
max_turns: input.maxTurns ?? null,
|
|
946
|
-
spec: input.specId || input.specHash || input.specPath
|
|
947
|
-
? {
|
|
948
|
-
id: input.specId,
|
|
949
|
-
hash: input.specHash,
|
|
950
|
-
path: input.specPath,
|
|
951
|
-
}
|
|
952
|
-
: undefined,
|
|
953
|
-
...specFields,
|
|
954
942
|
constraints: {
|
|
955
943
|
protected_paths: protectedPaths,
|
|
956
944
|
deny_paths: [...new Set(input.denyPaths ?? [])],
|
|
@@ -1002,6 +990,39 @@ export class Orchestrator {
|
|
|
1002
990
|
* execution — owner Quiz-5a); reviewers and the auth smoke build their own
|
|
1003
991
|
* specs and never call this.
|
|
1004
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
|
+
}
|
|
1005
1026
|
harnessSpecKnobs(contract, knobs, intent) {
|
|
1006
1027
|
return {
|
|
1007
1028
|
external_context_policy: knobs.webPolicy,
|
|
@@ -1015,11 +1036,10 @@ export class Orchestrator {
|
|
|
1015
1036
|
max_turns: knobs.maxTurns,
|
|
1016
1037
|
...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
|
|
1017
1038
|
// The user's answer contract rides every answer-producing lane INCLUDING
|
|
1018
|
-
// synthesis (its answer can become the final one)
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
|
|
1022
|
-
...(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
|
|
1023
1043
|
? { output_schema: strictifyOutputSchema(contract.output_schema) }
|
|
1024
1044
|
: {}),
|
|
1025
1045
|
};
|
|
@@ -1073,6 +1093,111 @@ export class Orchestrator {
|
|
|
1073
1093
|
ignored,
|
|
1074
1094
|
};
|
|
1075
1095
|
}
|
|
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
|
+
}
|
|
1076
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) {
|
|
1077
1202
|
const adapter = routed.adapter;
|
|
1078
1203
|
const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
|
|
@@ -1080,12 +1205,26 @@ export class Orchestrator {
|
|
|
1080
1205
|
const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
|
|
1081
1206
|
const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
|
|
1082
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;
|
|
1083
1218
|
let spec = HarnessRunSpec.parse({
|
|
1084
1219
|
session_id: newId("ses"),
|
|
1085
1220
|
intent,
|
|
1086
|
-
prompt: promptWithProtectedPathConstraint(
|
|
1221
|
+
prompt: promptWithProtectedPathConstraint(promptWithContinuity, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
|
|
1087
1222
|
attachments: runInput?.attachments ?? [],
|
|
1088
|
-
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),
|
|
1089
1228
|
cwd: envelope.worktree_path,
|
|
1090
1229
|
access: routed.adapterAccess,
|
|
1091
1230
|
...this.harnessSpecKnobs(contract, knobs, intent),
|
|
@@ -1105,16 +1244,6 @@ export class Orchestrator {
|
|
|
1105
1244
|
raw_context_packet: rawContextPacket,
|
|
1106
1245
|
stream_deltas: streamDeltas,
|
|
1107
1246
|
});
|
|
1108
|
-
if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
|
|
1109
|
-
log?.emit("session.rebound", SessionReboundLineageSchema.parse({
|
|
1110
|
-
thread_id: runInput.threadId,
|
|
1111
|
-
harness_id: adapter.id,
|
|
1112
|
-
from_native_session_id: sessionFields.resume_session_id,
|
|
1113
|
-
to_session_id: null,
|
|
1114
|
-
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",
|
|
1115
|
-
reason: "not_portable",
|
|
1116
|
-
}));
|
|
1117
|
-
}
|
|
1118
1247
|
if (interaction)
|
|
1119
1248
|
spec.extra["interactionChannel"] = interaction;
|
|
1120
1249
|
const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
|
|
@@ -1482,10 +1611,12 @@ export class Orchestrator {
|
|
|
1482
1611
|
"Retry the run",
|
|
1483
1612
|
],
|
|
1484
1613
|
});
|
|
1485
|
-
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`);
|
|
1486
1615
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1487
1616
|
log.emit("run.failed", {
|
|
1488
|
-
|
|
1617
|
+
lifecycle: "failed",
|
|
1618
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1619
|
+
reason: "harness_failed",
|
|
1489
1620
|
phase: "workspace",
|
|
1490
1621
|
error: message,
|
|
1491
1622
|
failure_ref: "final/failure.yaml",
|
|
@@ -1493,6 +1624,37 @@ export class Orchestrator {
|
|
|
1493
1624
|
return message;
|
|
1494
1625
|
}
|
|
1495
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
|
+
}
|
|
1496
1658
|
async runRace(input, mode, announce) {
|
|
1497
1659
|
const taskId = input.taskId ?? newId("task");
|
|
1498
1660
|
const runId = input.runId ?? newId("run");
|
|
@@ -1503,6 +1665,7 @@ export class Orchestrator {
|
|
|
1503
1665
|
const store = this.artifactStore(input);
|
|
1504
1666
|
const paths = store.createRun(runId);
|
|
1505
1667
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
1668
|
+
input = this.withPlanBrief(input, store, paths, log);
|
|
1506
1669
|
// The execution root is the tree the harness mutates: the project itself for
|
|
1507
1670
|
// in-place threads/ordinary runs, or the thread's persistent worktree for an
|
|
1508
1671
|
// isolated thread. Config/artifacts/contract stay anchored to repoRoot. Both
|
|
@@ -1536,7 +1699,8 @@ export class Orchestrator {
|
|
|
1536
1699
|
runId,
|
|
1537
1700
|
taskId,
|
|
1538
1701
|
mode,
|
|
1539
|
-
|
|
1702
|
+
lifecycle: "failed",
|
|
1703
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1540
1704
|
winner: null,
|
|
1541
1705
|
runDir: paths.root,
|
|
1542
1706
|
summary: gitPreconditionError,
|
|
@@ -1566,6 +1730,7 @@ export class Orchestrator {
|
|
|
1566
1730
|
});
|
|
1567
1731
|
let adapters;
|
|
1568
1732
|
try {
|
|
1733
|
+
// Best-of races the whole pool (no divergence `log`: the pool still runs).
|
|
1569
1734
|
adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger);
|
|
1570
1735
|
}
|
|
1571
1736
|
catch (err) {
|
|
@@ -1577,10 +1742,12 @@ export class Orchestrator {
|
|
|
1577
1742
|
safeMessage: message,
|
|
1578
1743
|
runDir: paths.root,
|
|
1579
1744
|
});
|
|
1580
|
-
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`);
|
|
1581
1746
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1582
1747
|
log.emit("run.failed", {
|
|
1583
|
-
|
|
1748
|
+
lifecycle: "failed",
|
|
1749
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1750
|
+
reason: "harness_failed",
|
|
1584
1751
|
phase: "routing",
|
|
1585
1752
|
error: message,
|
|
1586
1753
|
failure_ref: "final/failure.yaml",
|
|
@@ -1589,7 +1756,8 @@ export class Orchestrator {
|
|
|
1589
1756
|
runId,
|
|
1590
1757
|
taskId,
|
|
1591
1758
|
mode,
|
|
1592
|
-
|
|
1759
|
+
lifecycle: "failed",
|
|
1760
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
1593
1761
|
winner: null,
|
|
1594
1762
|
runDir: paths.root,
|
|
1595
1763
|
summary: message,
|
|
@@ -1811,29 +1979,34 @@ export class Orchestrator {
|
|
|
1811
1979
|
})), null), ledger.spend(), input.signal, store);
|
|
1812
1980
|
}
|
|
1813
1981
|
if (runs.length === 0) {
|
|
1814
|
-
const
|
|
1982
|
+
const budgetReason = ledger.terminal();
|
|
1983
|
+
const facts = makeOutcomeFacts("failed", {
|
|
1984
|
+
reason: budgetReason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
|
|
1985
|
+
noChanges: true,
|
|
1986
|
+
});
|
|
1815
1987
|
const why = budgetStopped
|
|
1816
1988
|
? "budget exhausted before any candidate run"
|
|
1817
1989
|
: "no candidates produced";
|
|
1818
1990
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1819
1991
|
winner: null,
|
|
1820
|
-
|
|
1821
|
-
outcome: "blocked",
|
|
1992
|
+
facts,
|
|
1822
1993
|
why_winner: why,
|
|
1823
1994
|
evidence_facts: ["no candidates were produced"],
|
|
1824
1995
|
apply_recommendation: "continue",
|
|
1825
1996
|
budget_summary: { spend_usd: ledger.spend(), estimated: false },
|
|
1826
1997
|
});
|
|
1827
|
-
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`);
|
|
1828
1999
|
writeFailure(store, paths, {
|
|
1829
2000
|
phase: "budget",
|
|
1830
|
-
category: isBudgetTerminal(
|
|
2001
|
+
category: isBudgetTerminal(facts.reason) ? "budget" : "internal",
|
|
1831
2002
|
safeMessage: why,
|
|
1832
2003
|
runDir: paths.root,
|
|
1833
2004
|
});
|
|
1834
2005
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1835
2006
|
log.emit("run.failed", {
|
|
1836
|
-
|
|
2007
|
+
lifecycle: facts.lifecycle,
|
|
2008
|
+
facts,
|
|
2009
|
+
reason: facts.reason,
|
|
1837
2010
|
phase: "budget",
|
|
1838
2011
|
error: why,
|
|
1839
2012
|
failure_ref: "final/failure.yaml",
|
|
@@ -1842,7 +2015,8 @@ export class Orchestrator {
|
|
|
1842
2015
|
runId,
|
|
1843
2016
|
taskId,
|
|
1844
2017
|
mode,
|
|
1845
|
-
|
|
2018
|
+
lifecycle: facts.lifecycle,
|
|
2019
|
+
facts,
|
|
1846
2020
|
winner: null,
|
|
1847
2021
|
runDir: paths.root,
|
|
1848
2022
|
summary: why,
|
|
@@ -1863,11 +2037,10 @@ export class Orchestrator {
|
|
|
1863
2037
|
const rootCause = runs
|
|
1864
2038
|
.map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
|
|
1865
2039
|
.join("; ");
|
|
1866
|
-
const
|
|
2040
|
+
const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
|
|
1867
2041
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
1868
2042
|
winner: null,
|
|
1869
|
-
|
|
1870
|
-
outcome: "blocked",
|
|
2043
|
+
facts,
|
|
1871
2044
|
why_winner: rootCause,
|
|
1872
2045
|
evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
|
|
1873
2046
|
apply_recommendation: "continue",
|
|
@@ -1878,7 +2051,7 @@ export class Orchestrator {
|
|
|
1878
2051
|
harnessId: r.harnessId,
|
|
1879
2052
|
telemetry: r.telemetry,
|
|
1880
2053
|
})), null);
|
|
1881
|
-
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`);
|
|
1882
2055
|
const existingEventRefs = runs
|
|
1883
2056
|
.map((r) => `attempts/${r.attemptId}/events.jsonl`)
|
|
1884
2057
|
.filter((rel) => existsSync(join(paths.root, rel)));
|
|
@@ -1897,7 +2070,9 @@ export class Orchestrator {
|
|
|
1897
2070
|
});
|
|
1898
2071
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
1899
2072
|
log.emit("run.failed", {
|
|
1900
|
-
|
|
2073
|
+
lifecycle: facts.lifecycle,
|
|
2074
|
+
facts,
|
|
2075
|
+
reason: facts.reason,
|
|
1901
2076
|
phase,
|
|
1902
2077
|
error: rootCause,
|
|
1903
2078
|
failure_ref: "final/failure.yaml",
|
|
@@ -1906,7 +2081,8 @@ export class Orchestrator {
|
|
|
1906
2081
|
runId,
|
|
1907
2082
|
taskId,
|
|
1908
2083
|
mode,
|
|
1909
|
-
|
|
2084
|
+
lifecycle: facts.lifecycle,
|
|
2085
|
+
facts,
|
|
1910
2086
|
winner: null,
|
|
1911
2087
|
runDir: paths.root,
|
|
1912
2088
|
summary: rootCause,
|
|
@@ -2036,7 +2212,7 @@ export class Orchestrator {
|
|
|
2036
2212
|
}
|
|
2037
2213
|
log.emit("arbitration.completed", {
|
|
2038
2214
|
winner: result.decision.winner,
|
|
2039
|
-
|
|
2215
|
+
lifecycle: result.decision.facts.lifecycle,
|
|
2040
2216
|
});
|
|
2041
2217
|
// Winner can only be a candidate that actually produced work; corpses are
|
|
2042
2218
|
// excluded from arbitration upstream and from the fallback here.
|
|
@@ -2049,9 +2225,18 @@ export class Orchestrator {
|
|
|
2049
2225
|
const actualReviewVerified = winnerRun
|
|
2050
2226
|
? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
|
|
2051
2227
|
: evidences.length > 0 && evidences.every((e) => e.reviewVerified);
|
|
2052
|
-
let
|
|
2053
|
-
|
|
2054
|
-
|
|
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
|
+
}
|
|
2055
2240
|
// FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
|
|
2056
2241
|
let finalVerify = null;
|
|
2057
2242
|
let finalVerifyFailed = false;
|
|
@@ -2065,20 +2250,24 @@ export class Orchestrator {
|
|
|
2065
2250
|
!inPlaceWinner &&
|
|
2066
2251
|
!deferredRaceVerify &&
|
|
2067
2252
|
winnerRun.diff.trim().length > 0 &&
|
|
2068
|
-
|
|
2253
|
+
facts.lifecycle === "succeeded" &&
|
|
2254
|
+
facts.review !== "blocked" &&
|
|
2069
2255
|
!input.signal?.aborted) {
|
|
2070
2256
|
finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
|
|
2071
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).
|
|
2072
2259
|
finalVerifyFailed = finalVerifyBlocks(finalVerify);
|
|
2073
2260
|
if (finalVerifyFailed)
|
|
2074
|
-
|
|
2261
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
2075
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";
|
|
2076
2266
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
|
|
2077
2267
|
...result.decision,
|
|
2078
|
-
|
|
2079
|
-
...(status === "blocked"
|
|
2268
|
+
...(needsDec
|
|
2080
2269
|
? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
|
|
2081
|
-
: {}),
|
|
2270
|
+
: { facts }),
|
|
2082
2271
|
review_verified: actualReviewVerified,
|
|
2083
2272
|
final_verify: finalVerify,
|
|
2084
2273
|
});
|
|
@@ -2124,8 +2313,9 @@ export class Orchestrator {
|
|
|
2124
2313
|
answerText: winnerAnswer,
|
|
2125
2314
|
});
|
|
2126
2315
|
}
|
|
2127
|
-
// Only a fully verified success may auto-adopt;
|
|
2128
|
-
|
|
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";
|
|
2129
2319
|
let adopted = null;
|
|
2130
2320
|
let applyState = "not_applied";
|
|
2131
2321
|
let postTurnSha = null;
|
|
@@ -2169,7 +2359,7 @@ export class Orchestrator {
|
|
|
2169
2359
|
adopted = false;
|
|
2170
2360
|
applyState = "not_applied";
|
|
2171
2361
|
deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
|
|
2172
|
-
|
|
2362
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
2173
2363
|
if (finalVerifyBlocks(finalVerify))
|
|
2174
2364
|
finalVerifyFailed = true;
|
|
2175
2365
|
log.emit("work_product.adopted", {
|
|
@@ -2183,7 +2373,7 @@ export class Orchestrator {
|
|
|
2183
2373
|
}
|
|
2184
2374
|
writeRaceDeliveryDecision(store, decisionPath, {
|
|
2185
2375
|
decision: result.decision,
|
|
2186
|
-
|
|
2376
|
+
facts,
|
|
2187
2377
|
reviewVerified: actualReviewVerified,
|
|
2188
2378
|
finalVerify,
|
|
2189
2379
|
deliveryFailureReason,
|
|
@@ -2204,8 +2394,9 @@ export class Orchestrator {
|
|
|
2204
2394
|
harness_id: winnerRun.harnessId,
|
|
2205
2395
|
synthesis: synth,
|
|
2206
2396
|
mode,
|
|
2207
|
-
// Artifact-only apply reads the same terminal
|
|
2208
|
-
|
|
2397
|
+
// Artifact-only apply reads the same terminal axes as the daemon (D8).
|
|
2398
|
+
lifecycle: facts.lifecycle,
|
|
2399
|
+
outcome_facts: facts,
|
|
2209
2400
|
review_verified: actualReviewVerified,
|
|
2210
2401
|
budget_stopped: budgetStopped,
|
|
2211
2402
|
patch_sha256: patchSha256,
|
|
@@ -2223,18 +2414,31 @@ export class Orchestrator {
|
|
|
2223
2414
|
revert_anchor_id: revertAnchorId,
|
|
2224
2415
|
},
|
|
2225
2416
|
});
|
|
2226
|
-
store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision,
|
|
2227
|
-
//
|
|
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.
|
|
2228
2422
|
log.emit("output.ready", {
|
|
2229
2423
|
kind: "summary",
|
|
2230
2424
|
path: "final/summary.md",
|
|
2231
|
-
state:
|
|
2425
|
+
state: (facts.lifecycle === "succeeded" &&
|
|
2426
|
+
facts.review === "approved" &&
|
|
2427
|
+
facts.checks !== "failed") ||
|
|
2428
|
+
winnerAnswer.length > 0
|
|
2429
|
+
? "ready"
|
|
2430
|
+
: "diagnostic",
|
|
2232
2431
|
});
|
|
2233
2432
|
}
|
|
2234
|
-
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"
|
|
2235
2434
|
? result.decision.winner
|
|
2236
2435
|
: (winnerRun?.attemptId ?? null));
|
|
2237
|
-
|
|
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;
|
|
2238
2442
|
if (deliveryFailureReason && !finalVerifyFailed) {
|
|
2239
2443
|
writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
|
|
2240
2444
|
}
|
|
@@ -2259,14 +2463,14 @@ export class Orchestrator {
|
|
|
2259
2463
|
],
|
|
2260
2464
|
});
|
|
2261
2465
|
}
|
|
2262
|
-
else if (
|
|
2466
|
+
else if (isFailureTerminal) {
|
|
2263
2467
|
writeFailure(store, paths, {
|
|
2264
2468
|
phase: needsHuman ? "review" : "arbitration",
|
|
2265
2469
|
category: needsHuman
|
|
2266
2470
|
? "policy"
|
|
2267
2471
|
: winnerRun?.errored
|
|
2268
2472
|
? "harness_error"
|
|
2269
|
-
: isBudgetTerminal(
|
|
2473
|
+
: isBudgetTerminal(facts.reason)
|
|
2270
2474
|
? "budget"
|
|
2271
2475
|
: "internal",
|
|
2272
2476
|
harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
|
|
@@ -2291,7 +2495,7 @@ export class Orchestrator {
|
|
|
2291
2495
|
],
|
|
2292
2496
|
});
|
|
2293
2497
|
if (!winnerRun) {
|
|
2294
|
-
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`);
|
|
2295
2499
|
log.emit("output.ready", {
|
|
2296
2500
|
kind: "summary",
|
|
2297
2501
|
path: "final/summary.md",
|
|
@@ -2300,14 +2504,15 @@ export class Orchestrator {
|
|
|
2300
2504
|
}
|
|
2301
2505
|
}
|
|
2302
2506
|
log.emit("work_product.emitted", { winner: result.decision.winner });
|
|
2303
|
-
if (
|
|
2304
|
-
log.emit("run.completed", {
|
|
2507
|
+
if (!isFailureTerminal) {
|
|
2508
|
+
log.emit("run.completed", { lifecycle: facts.lifecycle, facts, reason: facts.reason });
|
|
2305
2509
|
}
|
|
2306
|
-
else if (
|
|
2307
|
-
//
|
|
2308
|
-
// 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").
|
|
2309
2513
|
log.emit("run.blocked", {
|
|
2310
|
-
|
|
2514
|
+
lifecycle: facts.lifecycle,
|
|
2515
|
+
facts,
|
|
2311
2516
|
phase: deliveryFailureReason && !finalVerifyFailed
|
|
2312
2517
|
? "delivery"
|
|
2313
2518
|
: finalVerifyFailed
|
|
@@ -2317,13 +2522,20 @@ export class Orchestrator {
|
|
|
2317
2522
|
});
|
|
2318
2523
|
}
|
|
2319
2524
|
else {
|
|
2320
|
-
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
|
+
});
|
|
2321
2532
|
}
|
|
2322
2533
|
return {
|
|
2323
2534
|
runId,
|
|
2324
2535
|
taskId,
|
|
2325
2536
|
mode,
|
|
2326
|
-
|
|
2537
|
+
lifecycle: facts.lifecycle,
|
|
2538
|
+
facts,
|
|
2327
2539
|
winner: result.decision.winner,
|
|
2328
2540
|
runDir: paths.root,
|
|
2329
2541
|
summary: result.decision.why_winner,
|
|
@@ -2531,6 +2743,7 @@ export class Orchestrator {
|
|
|
2531
2743
|
const store = this.artifactStore(input);
|
|
2532
2744
|
const paths = store.createRun(runId);
|
|
2533
2745
|
const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
|
|
2746
|
+
input = this.withPlanBrief(input, store, paths, log);
|
|
2534
2747
|
// The execution root is the tree the harness mutates (thread worktree for an
|
|
2535
2748
|
// isolated thread, else the project). The WorkspaceManager AND the git
|
|
2536
2749
|
// boundary must resolve against the SAME root — the race path does so via the
|
|
@@ -2563,7 +2776,8 @@ export class Orchestrator {
|
|
|
2563
2776
|
runId,
|
|
2564
2777
|
taskId,
|
|
2565
2778
|
mode,
|
|
2566
|
-
|
|
2779
|
+
lifecycle: "failed",
|
|
2780
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2567
2781
|
winner: null,
|
|
2568
2782
|
runDir: paths.root,
|
|
2569
2783
|
summary: gitPreconditionError,
|
|
@@ -2585,7 +2799,7 @@ export class Orchestrator {
|
|
|
2585
2799
|
// One envelope carried forward across attempts so the harness can repair its own work.
|
|
2586
2800
|
let adapterPool;
|
|
2587
2801
|
try {
|
|
2588
|
-
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
|
|
2802
|
+
adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
|
|
2589
2803
|
this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
|
|
2590
2804
|
}
|
|
2591
2805
|
catch (err) {
|
|
@@ -2597,10 +2811,12 @@ export class Orchestrator {
|
|
|
2597
2811
|
safeMessage: message,
|
|
2598
2812
|
runDir: paths.root,
|
|
2599
2813
|
});
|
|
2600
|
-
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`);
|
|
2601
2815
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2602
2816
|
log.emit("run.failed", {
|
|
2603
|
-
|
|
2817
|
+
lifecycle: "failed",
|
|
2818
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2819
|
+
reason: "harness_failed",
|
|
2604
2820
|
phase: "routing",
|
|
2605
2821
|
error: message,
|
|
2606
2822
|
failure_ref: "final/failure.yaml",
|
|
@@ -2610,7 +2826,8 @@ export class Orchestrator {
|
|
|
2610
2826
|
runId,
|
|
2611
2827
|
taskId,
|
|
2612
2828
|
mode,
|
|
2613
|
-
|
|
2829
|
+
lifecycle: "failed",
|
|
2830
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2614
2831
|
winner: null,
|
|
2615
2832
|
runDir: paths.root,
|
|
2616
2833
|
summary: message,
|
|
@@ -2634,10 +2851,12 @@ export class Orchestrator {
|
|
|
2634
2851
|
"Check harness doctor for reviewer readiness",
|
|
2635
2852
|
],
|
|
2636
2853
|
});
|
|
2637
|
-
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`);
|
|
2638
2855
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
2639
2856
|
log.emit("run.failed", {
|
|
2640
|
-
|
|
2857
|
+
lifecycle: "failed",
|
|
2858
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2859
|
+
reason: "harness_failed",
|
|
2641
2860
|
phase: "review",
|
|
2642
2861
|
error: message,
|
|
2643
2862
|
failure_ref: "final/failure.yaml",
|
|
@@ -2647,7 +2866,8 @@ export class Orchestrator {
|
|
|
2647
2866
|
runId,
|
|
2648
2867
|
taskId,
|
|
2649
2868
|
mode,
|
|
2650
|
-
|
|
2869
|
+
lifecycle: "failed",
|
|
2870
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
2651
2871
|
winner: null,
|
|
2652
2872
|
runDir: paths.root,
|
|
2653
2873
|
summary: message,
|
|
@@ -3019,17 +3239,18 @@ export class Orchestrator {
|
|
|
3019
3239
|
if (envelope)
|
|
3020
3240
|
await wsm.dispose(envelope);
|
|
3021
3241
|
}
|
|
3022
|
-
|
|
3023
|
-
|
|
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" })
|
|
3024
3247
|
: converged
|
|
3025
|
-
? "
|
|
3248
|
+
? makeOutcomeFacts("succeeded")
|
|
3026
3249
|
: stuckNoProgress
|
|
3027
|
-
? "stuck_no_progress"
|
|
3250
|
+
? makeOutcomeFacts("failed", { reason: "stuck_no_progress" })
|
|
3028
3251
|
: exhausted
|
|
3029
|
-
? "
|
|
3030
|
-
: "not_converged";
|
|
3031
|
-
if (status === "success" && ledger.terminal() !== null)
|
|
3032
|
-
status = ledger.terminal();
|
|
3252
|
+
? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
|
|
3253
|
+
: makeOutcomeFacts("failed", { reason: "not_converged" });
|
|
3033
3254
|
let decision = null;
|
|
3034
3255
|
if (lastRun) {
|
|
3035
3256
|
const arb = arbitrate([
|
|
@@ -3040,17 +3261,29 @@ export class Orchestrator {
|
|
|
3040
3261
|
});
|
|
3041
3262
|
decision = arb.decision;
|
|
3042
3263
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
3043
|
-
|
|
3044
|
-
|
|
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;
|
|
3045
3269
|
}
|
|
3046
|
-
else if (
|
|
3047
|
-
|
|
3270
|
+
else if (facts.reason === "not_converged" && decision.facts.lifecycle === "succeeded") {
|
|
3271
|
+
facts = decision.facts;
|
|
3048
3272
|
}
|
|
3049
3273
|
}
|
|
3050
|
-
// 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.
|
|
3051
3283
|
const needsHuman = lastFindings.some((f) => f.severity === "NEEDS_HUMAN" && isBlocking(f));
|
|
3052
|
-
if (needsHuman &&
|
|
3053
|
-
|
|
3284
|
+
if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
|
|
3285
|
+
facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
|
|
3286
|
+
}
|
|
3054
3287
|
// FinalVerifier (INV-115) applies to EVERY applyable envelope-mode patch,
|
|
3055
3288
|
// not only race winners: a convergence run's delivered patch must also
|
|
3056
3289
|
// survive a fresh tree at its own base + the deterministic gates there.
|
|
@@ -3061,20 +3294,22 @@ export class Orchestrator {
|
|
|
3061
3294
|
if (input.inPlace !== true &&
|
|
3062
3295
|
lastRun &&
|
|
3063
3296
|
lastRun.diff.trim().length > 0 &&
|
|
3064
|
-
|
|
3297
|
+
facts.lifecycle === "succeeded" &&
|
|
3298
|
+
facts.review !== "blocked" &&
|
|
3065
3299
|
!input.signal?.aborted) {
|
|
3066
3300
|
convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
|
|
3067
3301
|
if (finalVerifyBlocks(convFinalVerify))
|
|
3068
|
-
|
|
3302
|
+
facts = { ...facts, checks: "failed", reason: "checks_failed" };
|
|
3069
3303
|
}
|
|
3304
|
+
const convNeedsDecision = facts.review === "blocked" || facts.checks === "failed";
|
|
3070
3305
|
if (decision) {
|
|
3071
|
-
// Shared honesty owner (same as the race path): a
|
|
3306
|
+
// Shared honesty owner (same as the race path): a needs-decision terminal
|
|
3072
3307
|
// overrides the persisted decision; final_verify is recorded either way.
|
|
3073
3308
|
decision = {
|
|
3074
3309
|
...decision,
|
|
3075
|
-
...(
|
|
3310
|
+
...(convNeedsDecision
|
|
3076
3311
|
? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
|
|
3077
|
-
: {}),
|
|
3312
|
+
: { facts }),
|
|
3078
3313
|
final_verify: convFinalVerify,
|
|
3079
3314
|
};
|
|
3080
3315
|
store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
|
|
@@ -3089,7 +3324,7 @@ export class Orchestrator {
|
|
|
3089
3324
|
// convergence run with inPlace mutated the live tree directly across its
|
|
3090
3325
|
// attempts, so it is "applied" even when review blocked (Revert offered).
|
|
3091
3326
|
const convHasDiff = lastRun.diff.trim().length > 0;
|
|
3092
|
-
const convAdoptable =
|
|
3327
|
+
const convAdoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
|
|
3093
3328
|
const convAdopted = input.inPlace === true && convHasDiff ? true : null;
|
|
3094
3329
|
const convApplyState = convAdopted === true
|
|
3095
3330
|
? convAdoptable
|
|
@@ -3109,7 +3344,8 @@ export class Orchestrator {
|
|
|
3109
3344
|
result_kind: "patch",
|
|
3110
3345
|
mode,
|
|
3111
3346
|
attempts: attempt,
|
|
3112
|
-
|
|
3347
|
+
lifecycle: facts.lifecycle,
|
|
3348
|
+
outcome_facts: facts,
|
|
3113
3349
|
review_verified: actualReviewVerified,
|
|
3114
3350
|
patch_sha256: patchSha256,
|
|
3115
3351
|
adopted: convAdopted,
|
|
@@ -3119,42 +3355,45 @@ export class Orchestrator {
|
|
|
3119
3355
|
revert_anchor_id: revertAnchorId,
|
|
3120
3356
|
},
|
|
3121
3357
|
});
|
|
3122
|
-
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`);
|
|
3123
3359
|
// Lifecycle invariant (all modes): output.ready precedes the terminal
|
|
3124
3360
|
// event so a client that applied the terminal event has the output.
|
|
3125
3361
|
log.emit("output.ready", {
|
|
3126
3362
|
kind: "summary",
|
|
3127
3363
|
path: "final/summary.md",
|
|
3128
|
-
...(
|
|
3364
|
+
...(convAdoptable ? {} : { state: "diagnostic" }),
|
|
3129
3365
|
});
|
|
3130
3366
|
}
|
|
3131
|
-
|
|
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) {
|
|
3132
3371
|
writeFailure(store, paths, {
|
|
3133
|
-
phase: "convergence",
|
|
3134
|
-
category: isBudgetTerminal(
|
|
3372
|
+
phase: convNeedsDecision ? "review" : "convergence",
|
|
3373
|
+
category: isBudgetTerminal(facts.reason)
|
|
3135
3374
|
? "budget"
|
|
3136
|
-
:
|
|
3375
|
+
: facts.lifecycle === "cancelled"
|
|
3137
3376
|
? "cancelled"
|
|
3138
|
-
:
|
|
3377
|
+
: convNeedsDecision
|
|
3139
3378
|
? "policy"
|
|
3140
3379
|
: "internal",
|
|
3141
|
-
safeMessage:
|
|
3380
|
+
safeMessage: convNeedsDecision
|
|
3142
3381
|
? `review escalated to a human decision after ${attempt} attempt(s)`
|
|
3143
|
-
:
|
|
3382
|
+
: facts.reason === "stuck_no_progress"
|
|
3144
3383
|
? (stuckNoProgressReason ?? `stuck_no_progress after ${attempt} attempt(s)`)
|
|
3145
|
-
: `${
|
|
3384
|
+
: `${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""} after ${attempt} attempt(s)${lastDiffStable ? "" : " (diff changed after review; review is stale)"}`,
|
|
3146
3385
|
harnessId: lastRun?.harnessId,
|
|
3147
3386
|
attemptId: lastRun?.attemptId,
|
|
3148
3387
|
runDir: paths.root,
|
|
3149
|
-
nextActions:
|
|
3388
|
+
nextActions: facts.lifecycle === "cancelled"
|
|
3150
3389
|
? ["Retry if cancellation was accidental"]
|
|
3151
|
-
:
|
|
3390
|
+
: convNeedsDecision
|
|
3152
3391
|
? [
|
|
3153
3392
|
"Open the review queue",
|
|
3154
3393
|
"Decide the NEEDS_HUMAN findings",
|
|
3155
3394
|
"Re-run after the decision",
|
|
3156
3395
|
]
|
|
3157
|
-
:
|
|
3396
|
+
: facts.reason === "stuck_no_progress"
|
|
3158
3397
|
? [
|
|
3159
3398
|
"Inspect the stable patch",
|
|
3160
3399
|
"Inspect the failing gate output",
|
|
@@ -3167,7 +3406,7 @@ export class Orchestrator {
|
|
|
3167
3406
|
],
|
|
3168
3407
|
});
|
|
3169
3408
|
if (!lastRun) {
|
|
3170
|
-
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`);
|
|
3171
3410
|
log.emit("output.ready", {
|
|
3172
3411
|
kind: "summary",
|
|
3173
3412
|
path: "final/summary.md",
|
|
@@ -3176,13 +3415,18 @@ export class Orchestrator {
|
|
|
3176
3415
|
}
|
|
3177
3416
|
}
|
|
3178
3417
|
log.emit("work_product.emitted", { winner: lastRun?.attemptId ?? null });
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3418
|
+
if (!convIsFailureTerminal) {
|
|
3419
|
+
log.emit("run.completed", {
|
|
3420
|
+
lifecycle: facts.lifecycle,
|
|
3421
|
+
facts,
|
|
3422
|
+
reason: facts.reason,
|
|
3423
|
+
attempts: attempt,
|
|
3424
|
+
});
|
|
3182
3425
|
}
|
|
3183
|
-
else if (
|
|
3426
|
+
else if (facts.lifecycle === "succeeded") {
|
|
3184
3427
|
log.emit("run.blocked", {
|
|
3185
|
-
|
|
3428
|
+
lifecycle: facts.lifecycle,
|
|
3429
|
+
facts,
|
|
3186
3430
|
attempts: attempt,
|
|
3187
3431
|
phase: "review",
|
|
3188
3432
|
failure_ref: "final/failure.yaml",
|
|
@@ -3190,7 +3434,9 @@ export class Orchestrator {
|
|
|
3190
3434
|
}
|
|
3191
3435
|
else {
|
|
3192
3436
|
log.emit("run.failed", {
|
|
3193
|
-
|
|
3437
|
+
lifecycle: facts.lifecycle,
|
|
3438
|
+
facts,
|
|
3439
|
+
reason: facts.reason,
|
|
3194
3440
|
attempts: attempt,
|
|
3195
3441
|
phase: "convergence",
|
|
3196
3442
|
failure_ref: "final/failure.yaml",
|
|
@@ -3201,19 +3447,26 @@ export class Orchestrator {
|
|
|
3201
3447
|
runId,
|
|
3202
3448
|
taskId,
|
|
3203
3449
|
mode,
|
|
3204
|
-
|
|
3450
|
+
lifecycle: facts.lifecycle,
|
|
3451
|
+
facts,
|
|
3205
3452
|
winner: lastRun?.attemptId ?? null,
|
|
3206
3453
|
runDir: paths.root,
|
|
3207
3454
|
summary: converged
|
|
3208
3455
|
? `converged in ${attempt} attempt(s)`
|
|
3209
|
-
: `${
|
|
3456
|
+
: `${facts.lifecycle} after ${attempt} attempt(s)`,
|
|
3210
3457
|
candidates: lastRun
|
|
3211
|
-
? [
|
|
3458
|
+
? [
|
|
3459
|
+
{
|
|
3460
|
+
attemptId: lastRun.attemptId,
|
|
3461
|
+
harnessId: lastRun.harnessId,
|
|
3462
|
+
status: facts.lifecycle,
|
|
3463
|
+
},
|
|
3464
|
+
]
|
|
3212
3465
|
: [],
|
|
3213
3466
|
reviewVerified: actualReviewVerified,
|
|
3214
3467
|
};
|
|
3215
3468
|
}
|
|
3216
|
-
/** plan mode: multi-harness planning -> aggregate -> (optional) plan review ->
|
|
3469
|
+
/** plan mode: multi-harness planning -> aggregate -> (optional) plan review -> plan. Read-only. */
|
|
3217
3470
|
/**
|
|
3218
3471
|
* Wrap the user's goal in an explicit "plan, do not implement" instruction.
|
|
3219
3472
|
* Without this the raw prompt ("make a racing game") reaches the harness with
|
|
@@ -3232,10 +3485,222 @@ export class Orchestrator {
|
|
|
3232
3485
|
`1. Approach — 2-3 sentences on how you'd solve this.`,
|
|
3233
3486
|
`2. Steps — a numbered list; each step names the file(s) it touches and what changes.`,
|
|
3234
3487
|
`3. Risks & edge cases.`,
|
|
3235
|
-
`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
|
+
``,
|
|
3236
3500
|
`Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
|
|
3237
3501
|
].join("\n");
|
|
3238
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
|
+
}
|
|
3239
3704
|
async runPlan(input, announce) {
|
|
3240
3705
|
const taskId = input.taskId ?? newId("task");
|
|
3241
3706
|
const runId = input.runId ?? newId("run");
|
|
@@ -3260,16 +3725,19 @@ export class Orchestrator {
|
|
|
3260
3725
|
});
|
|
3261
3726
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3262
3727
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3263
|
-
const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
|
|
3264
|
-
if ("failed" in reviewersOutcome)
|
|
3265
|
-
return reviewersOutcome.failed;
|
|
3266
|
-
const reviewers = reviewersOutcome.reviewers;
|
|
3267
3728
|
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3268
|
-
// 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.
|
|
3269
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);
|
|
3270
3738
|
let adapters;
|
|
3271
3739
|
try {
|
|
3272
|
-
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
|
|
3740
|
+
adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
|
|
3273
3741
|
}
|
|
3274
3742
|
catch (err) {
|
|
3275
3743
|
roHome.dispose();
|
|
@@ -3281,10 +3749,12 @@ export class Orchestrator {
|
|
|
3281
3749
|
safeMessage: message,
|
|
3282
3750
|
runDir: paths.root,
|
|
3283
3751
|
});
|
|
3284
|
-
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`);
|
|
3285
3753
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3286
3754
|
log.emit("run.failed", {
|
|
3287
|
-
|
|
3755
|
+
lifecycle: "failed",
|
|
3756
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3757
|
+
reason: "harness_failed",
|
|
3288
3758
|
phase: "routing",
|
|
3289
3759
|
error: message,
|
|
3290
3760
|
failure_ref: "final/failure.yaml",
|
|
@@ -3294,7 +3764,8 @@ export class Orchestrator {
|
|
|
3294
3764
|
runId,
|
|
3295
3765
|
taskId,
|
|
3296
3766
|
mode: "plan",
|
|
3297
|
-
|
|
3767
|
+
lifecycle: "failed",
|
|
3768
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3298
3769
|
winner: null,
|
|
3299
3770
|
runDir: paths.root,
|
|
3300
3771
|
summary: message,
|
|
@@ -3316,10 +3787,12 @@ export class Orchestrator {
|
|
|
3316
3787
|
safeMessage: message,
|
|
3317
3788
|
runDir: paths.root,
|
|
3318
3789
|
});
|
|
3319
|
-
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`);
|
|
3320
3791
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3321
3792
|
log.emit("run.failed", {
|
|
3322
|
-
|
|
3793
|
+
lifecycle: "failed",
|
|
3794
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3795
|
+
reason: "harness_failed",
|
|
3323
3796
|
phase: "context",
|
|
3324
3797
|
error: message,
|
|
3325
3798
|
failure_ref: "final/failure.yaml",
|
|
@@ -3329,188 +3802,106 @@ export class Orchestrator {
|
|
|
3329
3802
|
runId,
|
|
3330
3803
|
taskId,
|
|
3331
3804
|
mode: "plan",
|
|
3332
|
-
|
|
3805
|
+
lifecycle: "failed",
|
|
3806
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3333
3807
|
winner: null,
|
|
3334
3808
|
runDir: paths.root,
|
|
3335
3809
|
summary: `context failed: ${message}`,
|
|
3336
3810
|
candidates: [],
|
|
3337
3811
|
};
|
|
3338
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
|
+
}
|
|
3339
3831
|
const plans = [];
|
|
3832
|
+
let fallbackFrom = null;
|
|
3340
3833
|
const planAttempts = [];
|
|
3341
3834
|
const attemptTelemetries = [];
|
|
3342
3835
|
try {
|
|
3343
3836
|
for (const [idx, routed] of adapters.entries()) {
|
|
3344
3837
|
if (input.signal?.aborted)
|
|
3345
3838
|
break;
|
|
3346
|
-
const adapter = routed.adapter;
|
|
3347
3839
|
const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
|
|
3348
|
-
const
|
|
3840
|
+
const outcome = await this.runPlannerAttempt({
|
|
3841
|
+
input,
|
|
3842
|
+
contract,
|
|
3349
3843
|
taskId,
|
|
3844
|
+
runId,
|
|
3845
|
+
log,
|
|
3846
|
+
store,
|
|
3847
|
+
paths,
|
|
3848
|
+
ledger,
|
|
3849
|
+
routed,
|
|
3350
3850
|
attemptId,
|
|
3851
|
+
laneRun,
|
|
3852
|
+
fallbackHome: roHome.env,
|
|
3853
|
+
promptBody: this.planPrompt(input.prompt) + contextSection,
|
|
3351
3854
|
intent: "plan",
|
|
3352
|
-
harnessId: adapter.id,
|
|
3353
|
-
cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
|
|
3354
3855
|
});
|
|
3355
|
-
if (
|
|
3356
|
-
log.emit("budget.lease.created", {
|
|
3357
|
-
granted: false,
|
|
3358
|
-
reason: lease.reason,
|
|
3359
|
-
attempt_id: attemptId,
|
|
3360
|
-
harness_id: adapter.id,
|
|
3361
|
-
});
|
|
3856
|
+
if (outcome.budgetDenied)
|
|
3362
3857
|
break;
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
intent: "plan",
|
|
3369
|
-
prompt: this.planPrompt(input.prompt) + contextSection + relayPriorPlansSection(plans),
|
|
3370
|
-
cwd: this.execRootOf(input),
|
|
3371
|
-
access: "readonly",
|
|
3372
|
-
// Planners must SEE any image/file the user attached (e.g. "plan a fix for
|
|
3373
|
-
// what's in this screenshot"), not just agent/race runs.
|
|
3374
|
-
attachments: input.attachments ?? [],
|
|
3375
|
-
...this.sessionSpecFields(input, adapter.id, log),
|
|
3376
|
-
...this.harnessSpecKnobs(contract, knobs, "plan"),
|
|
3377
|
-
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
3378
|
-
env: roHome.env,
|
|
3379
|
-
});
|
|
3380
|
-
const plannerAbort = new AbortController();
|
|
3381
|
-
spec.extra["abortSignal"] = input.signal
|
|
3382
|
-
? AbortSignal.any([input.signal, plannerAbort.signal])
|
|
3383
|
-
: plannerAbort.signal;
|
|
3384
|
-
const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
|
|
3385
|
-
if (planInteraction)
|
|
3386
|
-
spec.extra["interactionChannel"] = planInteraction;
|
|
3387
|
-
const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
|
|
3388
|
-
const answer = new AnswerAssembly();
|
|
3389
|
-
const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
|
|
3390
|
-
knobs.webPolicy === "cached" ||
|
|
3391
|
-
knobs.webPolicy === "live", effectiveWeb, [],
|
|
3392
|
-
// Requested-model capture: a plan lane silently downgraded to another
|
|
3393
|
-
// model surfaces the mismatch in its route receipt, just like agent.
|
|
3394
|
-
knobs.model);
|
|
3395
|
-
const onAbort = () => {
|
|
3396
|
-
void adapter.cancel?.(spec.session_id)?.catch(() => { });
|
|
3397
|
-
};
|
|
3398
|
-
if (input.signal) {
|
|
3399
|
-
if (input.signal.aborted)
|
|
3400
|
-
onAbort();
|
|
3401
|
-
else
|
|
3402
|
-
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
3403
|
-
}
|
|
3404
|
-
let cost = 0;
|
|
3405
|
-
let costEstimated = false;
|
|
3406
|
-
let harnessError = null;
|
|
3407
|
-
const budgetSignalState = { quotaPressureDisclosed: false };
|
|
3408
|
-
try {
|
|
3409
|
-
log.emit("harness.started", {
|
|
3410
|
-
harness_id: adapter.id,
|
|
3411
|
-
attempt_id: attemptId,
|
|
3412
|
-
external_context_policy: knobs.webPolicy,
|
|
3413
|
-
...(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,
|
|
3414
3863
|
});
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
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",
|
|
3423
3879
|
});
|
|
3424
|
-
for await (const ev of watchedPlan) {
|
|
3425
|
-
if (input.signal?.aborted)
|
|
3426
|
-
break;
|
|
3427
|
-
const safeEv = redactHarnessEvent(ev);
|
|
3428
|
-
safeInvoke(input.onHarnessEvent, safeEv);
|
|
3429
|
-
// NOT observed for resume: a read-only planner is not a chat turn,
|
|
3430
|
-
// and attaching its session id would poison thread continuity (and
|
|
3431
|
-
// race parallel planner/reviewer sessions), regardless of whether
|
|
3432
|
-
// the vendor stored that session in the scoped or native store.
|
|
3433
|
-
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
3434
|
-
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
3435
|
-
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
3436
|
-
observeAttemptTelemetry(telemetry, safeEv);
|
|
3437
|
-
if (safeEv.plan_progress) {
|
|
3438
|
-
log.emit("plan.progress", {
|
|
3439
|
-
attempt_id: attemptId,
|
|
3440
|
-
harness_id: adapter.id,
|
|
3441
|
-
items: safeEv.plan_progress.items,
|
|
3442
|
-
});
|
|
3443
|
-
}
|
|
3444
|
-
// read-only routes burn quota too (the orchestrate PLANNER is
|
|
3445
|
-
// the loudest) — same single owner as the agent loop.
|
|
3446
|
-
observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
|
|
3447
|
-
this.deps.quotaEventSink?.(adapter.id, safeEv);
|
|
3448
|
-
if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
|
|
3449
|
-
cost += safeEv.usage.cost_usd;
|
|
3450
|
-
if (safeEv.usage.estimated)
|
|
3451
|
-
costEstimated = true;
|
|
3452
|
-
log.emit("budget.observation", {
|
|
3453
|
-
harness_id: adapter.id,
|
|
3454
|
-
attempt_id: attemptId,
|
|
3455
|
-
kind: "spend",
|
|
3456
|
-
usd: safeEv.usage.cost_usd,
|
|
3457
|
-
estimated: safeEv.usage.estimated === true,
|
|
3458
|
-
});
|
|
3459
|
-
}
|
|
3460
|
-
// A TYPED final message wins verbatim over joined narration.
|
|
3461
|
-
answer.observe(safeEv);
|
|
3462
|
-
if (safeEv.type === "error")
|
|
3463
|
-
harnessError = safeEv.error
|
|
3464
|
-
? redactSecrets(safeEv.error)
|
|
3465
|
-
: "harness emitted an error";
|
|
3466
|
-
}
|
|
3467
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;
|
|
3468
3889
|
}
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
}
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
const webBlocked = webUnsatisfied(telemetry);
|
|
3479
|
-
if (!harnessError && webBlocked) {
|
|
3480
|
-
harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
|
|
3481
|
-
}
|
|
3482
|
-
if (!harnessError && unrecovered.length > 0) {
|
|
3483
|
-
const first = unrecovered[0];
|
|
3484
|
-
harnessError = `${first.tool} failed without recovery: ${first.summary}`;
|
|
3485
|
-
}
|
|
3486
|
-
if (harnessError) {
|
|
3487
|
-
// One failed planner does not abort a multi-harness plan; the run fails
|
|
3488
|
-
// only when EVERY planner fails (parity with explore).
|
|
3489
|
-
log.emit("harness.completed", {
|
|
3490
|
-
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,
|
|
3491
3899
|
attempt_id: attemptId,
|
|
3492
|
-
status:
|
|
3493
|
-
|
|
3494
|
-
...telemetrySummary(telemetry),
|
|
3495
|
-
});
|
|
3496
|
-
planAttempts.push({
|
|
3497
|
-
attemptId,
|
|
3498
|
-
harnessId: adapter.id,
|
|
3499
|
-
status: webBlocked ? "blocked" : "failed",
|
|
3500
|
-
error: harnessError,
|
|
3900
|
+
status: "success",
|
|
3901
|
+
reason: "planner_failed",
|
|
3501
3902
|
});
|
|
3502
|
-
continue;
|
|
3503
3903
|
}
|
|
3504
|
-
|
|
3505
|
-
log.emit("harness.completed", {
|
|
3506
|
-
harness_id: adapter.id,
|
|
3507
|
-
attempt_id: attemptId,
|
|
3508
|
-
status: "success",
|
|
3509
|
-
...telemetrySummary(telemetry),
|
|
3510
|
-
});
|
|
3511
|
-
planAttempts.push({ attemptId, harnessId: adapter.id, status: "success", error: null });
|
|
3512
|
-
plans.push({ id: adapter.id, text });
|
|
3513
|
-
store.writeText(join(paths.root, "plans", `${adapter.id}.md`), redactSecrets(text) + "\n");
|
|
3904
|
+
break;
|
|
3514
3905
|
}
|
|
3515
3906
|
}
|
|
3516
3907
|
finally {
|
|
@@ -3525,117 +3916,18 @@ export class Orchestrator {
|
|
|
3525
3916
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3526
3917
|
}
|
|
3527
3918
|
if (plans.length === 0) {
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
.join("\n") || "all planners failed";
|
|
3532
|
-
this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null);
|
|
3533
|
-
store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${message}\n`);
|
|
3534
|
-
writeFailure(store, paths, {
|
|
3535
|
-
phase: "harness",
|
|
3536
|
-
category: blocked ? "policy" : "harness_error",
|
|
3537
|
-
safeMessage: message,
|
|
3538
|
-
eventRefs: planAttempts.map((p) => `attempts/${p.attemptId}/events.jsonl`),
|
|
3539
|
-
runDir: paths.root,
|
|
3540
|
-
nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
|
|
3541
|
-
});
|
|
3542
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: ${blocked ? "blocked" : "failed"}\n\n${message}\n`);
|
|
3543
|
-
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3544
|
-
if (blocked)
|
|
3545
|
-
log.emit("run.blocked", {
|
|
3546
|
-
status: "blocked",
|
|
3547
|
-
phase: "harness",
|
|
3548
|
-
error: message,
|
|
3549
|
-
failure_ref: "final/failure.yaml",
|
|
3550
|
-
});
|
|
3551
|
-
else
|
|
3552
|
-
log.emit("run.failed", {
|
|
3553
|
-
status: "failed",
|
|
3554
|
-
phase: "harness",
|
|
3555
|
-
error: message,
|
|
3556
|
-
failure_ref: "final/failure.yaml",
|
|
3557
|
-
});
|
|
3558
|
-
return {
|
|
3559
|
-
spendUsd: ledger.spend(),
|
|
3560
|
-
runId,
|
|
3561
|
-
taskId,
|
|
3562
|
-
mode: "plan",
|
|
3563
|
-
status: blocked ? "blocked" : "failed",
|
|
3564
|
-
winner: null,
|
|
3565
|
-
runDir: paths.root,
|
|
3566
|
-
summary: message,
|
|
3567
|
-
candidates: planAttempts.map((p) => ({
|
|
3568
|
-
attemptId: p.attemptId,
|
|
3569
|
-
harnessId: p.harnessId,
|
|
3570
|
-
status: p.status,
|
|
3571
|
-
})),
|
|
3572
|
-
};
|
|
3573
|
-
}
|
|
3574
|
-
let ambiguities = [];
|
|
3575
|
-
let reviewFindings = [];
|
|
3576
|
-
if (reviewers.length > 0 && plans.length > 0) {
|
|
3577
|
-
const reviewDir = join(paths.root, "review-evidence");
|
|
3578
|
-
const planEvidence = plans.map((p) => `## Plan from ${p.id}\n${p.text}`).join("\n\n");
|
|
3579
|
-
const planReviewDiff = "(plan review — no code diff)\n";
|
|
3580
|
-
writeEvidencePacket(reviewDir, {
|
|
3581
|
-
userIntent: redactSecrets(input.prompt),
|
|
3582
|
-
planAccepted: planEvidence,
|
|
3583
|
-
diff: planReviewDiff,
|
|
3584
|
-
tests: renderTestsEvidence(contract),
|
|
3585
|
-
});
|
|
3586
|
-
// Reserve BEFORE spending: a hard budget tier must stop the paid plan
|
|
3587
|
-
// review from starting, not account for it after the fact.
|
|
3588
|
-
const lease = ledger.reserve({
|
|
3919
|
+
return writePlanHarnessFailure(this.planRunDeps(), {
|
|
3920
|
+
input,
|
|
3921
|
+
contract,
|
|
3589
3922
|
taskId,
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
reviewSubject: "plan",
|
|
3599
|
-
diff: planReviewDiff,
|
|
3600
|
-
evidenceDir: reviewDir,
|
|
3601
|
-
artifactsDir: join(paths.reviewsDir, "plan-reviewers"),
|
|
3602
|
-
cwd: this.execRootOf(input),
|
|
3603
|
-
reviewers,
|
|
3604
|
-
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
3605
|
-
signal: input.signal,
|
|
3606
|
-
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
3607
|
-
});
|
|
3608
|
-
reviewFindings = await revalidateFindings(res.findings, {
|
|
3609
|
-
candidateRoot: this.execRootOf(input),
|
|
3610
|
-
evidenceDir: reviewDir,
|
|
3611
|
-
});
|
|
3612
|
-
ambiguities = reviewFindings.filter((f) => f.category === "spec_gap" || f.severity === "NEEDS_HUMAN");
|
|
3613
|
-
store.writeYaml(join(paths.reviewsDir, "plan-review.yaml"), {
|
|
3614
|
-
findings: reviewFindings,
|
|
3615
|
-
route_proofs: res.routeProofs,
|
|
3616
|
-
reviewer_requests: res.reviewerRequests,
|
|
3617
|
-
});
|
|
3618
|
-
ledger.settle(lease.lease?.lease_id ?? "", reviewUsageCostSettlement(res.reviewCashUsd, res.reviewValuationUsd, res.reviewSpendEstimated, ["attempt:plan-review", "review:panel"], res.reviewUnknownUsd));
|
|
3619
|
-
if ((res.reviewSpendUsd ?? 0) > 0) {
|
|
3620
|
-
log.emit("budget.observation", {
|
|
3621
|
-
harness_id: "review-panel",
|
|
3622
|
-
kind: "spend",
|
|
3623
|
-
usd: res.reviewSpendUsd,
|
|
3624
|
-
cash_usd: res.reviewCashUsd,
|
|
3625
|
-
valuation_usd: res.reviewValuationUsd,
|
|
3626
|
-
unknown_usd: res.reviewUnknownUsd,
|
|
3627
|
-
estimated: res.reviewSpendEstimated,
|
|
3628
|
-
});
|
|
3629
|
-
}
|
|
3630
|
-
}
|
|
3631
|
-
else {
|
|
3632
|
-
log.emit("budget.lease.created", {
|
|
3633
|
-
granted: false,
|
|
3634
|
-
reason: lease.reason,
|
|
3635
|
-
attempt_id: "plan-review",
|
|
3636
|
-
harness_id: "review-panel",
|
|
3637
|
-
});
|
|
3638
|
-
}
|
|
3923
|
+
runId,
|
|
3924
|
+
store,
|
|
3925
|
+
paths,
|
|
3926
|
+
log,
|
|
3927
|
+
ledger,
|
|
3928
|
+
planAttempts,
|
|
3929
|
+
attemptTelemetries,
|
|
3930
|
+
}, "all planners failed");
|
|
3639
3931
|
}
|
|
3640
3932
|
if (input.signal?.aborted) {
|
|
3641
3933
|
return cancelledResult(log, runId, taskId, "plan", paths.root, planAttempts.map((p) => ({
|
|
@@ -3644,132 +3936,69 @@ export class Orchestrator {
|
|
|
3644
3936
|
status: p.status,
|
|
3645
3937
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
3646
3938
|
}
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
`## Goal`,
|
|
3661
|
-
redactSecrets(input.prompt),
|
|
3662
|
-
"",
|
|
3663
|
-
`## Plan${plans.length > 1 ? "s" : ""} (${plans.length}/${planAttempts.length} planner${planAttempts.length === 1 ? "" : "s"})`,
|
|
3664
|
-
...plans.map((p) => `\n### Plan — ${p.id}\n${redactSecrets(p.text)}`),
|
|
3665
|
-
...(reviewFindings.length > 0
|
|
3666
|
-
? [
|
|
3667
|
-
"",
|
|
3668
|
-
"## Review findings",
|
|
3669
|
-
...reviewFindings.map((f) => `- ${sevMark[f.severity] ?? f.severity}: ${redactSecrets(f.claim)}`),
|
|
3670
|
-
]
|
|
3671
|
-
: []),
|
|
3672
|
-
...(ambiguities.length > 0
|
|
3673
|
-
? ["", "## Open questions", ...ambiguities.map((a) => `- ${redactSecrets(a.claim)}`)]
|
|
3674
|
-
: []),
|
|
3675
|
-
...(failedPlanners.length > 0
|
|
3676
|
-
? [
|
|
3677
|
-
"",
|
|
3678
|
-
"## Planner omissions",
|
|
3679
|
-
...failedPlanners.map((p) => `- ${p.attemptId} / ${p.harnessId} ${p.status}: ${p.error}`),
|
|
3680
|
-
]
|
|
3681
|
-
: []),
|
|
3682
|
-
"",
|
|
3683
|
-
].join("\n");
|
|
3684
|
-
store.writeText(join(paths.finalDir, "plan.md"), planDoc + "\n");
|
|
3685
|
-
// A plan is a delivered work product (a report), even with risks — parity
|
|
3686
|
-
// with the other read-only modes (removes the "only successful mode with no
|
|
3687
|
-
// work_product" anomaly). result_kind=plan tells surfaces NO files changed.
|
|
3688
|
-
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
3689
|
-
id: newId("wp"),
|
|
3690
|
-
kind: "report",
|
|
3691
|
-
source_task_id: taskId,
|
|
3692
|
-
producer_attempt_id: planAttempts.find((p) => p.status === "success")?.attemptId ?? null,
|
|
3693
|
-
meta: {
|
|
3694
|
-
mode: "plan",
|
|
3695
|
-
result_kind: "plan",
|
|
3696
|
-
planners: plans.length,
|
|
3697
|
-
diffstat: { files: 0, additions: 0, deletions: 0 },
|
|
3698
|
-
blockers: blockingFindings.length,
|
|
3699
|
-
adopted: null,
|
|
3700
|
-
},
|
|
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,
|
|
3701
3952
|
});
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
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() {
|
|
3707
3958
|
return {
|
|
3708
|
-
|
|
3709
|
-
runId,
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
status: "success",
|
|
3713
|
-
winner: null,
|
|
3714
|
-
runDir: paths.root,
|
|
3715
|
-
summary: `Plan from ${plans.length} planner(s); ${blockingFindings.length} blocker(s), ${ambiguities.length} open question(s).`,
|
|
3716
|
-
candidates: planAttempts.map((p) => ({
|
|
3717
|
-
attemptId: p.attemptId,
|
|
3718
|
-
harnessId: p.harnessId,
|
|
3719
|
-
status: p.status,
|
|
3720
|
-
})),
|
|
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),
|
|
3721
3963
|
};
|
|
3722
3964
|
}
|
|
3723
3965
|
/** ask: one selected harness answers read-only questions; no patch/apply controls. */
|
|
3724
3966
|
async runAsk(input, announce) {
|
|
3725
3967
|
return this.runReadOnlyReport(input, {
|
|
3726
3968
|
mode: "ask",
|
|
3727
|
-
|
|
3969
|
+
deepScan: false,
|
|
3728
3970
|
intent: "explain",
|
|
3729
3971
|
title: "Answer",
|
|
3730
3972
|
artifactName: "answer.md",
|
|
3731
3973
|
defaultPrompt: "Answer the user's question.",
|
|
3732
3974
|
}, announce);
|
|
3733
3975
|
}
|
|
3734
|
-
/**
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
mode: "audit",
|
|
3738
|
-
swarm: true,
|
|
3739
|
-
intent: "audit",
|
|
3740
|
-
title: "Explore synthesis",
|
|
3741
|
-
artifactName: "explore.md",
|
|
3742
|
-
defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
|
|
3743
|
-
}, announce);
|
|
3744
|
-
}
|
|
3745
|
-
/** audit: single read-only audit/map report. */
|
|
3746
|
-
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) {
|
|
3747
3979
|
return this.runReadOnlyReport(input, {
|
|
3748
|
-
mode: "
|
|
3749
|
-
|
|
3980
|
+
mode: "ask",
|
|
3981
|
+
deepScan: true,
|
|
3750
3982
|
intent: "audit",
|
|
3751
|
-
title: "
|
|
3983
|
+
title: "Deep scan synthesis",
|
|
3752
3984
|
artifactName: "report.md",
|
|
3753
|
-
defaultPrompt: "
|
|
3985
|
+
defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
|
|
3754
3986
|
}, announce);
|
|
3755
3987
|
}
|
|
3756
3988
|
resolvePaidBudget(inputBudget, cfg) {
|
|
3757
3989
|
return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
|
|
3758
3990
|
}
|
|
3759
|
-
rootLedger(
|
|
3760
|
-
//
|
|
3761
|
-
//
|
|
3762
|
-
//
|
|
3763
|
-
//
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
valuation_usd: valuationUsd,
|
|
3771
|
-
}),
|
|
3772
|
-
});
|
|
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
|
+
});
|
|
3773
4002
|
for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
|
|
3774
4003
|
ledger.observeQuotaSnapshot(snapshot);
|
|
3775
4004
|
}
|
|
@@ -3788,55 +4017,12 @@ export class Orchestrator {
|
|
|
3788
4017
|
? "metered"
|
|
3789
4018
|
: "unknown";
|
|
3790
4019
|
}
|
|
3791
|
-
async runOrchestrate(input, announce) {
|
|
3792
|
-
// "Doctor-verified" must mean status ok — degraded key-present routes are
|
|
3793
|
-
// excluded from the pool the planner plans over (readiness honesty).
|
|
3794
|
-
const pool = await this.gateway.doctorOkReal({ cwd: input.repoRoot }, "orchestrate");
|
|
3795
|
-
const crossFamily = pool.length >= 2;
|
|
3796
|
-
const goal = input.prompt || "Plan the next move for this repository.";
|
|
3797
|
-
// The typed orchestration contract is a REAL persisted artifact (producer
|
|
3798
|
-
// here, consumers: the planner prompt below + the plan validator).
|
|
3799
|
-
// Autonomy is producer-supplied (control-api/CLI -> daemon -> RunInput);
|
|
3800
|
-
// the executor below is its consumer. Default `suggest` (plan-only) preserves
|
|
3801
|
-
// the read-only contract when no autonomy is requested.
|
|
3802
|
-
const autonomy = input.autonomy ?? "suggest";
|
|
3803
|
-
const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
|
|
3804
|
-
const orchestrateContract = OrchestrateContractSchema.parse({
|
|
3805
|
-
thread_id: input.threadId ?? newId("th"),
|
|
3806
|
-
goal,
|
|
3807
|
-
budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
|
|
3808
|
-
autonomy,
|
|
3809
|
-
});
|
|
3810
|
-
const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
|
|
3811
|
-
return this.runReadOnlyReport(
|
|
3812
|
-
// The executed pool is pinned to the PLANNED pool (no double doctor
|
|
3813
|
-
// resolution drift between the prompt's claims and the actual route).
|
|
3814
|
-
// The planner must NOT resume or overwrite the thread's conversational
|
|
3815
|
-
// session — it speaks its own tool-belt framing, not the user's chat.
|
|
3816
|
-
{
|
|
3817
|
-
...input,
|
|
3818
|
-
resumeSessions: undefined,
|
|
3819
|
-
onSessionObserved: undefined,
|
|
3820
|
-
harnesses: input.harnesses ?? (pool.length > 0 ? pool : undefined),
|
|
3821
|
-
prompt: plannerPrompt,
|
|
3822
|
-
}, {
|
|
3823
|
-
mode: "orchestrate",
|
|
3824
|
-
swarm: false,
|
|
3825
|
-
intent: "orchestrate",
|
|
3826
|
-
title: "Orchestration plan",
|
|
3827
|
-
artifactName: "orchestration.md",
|
|
3828
|
-
defaultPrompt: plannerPrompt,
|
|
3829
|
-
contractIntent: goal,
|
|
3830
|
-
orchestrateContract,
|
|
3831
|
-
}, announce);
|
|
3832
|
-
}
|
|
3833
4020
|
async runReadOnlyReport(input, opts, announce) {
|
|
3834
4021
|
const taskId = input.taskId ?? newId("task");
|
|
3835
4022
|
const runId = input.runId ?? newId("run");
|
|
3836
4023
|
const prompt = input.prompt || opts.defaultPrompt;
|
|
3837
4024
|
// Contract validation BEFORE the run is announced (see runRace). The
|
|
3838
|
-
// recorded user intent is the CALLER's goal
|
|
3839
|
-
// prompt (orchestrate wraps the goal in a planner prompt).
|
|
4025
|
+
// recorded user intent is the CALLER's goal.
|
|
3840
4026
|
const contract = this.buildContract({ ...input, prompt: opts.contractIntent ?? prompt }, taskId, opts.mode);
|
|
3841
4027
|
const store = this.artifactStore(input);
|
|
3842
4028
|
const paths = store.createRun(runId);
|
|
@@ -3856,61 +4042,32 @@ export class Orchestrator {
|
|
|
3856
4042
|
});
|
|
3857
4043
|
store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
|
|
3858
4044
|
log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
// Lazy ContextPack: explore/audit attach the compact scope atlas; ask stays bare.
|
|
3863
|
-
let contextSection = "";
|
|
3864
|
-
if (opts.mode !== "ask") {
|
|
3865
|
-
try {
|
|
3866
|
-
contextSection = await this.lazyContextSection(input, contract, store, paths, log);
|
|
3867
|
-
}
|
|
3868
|
-
catch (err) {
|
|
3869
|
-
const message = safeErrorMessage(err);
|
|
3870
|
-
store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
|
|
3871
|
-
writeFailure(store, paths, {
|
|
3872
|
-
phase: "context",
|
|
3873
|
-
category: "project",
|
|
3874
|
-
safeMessage: message,
|
|
3875
|
-
runDir: paths.root,
|
|
3876
|
-
});
|
|
3877
|
-
store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: failed\n- Phase: context\n\n${message}\n`);
|
|
3878
|
-
log.emit("output.ready", {
|
|
3879
|
-
kind: "summary",
|
|
3880
|
-
path: "final/summary.md",
|
|
3881
|
-
state: "diagnostic",
|
|
3882
|
-
});
|
|
3883
|
-
log.emit("run.failed", {
|
|
3884
|
-
status: "failed",
|
|
3885
|
-
phase: "context",
|
|
3886
|
-
error: message,
|
|
3887
|
-
failure_ref: "final/failure.yaml",
|
|
3888
|
-
});
|
|
3889
|
-
return {
|
|
3890
|
-
runId,
|
|
3891
|
-
taskId,
|
|
3892
|
-
mode: opts.mode,
|
|
3893
|
-
status: "failed",
|
|
3894
|
-
winner: null,
|
|
3895
|
-
runDir: paths.root,
|
|
3896
|
-
summary: `context failed: ${message}`,
|
|
3897
|
-
candidates: [],
|
|
3898
|
-
};
|
|
3899
|
-
}
|
|
3900
|
-
}
|
|
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 = "";
|
|
3901
4048
|
const externalContextPolicy = contract.external_context.policy;
|
|
3902
|
-
const width = opts.
|
|
4049
|
+
const width = opts.deepScan
|
|
3903
4050
|
? Math.min(Math.max(input.n ?? 4, 1), 8)
|
|
3904
4051
|
: externalContextPolicy === "off"
|
|
3905
4052
|
? 1
|
|
3906
4053
|
: Math.min(Math.max(input.n ?? 2, 1), 3);
|
|
3907
4054
|
// W3.3: ONE resolved read-only context — the routing point-probe and every
|
|
3908
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.
|
|
3909
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;
|
|
3910
4067
|
let adapters;
|
|
3911
4068
|
try {
|
|
3912
|
-
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
|
|
3913
|
-
if (!opts.
|
|
4069
|
+
adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome);
|
|
4070
|
+
if (!opts.deepScan) {
|
|
3914
4071
|
const seen = new Set();
|
|
3915
4072
|
adapters = adapters.filter((routed) => {
|
|
3916
4073
|
if (seen.has(routed.adapter.id))
|
|
@@ -3930,10 +4087,12 @@ export class Orchestrator {
|
|
|
3930
4087
|
safeMessage: message,
|
|
3931
4088
|
runDir: paths.root,
|
|
3932
4089
|
});
|
|
3933
|
-
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`);
|
|
3934
4091
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
3935
4092
|
log.emit("run.failed", {
|
|
3936
|
-
|
|
4093
|
+
lifecycle: "failed",
|
|
4094
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
4095
|
+
reason: "harness_failed",
|
|
3937
4096
|
phase: "routing",
|
|
3938
4097
|
error: message,
|
|
3939
4098
|
failure_ref: "final/failure.yaml",
|
|
@@ -3942,7 +4101,8 @@ export class Orchestrator {
|
|
|
3942
4101
|
runId,
|
|
3943
4102
|
taskId,
|
|
3944
4103
|
mode: opts.mode,
|
|
3945
|
-
|
|
4104
|
+
lifecycle: "failed",
|
|
4105
|
+
facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
|
|
3946
4106
|
winner: null,
|
|
3947
4107
|
runDir: paths.root,
|
|
3948
4108
|
summary: message,
|
|
@@ -3983,17 +4143,26 @@ export class Orchestrator {
|
|
|
3983
4143
|
}
|
|
3984
4144
|
const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
|
|
3985
4145
|
const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
|
|
3986
|
-
const explorerPrompt = (opts.
|
|
4146
|
+
const explorerPrompt = (opts.deepScan
|
|
3987
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.`
|
|
3988
4148
|
: prompt) + contextSection;
|
|
3989
4149
|
const sessionFields = this.sessionSpecFields(input, adapter.id, log);
|
|
3990
4150
|
const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
|
|
3991
4151
|
if (grantResume)
|
|
3992
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;
|
|
3993
4160
|
let spec = HarnessRunSpec.parse({
|
|
3994
4161
|
session_id: newId("ses"),
|
|
3995
4162
|
intent: opts.intent,
|
|
3996
|
-
prompt:
|
|
4163
|
+
prompt: laneContinuity?.pointerLine
|
|
4164
|
+
? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
|
|
4165
|
+
: explorerPrompt,
|
|
3997
4166
|
cwd: this.execRootOf(input),
|
|
3998
4167
|
access: "readonly",
|
|
3999
4168
|
// ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
|
|
@@ -4005,19 +4174,10 @@ export class Orchestrator {
|
|
|
4005
4174
|
resume_session_id: grantResume ? sessionFields.resume_session_id : null,
|
|
4006
4175
|
...this.harnessSpecKnobs(contract, knobs, opts.intent),
|
|
4007
4176
|
env_inheritance: envInheritance(this.config(input.repoRoot)),
|
|
4008
|
-
|
|
4009
|
-
//
|
|
4010
|
-
//
|
|
4011
|
-
|
|
4012
|
-
// gated off when this spec will ride the INTERACTIVE stream-json
|
|
4013
|
-
// transport (an interaction channel will be offered): --json-schema x
|
|
4014
|
-
// interactive is an unverified vendor combination — fenced parsing
|
|
4015
|
-
// carries those runs until it is live-verified.
|
|
4016
|
-
...(opts.intent === "orchestrate" &&
|
|
4017
|
-
routed.supportsJsonSchemaOutput &&
|
|
4018
|
-
!(Boolean(input.onInteraction) && routed.supportsInteractive)
|
|
4019
|
-
? { output_schema: orchestratePlanJsonSchema() }
|
|
4020
|
-
: {}),
|
|
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,
|
|
4021
4181
|
});
|
|
4022
4182
|
const reportAbort = new AbortController();
|
|
4023
4183
|
spec.extra["abortSignal"] = input.signal
|
|
@@ -4084,9 +4244,14 @@ export class Orchestrator {
|
|
|
4084
4244
|
break;
|
|
4085
4245
|
const safeEv = redactHarnessEvent(ev);
|
|
4086
4246
|
safeInvoke(input.onHarnessEvent, safeEv);
|
|
4087
|
-
//
|
|
4088
|
-
//
|
|
4089
|
-
//
|
|
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);
|
|
4090
4255
|
observeAuthSwitch(log, adapter.id, attemptId, safeEv);
|
|
4091
4256
|
log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
|
|
4092
4257
|
appendLine(attemptEventsPath, JSON.stringify(safeEv));
|
|
@@ -4222,7 +4387,7 @@ export class Orchestrator {
|
|
|
4222
4387
|
error: harnessError,
|
|
4223
4388
|
telemetry,
|
|
4224
4389
|
});
|
|
4225
|
-
if (opts.
|
|
4390
|
+
if (opts.deepScan) {
|
|
4226
4391
|
store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
|
|
4227
4392
|
}
|
|
4228
4393
|
return;
|
|
@@ -4241,7 +4406,7 @@ export class Orchestrator {
|
|
|
4241
4406
|
error: null,
|
|
4242
4407
|
telemetry,
|
|
4243
4408
|
});
|
|
4244
|
-
if (opts.
|
|
4409
|
+
if (opts.deepScan) {
|
|
4245
4410
|
const warningNote = toolWarnings(telemetry).length
|
|
4246
4411
|
? `\n\n> Tool warnings: ${toolWarnings(telemetry)
|
|
4247
4412
|
.map((e) => `${e.tool}: ${e.summary}`)
|
|
@@ -4251,7 +4416,7 @@ export class Orchestrator {
|
|
|
4251
4416
|
}
|
|
4252
4417
|
};
|
|
4253
4418
|
try {
|
|
4254
|
-
if (opts.
|
|
4419
|
+
if (opts.deepScan) {
|
|
4255
4420
|
// Explorer swarm runs in parallel (bounded), mirroring parallel candidates.
|
|
4256
4421
|
await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), runReadonlyAttempt);
|
|
4257
4422
|
}
|
|
@@ -4341,7 +4506,7 @@ export class Orchestrator {
|
|
|
4341
4506
|
})), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
|
|
4342
4507
|
}
|
|
4343
4508
|
const succeededReadonly = attempts.filter((a) => a.status === "success");
|
|
4344
|
-
if (!opts.
|
|
4509
|
+
if (!opts.deepScan && succeededReadonly.length === 0) {
|
|
4345
4510
|
const last = attempts[attempts.length - 1];
|
|
4346
4511
|
const webBlocked = attempts.some((a) => a.status === "blocked");
|
|
4347
4512
|
const singleError = last?.error ?? (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
|
|
@@ -4375,16 +4540,17 @@ export class Orchestrator {
|
|
|
4375
4540
|
runDir: paths.root,
|
|
4376
4541
|
nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
|
|
4377
4542
|
});
|
|
4378
|
-
const
|
|
4379
|
-
? "blocked"
|
|
4543
|
+
const terminalFacts = webBlocked
|
|
4544
|
+
? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
|
|
4380
4545
|
: budgetStopped && attempts.length === 0
|
|
4381
|
-
? "
|
|
4382
|
-
: "failed";
|
|
4383
|
-
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`);
|
|
4384
4549
|
log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
|
|
4385
|
-
if (
|
|
4550
|
+
if (terminalFacts.lifecycle === "succeeded") {
|
|
4386
4551
|
log.emit("run.blocked", {
|
|
4387
|
-
|
|
4552
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4553
|
+
facts: terminalFacts,
|
|
4388
4554
|
harness_id: last?.harnessId,
|
|
4389
4555
|
error: singleError,
|
|
4390
4556
|
failure_ref: "final/failure.yaml",
|
|
@@ -4392,7 +4558,9 @@ export class Orchestrator {
|
|
|
4392
4558
|
}
|
|
4393
4559
|
else {
|
|
4394
4560
|
log.emit("run.failed", {
|
|
4395
|
-
|
|
4561
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4562
|
+
facts: terminalFacts,
|
|
4563
|
+
reason: terminalFacts.reason,
|
|
4396
4564
|
harness_id: last?.harnessId,
|
|
4397
4565
|
error: singleError,
|
|
4398
4566
|
failure_ref: "final/failure.yaml",
|
|
@@ -4403,7 +4571,8 @@ export class Orchestrator {
|
|
|
4403
4571
|
runId,
|
|
4404
4572
|
taskId,
|
|
4405
4573
|
mode: opts.mode,
|
|
4406
|
-
|
|
4574
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4575
|
+
facts: terminalFacts,
|
|
4407
4576
|
winner: null,
|
|
4408
4577
|
runDir: paths.root,
|
|
4409
4578
|
summary: singleError,
|
|
@@ -4415,7 +4584,7 @@ export class Orchestrator {
|
|
|
4415
4584
|
};
|
|
4416
4585
|
}
|
|
4417
4586
|
const succeeded = succeededReadonly;
|
|
4418
|
-
if (opts.
|
|
4587
|
+
if (opts.deepScan && succeeded.length === 0) {
|
|
4419
4588
|
const message = attempts
|
|
4420
4589
|
.map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
|
|
4421
4590
|
.join("\n");
|
|
@@ -4434,18 +4603,24 @@ export class Orchestrator {
|
|
|
4434
4603
|
"Retry after setup",
|
|
4435
4604
|
],
|
|
4436
4605
|
});
|
|
4437
|
-
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`);
|
|
4438
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" });
|
|
4439
4611
|
if (blocked)
|
|
4440
4612
|
log.emit("run.blocked", {
|
|
4441
|
-
|
|
4613
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4614
|
+
facts: scanFailFacts,
|
|
4442
4615
|
phase: "harness",
|
|
4443
4616
|
error: message,
|
|
4444
4617
|
failure_ref: "final/failure.yaml",
|
|
4445
4618
|
});
|
|
4446
4619
|
else
|
|
4447
4620
|
log.emit("run.failed", {
|
|
4448
|
-
|
|
4621
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4622
|
+
facts: scanFailFacts,
|
|
4623
|
+
reason: scanFailFacts.reason,
|
|
4449
4624
|
phase: "harness",
|
|
4450
4625
|
error: message,
|
|
4451
4626
|
failure_ref: "final/failure.yaml",
|
|
@@ -4455,7 +4630,8 @@ export class Orchestrator {
|
|
|
4455
4630
|
runId,
|
|
4456
4631
|
taskId,
|
|
4457
4632
|
mode: opts.mode,
|
|
4458
|
-
|
|
4633
|
+
lifecycle: scanFailFacts.lifecycle,
|
|
4634
|
+
facts: scanFailFacts,
|
|
4459
4635
|
winner: null,
|
|
4460
4636
|
runDir: paths.root,
|
|
4461
4637
|
summary: message,
|
|
@@ -4467,7 +4643,7 @@ export class Orchestrator {
|
|
|
4467
4643
|
};
|
|
4468
4644
|
}
|
|
4469
4645
|
const unsuccessful = attempts.filter((a) => a.status !== "success");
|
|
4470
|
-
const report = opts.
|
|
4646
|
+
const report = opts.deepScan
|
|
4471
4647
|
? [
|
|
4472
4648
|
`Explorers succeeded: ${succeeded.length}/${attempts.length}.`,
|
|
4473
4649
|
"",
|
|
@@ -4505,36 +4681,12 @@ export class Orchestrator {
|
|
|
4505
4681
|
answerText: succeeded[0]?.report ?? "",
|
|
4506
4682
|
});
|
|
4507
4683
|
}
|
|
4508
|
-
|
|
4509
|
-
// the required fenced JSON block, validate it against the tool belt, and
|
|
4510
|
-
// persist final/orchestration.yaml; a missing/invalid block is disclosed in
|
|
4511
|
-
// the summary and events (suggest autonomy: the plan is the work product).
|
|
4512
|
-
let typedPlanNote = "";
|
|
4513
|
-
let orchestratePlan = null;
|
|
4514
|
-
if (opts.mode === "orchestrate") {
|
|
4515
|
-
const extracted = extractOrchestratePlan(report);
|
|
4516
|
-
if (extracted.plan) {
|
|
4517
|
-
orchestratePlan = extracted.plan;
|
|
4518
|
-
store.writeYaml(join(paths.finalDir, "orchestration.yaml"), extracted.plan);
|
|
4519
|
-
log.emit("output.ready", { kind: "report", path: "final/orchestration.yaml" });
|
|
4520
|
-
typedPlanNote = `\n- Typed plan: final/orchestration.yaml (${extracted.plan.tool_calls.length} tool call(s))`;
|
|
4521
|
-
}
|
|
4522
|
-
else {
|
|
4523
|
-
store.writeText(join(paths.finalDir, "orchestration_parse_error.md"), `# Typed plan missing\n\n${extracted.error}\n`);
|
|
4524
|
-
log.emit("output.ready", {
|
|
4525
|
-
kind: "report",
|
|
4526
|
-
path: "final/orchestration_parse_error.md",
|
|
4527
|
-
state: "diagnostic",
|
|
4528
|
-
});
|
|
4529
|
-
typedPlanNote = `\n- Typed plan: MISSING (${extracted.error}); the markdown plan above is the only artifact`;
|
|
4530
|
-
}
|
|
4531
|
-
}
|
|
4532
|
-
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));
|
|
4533
4685
|
log.emit("output.ready", {
|
|
4534
4686
|
kind: opts.mode === "ask" ? "answer" : "report",
|
|
4535
4687
|
path: `final/${opts.artifactName}`,
|
|
4536
4688
|
});
|
|
4537
|
-
if (opts.
|
|
4689
|
+
if (opts.deepScan) {
|
|
4538
4690
|
store.writeYaml(join(paths.finalDir, "explore-findings.yaml"), {
|
|
4539
4691
|
mode: "explore",
|
|
4540
4692
|
width,
|
|
@@ -4556,99 +4708,60 @@ export class Orchestrator {
|
|
|
4556
4708
|
});
|
|
4557
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`);
|
|
4558
4710
|
}
|
|
4559
|
-
//
|
|
4560
|
-
//
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
|
|
4566
|
-
let terminal = "success";
|
|
4567
|
-
let orchestrateReadOnly = true;
|
|
4568
|
-
let orchestrateReceiptRefs = [];
|
|
4569
|
-
if (ledger.terminal() !== null)
|
|
4570
|
-
terminal = ledger.terminal();
|
|
4571
|
-
// orchestrate's contract output IS the typed plan. If the planner failed to
|
|
4572
|
-
// produce a valid one, the run is NOT a clean success — disclose it honestly
|
|
4573
|
-
// (the markdown plan stays as a diagnostic artifact) rather than reporting
|
|
4574
|
-
// success alongside an orchestration_parse_error.md.
|
|
4575
|
-
if (opts.mode === "orchestrate" && !orchestratePlan)
|
|
4576
|
-
terminal = "not_converged";
|
|
4577
|
-
if (opts.mode === "orchestrate" && autonomy !== "suggest" && orchestratePlan) {
|
|
4578
|
-
// Thread the GENERATED runId onto input so the executor's answer_question
|
|
4579
|
-
// step keys the interaction registry by this orchestrate run's id (callers
|
|
4580
|
-
// often invoke run() without a preassigned runId).
|
|
4581
|
-
const executionInput = { ...input, runId };
|
|
4582
|
-
const exec = await executeOrchestratePlan({
|
|
4583
|
-
plan: orchestratePlan,
|
|
4584
|
-
autonomy,
|
|
4585
|
-
maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
|
|
4586
|
-
signal: input.signal,
|
|
4587
|
-
store,
|
|
4588
|
-
paths,
|
|
4589
|
-
log,
|
|
4590
|
-
ledger,
|
|
4591
|
-
executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
|
|
4592
|
-
executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
|
|
4593
|
-
});
|
|
4594
|
-
terminal = exec.terminal;
|
|
4595
|
-
orchestrateReadOnly = exec.readOnly;
|
|
4596
|
-
orchestrateReceiptRefs = exec.receiptRefs;
|
|
4597
|
-
typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
|
|
4598
|
-
}
|
|
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 });
|
|
4599
4717
|
const harnessLabel = attempts
|
|
4600
4718
|
.map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
|
|
4601
4719
|
.join(", ");
|
|
4602
|
-
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`);
|
|
4603
4721
|
store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
|
|
4604
4722
|
id: newId("wp"),
|
|
4605
4723
|
kind: "report",
|
|
4606
4724
|
source_task_id: taskId,
|
|
4607
4725
|
producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
|
|
4608
|
-
files: Object.fromEntries([
|
|
4609
|
-
[opts.artifactName, join(paths.finalDir, opts.artifactName)],
|
|
4610
|
-
...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
|
|
4611
|
-
]),
|
|
4726
|
+
files: Object.fromEntries([[opts.artifactName, join(paths.finalDir, opts.artifactName)]]),
|
|
4612
4727
|
meta: {
|
|
4613
4728
|
harnesses: attempts.map((a) => a.harnessId),
|
|
4614
4729
|
mode: opts.mode,
|
|
4615
4730
|
intent: opts.intent,
|
|
4616
|
-
read_only:
|
|
4731
|
+
read_only: true,
|
|
4617
4732
|
},
|
|
4618
4733
|
});
|
|
4619
4734
|
log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
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"],
|
|
4627
4742
|
});
|
|
4628
|
-
}
|
|
4629
|
-
else if (orchestrateFailure) {
|
|
4630
|
-
writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
|
|
4631
4743
|
log.emit("run.failed", {
|
|
4632
|
-
|
|
4633
|
-
|
|
4744
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4745
|
+
facts: terminalFacts,
|
|
4746
|
+
reason: terminalFacts.reason,
|
|
4747
|
+
phase: "executor",
|
|
4634
4748
|
failure_ref: "final/failure.yaml",
|
|
4635
4749
|
});
|
|
4636
4750
|
}
|
|
4637
|
-
else if (terminal === "cancelled") {
|
|
4638
|
-
// Cancel is failure-shaped (parity with every other mode's cancel
|
|
4639
|
-
// terminal): tailers waiting for run.completed-as-success must not
|
|
4640
|
-
// mistake an operator abort for a clean report.
|
|
4641
|
-
log.emit("run.failed", { status: terminal });
|
|
4642
|
-
}
|
|
4643
4751
|
else {
|
|
4644
|
-
log.emit("run.completed", {
|
|
4752
|
+
log.emit("run.completed", {
|
|
4753
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4754
|
+
facts: terminalFacts,
|
|
4755
|
+
reason: terminalFacts.reason,
|
|
4756
|
+
});
|
|
4645
4757
|
}
|
|
4646
4758
|
return {
|
|
4647
4759
|
spendUsd: ledger.spend(),
|
|
4648
4760
|
runId,
|
|
4649
4761
|
taskId,
|
|
4650
4762
|
mode: opts.mode,
|
|
4651
|
-
|
|
4763
|
+
lifecycle: terminalFacts.lifecycle,
|
|
4764
|
+
facts: terminalFacts,
|
|
4652
4765
|
winner: null,
|
|
4653
4766
|
runDir: paths.root,
|
|
4654
4767
|
summary: redactSecrets(report).slice(0, 400),
|
|
@@ -4659,228 +4772,6 @@ export class Orchestrator {
|
|
|
4659
4772
|
})),
|
|
4660
4773
|
};
|
|
4661
4774
|
}
|
|
4662
|
-
/**
|
|
4663
|
-
* Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
|
|
4664
|
-
* (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
|
|
4665
|
-
* answer delivery that never mutate the live tree.
|
|
4666
|
-
*/
|
|
4667
|
-
async executeSafeStep(input, call, log, store, paths, ledger) {
|
|
4668
|
-
switch (call.tool) {
|
|
4669
|
-
case "start_run":
|
|
4670
|
-
case "race": {
|
|
4671
|
-
// Isolated envelope sub-run (construction owned by runSupport);
|
|
4672
|
-
// recursion guard via orchestrateDepth+1, may NOT orchestrate.
|
|
4673
|
-
const subInput = {
|
|
4674
|
-
...buildEnvelopeSubInput(input, call, ledger),
|
|
4675
|
-
};
|
|
4676
|
-
// SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
|
|
4677
|
-
// isolated envelope — never a live in-place turn.
|
|
4678
|
-
assertEnvelopeSubRun(subInput);
|
|
4679
|
-
log.emit("orchestrate.subrun.started", {
|
|
4680
|
-
tool: call.tool,
|
|
4681
|
-
mode: subInput.mode,
|
|
4682
|
-
n: subInput.n ?? null,
|
|
4683
|
-
});
|
|
4684
|
-
const res = await this.run(subInput);
|
|
4685
|
-
return {
|
|
4686
|
-
status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
|
|
4687
|
-
terminalStatus: res.status,
|
|
4688
|
-
terminalSource: "subrun",
|
|
4689
|
-
evidenceRefs: [`run:${res.runId}`],
|
|
4690
|
-
runId: res.runId,
|
|
4691
|
-
spendUsd: res.spendUsd ?? null,
|
|
4692
|
-
detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
|
|
4693
|
-
};
|
|
4694
|
-
}
|
|
4695
|
-
case "status": {
|
|
4696
|
-
// Pure read of the referenced run's decision/work_product artifacts.
|
|
4697
|
-
const read = readRunStatus(input.repoRoot, call.run_id);
|
|
4698
|
-
return {
|
|
4699
|
-
status: read ? "done" : "skipped",
|
|
4700
|
-
terminalStatus: read?.status ?? null,
|
|
4701
|
-
terminalSource: "subrun",
|
|
4702
|
-
evidenceRefs: read?.evidenceRefs ?? [],
|
|
4703
|
-
runId: call.run_id,
|
|
4704
|
-
detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
|
|
4705
|
-
};
|
|
4706
|
-
}
|
|
4707
|
-
case "review": {
|
|
4708
|
-
// Read-only review over the referenced run's recorded patch diff. The
|
|
4709
|
-
// step ACTUALLY runs the reviewer panel (evidence beats summaries — a
|
|
4710
|
-
// "done" review must mean a review happened), persists its artifacts, and
|
|
4711
|
-
// reports the real outcome; eligibility alone is never reported as done.
|
|
4712
|
-
const diff = readRunPatch(input.repoRoot, call.run_id);
|
|
4713
|
-
if (diff === null)
|
|
4714
|
-
return {
|
|
4715
|
-
status: "skipped",
|
|
4716
|
-
terminalStatus: null,
|
|
4717
|
-
terminalSource: "review",
|
|
4718
|
-
evidenceRefs: [],
|
|
4719
|
-
runId: call.run_id,
|
|
4720
|
-
detail: `run ${call.run_id} has no patch.diff to review`,
|
|
4721
|
-
};
|
|
4722
|
-
// Aggregate honesty: reviewer panels spend real money on
|
|
4723
|
-
// API-keyed routes and the spend is charged AFTER the fact — with no
|
|
4724
|
-
// remaining headroom the review must not start at all.
|
|
4725
|
-
if (ledger.terminal() !== null) {
|
|
4726
|
-
return {
|
|
4727
|
-
status: "skipped",
|
|
4728
|
-
terminalStatus: null,
|
|
4729
|
-
terminalSource: "review",
|
|
4730
|
-
evidenceRefs: [],
|
|
4731
|
-
runId: call.run_id,
|
|
4732
|
-
detail: "aggregate budget exhausted before the review step",
|
|
4733
|
-
};
|
|
4734
|
-
}
|
|
4735
|
-
const reviewers = await this.resolveReviewers(input.repoRoot, input.authPreference);
|
|
4736
|
-
if (reviewers.length === 0)
|
|
4737
|
-
return {
|
|
4738
|
-
status: "skipped",
|
|
4739
|
-
terminalStatus: null,
|
|
4740
|
-
terminalSource: "review",
|
|
4741
|
-
evidenceRefs: [],
|
|
4742
|
-
runId: call.run_id,
|
|
4743
|
-
detail: "no doctor-OK reviewers available",
|
|
4744
|
-
};
|
|
4745
|
-
const reviewLease = ledger.reserve({
|
|
4746
|
-
taskId: input.taskId ?? "orchestrate",
|
|
4747
|
-
attemptId: `review-${call.run_id}`,
|
|
4748
|
-
intent: "review",
|
|
4749
|
-
harnessId: "review-panel",
|
|
4750
|
-
cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
|
|
4751
|
-
});
|
|
4752
|
-
if (!reviewLease.granted)
|
|
4753
|
-
return {
|
|
4754
|
-
status: "skipped",
|
|
4755
|
-
terminalStatus: ledger.terminal(),
|
|
4756
|
-
terminalSource: "review",
|
|
4757
|
-
evidenceRefs: [],
|
|
4758
|
-
runId: call.run_id,
|
|
4759
|
-
detail: reviewLease.reason ?? "root paid budget refused the review step",
|
|
4760
|
-
};
|
|
4761
|
-
const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
|
|
4762
|
-
writeEvidencePacket(evidenceDir, {
|
|
4763
|
-
userIntent: redactSecrets(input.prompt),
|
|
4764
|
-
planAccepted: `orchestrate review tool requested a read-only review of run ${call.run_id}.`,
|
|
4765
|
-
diff,
|
|
4766
|
-
tests: input.tests?.join("\n") || "(no test commands configured)",
|
|
4767
|
-
decidedTradeoffs: "This review is scoped to the referenced run patch and must use typed reviewer artifacts, not summary-only evidence.",
|
|
4768
|
-
});
|
|
4769
|
-
const result = await this.reviewScoped({
|
|
4770
|
-
candidateLabel: `Run ${call.run_id}`,
|
|
4771
|
-
diff,
|
|
4772
|
-
evidenceDir,
|
|
4773
|
-
artifactsDir: join(paths.reviewsDir, `orchestrate-${call.run_id}`),
|
|
4774
|
-
cwd: input.repoRoot,
|
|
4775
|
-
reviewers,
|
|
4776
|
-
envInheritance: envInheritance(this.config(input.repoRoot)),
|
|
4777
|
-
signal: input.signal,
|
|
4778
|
-
onReviewerEvent: (event) => log.emit(event.type, { ...event }),
|
|
4779
|
-
}).catch((error) => {
|
|
4780
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
|
|
4781
|
-
throw error;
|
|
4782
|
-
});
|
|
4783
|
-
ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`orchestrate:review:${call.run_id}`], result.reviewUnknownUsd));
|
|
4784
|
-
const revalidated = await revalidateFindings(result.findings, {
|
|
4785
|
-
candidateRoot: input.repoRoot,
|
|
4786
|
-
evidenceDir,
|
|
4787
|
-
});
|
|
4788
|
-
store.writeYaml(join(paths.reviewsDir, `orchestrate-${call.run_id}.yaml`), {
|
|
4789
|
-
target_run_id: call.run_id,
|
|
4790
|
-
cross_family_healthy: result.crossFamilyHealthy,
|
|
4791
|
-
cross_family_verified: result.crossFamilyVerified,
|
|
4792
|
-
findings: revalidated,
|
|
4793
|
-
route_proofs: result.routeProofs,
|
|
4794
|
-
});
|
|
4795
|
-
const blockers = revalidated.filter((f) => isBlocking(f)).length;
|
|
4796
|
-
return {
|
|
4797
|
-
status: "done",
|
|
4798
|
-
terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
|
|
4799
|
-
terminalSource: "review",
|
|
4800
|
-
evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
|
|
4801
|
-
runId: call.run_id,
|
|
4802
|
-
detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
|
|
4803
|
-
// Reviewer panels can spend real money on API-keyed routes; the
|
|
4804
|
-
// aggregate cap must charge it like any other step.
|
|
4805
|
-
spendUsd: result.reviewCashUsd,
|
|
4806
|
-
};
|
|
4807
|
-
}
|
|
4808
|
-
case "answer_question": {
|
|
4809
|
-
// Delivery + registry-keying rationale owned by runSupport.
|
|
4810
|
-
const answer = await deliverPlanAnswer(input, call);
|
|
4811
|
-
return {
|
|
4812
|
-
...answer,
|
|
4813
|
-
terminalStatus: answer.status === "done" ? "success" : null,
|
|
4814
|
-
terminalSource: "executor",
|
|
4815
|
-
evidenceRefs: [],
|
|
4816
|
-
};
|
|
4817
|
-
}
|
|
4818
|
-
default: {
|
|
4819
|
-
// FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
|
|
4820
|
-
// the caller routes risky steps to executeApplyStep / the auto_safe block.
|
|
4821
|
-
throw new Error(`executeSafeStep refused a non-safe tool '${call.tool}' (risky tools must not run as safe steps)`);
|
|
4822
|
-
}
|
|
4823
|
-
}
|
|
4824
|
-
}
|
|
4825
|
-
/** Execute an auto_full apply through the shared fresh-verification gate. */
|
|
4826
|
-
async executeApplyStep(input, call, log) {
|
|
4827
|
-
const store = new ArtifactStore(input.repoRoot);
|
|
4828
|
-
const sub = store.runPaths(call.run_id);
|
|
4829
|
-
const patchPath = join(sub.finalDir, "patch.diff");
|
|
4830
|
-
const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : null;
|
|
4831
|
-
if (patchText === null)
|
|
4832
|
-
return {
|
|
4833
|
-
ok: false,
|
|
4834
|
-
runId: call.run_id,
|
|
4835
|
-
detail: `run ${call.run_id} has no patch.diff`,
|
|
4836
|
-
receipt: null,
|
|
4837
|
-
};
|
|
4838
|
-
if (containsSecretLikeToken(patchText))
|
|
4839
|
-
return {
|
|
4840
|
-
ok: false,
|
|
4841
|
-
runId: call.run_id,
|
|
4842
|
-
detail: "patch contains a secret-like token; refusing apply",
|
|
4843
|
-
receipt: null,
|
|
4844
|
-
};
|
|
4845
|
-
const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
|
|
4846
|
-
const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
|
|
4847
|
-
const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
|
|
4848
|
-
const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
|
|
4849
|
-
const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
|
|
4850
|
-
if (!taskContract.success)
|
|
4851
|
-
return {
|
|
4852
|
-
ok: false,
|
|
4853
|
-
runId: call.run_id,
|
|
4854
|
-
detail: "fresh verification contract is missing",
|
|
4855
|
-
receipt: null,
|
|
4856
|
-
};
|
|
4857
|
-
const applyGateInput = {
|
|
4858
|
-
state: null,
|
|
4859
|
-
decision: parsedDecision?.success ? parsedDecision.data : null,
|
|
4860
|
-
workProduct: parsedWp?.success ? parsedWp.data : null,
|
|
4861
|
-
patch: patchText,
|
|
4862
|
-
originalRepoRoot: input.repoRoot,
|
|
4863
|
-
targetRepoRoot: input.repoRoot,
|
|
4864
|
-
operatorDecision: null,
|
|
4865
|
-
};
|
|
4866
|
-
const gateError = validateApplyGate(applyGateInput);
|
|
4867
|
-
if (gateError)
|
|
4868
|
-
return {
|
|
4869
|
-
ok: false,
|
|
4870
|
-
runId: call.run_id,
|
|
4871
|
-
detail: `apply gate refused: ${gateError}`,
|
|
4872
|
-
receipt: null,
|
|
4873
|
-
};
|
|
4874
|
-
const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
|
|
4875
|
-
return {
|
|
4876
|
-
ok: delivered.applied,
|
|
4877
|
-
runId: call.run_id,
|
|
4878
|
-
detail: delivered.applied
|
|
4879
|
-
? `applied (${call.mode})`
|
|
4880
|
-
: `deliver failed: ${delivered.detail ?? "unknown"}`,
|
|
4881
|
-
receipt: delivered,
|
|
4882
|
-
};
|
|
4883
|
-
}
|
|
4884
4775
|
}
|
|
4885
4776
|
function assertNoSecretLikeTokens(label, text) {
|
|
4886
4777
|
if (containsSecretLikeToken(text)) {
|