@wildorder/nightshift 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/agent-runner.d.ts +56 -4
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +253 -28
- package/dist/agent-runner.js.map +1 -1
- package/dist/author.d.ts +8 -0
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +84 -57
- package/dist/author.js.map +1 -1
- package/dist/causal-analysis.d.ts +212 -0
- package/dist/causal-analysis.d.ts.map +1 -0
- package/dist/causal-analysis.js +733 -0
- package/dist/causal-analysis.js.map +1 -0
- package/dist/decider-review.d.ts +6 -1
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +19 -7
- package/dist/decider-review.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/preflight.d.ts +3 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +73 -57
- package/dist/preflight.js.map +1 -1
- package/dist/prompt-telemetry.d.ts +64 -0
- package/dist/prompt-telemetry.d.ts.map +1 -0
- package/dist/prompt-telemetry.js +112 -0
- package/dist/prompt-telemetry.js.map +1 -0
- package/dist/provider-telemetry.d.ts +106 -0
- package/dist/provider-telemetry.d.ts.map +1 -0
- package/dist/provider-telemetry.js +423 -0
- package/dist/provider-telemetry.js.map +1 -0
- package/dist/run-analytics-report.d.ts +173 -0
- package/dist/run-analytics-report.d.ts.map +1 -0
- package/dist/run-analytics-report.js +650 -0
- package/dist/run-analytics-report.js.map +1 -0
- package/dist/run-analytics.d.ts +738 -0
- package/dist/run-analytics.d.ts.map +1 -0
- package/dist/run-analytics.js +545 -0
- package/dist/run-analytics.js.map +1 -0
- package/dist/run-program.d.ts +69 -1
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +1254 -714
- package/dist/run-program.js.map +1 -1
- package/dist/whole-program-review.d.ts +3 -0
- package/dist/whole-program-review.d.ts.map +1 -1
- package/dist/whole-program-review.js +8 -1
- package/dist/whole-program-review.js.map +1 -1
- package/package.json +2 -2
package/dist/run-program.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveAuthorAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
|
|
@@ -18,9 +19,21 @@ import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
|
|
|
18
19
|
import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
|
|
19
20
|
import { buildPermitsContext } from "./permits.js";
|
|
20
21
|
import { CouldNotStartError } from "./exit-codes.js";
|
|
22
|
+
import { clippedInputPoint, createBriefBuilder, promptComponentSizePoints, } from "./prompt-telemetry.js";
|
|
23
|
+
import { NOOP_RUN_RECORDER, readPriorRunId, RunRecorder, } from "./run-analytics.js";
|
|
24
|
+
import { renderAnalyticsSection } from "./run-analytics-report.js";
|
|
21
25
|
import { runReportPath } from "./report-path.js";
|
|
22
26
|
import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
|
|
27
|
+
import { runCausalAnalysis, renderCausalAnalysisSection, } from "./causal-analysis.js";
|
|
23
28
|
const execFileAsync = promisify(execFile);
|
|
29
|
+
/** The attempt-loop seat label to its stage (WS-02) — the role-derived
|
|
30
|
+
* `STAGE_FOR_ROLE` default in `agent-runner.ts` cannot distinguish these
|
|
31
|
+
* three, since `agent` drives both `implementer` and `informed-retry`. */
|
|
32
|
+
const STAGE_FOR_SEAT = {
|
|
33
|
+
implementer: "implementer",
|
|
34
|
+
recovery: "recovery",
|
|
35
|
+
"informed retry": "informed-retry",
|
|
36
|
+
};
|
|
24
37
|
/** Matches every wording git uses to report an empty commit attempt. */
|
|
25
38
|
const NOTHING_TO_COMMIT = /nothing to commit|nothing added to commit|no changes added to commit/u;
|
|
26
39
|
/**
|
|
@@ -224,6 +237,38 @@ export const defaultGitOps = {
|
|
|
224
237
|
await execFileAsync("git", ["update-ref", ref, commit], { cwd });
|
|
225
238
|
},
|
|
226
239
|
};
|
|
240
|
+
/**
|
|
241
|
+
* Fail-open span wrapper for a runner-owned git-and-persistence operation
|
|
242
|
+
* (WS-02): opens a span, awaits `fn`, closes it `success` on completion or
|
|
243
|
+
* `interrupted` on a throw (re-thrown unchanged, via `finally`) — this
|
|
244
|
+
* module's stage functions already guard the *run* error a wrapped call can
|
|
245
|
+
* raise, so this wrapper only ever adds an observation, never changes what
|
|
246
|
+
* the caller sees. `evidenceFor`, when given, derives an evidence reference
|
|
247
|
+
* from the successful result (e.g. a commit sha) rather than every call
|
|
248
|
+
* needing to know the schema's evidence-kind vocabulary itself. Shared by
|
|
249
|
+
* `run-program.ts`'s own git/manifest/ledger call sites and imported by
|
|
250
|
+
* `author.ts` and `decider-review.ts` for theirs, so every runner-owned
|
|
251
|
+
* commit, large diff, and manifest/ledger persist is instrumented through
|
|
252
|
+
* one implementation.
|
|
253
|
+
*/
|
|
254
|
+
export async function timed(recorder, dims, fn, evidenceFor) {
|
|
255
|
+
const span = recorder.span(dims);
|
|
256
|
+
let result;
|
|
257
|
+
try {
|
|
258
|
+
result = await fn();
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
span.close({ outcome: "interrupted" });
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
const evidence = evidenceFor?.(result);
|
|
265
|
+
span.close({ outcome: "success", ...(evidence && evidence.length > 0 ? { evidence } : {}) });
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
/** `timed`'s `evidenceFor` for a git commit call: a defined, non-empty sha becomes a `commit` evidence ref; "nothing to commit" (`undefined`) carries none. */
|
|
269
|
+
export function commitEvidence(sha) {
|
|
270
|
+
return sha === undefined ? undefined : [{ kind: "commit", locality: "local", ref: sha }];
|
|
271
|
+
}
|
|
227
272
|
/**
|
|
228
273
|
* Ledger decisions for one workstream, projected into its brief. A
|
|
229
274
|
* human-decided record is binding — the implementer is told to build that
|
|
@@ -311,6 +356,13 @@ function shellPolicySection(shellPolicy) {
|
|
|
311
356
|
"",
|
|
312
357
|
];
|
|
313
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Builds the implementer's brief, byte-for-byte identical to the plain
|
|
361
|
+
* `array.join("\n")` this produced before WS-03 — but through a
|
|
362
|
+
* `BriefBuilder` so every line is also classified into an SC-07 prompt
|
|
363
|
+
* component, with `components` summing exactly to the joined brief's byte
|
|
364
|
+
* length.
|
|
365
|
+
*/
|
|
314
366
|
function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, priorFailure, priorDiagnosis) {
|
|
315
367
|
const roster = manifest.workstreams
|
|
316
368
|
.map((entry) => {
|
|
@@ -321,64 +373,46 @@ function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, prior
|
|
|
321
373
|
const relevantDecisions = ledger.decisions.filter((record) => record.workstream === workstream.id &&
|
|
322
374
|
(record.status === "human-decided" || record.status === "ratified"));
|
|
323
375
|
const relevantFindings = ledger.findings.filter((record) => record.workstream === workstream.id && record.status === "human-decided");
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
: []),
|
|
365
|
-
"## Specification",
|
|
366
|
-
"",
|
|
367
|
-
spec.trim(),
|
|
368
|
-
"",
|
|
369
|
-
"## Rules",
|
|
370
|
-
"",
|
|
371
|
-
"- Never commit; the runner owns commits and commits only verified work.",
|
|
372
|
-
"- Follow the repository's AGENTS.md if present.",
|
|
373
|
-
"- Run the project's checks yourself as you work; the runner verifies",
|
|
374
|
-
" independently afterwards and your claim of success is never trusted",
|
|
375
|
-
" without it.",
|
|
376
|
-
"",
|
|
377
|
-
...shellPolicySection(shellPolicy),
|
|
378
|
-
decisionContract(),
|
|
379
|
-
"",
|
|
380
|
-
summaryContract(),
|
|
381
|
-
].join("\n");
|
|
376
|
+
const builder = createBriefBuilder();
|
|
377
|
+
builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
|
|
378
|
+
builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
|
|
379
|
+
builder.push("framing", "", "You are implementing exactly one workstream of a larger program. The", "full roster, so you know what exists and do not rebuild another", "workstream's work (conform only to your declared dependencies):", "");
|
|
380
|
+
builder.push("roster", roster);
|
|
381
|
+
builder.push("framing", "");
|
|
382
|
+
builder.push("ledger-rulings", ...decisionsRuledOnSection(relevantDecisions), ...findingsRuledOnSection(relevantFindings));
|
|
383
|
+
builder.push("prior-diagnostics", ...(priorFailure
|
|
384
|
+
? [
|
|
385
|
+
"## Previous attempt failed",
|
|
386
|
+
"",
|
|
387
|
+
"A previous attempt at this workstream failed. The verify output:",
|
|
388
|
+
"",
|
|
389
|
+
priorFailure,
|
|
390
|
+
"",
|
|
391
|
+
...(priorDiagnosis
|
|
392
|
+
? [
|
|
393
|
+
"An independent reviewer read the failing tree before you were",
|
|
394
|
+
"spawned. Its diagnosis:",
|
|
395
|
+
"",
|
|
396
|
+
priorDiagnosis,
|
|
397
|
+
"",
|
|
398
|
+
"The tests in the tree were written by the failed attempt, not",
|
|
399
|
+
"by a human. When the diagnosis says an assertion is wrong,",
|
|
400
|
+
"rewrite or delete that assertion rather than bending the",
|
|
401
|
+
"implementation to satisfy it — the spec, not the failing",
|
|
402
|
+
"test, is the contract.",
|
|
403
|
+
"",
|
|
404
|
+
]
|
|
405
|
+
: []),
|
|
406
|
+
"Start from the diagnosis; the working tree may already contain",
|
|
407
|
+
"partial work from that attempt.",
|
|
408
|
+
"",
|
|
409
|
+
]
|
|
410
|
+
: []));
|
|
411
|
+
builder.push("framing", "## Specification", "");
|
|
412
|
+
builder.push("workstream-spec", spec.trim());
|
|
413
|
+
builder.push("framing", "", "## Rules", "", "- Never commit; the runner owns commits and commits only verified work.", "- Follow the repository's AGENTS.md if present.", "- Run the project's checks yourself as you work; the runner verifies", " independently afterwards and your claim of success is never trusted", " without it.", "");
|
|
414
|
+
builder.push("runner-instructions", ...shellPolicySection(shellPolicy), decisionContract(), "", summaryContract());
|
|
415
|
+
return { brief: builder.join(), components: builder.components() };
|
|
382
416
|
}
|
|
383
417
|
/** Known `docs/programs/` artifact suffixes, longest-specific first is not required — each is checked independently. */
|
|
384
418
|
const DOCS_PROGRAMS_SUFFIXES = [
|
|
@@ -386,6 +420,7 @@ const DOCS_PROGRAMS_SUFFIXES = [
|
|
|
386
420
|
"-manifest.json",
|
|
387
421
|
"-run-report.md",
|
|
388
422
|
"-decisions.jsonl",
|
|
423
|
+
"-run-analytics.json",
|
|
389
424
|
];
|
|
390
425
|
/**
|
|
391
426
|
* The program id a planning-artifact path belongs to, or undefined when the
|
|
@@ -527,6 +562,46 @@ async function runProgramWith(options, permits) {
|
|
|
527
562
|
const git = options.git ?? defaultGitOps;
|
|
528
563
|
const log = options.log ?? ((line) => console.log(line));
|
|
529
564
|
const now = options.now ?? (() => new Date());
|
|
565
|
+
const monotonic = options.monotonic ?? (() => performance.now());
|
|
566
|
+
// Read before anything durable happens (a plain read, safe before the
|
|
567
|
+
// could-not-start gate): a prior finalized artifact's run id becomes this
|
|
568
|
+
// run's `parentRunId`, so a resumed invocation carries explicit lineage
|
|
569
|
+
// instead of silently blending its elapsed time with a prior attempt
|
|
570
|
+
// (SC-14). Created here — before the gate — so pre-gate stages (preflight,
|
|
571
|
+
// baseline verification) can be buffered in memory; it stays disarmed,
|
|
572
|
+
// writing nothing durable, until `arm()` is called below.
|
|
573
|
+
const priorRunId = await readPriorRunId(root, options.programId);
|
|
574
|
+
const recorder = options.recorder ??
|
|
575
|
+
RunRecorder.create({
|
|
576
|
+
clock: { now, monotonic },
|
|
577
|
+
root,
|
|
578
|
+
programId: options.programId,
|
|
579
|
+
...(priorRunId === undefined ? {} : { parentRunId: priorRunId }),
|
|
580
|
+
log,
|
|
581
|
+
});
|
|
582
|
+
// The run-level reference span (WS-01's own; the fourth reference span
|
|
583
|
+
// alongside the three named seams — see the WS-01/WS-02 emission-boundary
|
|
584
|
+
// decision). Opened here, at the very first monotonic tick this function
|
|
585
|
+
// can observe — before preflight, before baseline verification, before the
|
|
586
|
+
// could-not-start gate — so it covers the true run start, not merely the
|
|
587
|
+
// portion after arming. `span()` buffers in memory regardless of arm
|
|
588
|
+
// state, so a could-not-start throw before `arm()` simply discards it,
|
|
589
|
+
// same as every other pre-gate observation (SC-02). Its bucket is
|
|
590
|
+
// `unattributed` by construction — see STAGE_BUCKET's doc comment.
|
|
591
|
+
//
|
|
592
|
+
// It is deliberately never closed on the normal-completion path (see the
|
|
593
|
+
// `report-commit` span below for why): the reconciled numbers stop at the
|
|
594
|
+
// pre-render snapshot (SC-03, SC-14), but the run's true end is the report
|
|
595
|
+
// commit, and closing this span before the report is written and
|
|
596
|
+
// committed would stamp an end that has not happened — exactly the defect
|
|
597
|
+
// an earlier version of
|
|
598
|
+
// this design had, closing here and silently excluding the trailing write
|
|
599
|
+
// and commit from every span instead of from just this one. Leaving it
|
|
600
|
+
// open is not a special case for that fix: it is the same "true end not
|
|
601
|
+
// observed" semantics the schema already uses for a crashed run, applied
|
|
602
|
+
// honestly to the one boundary this recorder can never observe closing
|
|
603
|
+
// even on success.
|
|
604
|
+
const runSpan = recorder.span({ stage: "run" });
|
|
530
605
|
let manifest;
|
|
531
606
|
try {
|
|
532
607
|
manifest = await loadManifest(root, options.programId);
|
|
@@ -654,8 +729,10 @@ async function runProgramWith(options, permits) {
|
|
|
654
729
|
// The run's own identity — distinct from runStartCommit, which two
|
|
655
730
|
// invocations can share (the same commit) or lack entirely (no
|
|
656
731
|
// repository). Stamped on each `prerequisite-verified` ledger event so the
|
|
657
|
-
// ledger records which run verified a prerequisite.
|
|
658
|
-
|
|
732
|
+
// ledger records which run verified a prerequisite. Promoted from the
|
|
733
|
+
// recorder's identity (SC-14) rather than minted separately, so the ledger
|
|
734
|
+
// and the analytics artifact always carry the same run id.
|
|
735
|
+
const runId = recorder.runId;
|
|
659
736
|
// Preflight: every *pending* prerequisite's verifyCommand executes here —
|
|
660
737
|
// before baseline verification and before any agent spawns (SC-03). This
|
|
661
738
|
// mutates manifest.prerequisites in memory (pending -> satisfied on a met
|
|
@@ -670,6 +747,7 @@ async function runProgramWith(options, permits) {
|
|
|
670
747
|
runId,
|
|
671
748
|
runStartCommit,
|
|
672
749
|
log,
|
|
750
|
+
recorder,
|
|
673
751
|
});
|
|
674
752
|
// Baseline verification: the verify suite on the untouched tree, before
|
|
675
753
|
// the first agent is spawned. A red baseline on a fresh start means the
|
|
@@ -690,7 +768,7 @@ async function runProgramWith(options, permits) {
|
|
|
690
768
|
let baselineFingerprint;
|
|
691
769
|
if (Object.keys(config.verify).length > 0) {
|
|
692
770
|
log("baseline: running the verify commands on the untouched tree");
|
|
693
|
-
const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0);
|
|
771
|
+
const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline" });
|
|
694
772
|
if (baselineFailure === undefined) {
|
|
695
773
|
log("baseline: clean");
|
|
696
774
|
}
|
|
@@ -715,10 +793,14 @@ async function runProgramWith(options, permits) {
|
|
|
715
793
|
// makes the only crash-between-writes residue a harmless idempotent
|
|
716
794
|
// duplicate event, never a lost one. A could-not-start throw above has
|
|
717
795
|
// already unwound the process before reaching here, so an exit-3 run
|
|
718
|
-
// mutates neither the manifest nor the ledger
|
|
796
|
+
// mutates neither the manifest nor the ledger — nor, from this line on, the
|
|
797
|
+
// analytics artifact: `arm()` enables the recorder's durable writes, so a
|
|
798
|
+
// could-not-start run leaves no working log and no finalized artifact
|
|
799
|
+
// (SC-02).
|
|
800
|
+
recorder.arm();
|
|
719
801
|
if (preflight.events.length > 0) {
|
|
720
|
-
await appendLedgerEvents(root, options.programId, preflight.events);
|
|
721
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
802
|
+
await timed(recorder, { stage: "ledger-persist" }, () => appendLedgerEvents(root, options.programId, preflight.events));
|
|
803
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
722
804
|
}
|
|
723
805
|
// Subject ids already sent to the decider this run — shared across the
|
|
724
806
|
// authoring and build stages (a run is one process) and across the
|
|
@@ -732,671 +814,870 @@ async function runProgramWith(options, permits) {
|
|
|
732
814
|
// report's "this run" triage ratio — see decider-review.ts's doc comment
|
|
733
815
|
// on `triaged`.
|
|
734
816
|
const triaged = new Set();
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
log,
|
|
747
|
-
now,
|
|
748
|
-
reviewed,
|
|
749
|
-
triaged,
|
|
750
|
-
});
|
|
751
|
-
manifest = await loadManifest(root, options.programId);
|
|
752
|
-
// Loaded once so every brief in this run projects the same picture of
|
|
753
|
-
// human-decided and ratified choices; decisions this run itself journals
|
|
754
|
-
// are picked up fresh by `readDecisionLedger` at the end, for escalations.
|
|
755
|
-
const ledgerAtStart = await readDecisionLedger(root, options.programId);
|
|
756
|
-
const ordered = stableTopologicalOrder(manifest.workstreams);
|
|
757
|
-
const results = [];
|
|
758
|
-
// Every workstream whose spec authoring failed or parked. Seeds `blocked`
|
|
759
|
-
// below (their briefs would be missing a producer's spec) and also guards
|
|
760
|
-
// the awaiting_human branch in the build loop: an authoring failure must
|
|
761
|
-
// never be repainted as a planned wait — see that branch's own comment.
|
|
762
|
-
const authoringFailed = new Set(authorResult.results
|
|
763
|
-
.filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
|
|
764
|
-
.map((entry) => entry.id));
|
|
765
|
-
const blocked = new Set(authoringFailed);
|
|
766
|
-
// The awaiting cone: every workstream directly referencing an unmet
|
|
767
|
-
// prerequisite, plus everything transitively downstream of one — the same
|
|
768
|
-
// downstream-cone machinery failure isolation uses, with the semantics
|
|
769
|
-
// inverted (no agent spawned, no retry, no diagnosis; see the build loop).
|
|
770
|
-
const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
|
|
771
|
-
const directlyAwaiting = new Set(manifest.workstreams
|
|
772
|
-
.filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
|
|
773
|
-
.map((workstream) => workstream.id));
|
|
774
|
-
const awaitingCone = new Set([
|
|
775
|
-
...directlyAwaiting,
|
|
776
|
-
...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
|
|
777
|
-
]);
|
|
778
|
-
// Set when a workstream's verify failure reproduced the pre-run baseline:
|
|
779
|
-
// the environment is broken, so every remaining workstream parks instead
|
|
780
|
-
// of spending its budget on the same crash.
|
|
781
|
-
let environmentalHalt = false;
|
|
782
|
-
for (const workstream of ordered) {
|
|
783
|
-
if (workstream.status === "complete") {
|
|
784
|
-
results.push({
|
|
785
|
-
id: workstream.id,
|
|
786
|
-
name: workstream.name,
|
|
787
|
-
outcome: { status: "skipped", reason: "already complete" },
|
|
788
|
-
decisionIds: [],
|
|
789
|
-
decisionErrors: [],
|
|
790
|
-
});
|
|
791
|
-
continue;
|
|
792
|
-
}
|
|
793
|
-
if (environmentalHalt) {
|
|
794
|
-
workstream.status = "parked";
|
|
795
|
-
results.push({
|
|
796
|
-
id: workstream.id,
|
|
797
|
-
name: workstream.name,
|
|
798
|
-
outcome: {
|
|
799
|
-
status: "parked",
|
|
800
|
-
reason: "the run halted on an environmental verification failure; parked, not attempted",
|
|
801
|
-
},
|
|
802
|
-
decisionIds: [],
|
|
803
|
-
decisionErrors: [],
|
|
804
|
-
});
|
|
805
|
-
continue;
|
|
806
|
-
}
|
|
807
|
-
// Authoring failures take precedence over awaiting: a workstream whose
|
|
808
|
-
// spec authoring failed or parked must fall through to the `blocked`
|
|
809
|
-
// branch below and render as the genuine failure it is, never be
|
|
810
|
-
// repainted `awaiting_human` — an authoring failure has no other
|
|
811
|
-
// representation in `results` than that branch's own `parked` result, so
|
|
812
|
-
// rewriting it here would erase the only trace of it and the run would
|
|
813
|
-
// wrongly look like a pure intermission. A workstream that is merely
|
|
814
|
-
// downstream of a *build* failure and also in the awaiting cone still
|
|
815
|
-
// resolves to awaiting_human here; that is safe because the build
|
|
816
|
-
// failure self-reports `failed` in `results`, so the run is classified a
|
|
817
|
-
// partial regardless (see the intermission classification below).
|
|
818
|
-
if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
|
|
819
|
-
workstream.status = "awaiting_human";
|
|
820
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
821
|
-
const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
|
|
822
|
-
const reason = ownUnmet.length > 0
|
|
823
|
-
? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
|
|
824
|
-
: "an upstream workstream is awaiting a human prerequisite; not attempted.";
|
|
825
|
-
results.push({
|
|
826
|
-
id: workstream.id,
|
|
827
|
-
name: workstream.name,
|
|
828
|
-
outcome: { status: "awaiting_human", reason },
|
|
829
|
-
decisionIds: [],
|
|
830
|
-
decisionErrors: [],
|
|
831
|
-
});
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
if (blocked.has(workstream.id)) {
|
|
835
|
-
workstream.status = "parked";
|
|
836
|
-
results.push({
|
|
837
|
-
id: workstream.id,
|
|
838
|
-
name: workstream.name,
|
|
839
|
-
outcome: {
|
|
840
|
-
status: "parked",
|
|
841
|
-
reason: "an upstream dependency failed; parked, not attempted",
|
|
842
|
-
},
|
|
843
|
-
decisionIds: [],
|
|
844
|
-
decisionErrors: [],
|
|
845
|
-
});
|
|
846
|
-
continue;
|
|
847
|
-
}
|
|
848
|
-
const result = await runWorkstream(workstream);
|
|
849
|
-
results.push(result);
|
|
850
|
-
if (result.environmental === true)
|
|
851
|
-
environmentalHalt = true;
|
|
852
|
-
if (result.outcome.status === "failed") {
|
|
853
|
-
// Failure isolation: park the downstream cone, keep building the rest.
|
|
854
|
-
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
855
|
-
blocked.add(id);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
const complete = results.every((result) => result.outcome.status === "complete" ||
|
|
860
|
-
result.outcome.status === "skipped");
|
|
861
|
-
const anyAwaiting = results.some((result) => result.outcome.status === "awaiting_human");
|
|
862
|
-
const anyFailedOrParked = results.some((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
|
|
863
|
-
// Author-stage failures are counted too: an authoring failure the awaiting
|
|
864
|
-
// guard did NOT catch — e.g. a workstream that authored fine but sits
|
|
865
|
-
// downstream of an authoring failure — must still sink the intermission.
|
|
866
|
-
// Same set that seeded `blocked`/guarded the awaiting branch above;
|
|
867
|
-
// classifying on it here as well makes "any genuine failure => ordinary
|
|
868
|
-
// partial" true independent of how the cone painted it.
|
|
869
|
-
const anyAuthoringFailure = authorResult.results.some((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
|
|
870
|
-
// A planned intermission: nothing failed anywhere (build or authoring), at
|
|
871
|
-
// least one workstream is waiting on a human, and not everything built. A
|
|
872
|
-
// genuine failure makes it an ordinary partial instead — the human should
|
|
873
|
-
// read a diagnosis, not a checklist.
|
|
874
|
-
const intermission = !complete && anyAwaiting && !anyFailedOrParked && !anyAuthoringFailure;
|
|
875
|
-
manifest.program.status = complete
|
|
876
|
-
? "complete"
|
|
877
|
-
: intermission
|
|
878
|
-
? "awaiting_human"
|
|
879
|
-
: "partial";
|
|
880
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
881
|
-
const wholeProgram = await runWholeProgramStage({
|
|
882
|
-
root,
|
|
883
|
-
programId: options.programId,
|
|
884
|
-
manifest,
|
|
885
|
-
config,
|
|
886
|
-
agentRunner,
|
|
887
|
-
permits,
|
|
888
|
-
git,
|
|
889
|
-
isRepository,
|
|
890
|
-
results,
|
|
891
|
-
authorResult,
|
|
892
|
-
runStartCommit,
|
|
893
|
-
decider,
|
|
894
|
-
reviewed,
|
|
895
|
-
triaged,
|
|
896
|
-
now,
|
|
897
|
-
log,
|
|
898
|
-
});
|
|
899
|
-
const ledger = await readDecisionLedger(root, options.programId);
|
|
900
|
-
const escalations = escalatedRecords(ledger);
|
|
901
|
-
const reportPath = runReportPath(root, options.programId);
|
|
902
|
-
// Every workstream verdict, `complete`, and the exit-code mapping are
|
|
903
|
-
// already settled above; writing and committing the report is bookkeeping
|
|
904
|
-
// that must not be able to reject `runProgram` after the fact (SC-05,
|
|
905
|
-
// SC-12) — a full disk or an EISDIR here is a logged line, not a thrown
|
|
906
|
-
// run.
|
|
817
|
+
// Everything from here through the run's return is wrapped in one more
|
|
818
|
+
// level of function nesting so a thrown crash still finalizes the
|
|
819
|
+
// analytics artifact before control leaves (SC-02) without pulling any of
|
|
820
|
+
// this region's locals out of scope of the stage functions declared after
|
|
821
|
+
// its `return` (they are hoisted declarations inside `runProgramBody`,
|
|
822
|
+
// exactly as they were inside `runProgramWith` before this wrapping).
|
|
823
|
+
// `finalize()` is fail-open and cannot itself throw or mask the original
|
|
824
|
+
// error. On the normal path the artifact is finalized explicitly, below,
|
|
825
|
+
// before the report is written and committed, so it rides into the same
|
|
826
|
+
// `docs/programs` commit as the report; this catch exists only for the
|
|
827
|
+
// crash path.
|
|
907
828
|
try {
|
|
908
|
-
await
|
|
909
|
-
// `triaged` is the run-local set of subject ids the decider actually
|
|
910
|
-
// ruled on this run (built up across the authoring, build, and
|
|
911
|
-
// whole-program stages, see its declaration above) — exactly the
|
|
912
|
-
// `triagedThisRun` basis the "this run" triage ratio needs, since the
|
|
913
|
-
// projected ledger carries no run identifier of its own. It excludes
|
|
914
|
-
// ids that were merely sent but whose invocation failed or returned no
|
|
915
|
-
// valid verdict.
|
|
916
|
-
renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart), "utf8");
|
|
917
|
-
if (isRepository) {
|
|
918
|
-
await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
|
|
919
|
-
}
|
|
829
|
+
return await runProgramBody();
|
|
920
830
|
}
|
|
921
831
|
catch (error) {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
let spawnFailure;
|
|
991
|
-
try {
|
|
992
|
-
invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")));
|
|
993
|
-
}
|
|
994
|
-
catch (error) {
|
|
995
|
-
// The agent process never started — its command is wrong, missing,
|
|
996
|
-
// or unrunnable. Nothing was built; this is the captured
|
|
997
|
-
// silent-exit incident (SC-12). Make it a diagnosed failure via the
|
|
998
|
-
// existing retry/park machinery below, never a process exit.
|
|
999
|
-
spawnFailure =
|
|
1000
|
-
`the ${attempt.label} agent could not be spawned: ` +
|
|
1001
|
-
`${error.message}. The configured command ` +
|
|
1002
|
-
`\`${describeAgent(attempt.agent)}\` did not start, so nothing was built.`;
|
|
1003
|
-
invocation = { exitCode: 1, output: "" };
|
|
1004
|
-
}
|
|
1005
|
-
recordTranscript(invocation.transcript);
|
|
1006
|
-
const summary = resolveSummary(invocation.output);
|
|
1007
|
-
base.summary = summary.text;
|
|
1008
|
-
await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
|
|
1009
|
-
const parsed = extractDecisions(invocation.output);
|
|
1010
|
-
base.decisionErrors.push(...parsed.errors);
|
|
1011
|
-
await journalDecisions(workstream, parsed.decisions, baseCommit);
|
|
1012
|
-
base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
|
|
1013
|
-
let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
|
|
1014
|
-
// Deterministic protocol failures the runner can see without a model:
|
|
1015
|
-
// no diagnosis is spent on them, because no read of the tree could add
|
|
1016
|
-
// anything to what the runner already knows.
|
|
1017
|
-
let deterministicFailure = false;
|
|
1018
|
-
// A spawn failure takes precedence over whatever verifyAttempt saw
|
|
1019
|
-
// (the untouched tree can verify green on its own) — no reviewer
|
|
1020
|
-
// diagnosis is spent reading an empty diff.
|
|
1021
|
-
if (spawnFailure !== undefined) {
|
|
1022
|
-
failure = spawnFailure;
|
|
1023
|
-
deterministicFailure = true;
|
|
1024
|
-
}
|
|
1025
|
-
// A brief that never reached the agent's stdin means the process
|
|
1026
|
-
// crashed at startup — whatever it printed was not an answer to the
|
|
1027
|
-
// brief, even when it exited 0.
|
|
1028
|
-
if (failure === undefined && invocation.inputError !== undefined) {
|
|
1029
|
-
failure =
|
|
1030
|
-
`the brief could not be delivered to the agent's stdin ` +
|
|
1031
|
-
`(${invocation.inputError}) — the agent likely crashed at startup, ` +
|
|
1032
|
-
`so its reply answered nothing.`;
|
|
1033
|
-
deterministicFailure = true;
|
|
1034
|
-
}
|
|
1035
|
-
// A green verify over an unchanged tree is just the baseline passing
|
|
1036
|
-
// again, not built work. Without this, an agent that crashes at
|
|
1037
|
-
// startup while exiting 0 (or replies without working) sweeps only
|
|
1038
|
-
// the runner's own in_progress manifest write into a metadata-only
|
|
1039
|
-
// commit and walks through the gate as "complete". A reply that never
|
|
1040
|
-
// produces the work is a protocol failure, not an empty result.
|
|
1041
|
-
if (failure === undefined && isRepository) {
|
|
1042
|
-
const dirty = await git.dirtyPaths(root);
|
|
1043
|
-
const meaningful = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
|
|
1044
|
-
if (!meaningful) {
|
|
1045
|
-
failure =
|
|
1046
|
-
"the attempt verified green but changed nothing outside " +
|
|
1047
|
-
"docs/programs/ — nothing was built, and the passing verify was " +
|
|
1048
|
-
"the untouched baseline. A no-op attempt is a protocol failure " +
|
|
1049
|
-
"(the agent may have crashed at startup or replied without " +
|
|
1050
|
-
"working), not a completed workstream.";
|
|
1051
|
-
deterministicFailure = true;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
if (failure === undefined) {
|
|
1055
|
-
let c0;
|
|
1056
|
-
if (isRepository) {
|
|
1057
|
-
// Unlike the authoring, run-report, and replay commits, this one
|
|
1058
|
-
// stays a whole-tree sweep: an implementing agent touches whatever
|
|
1059
|
-
// files the work required, and that set is exactly what the
|
|
1060
|
-
// runner cannot know in advance. c0 is the green anchor the test
|
|
1061
|
-
// critique's fix loop resets to on a failing fix (SC-07).
|
|
1062
|
-
c0 = await git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`);
|
|
1063
|
-
}
|
|
1064
|
-
const critique = isRepository && c0 !== undefined
|
|
1065
|
-
? await runTestCritique({
|
|
1066
|
-
root,
|
|
1067
|
-
manifest,
|
|
1068
|
-
workstream,
|
|
1069
|
-
spec,
|
|
1070
|
-
config,
|
|
1071
|
-
agentRunner,
|
|
1072
|
-
permits,
|
|
1073
|
-
verifyRunner,
|
|
1074
|
-
git,
|
|
1075
|
-
reviewer,
|
|
1076
|
-
agent,
|
|
1077
|
-
baseCommit,
|
|
1078
|
-
greenCommit: c0,
|
|
1079
|
-
now,
|
|
1080
|
-
log,
|
|
1081
|
-
transcriptSink,
|
|
1082
|
-
})
|
|
1083
|
-
: undefined;
|
|
1084
|
-
// The manifest's single commit field records the workstream's final
|
|
1085
|
-
// verified state — after any kept fix, that is the fix commit, not
|
|
1086
|
-
// the earlier green one.
|
|
1087
|
-
let finalCommit = critique?.finalCommit ?? c0;
|
|
1088
|
-
if (critique && critique.stageErrors.length > 0) {
|
|
1089
|
-
(base.stageErrors ??= []).push(...critique.stageErrors);
|
|
1090
|
-
}
|
|
1091
|
-
for (const transcript of critique?.transcripts ?? [])
|
|
1092
|
-
recordTranscript(transcript);
|
|
1093
|
-
// Findings anchor to c0 (the green, pre-critique commit) — the
|
|
1094
|
-
// honest rollback point — while the decider below diffs from
|
|
1095
|
-
// baseCommit (pre-workstream), so it sees the whole workstream.
|
|
1096
|
-
const findingEvents = findingsToLedgerEvents({
|
|
1097
|
-
workstreamId: workstream.id,
|
|
1098
|
-
findings: (critique?.outcome.open ?? []).filter(hasRoutableEvidence),
|
|
1099
|
-
...(c0 === undefined ? {} : { baseCommit: c0 }),
|
|
1100
|
-
now,
|
|
832
|
+
const finalizeStart = monotonic();
|
|
833
|
+
await recorder.finalize();
|
|
834
|
+
recorder.point({
|
|
835
|
+
kind: "analytics-overhead",
|
|
836
|
+
coverage: "observed",
|
|
837
|
+
value: monotonic() - finalizeStart,
|
|
838
|
+
unit: "ms",
|
|
839
|
+
label: "finalize",
|
|
840
|
+
});
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
async function runProgramBody() {
|
|
844
|
+
// Authoring runs before building: every workstream whose spec is missing
|
|
845
|
+
// gets one, in dependency order, before anything is implemented. It
|
|
846
|
+
// reloads the manifest afterward because authoring may have merged
|
|
847
|
+
// discovered dependency edges or parked workstreams it could not author.
|
|
848
|
+
const authorResult = await authorProgram({
|
|
849
|
+
cwd: options.cwd,
|
|
850
|
+
programId: options.programId,
|
|
851
|
+
config,
|
|
852
|
+
agentRunner,
|
|
853
|
+
permits,
|
|
854
|
+
git,
|
|
855
|
+
log,
|
|
856
|
+
now,
|
|
857
|
+
reviewed,
|
|
858
|
+
triaged,
|
|
859
|
+
recorder,
|
|
860
|
+
});
|
|
861
|
+
manifest = await loadManifest(root, options.programId);
|
|
862
|
+
// Loaded once so every brief in this run projects the same picture of
|
|
863
|
+
// human-decided and ratified choices; decisions this run itself journals
|
|
864
|
+
// are picked up fresh by `readDecisionLedger` at the end, for escalations.
|
|
865
|
+
const ledgerAtStart = await readDecisionLedger(root, options.programId);
|
|
866
|
+
const ordered = stableTopologicalOrder(manifest.workstreams);
|
|
867
|
+
const results = [];
|
|
868
|
+
// Every workstream whose spec authoring failed or parked. Seeds `blocked`
|
|
869
|
+
// below (their briefs would be missing a producer's spec) and also guards
|
|
870
|
+
// the awaiting_human branch in the build loop: an authoring failure must
|
|
871
|
+
// never be repainted as a planned wait — see that branch's own comment.
|
|
872
|
+
const authoringFailed = new Set(authorResult.results
|
|
873
|
+
.filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
|
|
874
|
+
.map((entry) => entry.id));
|
|
875
|
+
const blocked = new Set(authoringFailed);
|
|
876
|
+
// The awaiting cone: every workstream directly referencing an unmet
|
|
877
|
+
// prerequisite, plus everything transitively downstream of one — the same
|
|
878
|
+
// downstream-cone machinery failure isolation uses, with the semantics
|
|
879
|
+
// inverted (no agent spawned, no retry, no diagnosis; see the build loop).
|
|
880
|
+
const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
|
|
881
|
+
const directlyAwaiting = new Set(manifest.workstreams
|
|
882
|
+
.filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
|
|
883
|
+
.map((workstream) => workstream.id));
|
|
884
|
+
const awaitingCone = new Set([
|
|
885
|
+
...directlyAwaiting,
|
|
886
|
+
...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
|
|
887
|
+
]);
|
|
888
|
+
// Set when a workstream's verify failure reproduced the pre-run baseline:
|
|
889
|
+
// the environment is broken, so every remaining workstream parks instead
|
|
890
|
+
// of spending its budget on the same crash.
|
|
891
|
+
let environmentalHalt = false;
|
|
892
|
+
for (const workstream of ordered) {
|
|
893
|
+
if (workstream.status === "complete") {
|
|
894
|
+
results.push({
|
|
895
|
+
id: workstream.id,
|
|
896
|
+
name: workstream.name,
|
|
897
|
+
outcome: { status: "skipped", reason: "already complete" },
|
|
898
|
+
decisionIds: [],
|
|
899
|
+
decisionErrors: [],
|
|
1101
900
|
});
|
|
1102
|
-
|
|
1103
|
-
// The commit's own sha cannot be part of the tree it commits, so the
|
|
1104
|
-
// manifest records it only now — swept forward into whatever commits
|
|
1105
|
-
// next. Replay reads the manifest's current state, not the commit
|
|
1106
|
-
// that last touched it, so this lag is harmless.
|
|
1107
|
-
workstream.status = "complete";
|
|
1108
|
-
if (finalCommit !== undefined)
|
|
1109
|
-
workstream.commit = finalCommit;
|
|
1110
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1111
|
-
{
|
|
1112
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1113
|
-
if (spawnErrors.length > 0)
|
|
1114
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1115
|
-
}
|
|
1116
|
-
// Findings are triaged against the pre-workstream diff (baseCommit),
|
|
1117
|
-
// the same one the decider reviews decisions against — c0/finalCommit
|
|
1118
|
-
// are the fix loop's own rollback anchors, a different thing.
|
|
1119
|
-
{
|
|
1120
|
-
const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
|
|
1121
|
-
if (spawnErrors.length > 0)
|
|
1122
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1123
|
-
}
|
|
1124
|
-
// A fix-now triage drives exactly one bounded fix attempt, here —
|
|
1125
|
-
// after the triage that produced it, before the run advances. Only
|
|
1126
|
-
// reachable when there is a green commit to attempt from and a
|
|
1127
|
-
// decider that could have produced a fix-now verdict in the first
|
|
1128
|
-
// place (SC-13).
|
|
1129
|
-
if (finalCommit !== undefined && decider) {
|
|
1130
|
-
const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
|
|
1131
|
-
recordTranscript(driven.transcript);
|
|
1132
|
-
if (driven.spawnError !== undefined) {
|
|
1133
|
-
(base.stageErrors ??= []).push(driven.spawnError);
|
|
1134
|
-
}
|
|
1135
|
-
if (driven.commit !== finalCommit) {
|
|
1136
|
-
finalCommit = driven.commit;
|
|
1137
|
-
workstream.commit = finalCommit;
|
|
1138
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
if (critique)
|
|
1142
|
-
base.testCritique = critique.outcome;
|
|
1143
|
-
base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
|
|
1144
|
-
base.outcome = {
|
|
1145
|
-
status: "complete",
|
|
1146
|
-
...(finalCommit === undefined ? {} : { commit: finalCommit }),
|
|
1147
|
-
};
|
|
1148
|
-
return base;
|
|
1149
|
-
}
|
|
1150
|
-
priorFailure = failure;
|
|
1151
|
-
log(`${workstream.id}: ${attempt.label} attempt failed — ${failure}`);
|
|
1152
|
-
// A failure identical to the pre-run baseline (up to counts and
|
|
1153
|
-
// timings) was there before any workstream ran: environmental, not
|
|
1154
|
-
// this workstream's work. No retry or diagnosis is spent on it, and
|
|
1155
|
-
// the caller halts the run — every later workstream would be charged
|
|
1156
|
-
// for the same broken environment. Workstreams resumed with their own
|
|
1157
|
-
// leftover failure in the tree are exempt (see resumedIds).
|
|
1158
|
-
if (baselineFingerprint !== undefined &&
|
|
1159
|
-
!resumedIds.has(workstream.id) &&
|
|
1160
|
-
failureFingerprint(failure) === baselineFingerprint) {
|
|
1161
|
-
workstream.status = "failed";
|
|
1162
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1163
|
-
{
|
|
1164
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1165
|
-
if (spawnErrors.length > 0)
|
|
1166
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1167
|
-
}
|
|
1168
|
-
base.environmental = true;
|
|
1169
|
-
base.outcome = {
|
|
1170
|
-
status: "failed",
|
|
1171
|
-
reason: failure +
|
|
1172
|
-
"\n\nThis failure is identical (up to counts and timings) to the " +
|
|
1173
|
-
"verification failure recorded on the untouched tree before the " +
|
|
1174
|
-
"run began — the environment was broken before this workstream " +
|
|
1175
|
-
"ran. No retry was spent, and the run halted here.",
|
|
1176
|
-
};
|
|
1177
|
-
return base;
|
|
1178
|
-
}
|
|
1179
|
-
// The informed retry is spent only when the diagnosed recovery attempt
|
|
1180
|
-
// moved the failure at all. A failure reproduced identically after a
|
|
1181
|
-
// diagnosis says the roster is stuck, not unlucky — that belongs in
|
|
1182
|
-
// the ledger, not in a third spawn.
|
|
1183
|
-
const stuck = attempt.label === "recovery" &&
|
|
1184
|
-
index < attempts.length - 1 &&
|
|
1185
|
-
implementerFingerprint !== undefined &&
|
|
1186
|
-
failureFingerprint(failure) === implementerFingerprint;
|
|
1187
|
-
if (index === attempts.length - 1 || stuck) {
|
|
1188
|
-
workstream.status = "failed";
|
|
1189
|
-
await saveManifest(root, options.programId, manifest, { log });
|
|
1190
|
-
// The work stays in the tree for a resume; decisions made on the way
|
|
1191
|
-
// to a failure are still journaled and still reviewable.
|
|
1192
|
-
{
|
|
1193
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1194
|
-
if (spawnErrors.length > 0)
|
|
1195
|
-
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1196
|
-
}
|
|
1197
|
-
base.outcome = {
|
|
1198
|
-
status: "failed",
|
|
1199
|
-
reason: stuck
|
|
1200
|
-
? failure +
|
|
1201
|
-
"\n\nThe recovery attempt, briefed with a reviewer diagnosis, " +
|
|
1202
|
-
"reproduced the implementer's failure (identical up to counts " +
|
|
1203
|
-
"and timings), so the final retry was not spent."
|
|
1204
|
-
: failure,
|
|
1205
|
-
};
|
|
1206
|
-
return base;
|
|
1207
|
-
}
|
|
1208
|
-
if (attempt.label === "implementer") {
|
|
1209
|
-
implementerFingerprint = failureFingerprint(failure);
|
|
901
|
+
continue;
|
|
1210
902
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
agentRunner,
|
|
1223
|
-
permits,
|
|
1224
|
-
reviewer,
|
|
1225
|
-
git,
|
|
1226
|
-
baseCommit,
|
|
1227
|
-
failure,
|
|
1228
|
-
observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
|
|
903
|
+
if (environmentalHalt) {
|
|
904
|
+
workstream.status = "parked";
|
|
905
|
+
results.push({
|
|
906
|
+
id: workstream.id,
|
|
907
|
+
name: workstream.name,
|
|
908
|
+
outcome: {
|
|
909
|
+
status: "parked",
|
|
910
|
+
reason: "the run halted on an environmental verification failure; parked, not attempted",
|
|
911
|
+
},
|
|
912
|
+
decisionIds: [],
|
|
913
|
+
decisionErrors: [],
|
|
1229
914
|
});
|
|
1230
|
-
|
|
1231
|
-
if (diagnosed.spawnError !== undefined) {
|
|
1232
|
-
(base.stageErrors ??= []).push(diagnosed.spawnError);
|
|
1233
|
-
log(`${workstream.id}: ${diagnosed.spawnError}`);
|
|
1234
|
-
}
|
|
1235
|
-
await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
|
|
1236
|
-
}
|
|
1237
|
-
else {
|
|
1238
|
-
priorDiagnosis = undefined;
|
|
915
|
+
continue;
|
|
1239
916
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
917
|
+
// Authoring failures take precedence over awaiting: a workstream whose
|
|
918
|
+
// spec authoring failed or parked must fall through to the `blocked`
|
|
919
|
+
// branch below and render as the genuine failure it is, never be
|
|
920
|
+
// repainted `awaiting_human` — an authoring failure has no other
|
|
921
|
+
// representation in `results` than that branch's own `parked` result, so
|
|
922
|
+
// rewriting it here would erase the only trace of it and the run would
|
|
923
|
+
// wrongly look like a pure intermission. A workstream that is merely
|
|
924
|
+
// downstream of a *build* failure and also in the awaiting cone still
|
|
925
|
+
// resolves to awaiting_human here; that is safe because the build
|
|
926
|
+
// failure self-reports `failed` in `results`, so the run is classified a
|
|
927
|
+
// partial regardless (see the intermission classification below).
|
|
928
|
+
if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
|
|
929
|
+
workstream.status = "awaiting_human";
|
|
930
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
931
|
+
const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
|
|
932
|
+
const reason = ownUnmet.length > 0
|
|
933
|
+
? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
|
|
934
|
+
: "an upstream workstream is awaiting a human prerequisite; not attempted.";
|
|
935
|
+
results.push({
|
|
936
|
+
id: workstream.id,
|
|
937
|
+
name: workstream.name,
|
|
938
|
+
outcome: { status: "awaiting_human", reason },
|
|
939
|
+
decisionIds: [],
|
|
940
|
+
decisionErrors: [],
|
|
1244
941
|
});
|
|
1245
|
-
|
|
942
|
+
continue;
|
|
1246
943
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
}));
|
|
1260
|
-
await appendLedgerEvents(root, options.programId, events);
|
|
1261
|
-
for (const decision of decisions) {
|
|
1262
|
-
log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
/**
|
|
1266
|
-
* Journals `permission-denied` events for one spawn (WS-03) — every
|
|
1267
|
-
* workstream-attributed spawn calls this once, passing its own transcript
|
|
1268
|
-
* and (for build roles only) the agent's `needsPermission` self-reports.
|
|
1269
|
-
* A no-op when the spawn produced neither.
|
|
1270
|
-
*/
|
|
1271
|
-
async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
|
|
1272
|
-
const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
|
|
1273
|
-
if (events.length === 0)
|
|
1274
|
-
return;
|
|
1275
|
-
await appendLedgerEvents(root, options.programId, events);
|
|
1276
|
-
for (const event of events) {
|
|
1277
|
-
if (event.kind !== "permission-denied")
|
|
944
|
+
if (blocked.has(workstream.id)) {
|
|
945
|
+
workstream.status = "parked";
|
|
946
|
+
results.push({
|
|
947
|
+
id: workstream.id,
|
|
948
|
+
name: workstream.name,
|
|
949
|
+
outcome: {
|
|
950
|
+
status: "parked",
|
|
951
|
+
reason: "an upstream dependency failed; parked, not attempted",
|
|
952
|
+
},
|
|
953
|
+
decisionIds: [],
|
|
954
|
+
decisionErrors: [],
|
|
955
|
+
});
|
|
1278
956
|
continue;
|
|
1279
|
-
|
|
957
|
+
}
|
|
958
|
+
const result = await runWorkstream(workstream);
|
|
959
|
+
results.push(result);
|
|
960
|
+
if (result.environmental === true)
|
|
961
|
+
environmentalHalt = true;
|
|
962
|
+
if (result.outcome.status === "failed") {
|
|
963
|
+
// Failure isolation: park the downstream cone, keep building the rest.
|
|
964
|
+
for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
|
|
965
|
+
blocked.add(id);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
1280
968
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
969
|
+
const complete = results.every((result) => result.outcome.status === "complete" ||
|
|
970
|
+
result.outcome.status === "skipped");
|
|
971
|
+
const anyAwaiting = results.some((result) => result.outcome.status === "awaiting_human");
|
|
972
|
+
const anyFailedOrParked = results.some((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
|
|
973
|
+
// Author-stage failures are counted too: an authoring failure the awaiting
|
|
974
|
+
// guard did NOT catch — e.g. a workstream that authored fine but sits
|
|
975
|
+
// downstream of an authoring failure — must still sink the intermission.
|
|
976
|
+
// Same set that seeded `blocked`/guarded the awaiting branch above;
|
|
977
|
+
// classifying on it here as well makes "any genuine failure => ordinary
|
|
978
|
+
// partial" true independent of how the cone painted it.
|
|
979
|
+
const anyAuthoringFailure = authorResult.results.some((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
|
|
980
|
+
// A planned intermission: nothing failed anywhere (build or authoring), at
|
|
981
|
+
// least one workstream is waiting on a human, and not everything built. A
|
|
982
|
+
// genuine failure makes it an ordinary partial instead — the human should
|
|
983
|
+
// read a diagnosis, not a checklist.
|
|
984
|
+
const intermission = !complete && anyAwaiting && !anyFailedOrParked && !anyAuthoringFailure;
|
|
985
|
+
manifest.program.status = complete
|
|
986
|
+
? "complete"
|
|
987
|
+
: intermission
|
|
988
|
+
? "awaiting_human"
|
|
989
|
+
: "partial";
|
|
990
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
991
|
+
const wholeProgram = await runWholeProgramStage({
|
|
1284
992
|
root,
|
|
1285
993
|
programId: options.programId,
|
|
1286
994
|
manifest,
|
|
1287
|
-
|
|
1288
|
-
decisions,
|
|
1289
|
-
baseCommit,
|
|
1290
|
-
decider,
|
|
995
|
+
config,
|
|
1291
996
|
agentRunner,
|
|
1292
997
|
permits,
|
|
1293
998
|
git,
|
|
1294
999
|
isRepository,
|
|
1000
|
+
results,
|
|
1001
|
+
authorResult,
|
|
1002
|
+
runStartCommit,
|
|
1003
|
+
decider,
|
|
1295
1004
|
reviewed,
|
|
1296
|
-
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
|
|
1297
1005
|
triaged,
|
|
1298
1006
|
now,
|
|
1299
1007
|
log,
|
|
1300
|
-
|
|
1008
|
+
recorder,
|
|
1301
1009
|
});
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1010
|
+
const ledger = await readDecisionLedger(root, options.programId);
|
|
1011
|
+
const escalations = escalatedRecords(ledger);
|
|
1012
|
+
// Runs after the ledger read above, so its brief already sees this run's
|
|
1013
|
+
// own whole-program findings/rulings, and before report assembly, so its
|
|
1014
|
+
// section renders next to WS-04's own (§3.2 of the WS-05 spec). Read-only
|
|
1015
|
+
// and fail-open: nothing here can change `results`, `ledger`, the run's
|
|
1016
|
+
// outcome, or the exit code.
|
|
1017
|
+
const causalAnalysis = await runCausalAnalysisStage({
|
|
1305
1018
|
root,
|
|
1306
1019
|
programId: options.programId,
|
|
1307
1020
|
manifest,
|
|
1308
|
-
|
|
1309
|
-
findings,
|
|
1310
|
-
baseCommit,
|
|
1311
|
-
decider,
|
|
1021
|
+
config,
|
|
1312
1022
|
agentRunner,
|
|
1313
1023
|
permits,
|
|
1314
1024
|
git,
|
|
1315
1025
|
isRepository,
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
triaged,
|
|
1320
|
-
now,
|
|
1026
|
+
results,
|
|
1027
|
+
runStartCommit,
|
|
1028
|
+
ledger,
|
|
1321
1029
|
log,
|
|
1030
|
+
recorder,
|
|
1322
1031
|
});
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
.
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1032
|
+
const reportPath = runReportPath(root, options.programId);
|
|
1033
|
+
// Rendering is the last stage the recorder can actually observe (SC-03):
|
|
1034
|
+
// captured as its own span, nested under the run span, rather than folded
|
|
1035
|
+
// silently into it. Kept in its own try/catch, separate from the write and
|
|
1036
|
+
// commit below, so a rendering bug still lets `finalize()` run and still
|
|
1037
|
+
// lets the write/commit attempt proceed against whatever report content
|
|
1038
|
+
// *is* available — a rendering failure is a logged line, not a thrown run,
|
|
1039
|
+
// exactly like the write/commit failure path below (SC-05, SC-12).
|
|
1040
|
+
let reportContent;
|
|
1041
|
+
try {
|
|
1042
|
+
const reportSpan = recorder.span({ stage: "report-assembly" });
|
|
1043
|
+
// `triaged` is the run-local set of subject ids the decider actually
|
|
1044
|
+
// ruled on this run (built up across the authoring, build, and
|
|
1045
|
+
// whole-program stages, see its declaration above) — exactly the
|
|
1046
|
+
// `triagedThisRun` basis the "this run" triage ratio needs, since the
|
|
1047
|
+
// projected ledger carries no run identifier of its own. It excludes
|
|
1048
|
+
// ids that were merely sent but whose invocation failed or returned no
|
|
1049
|
+
// valid verdict.
|
|
1050
|
+
// Computed before renderRunReport (§3.2/§3.9): renderAnalyticsSection is
|
|
1051
|
+
// WS-04's own never-throwing isolation boundary, so an analytics bug can
|
|
1052
|
+
// never propagate into this try's catch below and leave reportContent
|
|
1053
|
+
// undefined — which would suppress the entire report (SC-13). The
|
|
1054
|
+
// recorder's snapshot, not the finalized file, is the only artifact that
|
|
1055
|
+
// exists at this point in the sequence (finalize() runs later, below).
|
|
1056
|
+
// Passed as a thunk, not called eagerly here: `recorder.snapshot()` is
|
|
1057
|
+
// documented as non-throwing, but evaluating it as a plain argument would
|
|
1058
|
+
// run it *before* renderAnalyticsSection's own try/catch is reached, so a
|
|
1059
|
+
// violation of that contract would still escape this boundary.
|
|
1060
|
+
const analyticsSection = renderAnalyticsSection(() => recorder.snapshot());
|
|
1061
|
+
// The causal section's own "observed measurements" layer is rendered
|
|
1062
|
+
// from a *later* snapshot than the one the analyzer was briefed with
|
|
1063
|
+
// (`causalAnalysis` above) — one that now includes the analyzer's own
|
|
1064
|
+
// `causal-analysis` span — exactly as WS-04's own section above it does.
|
|
1065
|
+
// The two differ by exactly the analyzer's own cost; the section states
|
|
1066
|
+
// that boundary explicitly (§3.2).
|
|
1067
|
+
const causalAnalysisSection = renderCausalAnalysisSection(causalAnalysis.outcome, () => recorder.snapshot());
|
|
1068
|
+
reportContent = renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart, analyticsSection, causalAnalysisSection);
|
|
1069
|
+
reportSpan.close({ outcome: "success" });
|
|
1349
1070
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
log(`run report: could not render ${reportPath}: ${error.message}`);
|
|
1073
|
+
}
|
|
1074
|
+
// The `report-commit` span (the stage STAGE_BUCKET already reserves for
|
|
1075
|
+
// exactly this) covers writing the report to disk and committing
|
|
1076
|
+
// docs/programs, nested explicitly under `runSpan` rather than the
|
|
1077
|
+
// implicit stack, since `reportSpan` above has already closed and popped
|
|
1078
|
+
// itself. Both this span and its parent `runSpan` are opened/left open
|
|
1079
|
+
// rather than closed here, because the write and the commit they describe
|
|
1080
|
+
// happen *after* `finalize()` below persists this very artifact — closing
|
|
1081
|
+
// them now would stamp an `endOffsetMs` that is a lie the moment the write
|
|
1082
|
+
// or commit takes any measurable time at all. An absent `endOffsetMs` is
|
|
1083
|
+
// exactly the schema's existing "true end not observed" semantics; the
|
|
1084
|
+
// run's actual outcome classification is recorded as a point observation
|
|
1085
|
+
// instead of a span-close result, since it — unlike an interval's end — is
|
|
1086
|
+
// a fact already known at this instant.
|
|
1087
|
+
runSpan.child({ stage: "report-commit" });
|
|
1088
|
+
recorder.point({
|
|
1089
|
+
kind: "run-outcome",
|
|
1090
|
+
coverage: "observed",
|
|
1091
|
+
dimensions: { stage: "run", outcome: complete ? "success" : intermission ? "no-op" : "failed" },
|
|
1371
1092
|
});
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1093
|
+
// Finalized once every stage the recorder can observe closing has closed
|
|
1094
|
+
// (report-assembly above; report-commit and run intentionally remain
|
|
1095
|
+
// open — see above). Still runs before the report is written and
|
|
1096
|
+
// committed, so the artifact rides into the same `docs/programs` commit
|
|
1097
|
+
// (SC-02, SC-14). Fail-open: never throws, never delays or blocks the
|
|
1098
|
+
// report below.
|
|
1099
|
+
const finalizeStart = monotonic();
|
|
1100
|
+
await recorder.finalize();
|
|
1101
|
+
// `finalize()` seals the point/span arrays before its own duration is
|
|
1102
|
+
// known, so an overhead measure around it can never live in the artifact
|
|
1103
|
+
// it measures (§3.4.d) — emitted after, as a non-conserved sidecar-only
|
|
1104
|
+
// point: no `stage`, never summed into any bucket.
|
|
1105
|
+
recorder.point({
|
|
1106
|
+
kind: "analytics-overhead",
|
|
1107
|
+
coverage: "observed",
|
|
1108
|
+
value: monotonic() - finalizeStart,
|
|
1109
|
+
unit: "ms",
|
|
1110
|
+
label: "finalize",
|
|
1111
|
+
});
|
|
1112
|
+
// Every workstream verdict, `complete`, and the exit-code mapping are
|
|
1113
|
+
// already settled above; writing and committing the report is bookkeeping
|
|
1114
|
+
// that must not be able to reject `runProgram` after the fact (SC-05,
|
|
1115
|
+
// SC-12) — a full disk or an EISDIR here is a logged line, not a thrown
|
|
1116
|
+
// run.
|
|
1117
|
+
if (reportContent !== undefined) {
|
|
1118
|
+
try {
|
|
1119
|
+
await writeFile(reportPath, reportContent, "utf8");
|
|
1120
|
+
if (isRepository) {
|
|
1121
|
+
await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
|
|
1122
|
+
}
|
|
1123
|
+
// A best-effort forensic breadcrumb for the one boundary the
|
|
1124
|
+
// already-committed canonical artifact can never describe: `point()`
|
|
1125
|
+
// still appends to the gitignored sidecar once armed even though
|
|
1126
|
+
// `finalize()` has already run, and never touches the canonical JSON
|
|
1127
|
+
// again, so the working tree stays clean after a normal run.
|
|
1128
|
+
recorder.point({
|
|
1129
|
+
kind: "report-commit-result",
|
|
1130
|
+
coverage: "observed",
|
|
1131
|
+
dimensions: { stage: "report-commit", outcome: "success" },
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
log(`run report: could not write or commit ${reportPath}: ${error.message}`);
|
|
1136
|
+
recorder.point({
|
|
1137
|
+
kind: "report-commit-result",
|
|
1138
|
+
coverage: "observed",
|
|
1139
|
+
dimensions: { stage: "report-commit", outcome: "failed" },
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
log(`run report: ${reportPath}`);
|
|
1395
1144
|
return {
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1145
|
+
programId: options.programId,
|
|
1146
|
+
complete,
|
|
1147
|
+
intermission,
|
|
1148
|
+
workstreams: results,
|
|
1149
|
+
escalations,
|
|
1150
|
+
reportPath,
|
|
1151
|
+
wholeProgramReview: wholeProgram,
|
|
1152
|
+
causalAnalysis,
|
|
1399
1153
|
};
|
|
1154
|
+
async function runWorkstream(workstream) {
|
|
1155
|
+
const base = {
|
|
1156
|
+
id: workstream.id,
|
|
1157
|
+
name: workstream.name,
|
|
1158
|
+
outcome: { status: "failed", reason: "not attempted" },
|
|
1159
|
+
decisionIds: [],
|
|
1160
|
+
decisionErrors: [],
|
|
1161
|
+
buildAgentCommand: describeAgent(agent),
|
|
1162
|
+
};
|
|
1163
|
+
/** Every build-role spawn's transcript, for the report's per-workstream
|
|
1164
|
+
* Commands subsection (WS-02). Reviewer/decider spawns are excluded. */
|
|
1165
|
+
function recordTranscript(transcript) {
|
|
1166
|
+
if (transcript)
|
|
1167
|
+
(base.transcripts ??= []).push(transcript);
|
|
1168
|
+
}
|
|
1169
|
+
/** Names the JSONL file under build-logs/<programId>/, distinctly per spawn label. */
|
|
1170
|
+
function transcriptSink(label) {
|
|
1171
|
+
return { root, programId: options.programId, label: `${workstream.id}-${label}`, log };
|
|
1172
|
+
}
|
|
1173
|
+
let spec;
|
|
1174
|
+
try {
|
|
1175
|
+
spec = await readFile(join(root, workstream.taskFile), "utf8");
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
base.outcome = {
|
|
1179
|
+
status: "parked",
|
|
1180
|
+
reason: `spec not found at ${workstream.taskFile}`,
|
|
1181
|
+
};
|
|
1182
|
+
return base;
|
|
1183
|
+
}
|
|
1184
|
+
workstream.status = "in_progress";
|
|
1185
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1186
|
+
const baseCommit = isRepository
|
|
1187
|
+
? await git.currentCommit(root)
|
|
1188
|
+
: undefined;
|
|
1189
|
+
let priorFailure;
|
|
1190
|
+
let priorDiagnosis;
|
|
1191
|
+
let implementerFingerprint;
|
|
1192
|
+
const attempts = [
|
|
1193
|
+
{ agent, label: "implementer", role: "agent" },
|
|
1194
|
+
];
|
|
1195
|
+
if (recovery && !recovery.borrowedImplementer) {
|
|
1196
|
+
attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
|
|
1197
|
+
// The third seat exists only when a reviewer can inform it. An
|
|
1198
|
+
// uninformed retry has already been spent (recovery); running the
|
|
1199
|
+
// roster again blind is a coin flip the ledger should hear about
|
|
1200
|
+
// instead. Alternation is deliberate — the implementer returns with
|
|
1201
|
+
// the reviewer's diagnosis in hand, a composition neither prior
|
|
1202
|
+
// attempt had.
|
|
1203
|
+
if (reviewer)
|
|
1204
|
+
attempts.push({ agent, label: "informed retry", role: "agent" });
|
|
1205
|
+
}
|
|
1206
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
1207
|
+
log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
|
|
1208
|
+
const { brief, components } = implementerBrief(manifest, workstream, spec, ledgerAtStart, config.permits?.policy, priorFailure, priorDiagnosis);
|
|
1209
|
+
for (const point of promptComponentSizePoints(components, { role: attempt.role })) {
|
|
1210
|
+
recorder.point(point);
|
|
1211
|
+
}
|
|
1212
|
+
// Why this repetition happened (WS-02, SC-05/SC-06): the first attempt
|
|
1213
|
+
// is always `initial`; a later seat is `informed-by-diagnosis` when the
|
|
1214
|
+
// prior attempt's failure was read by the reviewer, else a plain
|
|
1215
|
+
// `verify-failure` retry (a deterministic failure skips diagnosis —
|
|
1216
|
+
// see below — so the next seat still spawns, just undiagnosed).
|
|
1217
|
+
const seatStage = STAGE_FOR_SEAT[attempt.label] ?? "implementer";
|
|
1218
|
+
const attemptReason = index === 0 ? "initial" : priorDiagnosis !== undefined ? "informed-by-diagnosis" : "verify-failure";
|
|
1219
|
+
/** This attempt's eventual `attempt-outcome` disposition (WS-02, §3.3)
|
|
1220
|
+
* — distinct from the spawn span's own success/failed outcome (an
|
|
1221
|
+
* undelivered-brief or green-but-empty attempt spawns "successfully"
|
|
1222
|
+
* but disposes as `no-op`/`discarded`). Set once, at the point each
|
|
1223
|
+
* failure reason is detected below; `undefined` means the attempt was
|
|
1224
|
+
* kept. */
|
|
1225
|
+
let dispositionOutcome;
|
|
1226
|
+
const emitAttemptOutcome = (outcome, reasonOverride) => {
|
|
1227
|
+
recorder.point({
|
|
1228
|
+
kind: "attempt-outcome",
|
|
1229
|
+
coverage: "observed",
|
|
1230
|
+
dimensions: {
|
|
1231
|
+
stage: seatStage,
|
|
1232
|
+
workstream: workstream.id,
|
|
1233
|
+
role: attempt.role,
|
|
1234
|
+
attemptSeat: attempt.label,
|
|
1235
|
+
attemptIndex: index,
|
|
1236
|
+
attemptReason: reasonOverride ?? attemptReason,
|
|
1237
|
+
outcome,
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
};
|
|
1241
|
+
let invocation;
|
|
1242
|
+
let spawnFailure;
|
|
1243
|
+
try {
|
|
1244
|
+
invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")), recorder, { stage: seatStage, workstream: workstream.id, attemptSeat: attempt.label, attemptIndex: index, attemptReason });
|
|
1245
|
+
}
|
|
1246
|
+
catch (error) {
|
|
1247
|
+
// The agent process never started — its command is wrong, missing,
|
|
1248
|
+
// or unrunnable. Nothing was built; this is the captured
|
|
1249
|
+
// silent-exit incident (SC-12). Make it a diagnosed failure via the
|
|
1250
|
+
// existing retry/park machinery below, never a process exit.
|
|
1251
|
+
spawnFailure =
|
|
1252
|
+
`the ${attempt.label} agent could not be spawned: ` +
|
|
1253
|
+
`${error.message}. The configured command ` +
|
|
1254
|
+
`\`${describeAgent(attempt.agent)}\` did not start, so nothing was built.`;
|
|
1255
|
+
invocation = { exitCode: 1, output: "" };
|
|
1256
|
+
}
|
|
1257
|
+
recordTranscript(invocation.transcript);
|
|
1258
|
+
const summary = resolveSummary(invocation.output);
|
|
1259
|
+
base.summary = summary.text;
|
|
1260
|
+
await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
|
|
1261
|
+
const parsed = extractDecisions(invocation.output);
|
|
1262
|
+
base.decisionErrors.push(...parsed.errors);
|
|
1263
|
+
await journalDecisions(workstream, parsed.decisions, baseCommit);
|
|
1264
|
+
base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
|
|
1265
|
+
let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
|
|
1266
|
+
programId: options.programId,
|
|
1267
|
+
phase: `post-${attempt.label.replaceAll(" ", "-")}`,
|
|
1268
|
+
workstream: workstream.id,
|
|
1269
|
+
role: attempt.role,
|
|
1270
|
+
attemptSeat: attempt.label,
|
|
1271
|
+
attemptIndex: index,
|
|
1272
|
+
});
|
|
1273
|
+
// Deterministic protocol failures the runner can see without a model:
|
|
1274
|
+
// no diagnosis is spent on them, because no read of the tree could add
|
|
1275
|
+
// anything to what the runner already knows.
|
|
1276
|
+
let deterministicFailure = false;
|
|
1277
|
+
// A spawn failure takes precedence over whatever verifyAttempt saw
|
|
1278
|
+
// (the untouched tree can verify green on its own) — no reviewer
|
|
1279
|
+
// diagnosis is spent reading an empty diff.
|
|
1280
|
+
if (spawnFailure !== undefined) {
|
|
1281
|
+
failure = spawnFailure;
|
|
1282
|
+
deterministicFailure = true;
|
|
1283
|
+
dispositionOutcome = "failed";
|
|
1284
|
+
}
|
|
1285
|
+
// A brief that never reached the agent's stdin means the process
|
|
1286
|
+
// crashed at startup — whatever it printed was not an answer to the
|
|
1287
|
+
// brief, even when it exited 0.
|
|
1288
|
+
if (failure === undefined && invocation.inputError !== undefined) {
|
|
1289
|
+
failure =
|
|
1290
|
+
`the brief could not be delivered to the agent's stdin ` +
|
|
1291
|
+
`(${invocation.inputError}) — the agent likely crashed at startup, ` +
|
|
1292
|
+
`so its reply answered nothing.`;
|
|
1293
|
+
deterministicFailure = true;
|
|
1294
|
+
dispositionOutcome = "no-op";
|
|
1295
|
+
}
|
|
1296
|
+
// A green verify over an unchanged tree is just the baseline passing
|
|
1297
|
+
// again, not built work. Without this, an agent that crashes at
|
|
1298
|
+
// startup while exiting 0 (or replies without working) sweeps only
|
|
1299
|
+
// the runner's own in_progress manifest write into a metadata-only
|
|
1300
|
+
// commit and walks through the gate as "complete". A reply that never
|
|
1301
|
+
// produces the work is a protocol failure, not an empty result.
|
|
1302
|
+
if (failure === undefined && isRepository) {
|
|
1303
|
+
const dirty = await git.dirtyPaths(root);
|
|
1304
|
+
const meaningful = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
|
|
1305
|
+
if (!meaningful) {
|
|
1306
|
+
failure =
|
|
1307
|
+
"the attempt verified green but changed nothing outside " +
|
|
1308
|
+
"docs/programs/ — nothing was built, and the passing verify was " +
|
|
1309
|
+
"the untouched baseline. A no-op attempt is a protocol failure " +
|
|
1310
|
+
"(the agent may have crashed at startup or replied without " +
|
|
1311
|
+
"working), not a completed workstream.";
|
|
1312
|
+
deterministicFailure = true;
|
|
1313
|
+
dispositionOutcome = "discarded";
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// A genuine verify failure (not spawn/inputError/green-empty) disposes
|
|
1317
|
+
// as `failed`, same as every other undetermined failure reason.
|
|
1318
|
+
if (failure !== undefined && dispositionOutcome === undefined) {
|
|
1319
|
+
dispositionOutcome = "failed";
|
|
1320
|
+
}
|
|
1321
|
+
if (failure === undefined) {
|
|
1322
|
+
let c0;
|
|
1323
|
+
if (isRepository) {
|
|
1324
|
+
// Unlike the authoring, run-report, and replay commits, this one
|
|
1325
|
+
// stays a whole-tree sweep: an implementing agent touches whatever
|
|
1326
|
+
// files the work required, and that set is exactly what the
|
|
1327
|
+
// runner cannot know in advance. c0 is the green anchor the test
|
|
1328
|
+
// critique's fix loop resets to on a failing fix (SC-07).
|
|
1329
|
+
c0 = await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`), commitEvidence);
|
|
1330
|
+
}
|
|
1331
|
+
const critique = isRepository && c0 !== undefined
|
|
1332
|
+
? await runTestCritique({
|
|
1333
|
+
root,
|
|
1334
|
+
manifest,
|
|
1335
|
+
workstream,
|
|
1336
|
+
spec,
|
|
1337
|
+
config,
|
|
1338
|
+
agentRunner,
|
|
1339
|
+
permits,
|
|
1340
|
+
verifyRunner,
|
|
1341
|
+
git,
|
|
1342
|
+
reviewer,
|
|
1343
|
+
agent,
|
|
1344
|
+
baseCommit,
|
|
1345
|
+
greenCommit: c0,
|
|
1346
|
+
now,
|
|
1347
|
+
log,
|
|
1348
|
+
transcriptSink,
|
|
1349
|
+
recorder,
|
|
1350
|
+
})
|
|
1351
|
+
: undefined;
|
|
1352
|
+
// The manifest's single commit field records the workstream's final
|
|
1353
|
+
// verified state — after any kept fix, that is the fix commit, not
|
|
1354
|
+
// the earlier green one.
|
|
1355
|
+
let finalCommit = critique?.finalCommit ?? c0;
|
|
1356
|
+
if (critique && critique.stageErrors.length > 0) {
|
|
1357
|
+
(base.stageErrors ??= []).push(...critique.stageErrors);
|
|
1358
|
+
}
|
|
1359
|
+
for (const transcript of critique?.transcripts ?? [])
|
|
1360
|
+
recordTranscript(transcript);
|
|
1361
|
+
// Findings anchor to c0 (the green, pre-critique commit) — the
|
|
1362
|
+
// honest rollback point — while the decider below diffs from
|
|
1363
|
+
// baseCommit (pre-workstream), so it sees the whole workstream.
|
|
1364
|
+
const findingEvents = findingsToLedgerEvents({
|
|
1365
|
+
workstreamId: workstream.id,
|
|
1366
|
+
findings: (critique?.outcome.open ?? []).filter(hasRoutableEvidence),
|
|
1367
|
+
...(c0 === undefined ? {} : { baseCommit: c0 }),
|
|
1368
|
+
now,
|
|
1369
|
+
});
|
|
1370
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, findingEvents));
|
|
1371
|
+
// The commit's own sha cannot be part of the tree it commits, so the
|
|
1372
|
+
// manifest records it only now — swept forward into whatever commits
|
|
1373
|
+
// next. Replay reads the manifest's current state, not the commit
|
|
1374
|
+
// that last touched it, so this lag is harmless.
|
|
1375
|
+
workstream.status = "complete";
|
|
1376
|
+
if (finalCommit !== undefined)
|
|
1377
|
+
workstream.commit = finalCommit;
|
|
1378
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1379
|
+
{
|
|
1380
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1381
|
+
if (spawnErrors.length > 0)
|
|
1382
|
+
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1383
|
+
}
|
|
1384
|
+
// Findings are triaged against the pre-workstream diff (baseCommit),
|
|
1385
|
+
// the same one the decider reviews decisions against — c0/finalCommit
|
|
1386
|
+
// are the fix loop's own rollback anchors, a different thing.
|
|
1387
|
+
{
|
|
1388
|
+
const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
|
|
1389
|
+
if (spawnErrors.length > 0)
|
|
1390
|
+
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1391
|
+
}
|
|
1392
|
+
// A fix-now triage drives exactly one bounded fix attempt, here —
|
|
1393
|
+
// after the triage that produced it, before the run advances. Only
|
|
1394
|
+
// reachable when there is a green commit to attempt from and a
|
|
1395
|
+
// decider that could have produced a fix-now verdict in the first
|
|
1396
|
+
// place (SC-13).
|
|
1397
|
+
if (finalCommit !== undefined && decider) {
|
|
1398
|
+
const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
|
|
1399
|
+
recordTranscript(driven.transcript);
|
|
1400
|
+
if (driven.spawnError !== undefined) {
|
|
1401
|
+
(base.stageErrors ??= []).push(driven.spawnError);
|
|
1402
|
+
}
|
|
1403
|
+
if (driven.commit !== finalCommit) {
|
|
1404
|
+
finalCommit = driven.commit;
|
|
1405
|
+
workstream.commit = finalCommit;
|
|
1406
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (critique)
|
|
1410
|
+
base.testCritique = critique.outcome;
|
|
1411
|
+
base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
|
|
1412
|
+
base.outcome = {
|
|
1413
|
+
status: "complete",
|
|
1414
|
+
...(finalCommit === undefined ? {} : { commit: finalCommit }),
|
|
1415
|
+
};
|
|
1416
|
+
emitAttemptOutcome("success");
|
|
1417
|
+
return base;
|
|
1418
|
+
}
|
|
1419
|
+
priorFailure = failure;
|
|
1420
|
+
log(`${workstream.id}: ${attempt.label} attempt failed — ${failure}`);
|
|
1421
|
+
// A failure identical to the pre-run baseline (up to counts and
|
|
1422
|
+
// timings) was there before any workstream ran: environmental, not
|
|
1423
|
+
// this workstream's work. No retry or diagnosis is spent on it, and
|
|
1424
|
+
// the caller halts the run — every later workstream would be charged
|
|
1425
|
+
// for the same broken environment. Workstreams resumed with their own
|
|
1426
|
+
// leftover failure in the tree are exempt (see resumedIds).
|
|
1427
|
+
if (baselineFingerprint !== undefined &&
|
|
1428
|
+
!resumedIds.has(workstream.id) &&
|
|
1429
|
+
failureFingerprint(failure) === baselineFingerprint) {
|
|
1430
|
+
workstream.status = "failed";
|
|
1431
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1432
|
+
{
|
|
1433
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1434
|
+
if (spawnErrors.length > 0)
|
|
1435
|
+
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1436
|
+
}
|
|
1437
|
+
base.environmental = true;
|
|
1438
|
+
base.outcome = {
|
|
1439
|
+
status: "failed",
|
|
1440
|
+
reason: failure +
|
|
1441
|
+
"\n\nThis failure is identical (up to counts and timings) to the " +
|
|
1442
|
+
"verification failure recorded on the untouched tree before the " +
|
|
1443
|
+
"run began — the environment was broken before this workstream " +
|
|
1444
|
+
"ran. No retry was spent, and the run halted here.",
|
|
1445
|
+
};
|
|
1446
|
+
emitAttemptOutcome("failed", "environmental");
|
|
1447
|
+
return base;
|
|
1448
|
+
}
|
|
1449
|
+
// The informed retry is spent only when the diagnosed recovery attempt
|
|
1450
|
+
// moved the failure at all. A failure reproduced identically after a
|
|
1451
|
+
// diagnosis says the roster is stuck, not unlucky — that belongs in
|
|
1452
|
+
// the ledger, not in a third spawn.
|
|
1453
|
+
const stuck = attempt.label === "recovery" &&
|
|
1454
|
+
index < attempts.length - 1 &&
|
|
1455
|
+
implementerFingerprint !== undefined &&
|
|
1456
|
+
failureFingerprint(failure) === implementerFingerprint;
|
|
1457
|
+
if (index === attempts.length - 1 || stuck) {
|
|
1458
|
+
workstream.status = "failed";
|
|
1459
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1460
|
+
// The work stays in the tree for a resume; decisions made on the way
|
|
1461
|
+
// to a failure are still journaled and still reviewable.
|
|
1462
|
+
{
|
|
1463
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
1464
|
+
if (spawnErrors.length > 0)
|
|
1465
|
+
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1466
|
+
}
|
|
1467
|
+
base.outcome = {
|
|
1468
|
+
status: "failed",
|
|
1469
|
+
reason: stuck
|
|
1470
|
+
? failure +
|
|
1471
|
+
"\n\nThe recovery attempt, briefed with a reviewer diagnosis, " +
|
|
1472
|
+
"reproduced the implementer's failure (identical up to counts " +
|
|
1473
|
+
"and timings), so the final retry was not spent."
|
|
1474
|
+
: failure,
|
|
1475
|
+
};
|
|
1476
|
+
emitAttemptOutcome(stuck ? "failed" : (dispositionOutcome ?? "failed"), stuck ? "stuck" : undefined);
|
|
1477
|
+
return base;
|
|
1478
|
+
}
|
|
1479
|
+
if (attempt.label === "implementer") {
|
|
1480
|
+
implementerFingerprint = failureFingerprint(failure);
|
|
1481
|
+
}
|
|
1482
|
+
// Diagnose the failure for the next attempt's brief. Overwrites any
|
|
1483
|
+
// prior diagnosis — a read of an older failure must not be pinned to
|
|
1484
|
+
// a newer one. Deterministic failures (no-op, undelivered brief) are
|
|
1485
|
+
// never diagnosed: the runner already knows exactly what happened,
|
|
1486
|
+
// and a reviewer read of an empty diff could only muddy it.
|
|
1487
|
+
if (reviewer && !deterministicFailure) {
|
|
1488
|
+
const diagnosed = await diagnoseFailure({
|
|
1489
|
+
root,
|
|
1490
|
+
manifest,
|
|
1491
|
+
workstream,
|
|
1492
|
+
spec,
|
|
1493
|
+
agentRunner,
|
|
1494
|
+
permits,
|
|
1495
|
+
reviewer,
|
|
1496
|
+
git,
|
|
1497
|
+
baseCommit,
|
|
1498
|
+
failure,
|
|
1499
|
+
attemptSeat: attempt.label,
|
|
1500
|
+
attemptIndex: index,
|
|
1501
|
+
observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
|
|
1502
|
+
recorder,
|
|
1503
|
+
});
|
|
1504
|
+
priorDiagnosis = diagnosed.diagnosis;
|
|
1505
|
+
if (diagnosed.spawnError !== undefined) {
|
|
1506
|
+
(base.stageErrors ??= []).push(diagnosed.spawnError);
|
|
1507
|
+
log(`${workstream.id}: ${diagnosed.spawnError}`);
|
|
1508
|
+
}
|
|
1509
|
+
await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
priorDiagnosis = undefined;
|
|
1513
|
+
}
|
|
1514
|
+
if (priorDiagnosis !== undefined) {
|
|
1515
|
+
(base.failureDiagnoses ??= []).push({
|
|
1516
|
+
attempt: attempt.label,
|
|
1517
|
+
verdict: priorDiagnosis,
|
|
1518
|
+
});
|
|
1519
|
+
log(`${workstream.id}: reviewer diagnosed the ${attempt.label} failure`);
|
|
1520
|
+
}
|
|
1521
|
+
emitAttemptOutcome(dispositionOutcome ?? "failed");
|
|
1522
|
+
}
|
|
1523
|
+
return base;
|
|
1524
|
+
}
|
|
1525
|
+
async function journalDecisions(workstream, decisions, baseCommit) {
|
|
1526
|
+
const events = decisions.map((decision) => ({
|
|
1527
|
+
kind: "decision-recorded",
|
|
1528
|
+
at: now().toISOString(),
|
|
1529
|
+
id: decisionFingerprint(workstream.id, decision),
|
|
1530
|
+
workstream: workstream.id,
|
|
1531
|
+
decision,
|
|
1532
|
+
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
1533
|
+
decidedBy: "implementer",
|
|
1534
|
+
}));
|
|
1535
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
|
|
1536
|
+
for (const decision of decisions) {
|
|
1537
|
+
log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Journals `permission-denied` events for one spawn (WS-03) — every
|
|
1542
|
+
* workstream-attributed spawn calls this once, passing its own transcript
|
|
1543
|
+
* and (for build roles only) the agent's `needsPermission` self-reports.
|
|
1544
|
+
* A no-op when the spawn produced neither.
|
|
1545
|
+
*/
|
|
1546
|
+
async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
|
|
1547
|
+
const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
|
|
1548
|
+
if (events.length === 0)
|
|
1549
|
+
return;
|
|
1550
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstreamId }, () => appendLedgerEvents(root, options.programId, events));
|
|
1551
|
+
for (const event of events) {
|
|
1552
|
+
if (event.kind !== "permission-denied")
|
|
1553
|
+
continue;
|
|
1554
|
+
log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
|
|
1558
|
+
return reviewDecisions({
|
|
1559
|
+
root,
|
|
1560
|
+
programId: options.programId,
|
|
1561
|
+
manifest,
|
|
1562
|
+
workstreamId,
|
|
1563
|
+
decisions,
|
|
1564
|
+
baseCommit,
|
|
1565
|
+
decider,
|
|
1566
|
+
agentRunner,
|
|
1567
|
+
permits,
|
|
1568
|
+
git,
|
|
1569
|
+
isRepository,
|
|
1570
|
+
reviewed,
|
|
1571
|
+
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
|
|
1572
|
+
triaged,
|
|
1573
|
+
now,
|
|
1574
|
+
log,
|
|
1575
|
+
observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
|
|
1576
|
+
recorder,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
|
|
1580
|
+
return triageFindings({
|
|
1581
|
+
root,
|
|
1582
|
+
programId: options.programId,
|
|
1583
|
+
manifest,
|
|
1584
|
+
workstreamId,
|
|
1585
|
+
findings,
|
|
1586
|
+
baseCommit,
|
|
1587
|
+
decider,
|
|
1588
|
+
agentRunner,
|
|
1589
|
+
permits,
|
|
1590
|
+
git,
|
|
1591
|
+
isRepository,
|
|
1592
|
+
reviewed,
|
|
1593
|
+
observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
|
|
1594
|
+
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
|
|
1595
|
+
triaged,
|
|
1596
|
+
now,
|
|
1597
|
+
log,
|
|
1598
|
+
recorder,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Drives the one bounded fix attempt a `fix-now` triage earns (WS-06).
|
|
1603
|
+
* Selects findings from the ledger projection — never from any in-memory
|
|
1604
|
+
* disposition — so a human-decided finding (status `"human-decided"`, not
|
|
1605
|
+
* `"fix-now"`) is never handed to the fix seam (SC-14). A workstream's
|
|
1606
|
+
* `fix-now` findings are fixed together, in one `attemptFix` call, then
|
|
1607
|
+
* recorded as one `finding-fix-attempted` event per finding id — a ledger
|
|
1608
|
+
* entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
|
|
1609
|
+
* commit; never throws, never blocks the run (SC-15).
|
|
1610
|
+
*/
|
|
1611
|
+
async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
|
|
1612
|
+
const ledger = await readDecisionLedger(root, options.programId);
|
|
1613
|
+
const fixNowIds = new Set(ledger.findings
|
|
1614
|
+
.filter((record) => record.workstream === workstream.id && record.status === "fix-now")
|
|
1615
|
+
.map((record) => record.id));
|
|
1616
|
+
if (fixNowIds.size === 0)
|
|
1617
|
+
return { commit: greenCommit };
|
|
1618
|
+
const toFix = [];
|
|
1619
|
+
const matchedIds = [];
|
|
1620
|
+
for (const candidate of openFindings) {
|
|
1621
|
+
const id = fingerprint({ ...candidate, workstreamId: workstream.id });
|
|
1622
|
+
if (!fixNowIds.has(id))
|
|
1623
|
+
continue;
|
|
1624
|
+
toFix.push(candidate);
|
|
1625
|
+
matchedIds.push(id);
|
|
1626
|
+
}
|
|
1627
|
+
// An id triaged fix-now but absent from the open set (should not
|
|
1628
|
+
// happen — it is where the id came from) is skipped, fail-open.
|
|
1629
|
+
if (toFix.length === 0)
|
|
1630
|
+
return { commit: greenCommit };
|
|
1631
|
+
const fix = await attemptFix({
|
|
1632
|
+
root,
|
|
1633
|
+
programId: options.programId,
|
|
1634
|
+
workstream,
|
|
1635
|
+
spec,
|
|
1636
|
+
config,
|
|
1637
|
+
agentRunner,
|
|
1638
|
+
permits,
|
|
1639
|
+
verifyRunner,
|
|
1640
|
+
git,
|
|
1641
|
+
agent,
|
|
1642
|
+
findings: toFix,
|
|
1643
|
+
greenCommit,
|
|
1644
|
+
label: "fix-now fix",
|
|
1645
|
+
log,
|
|
1646
|
+
now,
|
|
1647
|
+
observe,
|
|
1648
|
+
stage: "fix-now",
|
|
1649
|
+
attemptIndex: 0,
|
|
1650
|
+
recorder,
|
|
1651
|
+
});
|
|
1652
|
+
// "kept" only when the attempt both verified clean and actually landed a
|
|
1653
|
+
// commit — a clean-but-empty attempt is a decline, not a fix, and must
|
|
1654
|
+
// reach the human exactly like a failed verification does.
|
|
1655
|
+
const kept = fix.outcome === "kept" && fix.commit !== undefined;
|
|
1656
|
+
const note = kept
|
|
1657
|
+
? fix.summary
|
|
1658
|
+
: fix.outcome === "kept"
|
|
1659
|
+
? `${fix.summary} (the attempt verified clean but made no change; no fix landed)`
|
|
1660
|
+
: `${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
|
|
1661
|
+
const events = matchedIds.map((id) => ({
|
|
1662
|
+
kind: "finding-fix-attempted",
|
|
1663
|
+
at: now().toISOString(),
|
|
1664
|
+
id,
|
|
1665
|
+
outcome: kept ? "kept" : "failed",
|
|
1666
|
+
note,
|
|
1667
|
+
...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
|
|
1668
|
+
attemptedBy: "implementer",
|
|
1669
|
+
}));
|
|
1670
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
|
|
1671
|
+
log(kept
|
|
1672
|
+
? `${workstream.id}: fix-now fix verified and committed`
|
|
1673
|
+
: `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
|
|
1674
|
+
const resultCommit = kept ? fix.greenCommit : greenCommit;
|
|
1675
|
+
return {
|
|
1676
|
+
commit: resultCommit,
|
|
1677
|
+
...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
|
|
1678
|
+
...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1400
1681
|
}
|
|
1401
1682
|
}
|
|
1402
1683
|
/**
|
|
@@ -1487,6 +1768,7 @@ function notBuiltRoster(results, authorResult) {
|
|
|
1487
1768
|
*/
|
|
1488
1769
|
export async function runWholeProgramStage(args) {
|
|
1489
1770
|
const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
|
|
1771
|
+
const recorder = args.recorder ?? NOOP_RUN_RECORDER;
|
|
1490
1772
|
const errors = [];
|
|
1491
1773
|
let commit;
|
|
1492
1774
|
let commitSkipped = false;
|
|
@@ -1508,7 +1790,9 @@ export async function runWholeProgramStage(args) {
|
|
|
1508
1790
|
try {
|
|
1509
1791
|
safeLog("whole-program review: starting");
|
|
1510
1792
|
const base = await resolveProgramDiffBase({ root, git, isRepository, runStartCommit });
|
|
1511
|
-
const diff = base === undefined
|
|
1793
|
+
const diff = base === undefined
|
|
1794
|
+
? ""
|
|
1795
|
+
: await timed(recorder, { stage: "git-diff" }, () => git.diffSince(root, base));
|
|
1512
1796
|
const notBuilt = notBuiltRoster(results, authorResult);
|
|
1513
1797
|
const reviewCommit = isRepository ? await git.currentCommit(root) : undefined;
|
|
1514
1798
|
outcome = await runWholeProgramReview({
|
|
@@ -1523,6 +1807,7 @@ export async function runWholeProgramStage(args) {
|
|
|
1523
1807
|
...(notBuilt.length === 0 ? {} : { notBuilt }),
|
|
1524
1808
|
...(fs === undefined ? {} : { fs }),
|
|
1525
1809
|
log,
|
|
1810
|
+
recorder,
|
|
1526
1811
|
});
|
|
1527
1812
|
safeLog(outcome.status === "refreshed"
|
|
1528
1813
|
? `whole-program review: refreshed ${AS_BUILT_PATH}` +
|
|
@@ -1532,7 +1817,7 @@ export async function runWholeProgramStage(args) {
|
|
|
1532
1817
|
if (outcome.writtenPaths.length > 0) {
|
|
1533
1818
|
if (isRepository) {
|
|
1534
1819
|
try {
|
|
1535
|
-
commit = await git.commitPaths(root, `nightshift(${programId}): as-built snapshot`, outcome.writtenPaths);
|
|
1820
|
+
commit = await timed(recorder, { stage: "as-built-snapshot" }, () => git.commitPaths(root, `nightshift(${programId}): as-built snapshot`, outcome.writtenPaths), commitEvidence);
|
|
1536
1821
|
}
|
|
1537
1822
|
catch (error) {
|
|
1538
1823
|
errors.push(`could not commit the as-built snapshot: ${error.message}`);
|
|
@@ -1554,7 +1839,7 @@ export async function runWholeProgramStage(args) {
|
|
|
1554
1839
|
}
|
|
1555
1840
|
else {
|
|
1556
1841
|
try {
|
|
1557
|
-
await appendLedgerEvents(root, programId, events);
|
|
1842
|
+
await timed(recorder, { stage: "ledger-persist", workstream: WHOLE_PROGRAM_SUBJECT }, () => appendLedgerEvents(root, programId, events));
|
|
1558
1843
|
journaled = true;
|
|
1559
1844
|
findingIds = events.map((event) => event.id);
|
|
1560
1845
|
}
|
|
@@ -1580,6 +1865,7 @@ export async function runWholeProgramStage(args) {
|
|
|
1580
1865
|
triaged,
|
|
1581
1866
|
now,
|
|
1582
1867
|
log,
|
|
1868
|
+
recorder,
|
|
1583
1869
|
});
|
|
1584
1870
|
errors.push(...spawnErrors);
|
|
1585
1871
|
}
|
|
@@ -1599,16 +1885,195 @@ export async function runWholeProgramStage(args) {
|
|
|
1599
1885
|
errors,
|
|
1600
1886
|
};
|
|
1601
1887
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1888
|
+
function causalAnalysisStageFailure(reason) {
|
|
1889
|
+
return {
|
|
1890
|
+
ran: false,
|
|
1891
|
+
status: "analyzer-error",
|
|
1892
|
+
reason,
|
|
1893
|
+
unresolvedQuestions: [],
|
|
1894
|
+
parseErrors: [],
|
|
1895
|
+
inputClipped: false,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Projects the run's own `WorkstreamResult[]` into WS-05's narrow,
|
|
1900
|
+
* causal-analysis-owned timeline shape (mirrors `notBuiltRoster`'s mapping
|
|
1901
|
+
* pattern) — `causal-analysis.ts` cannot import `WorkstreamResult` itself
|
|
1902
|
+
* without creating a cycle back into this module.
|
|
1903
|
+
*/
|
|
1904
|
+
function causalAnalysisTimeline(results) {
|
|
1905
|
+
return results.map((result) => ({
|
|
1906
|
+
id: result.id,
|
|
1907
|
+
name: result.name,
|
|
1908
|
+
status: result.outcome.status,
|
|
1909
|
+
...(result.outcome.status === "complete" ? {} : { reason: result.outcome.reason }),
|
|
1910
|
+
...(result.failureDiagnoses === undefined ? {} : { failureDiagnoses: result.failureDiagnoses }),
|
|
1911
|
+
...(result.testCritique === undefined
|
|
1912
|
+
? {}
|
|
1913
|
+
: {
|
|
1914
|
+
testCritique: {
|
|
1915
|
+
stopReason: result.testCritique.stopReason,
|
|
1916
|
+
roundsRun: result.testCritique.roundsRun,
|
|
1917
|
+
openSubjects: result.testCritique.open.map((finding) => finding.subject),
|
|
1918
|
+
resolvedSubjects: result.testCritique.resolved.map((finding) => finding.subject),
|
|
1919
|
+
},
|
|
1920
|
+
}),
|
|
1921
|
+
}));
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* The end-of-run causal-analysis stage (WS-05): resolves the same diff base
|
|
1925
|
+
* the whole-program stage uses, takes the analytics snapshot *before*
|
|
1926
|
+
* spawning the analyzer (so it never reasons about its own cost, §3.2),
|
|
1927
|
+
* calls the never-throwing pass exactly once, and returns its outcome. Every
|
|
1928
|
+
* risky step gets its own catch so a stage failure is a sentence in the
|
|
1929
|
+
* report, never a change to the run's outcome (SC-13) — see the outer catch
|
|
1930
|
+
* below for the belt-and-braces case where a bug in this function's own body
|
|
1931
|
+
* throws before the pass has even run. Read-only: writes no file, journals
|
|
1932
|
+
* no ledger event, and is never a gate.
|
|
1933
|
+
*/
|
|
1934
|
+
export async function runCausalAnalysisStage(args) {
|
|
1935
|
+
const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, runStartCommit, ledger, log, } = args;
|
|
1936
|
+
const recorder = args.recorder ?? NOOP_RUN_RECORDER;
|
|
1937
|
+
const errors = [];
|
|
1938
|
+
let outcome = causalAnalysisStageFailure("the causal analysis stage did not complete");
|
|
1939
|
+
const safeLog = (line) => {
|
|
1940
|
+
try {
|
|
1941
|
+
log(line);
|
|
1942
|
+
}
|
|
1943
|
+
catch (error) {
|
|
1944
|
+
errors.push(`the causal analysis logger failed: ${error.message}`);
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
try {
|
|
1948
|
+
safeLog("causal analysis: starting");
|
|
1949
|
+
const base = await resolveProgramDiffBase({ root, git, isRepository, runStartCommit });
|
|
1950
|
+
const diff = base === undefined
|
|
1951
|
+
? ""
|
|
1952
|
+
: await timed(recorder, { stage: "git-diff" }, () => git.diffSince(root, base));
|
|
1953
|
+
// Taken before the analyzer spawns (§3.2 of the WS-05 spec): the pass is
|
|
1954
|
+
// briefed from the run as it stood at this instant, so it never reasons
|
|
1955
|
+
// about its own review time.
|
|
1956
|
+
const snapshot = recorder.snapshot();
|
|
1957
|
+
// The program document's own narrative — SC-12 requires the analyzer to
|
|
1958
|
+
// consume the plan, not just the manifest's roster/success-criteria
|
|
1959
|
+
// fields. Read fail-open: a missing or unreadable document is an honest
|
|
1960
|
+
// gap in the brief, never a thrown stage (SC-13).
|
|
1961
|
+
const programNarrative = await readFile(join(root, "docs", "programs", `${programId}-program.md`), "utf8").catch(() => undefined);
|
|
1962
|
+
// Every workstream's own spec file — SC-12 requires the analyzer to
|
|
1963
|
+
// consume the specifications themselves, not merely a path to them. Read
|
|
1964
|
+
// fail-open per workstream: a missing or unreadable spec is an honest
|
|
1965
|
+
// gap for that workstream, never a thrown stage (SC-13).
|
|
1966
|
+
const workstreamSpecs = await Promise.all(manifest.workstreams.map(async (workstream) => {
|
|
1967
|
+
const spec = await readFile(join(root, workstream.taskFile), "utf8").catch(() => undefined);
|
|
1968
|
+
return {
|
|
1969
|
+
id: workstream.id,
|
|
1970
|
+
name: workstream.name,
|
|
1971
|
+
taskFile: workstream.taskFile,
|
|
1972
|
+
...(spec === undefined ? {} : { spec }),
|
|
1973
|
+
};
|
|
1974
|
+
}));
|
|
1975
|
+
outcome = await runCausalAnalysis({
|
|
1976
|
+
root,
|
|
1977
|
+
programId,
|
|
1978
|
+
manifest,
|
|
1979
|
+
config,
|
|
1980
|
+
agentRunner,
|
|
1981
|
+
permits,
|
|
1982
|
+
recorder,
|
|
1983
|
+
snapshot,
|
|
1984
|
+
ledger,
|
|
1985
|
+
workstreams: causalAnalysisTimeline(results),
|
|
1986
|
+
diff,
|
|
1987
|
+
workstreamSpecs,
|
|
1988
|
+
...(base === undefined ? {} : { baseCommit: base }),
|
|
1989
|
+
...(programNarrative === undefined ? {} : { programNarrative }),
|
|
1990
|
+
log,
|
|
1991
|
+
});
|
|
1992
|
+
safeLog(`causal analysis: ${outcome.status}${outcome.reason ? ` — ${outcome.reason}` : ""}`);
|
|
1993
|
+
}
|
|
1994
|
+
catch (error) {
|
|
1995
|
+
errors.push(`the causal analysis stage failed unexpectedly: ${error.message}`);
|
|
1996
|
+
}
|
|
1997
|
+
return { outcome, errors };
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Fail-open durable write of one *executed* verify command's captured output
|
|
2001
|
+
* (WS-02, SC-05) to a gitignored `build-logs/<programId>/verify/` file,
|
|
2002
|
+
* returned as a `transcript` evidence ref for that command's span. Never
|
|
2003
|
+
* throws: a write failure (full disk, EACCES) simply omits the evidence ref,
|
|
2004
|
+
* never the span or the run (SC-13).
|
|
2005
|
+
*/
|
|
2006
|
+
async function writeVerifyOutputEvidence(root, programId, label, output) {
|
|
2007
|
+
try {
|
|
2008
|
+
const dir = join(root, "build-logs", programId, "verify");
|
|
2009
|
+
await mkdir(dir, { recursive: true });
|
|
2010
|
+
const fileName = `${label}-${randomBytes(4).toString("hex")}.log`;
|
|
2011
|
+
await writeFile(join(dir, fileName), output, "utf8");
|
|
2012
|
+
return {
|
|
2013
|
+
kind: "transcript",
|
|
2014
|
+
locality: "local",
|
|
2015
|
+
ref: `build-logs/${programId}/verify/${fileName}`,
|
|
2016
|
+
note: "verify output",
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
catch {
|
|
2020
|
+
return undefined;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Runs every configured verify command once, first non-zero wins — same
|
|
2025
|
+
* behavior as before WS-02, but the emission model changed (§3.4.a): rather
|
|
2026
|
+
* than one span for the whole pass, this emits one `verification-command` /
|
|
2027
|
+
* `baseline-verification` span per *executed* command, as depth-1 tiling
|
|
2028
|
+
* siblings (never a pass wrapper), so repeated full-suite cost and its
|
|
2029
|
+
* evidence are visible per command. A non-zero `agentExitCode` runs no
|
|
2030
|
+
* commands and emits no span at all — that attempt's failure is already
|
|
2031
|
+
* carried by its own `attempt-outcome` point (§3.3), so there is no
|
|
2032
|
+
* verification observation to fabricate. A command short-circuited by an
|
|
2033
|
+
* earlier command's failure is likewise absent, never a measured zero.
|
|
2034
|
+
* `stage` distinguishes the untouched-tree baseline check (preflight bucket)
|
|
2035
|
+
* from every configured command that follows an agent's own attempt
|
|
2036
|
+
* (verification bucket); `phase` names the invocation (`baseline`,
|
|
2037
|
+
* `post-implementer`, `test-critique-fix`, …) as each command span's
|
|
2038
|
+
* `attemptReason`. `programId`, when given, durably writes each executed
|
|
2039
|
+
* command's captured output as `transcript` evidence; a caller with no
|
|
2040
|
+
* `programId` (a bare test double) simply carries no evidence ref.
|
|
2041
|
+
* `role`/`attemptSeat`/`attemptIndex` carry the triggering attempt's own
|
|
2042
|
+
* dimensions (SC-05/SC-06) so a repeated verify pass can be uniquely
|
|
2043
|
+
* correlated back to the attempt that provoked it, not just its phase.
|
|
2044
|
+
*/
|
|
2045
|
+
async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder = NOOP_RUN_RECORDER, stage = "verification-command", opts = {}) {
|
|
1604
2046
|
if (agentExitCode !== 0) {
|
|
1605
2047
|
return `agent exited with code ${agentExitCode}`;
|
|
1606
2048
|
}
|
|
2049
|
+
const { programId, phase, workstream, role, attemptSeat, attemptIndex } = opts;
|
|
1607
2050
|
for (const [name, command] of Object.entries(config.verify)) {
|
|
1608
|
-
const
|
|
2051
|
+
const span = recorder.span({
|
|
2052
|
+
stage,
|
|
2053
|
+
verifyCommand: name,
|
|
2054
|
+
...(phase === undefined ? {} : { attemptReason: phase }),
|
|
2055
|
+
...(workstream === undefined ? {} : { workstream }),
|
|
2056
|
+
...(role === undefined ? {} : { role }),
|
|
2057
|
+
...(attemptSeat === undefined ? {} : { attemptSeat }),
|
|
2058
|
+
...(attemptIndex === undefined ? {} : { attemptIndex }),
|
|
2059
|
+
});
|
|
2060
|
+
let result;
|
|
2061
|
+
try {
|
|
2062
|
+
result = await verifyRunner(command, root);
|
|
2063
|
+
}
|
|
2064
|
+
catch (error) {
|
|
2065
|
+
span.close({ outcome: "failed" });
|
|
2066
|
+
throw error;
|
|
2067
|
+
}
|
|
2068
|
+
const evidenceRef = programId === undefined
|
|
2069
|
+
? undefined
|
|
2070
|
+
: await writeVerifyOutputEvidence(root, programId, `${phase ?? stage}-${name}`, result.output);
|
|
2071
|
+
const evidence = evidenceRef ? [evidenceRef] : undefined;
|
|
1609
2072
|
if (result.exitCode !== 0) {
|
|
2073
|
+
span.close({ outcome: "failed", ...(evidence ? { evidence } : {}) });
|
|
1610
2074
|
return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
|
|
1611
2075
|
}
|
|
2076
|
+
span.close({ outcome: "success", ...(evidence ? { evidence } : {}) });
|
|
1612
2077
|
}
|
|
1613
2078
|
return undefined;
|
|
1614
2079
|
}
|
|
@@ -1693,11 +2158,23 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
|
|
|
1693
2158
|
*/
|
|
1694
2159
|
async function diagnoseFailure(options) {
|
|
1695
2160
|
const { root, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
|
|
1696
|
-
const
|
|
1697
|
-
const
|
|
2161
|
+
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
2162
|
+
const rawDiff = baseCommit !== undefined
|
|
2163
|
+
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
|
|
2164
|
+
: "";
|
|
2165
|
+
const clippedSpec = clipForReview(spec, "spec");
|
|
2166
|
+
const clippedDiff = clipForReview(rawDiff, "diff");
|
|
2167
|
+
recorder.point(clippedInputPoint("failure-diagnosis-spec", spec, clippedSpec.text, { role: "reviewerAgent" }));
|
|
2168
|
+
recorder.point(clippedInputPoint("failure-diagnosis-diff", rawDiff, clippedDiff.text, { role: "reviewerAgent" }));
|
|
2169
|
+
const brief = failureDiagnosisBrief(manifest, workstream, clippedSpec.text, clippedDiff.text, failure);
|
|
1698
2170
|
let invocation;
|
|
1699
2171
|
try {
|
|
1700
|
-
invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe
|
|
2172
|
+
invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe, recorder, {
|
|
2173
|
+
stage: "failure-diagnosis",
|
|
2174
|
+
workstream: workstream.id,
|
|
2175
|
+
...(options.attemptSeat === undefined ? {} : { attemptSeat: options.attemptSeat }),
|
|
2176
|
+
...(options.attemptIndex === undefined ? {} : { attemptIndex: options.attemptIndex }),
|
|
2177
|
+
});
|
|
1701
2178
|
}
|
|
1702
2179
|
catch (error) {
|
|
1703
2180
|
return {
|
|
@@ -1875,15 +2352,36 @@ function testCritiqueFixBrief(workstream, spec, findings) {
|
|
|
1875
2352
|
* whether (and how many times) this is called.
|
|
1876
2353
|
*/
|
|
1877
2354
|
async function attemptFix(options) {
|
|
1878
|
-
const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, } = options;
|
|
2355
|
+
const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, stage, attemptIndex, } = options;
|
|
2356
|
+
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
2357
|
+
const attemptReason = stage === "fix-now" ? "fix-now-finding" : "critique-finding";
|
|
1879
2358
|
const brief = testCritiqueFixBrief(workstream, spec, findings);
|
|
1880
2359
|
let invocation;
|
|
1881
2360
|
try {
|
|
1882
|
-
invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe
|
|
2361
|
+
invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe, recorder, {
|
|
2362
|
+
stage,
|
|
2363
|
+
workstream: workstream.id,
|
|
2364
|
+
attemptSeat: label,
|
|
2365
|
+
attemptIndex,
|
|
2366
|
+
attemptReason,
|
|
2367
|
+
});
|
|
1883
2368
|
}
|
|
1884
2369
|
catch (error) {
|
|
1885
2370
|
const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
|
|
1886
2371
|
"the fix was not attempted and the green state was preserved.";
|
|
2372
|
+
recorder.point({
|
|
2373
|
+
kind: "attempt-outcome",
|
|
2374
|
+
coverage: "observed",
|
|
2375
|
+
dimensions: {
|
|
2376
|
+
stage,
|
|
2377
|
+
workstream: workstream.id,
|
|
2378
|
+
role: "agent",
|
|
2379
|
+
attemptSeat: label,
|
|
2380
|
+
attemptIndex,
|
|
2381
|
+
attemptReason,
|
|
2382
|
+
outcome: "failed",
|
|
2383
|
+
},
|
|
2384
|
+
});
|
|
1887
2385
|
return {
|
|
1888
2386
|
outcome: "failed",
|
|
1889
2387
|
greenCommit,
|
|
@@ -1897,14 +2395,36 @@ async function attemptFix(options) {
|
|
|
1897
2395
|
const summary = resolvedSummary.text;
|
|
1898
2396
|
const denialEvents = buildDenialEvents(workstream.id, label, transcript?.denials ?? [], resolvedSummary.needsPermission, config.permits?.deny ?? [], now().toISOString());
|
|
1899
2397
|
if (denialEvents.length > 0) {
|
|
1900
|
-
await appendLedgerEvents(root, programId, denialEvents);
|
|
2398
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, programId, denialEvents));
|
|
1901
2399
|
for (const event of denialEvents) {
|
|
1902
2400
|
if (event.kind !== "permission-denied")
|
|
1903
2401
|
continue;
|
|
1904
2402
|
log(`${workstream.id} ${label}: denied \`${event.command}\` (${event.source})`);
|
|
1905
2403
|
}
|
|
1906
2404
|
}
|
|
1907
|
-
const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode
|
|
2405
|
+
const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
|
|
2406
|
+
programId,
|
|
2407
|
+
phase: stage,
|
|
2408
|
+
workstream: workstream.id,
|
|
2409
|
+
role: "agent",
|
|
2410
|
+
attemptSeat: label,
|
|
2411
|
+
attemptIndex,
|
|
2412
|
+
});
|
|
2413
|
+
const emitOutcome = (outcome) => {
|
|
2414
|
+
recorder.point({
|
|
2415
|
+
kind: "attempt-outcome",
|
|
2416
|
+
coverage: "observed",
|
|
2417
|
+
dimensions: {
|
|
2418
|
+
stage,
|
|
2419
|
+
workstream: workstream.id,
|
|
2420
|
+
role: "agent",
|
|
2421
|
+
attemptSeat: label,
|
|
2422
|
+
attemptIndex,
|
|
2423
|
+
attemptReason,
|
|
2424
|
+
outcome,
|
|
2425
|
+
},
|
|
2426
|
+
});
|
|
2427
|
+
};
|
|
1908
2428
|
if (failure === undefined) {
|
|
1909
2429
|
// The runner's own pending ledger and manifest writes under
|
|
1910
2430
|
// docs/programs sit uncommitted in the tree until the run's final
|
|
@@ -1918,8 +2438,9 @@ async function attemptFix(options) {
|
|
|
1918
2438
|
const dirty = await git.dirtyPaths(root);
|
|
1919
2439
|
const meaningfulChange = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
|
|
1920
2440
|
const commit = meaningfulChange
|
|
1921
|
-
? await git.commitAll(root, `nightshift(${programId}): ${workstream.id} ${label}`)
|
|
2441
|
+
? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${programId}): ${workstream.id} ${label}`), commitEvidence)
|
|
1922
2442
|
: undefined;
|
|
2443
|
+
emitOutcome(commit === undefined ? "discarded" : "success");
|
|
1923
2444
|
return {
|
|
1924
2445
|
outcome: "kept",
|
|
1925
2446
|
greenCommit: commit ?? greenCommit,
|
|
@@ -1941,6 +2462,7 @@ async function attemptFix(options) {
|
|
|
1941
2462
|
const snapshot = await snapshotProgramsDir(root);
|
|
1942
2463
|
await git.resetHard(root, greenCommit);
|
|
1943
2464
|
await restoreProgramsDir(root, snapshot);
|
|
2465
|
+
emitOutcome("failed");
|
|
1944
2466
|
return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
|
|
1945
2467
|
}
|
|
1946
2468
|
/**
|
|
@@ -1952,6 +2474,7 @@ async function attemptFix(options) {
|
|
|
1952
2474
|
*/
|
|
1953
2475
|
async function runTestCritique(options) {
|
|
1954
2476
|
const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
|
|
2477
|
+
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
1955
2478
|
let greenCommit = options.greenCommit;
|
|
1956
2479
|
let diffClipped = false;
|
|
1957
2480
|
const stageErrors = [];
|
|
@@ -1966,16 +2489,20 @@ async function runTestCritique(options) {
|
|
|
1966
2489
|
};
|
|
1967
2490
|
}
|
|
1968
2491
|
const locate = (file) => locateInRepo(root, file);
|
|
1969
|
-
const review = async (
|
|
1970
|
-
const rawDiff = baseCommit !== undefined
|
|
2492
|
+
const review = async (round, priorOpen) => {
|
|
2493
|
+
const rawDiff = baseCommit !== undefined
|
|
2494
|
+
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
|
|
2495
|
+
: "";
|
|
1971
2496
|
const diff = clipForReview(rawDiff, "diff");
|
|
1972
2497
|
const clippedSpec = clipForReview(spec, "spec");
|
|
1973
2498
|
if (diff.clipped || clippedSpec.clipped)
|
|
1974
2499
|
diffClipped = true;
|
|
2500
|
+
recorder.point(clippedInputPoint("test-critique-diff", rawDiff, diff.text, { role: "reviewerAgent" }));
|
|
2501
|
+
recorder.point(clippedInputPoint("test-critique-spec", spec, clippedSpec.text, { role: "reviewerAgent" }));
|
|
1975
2502
|
const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
|
|
1976
2503
|
let invocation;
|
|
1977
2504
|
try {
|
|
1978
|
-
invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"));
|
|
2505
|
+
invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"), recorder, { workstream: workstream.id, attemptIndex: round });
|
|
1979
2506
|
}
|
|
1980
2507
|
catch (error) {
|
|
1981
2508
|
// Fail open, matching the existing reviewer-error path: no findings
|
|
@@ -1987,7 +2514,7 @@ async function runTestCritique(options) {
|
|
|
1987
2514
|
if (invocation.transcript) {
|
|
1988
2515
|
const denialEvents = buildDenialEvents(workstream.id, "reviewer", invocation.transcript.denials, [], config.permits?.deny ?? [], now().toISOString());
|
|
1989
2516
|
if (denialEvents.length > 0) {
|
|
1990
|
-
await appendLedgerEvents(root, manifest.program.id, denialEvents);
|
|
2517
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, denialEvents));
|
|
1991
2518
|
for (const event of denialEvents) {
|
|
1992
2519
|
if (event.kind !== "permission-denied")
|
|
1993
2520
|
continue;
|
|
@@ -2000,7 +2527,7 @@ async function runTestCritique(options) {
|
|
|
2000
2527
|
const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
|
|
2001
2528
|
return { findings, errors: parsed.errors, ran };
|
|
2002
2529
|
};
|
|
2003
|
-
const respond = async (
|
|
2530
|
+
const respond = async (round, findings) => {
|
|
2004
2531
|
const fix = await attemptFix({
|
|
2005
2532
|
root,
|
|
2006
2533
|
programId: manifest.program.id,
|
|
@@ -2018,6 +2545,9 @@ async function runTestCritique(options) {
|
|
|
2018
2545
|
log,
|
|
2019
2546
|
now,
|
|
2020
2547
|
observe: transcriptSink("test-critique-fix"),
|
|
2548
|
+
stage: "test-critique-fix",
|
|
2549
|
+
attemptIndex: round,
|
|
2550
|
+
recorder,
|
|
2021
2551
|
});
|
|
2022
2552
|
greenCommit = fix.greenCommit;
|
|
2023
2553
|
if (fix.transcript)
|
|
@@ -2350,7 +2880,11 @@ function renderCommandsDeniedSection(results, ledger, programId) {
|
|
|
2350
2880
|
}
|
|
2351
2881
|
return lines;
|
|
2352
2882
|
}
|
|
2353
|
-
export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended
|
|
2883
|
+
export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended,
|
|
2884
|
+
/** Pre-rendered "Where the time went" lines (WS-04), computed and isolated by the caller via `renderAnalyticsSection`. Omitted entirely, this function's output is unchanged from before WS-04. */
|
|
2885
|
+
analyticsSection,
|
|
2886
|
+
/** Pre-rendered "Why the time went there" lines (WS-05), computed and isolated by the caller via `renderCausalAnalysisSection`. Omitted entirely, this function's output is unchanged from before WS-05. */
|
|
2887
|
+
causalAnalysisSection) {
|
|
2354
2888
|
const programId = manifest.program.id;
|
|
2355
2889
|
const built = results.filter((result) => result.outcome.status === "complete" ||
|
|
2356
2890
|
result.outcome.status === "skipped").length;
|
|
@@ -2459,6 +2993,12 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
|
|
|
2459
2993
|
"workstream's own outcome is unaffected, but the reason it fell " +
|
|
2460
2994
|
"open belongs here.", "", ...stageErrors.map((error) => `- ${error}`), "");
|
|
2461
2995
|
}
|
|
2996
|
+
if (analyticsSection !== undefined) {
|
|
2997
|
+
lines.push(...analyticsSection);
|
|
2998
|
+
}
|
|
2999
|
+
if (causalAnalysisSection !== undefined) {
|
|
3000
|
+
lines.push(...causalAnalysisSection);
|
|
3001
|
+
}
|
|
2462
3002
|
return lines.join("\n");
|
|
2463
3003
|
}
|
|
2464
3004
|
//# sourceMappingURL=run-program.js.map
|