@wildorder/nightshift 0.17.0 → 0.18.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 +269 -1
- package/dist/agent-runner.d.ts +41 -3
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +197 -12
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-session.d.ts +123 -0
- package/dist/agent-session.d.ts.map +1 -0
- package/dist/agent-session.js +114 -0
- package/dist/agent-session.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +74 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/author.d.ts +112 -6
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +597 -137
- package/dist/author.js.map +1 -1
- package/dist/causal-analysis.d.ts +6 -2
- package/dist/causal-analysis.d.ts.map +1 -1
- package/dist/causal-analysis.js +55 -2
- package/dist/causal-analysis.js.map +1 -1
- package/dist/cli.js +22 -2
- package/dist/cli.js.map +1 -1
- package/dist/concurrency.d.ts +24 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +28 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/decider-review.d.ts +10 -3
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +6 -4
- package/dist/decider-review.js.map +1 -1
- package/dist/decision-ledger.d.ts +7 -0
- package/dist/decision-ledger.d.ts.map +1 -1
- package/dist/decision-ledger.js +25 -10
- package/dist/decision-ledger.js.map +1 -1
- package/dist/detect-package-manager.d.ts +10 -0
- package/dist/detect-package-manager.d.ts.map +1 -1
- package/dist/detect-package-manager.js +12 -0
- package/dist/detect-package-manager.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +24 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +254 -108
- package/dist/manifest.js.map +1 -1
- package/dist/permits.d.ts +18 -0
- package/dist/permits.d.ts.map +1 -1
- package/dist/permits.js +155 -0
- package/dist/permits.js.map +1 -1
- package/dist/preflight.d.ts +2 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js.map +1 -1
- package/dist/review-pass.d.ts +52 -10
- package/dist/review-pass.d.ts.map +1 -1
- package/dist/review-pass.js +114 -30
- package/dist/review-pass.js.map +1 -1
- package/dist/run-analytics-report.d.ts +114 -1
- package/dist/run-analytics-report.d.ts.map +1 -1
- package/dist/run-analytics-report.js +606 -14
- package/dist/run-analytics-report.js.map +1 -1
- package/dist/run-analytics.d.ts +107 -1
- package/dist/run-analytics.d.ts.map +1 -1
- package/dist/run-analytics.js +257 -25
- package/dist/run-analytics.js.map +1 -1
- package/dist/run-log.d.ts +16 -0
- package/dist/run-log.d.ts.map +1 -0
- package/dist/run-log.js +32 -0
- package/dist/run-log.js.map +1 -0
- package/dist/run-program.d.ts +203 -7
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +1526 -320
- package/dist/run-program.js.map +1 -1
- package/dist/scheduler.d.ts +75 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +126 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/verify-cache.d.ts +64 -0
- package/dist/verify-cache.d.ts.map +1 -0
- package/dist/verify-cache.js +56 -0
- package/dist/verify-cache.js.map +1 -0
- package/dist/whole-program-review.d.ts +2 -2
- package/dist/whole-program-review.d.ts.map +1 -1
- package/dist/whole-program-review.js.map +1 -1
- package/dist/worktree.d.ts +124 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +397 -0
- package/dist/worktree.js.map +1 -0
- package/dist/write-queue.d.ts +25 -0
- package/dist/write-queue.d.ts.map +1 -0
- package/dist/write-queue.js +52 -0
- package/dist/write-queue.js.map +1 -0
- package/package.json +2 -2
- package/skills/plan-program/SKILL.md +37 -5
- package/dist/worktree-guard.d.ts +0 -40
- package/dist/worktree-guard.d.ts.map +0 -1
- package/dist/worktree-guard.js +0 -91
- package/dist/worktree-guard.js.map +0 -1
package/dist/run-program.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
5
6
|
import { promisify } from "node:util";
|
|
6
7
|
import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveAuthorAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
|
|
8
|
+
import { describeSessionDecline, spawnWithSessionContinuity, SESSION_CONTINUATION_PREAMBLE, } from "./agent-session.js";
|
|
7
9
|
import { resolveSummary, summaryContract } from "./agent-summary.js";
|
|
8
|
-
import {
|
|
10
|
+
import { prepareAuthoring } from "./author.js";
|
|
9
11
|
import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
|
|
10
12
|
import { appendLedgerEvents, denialFingerprint, readDecisionLedger, } from "./decision-ledger.js";
|
|
11
13
|
import { escalatedRecords, renderDenial, renderRecord, } from "./decision-view.js";
|
|
12
14
|
import { reviewDecisions, triageFindings } from "./decider-review.js";
|
|
13
15
|
import { fingerprint } from "./findings.js";
|
|
14
16
|
import { findCycles, stableTopologicalOrder } from "./graph.js";
|
|
15
|
-
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, renderPassReport, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
|
|
17
|
+
import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, renderPassReport, reviewerAbsentOutcome, runReviewPass, verifyEvidence, TEST_CRITIQUE_CYCLES, TEST_CRITIQUE_CYCLE_NOTE, } from "./review-pass.js";
|
|
18
|
+
import { createVerifyCache, runnerOwnedVerifyPaths } from "./verify-cache.js";
|
|
16
19
|
import { loadManifest, manifestMergeCount, saveManifest, } from "./manifest.js";
|
|
17
20
|
import { detectDefaultBranch, programBranchName } from "./program-branch.js";
|
|
18
21
|
import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
|
|
19
|
-
import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
|
|
20
22
|
import { buildPermitsContext } from "./permits.js";
|
|
21
23
|
import { CouldNotStartError } from "./exit-codes.js";
|
|
22
24
|
import { clippedInputPoint, createBriefBuilder, promptComponentSizePoints, } from "./prompt-telemetry.js";
|
|
23
|
-
import { NOOP_RUN_RECORDER, readPriorRunId, RunRecorder, } from "./run-analytics.js";
|
|
25
|
+
import { NOOP_RUN_RECORDER, readPriorRunId, recordWorkstreamReady, recordWorkstreamScheduled, recordWorkstreamWaiting, RunRecorder, } from "./run-analytics.js";
|
|
24
26
|
import { renderAnalyticsSection } from "./run-analytics-report.js";
|
|
27
|
+
import { drainProgramWrites, programWriteKey } from "./write-queue.js";
|
|
28
|
+
import { schedulerLog, workstreamLog } from "./run-log.js";
|
|
29
|
+
import { createMutex } from "./concurrency.js";
|
|
30
|
+
import { normalizeMaxParallel, runReadySet } from "./scheduler.js";
|
|
31
|
+
import { defaultWorktreeOps, isRunnerOwnedArtifactPath, resolveWorktreeBase, retainedRefName, worktreeBranchName, worktreeBranchPrefix, worktreeInstallCommand, worktreePathFor, } from "./worktree.js";
|
|
25
32
|
import { runReportPath } from "./report-path.js";
|
|
26
33
|
import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
|
|
27
34
|
import { runCausalAnalysis, renderCausalAnalysisSection, } from "./causal-analysis.js";
|
|
@@ -146,8 +153,13 @@ export const defaultGitOps = {
|
|
|
146
153
|
return undefined;
|
|
147
154
|
}
|
|
148
155
|
},
|
|
149
|
-
async commitAll(cwd, message) {
|
|
150
|
-
|
|
156
|
+
async commitAll(cwd, message, excludePaths) {
|
|
157
|
+
if (excludePaths && excludePaths.length > 0) {
|
|
158
|
+
await execFileAsync("git", ["add", "-A", "--", ":(top)", ...excludePaths.map((path) => `:(top,exclude)${path}`)], { cwd });
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
await execFileAsync("git", ["add", "-A"], { cwd });
|
|
162
|
+
}
|
|
151
163
|
try {
|
|
152
164
|
await execFileAsync("git", ["commit", "-m", message], { cwd });
|
|
153
165
|
}
|
|
@@ -164,7 +176,12 @@ export const defaultGitOps = {
|
|
|
164
176
|
async commitPaths(cwd, message, paths) {
|
|
165
177
|
await execFileAsync("git", ["add", "--", ...paths], { cwd });
|
|
166
178
|
try {
|
|
167
|
-
|
|
179
|
+
// The trailing pathspec is load-bearing, not decoration: without it
|
|
180
|
+
// this commits the *whole* index, sweeping in anything else staged in
|
|
181
|
+
// `cwd` at the time (an unrelated human `git add`, or the runner's own
|
|
182
|
+
// residue from a prior failed commit) — the same hazard squashIntegrate
|
|
183
|
+
// guards against for the workstream integration commit (WS-02 §2, §3.5).
|
|
184
|
+
await execFileAsync("git", ["commit", "-m", message, "--", ...paths], { cwd });
|
|
168
185
|
}
|
|
169
186
|
catch (error) {
|
|
170
187
|
const output = String(error.stdout ?? "");
|
|
@@ -181,7 +198,10 @@ export const defaultGitOps = {
|
|
|
181
198
|
},
|
|
182
199
|
async diffSince(cwd, commit) {
|
|
183
200
|
try {
|
|
184
|
-
|
|
201
|
+
// `:(top)`-anchored pathspecs are repo-root-relative regardless of
|
|
202
|
+
// `cwd` — in a worktree, "repo root" is the worktree root, exactly the
|
|
203
|
+
// tree a reviewer or decider spawned there should see (WS-02).
|
|
204
|
+
const { stdout } = await execFileAsync("git", ["diff", commit, "--", ":(top)", ":(top,exclude)docs/programs"], { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
185
205
|
return stdout;
|
|
186
206
|
}
|
|
187
207
|
catch {
|
|
@@ -190,11 +210,20 @@ export const defaultGitOps = {
|
|
|
190
210
|
},
|
|
191
211
|
async dirtyPaths(cwd) {
|
|
192
212
|
try {
|
|
193
|
-
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-z"], { cwd });
|
|
214
|
+
// NUL-delimited so quoted and non-ASCII names come back verbatim and
|
|
215
|
+
// compare equal to `changedPaths`' `-z` output. A rename or copy entry
|
|
216
|
+
// carries its original path as a second token, which is skipped.
|
|
217
|
+
const tokens = stdout.split("\0").filter((token) => token !== "");
|
|
218
|
+
const paths = [];
|
|
219
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
220
|
+
const token = tokens[index];
|
|
221
|
+
const code = token.slice(0, 2);
|
|
222
|
+
paths.push(token.slice(3));
|
|
223
|
+
if (code.includes("R") || code.includes("C"))
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
return paths;
|
|
198
227
|
}
|
|
199
228
|
catch {
|
|
200
229
|
return [];
|
|
@@ -236,6 +265,43 @@ export const defaultGitOps = {
|
|
|
236
265
|
async createRef(cwd, ref, commit) {
|
|
237
266
|
await execFileAsync("git", ["update-ref", ref, commit], { cwd });
|
|
238
267
|
},
|
|
268
|
+
async deleteRef(cwd, ref) {
|
|
269
|
+
try {
|
|
270
|
+
await execFileAsync("git", ["update-ref", "-d", ref], { cwd });
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Already absent — nothing to delete.
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async treeHash(cwd, exclude) {
|
|
277
|
+
let tmpDir;
|
|
278
|
+
try {
|
|
279
|
+
tmpDir = await mkdtemp(join(tmpdir(), "nightshift-verify-cache-"));
|
|
280
|
+
const indexFile = join(tmpDir, "index");
|
|
281
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
282
|
+
await execFileAsync("git", ["read-tree", "HEAD"], { cwd, env });
|
|
283
|
+
await execFileAsync("git", ["add", "-A", "--", ":/"], { cwd, env });
|
|
284
|
+
if (exclude.length > 0) {
|
|
285
|
+
await execFileAsync("git", ["rm", "--cached", "-q", "--ignore-unmatch", "--", ...exclude], { cwd, env });
|
|
286
|
+
}
|
|
287
|
+
const { stdout } = await execFileAsync("git", ["write-tree"], { cwd, env });
|
|
288
|
+
return stdout.trim();
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
if (tmpDir !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// Best-effort cleanup; a leftover temp dir is not a correctness
|
|
300
|
+
// issue and must not mask the hash result above.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
},
|
|
239
305
|
};
|
|
240
306
|
/**
|
|
241
307
|
* Fail-open span wrapper for a runner-owned git-and-persistence operation
|
|
@@ -410,7 +476,7 @@ function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, prior
|
|
|
410
476
|
: []));
|
|
411
477
|
builder.push("framing", "## Specification", "");
|
|
412
478
|
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
|
|
479
|
+
builder.push("framing", "", "## Rules", "", "- Never commit; the runner owns commits and commits only verified work.", "- Follow the repository's AGENTS.md if present, with one exception,", " stated next.", "- Run only the checks that cover what you changed — the tests for the", " code you touched, and the command that was failing if you were given", " a failure above. Do not run the project's full build/type-check/test/", " lint suite: the runner runs it in full after you and gates on the", " result, so a full run of your own is duplicated work, not extra", " safety. This overrides AGENTS.md's \"verify before claiming", " completion\" directive for this spawn only. Your claim of success is", " never trusted either way, so you are not being asked to prove", " anything here — only to catch your own mistakes early.", "- You are working in a dedicated git worktree on a per-workstream", " branch. Do not switch branches, and do not edit `docs/programs/` —", " those are the runner's own artifacts and changes to them are", " discarded.", "");
|
|
414
480
|
builder.push("runner-instructions", ...shellPolicySection(shellPolicy), decisionContract(), "", summaryContract());
|
|
415
481
|
return { brief: builder.join(), components: builder.components() };
|
|
416
482
|
}
|
|
@@ -527,6 +593,522 @@ async function permissionsStartupLines(config, permits, agent, recovery, decider
|
|
|
527
593
|
}
|
|
528
594
|
return lines;
|
|
529
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* Reaps every worktree and per-workstream branch this program created,
|
|
598
|
+
* driven by `git worktree list --porcelain` filtered on the branch prefix —
|
|
599
|
+
* never on path, since git realpath-resolves the paths it reports (WS-02,
|
|
600
|
+
* §3.7). Runs on every run, not only a resume: a crashed prior run leaves the
|
|
601
|
+
* same debris. Retained `refs/nightshift/…` commits are never touched — they
|
|
602
|
+
* are the forensic record a human needs to salvage a parked integration.
|
|
603
|
+
*/
|
|
604
|
+
export async function reapProgramWorktrees(root, programId, worktrees, log,
|
|
605
|
+
/** Workstreams already `complete` in the manifest: their leftover
|
|
606
|
+
* worktree (a crash between integration and cleanup) holds nothing the
|
|
607
|
+
* program branch lacks, so it is reaped without salvage. */
|
|
608
|
+
completeWorkstreamIds = new Set()) {
|
|
609
|
+
const prefix = worktreeBranchPrefix(programId);
|
|
610
|
+
const reaped = [];
|
|
611
|
+
const salvaged = [];
|
|
612
|
+
const list = await worktrees.list(root);
|
|
613
|
+
for (const info of list) {
|
|
614
|
+
const short = info.branch?.startsWith("refs/heads/")
|
|
615
|
+
? info.branch.slice("refs/heads/".length)
|
|
616
|
+
: info.branch;
|
|
617
|
+
if (short === undefined || !short.startsWith(prefix))
|
|
618
|
+
continue;
|
|
619
|
+
const ref = await salvageWorktree(root, programId, short, info, worktrees, completeWorkstreamIds);
|
|
620
|
+
if (ref !== undefined)
|
|
621
|
+
salvaged.push(ref);
|
|
622
|
+
await worktrees.remove(root, info.path);
|
|
623
|
+
await worktrees.deleteBranch(root, short);
|
|
624
|
+
reaped.push(short);
|
|
625
|
+
}
|
|
626
|
+
await worktrees.prune(root);
|
|
627
|
+
const orphanBranches = await worktrees.branchesUnder(root, prefix);
|
|
628
|
+
for (const branch of orphanBranches) {
|
|
629
|
+
await worktrees.deleteBranch(root, branch);
|
|
630
|
+
if (!reaped.includes(branch))
|
|
631
|
+
reaped.push(branch);
|
|
632
|
+
}
|
|
633
|
+
if (salvaged.length > 0) {
|
|
634
|
+
log(`worktrees: salvaged unintegrated work from a prior run before reaping — inspect with ` +
|
|
635
|
+
`\`git log <ref>\` or \`git worktree add <path> <ref>\`: ${salvaged.join(", ")}`);
|
|
636
|
+
}
|
|
637
|
+
if (reaped.length > 0) {
|
|
638
|
+
log(`worktrees: reaped ${reaped.length} leftover branch(es) from a prior run: ${reaped.join(", ")}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Preserves a leftover worktree's work before it is reaped — the
|
|
643
|
+
* reversibility contract: a resume must never destroy the only copy of a
|
|
644
|
+
* failed attempt that the run report just told the human to inspect.
|
|
645
|
+
* Everything in the worktree is committed onto its own branch and, when
|
|
646
|
+
* that head is not already reachable from the program branch, pinned under
|
|
647
|
+
* `refs/nightshift/<program>/<workstream>-wip`. Returns the ref, or undefined
|
|
648
|
+
* when nothing needed salvaging (the workstream is complete, the directory
|
|
649
|
+
* is gone, or the `WorktreeOps` double has no salvage seam). Never throws:
|
|
650
|
+
* salvage is best-effort and must not block the run.
|
|
651
|
+
*/
|
|
652
|
+
async function salvageWorktree(root, programId, shortBranch, info, worktrees, completeWorkstreamIds) {
|
|
653
|
+
if (!worktrees.commitEverything || !worktrees.updateRef || !worktrees.isAncestor)
|
|
654
|
+
return undefined;
|
|
655
|
+
if (info.prunable === true)
|
|
656
|
+
return undefined;
|
|
657
|
+
const workstreamId = shortBranch.slice(worktreeBranchPrefix(programId).length);
|
|
658
|
+
if (workstreamId === "")
|
|
659
|
+
return undefined;
|
|
660
|
+
const lowered = new Set([...completeWorkstreamIds].map((id) => id.toLowerCase()));
|
|
661
|
+
if (lowered.has(workstreamId))
|
|
662
|
+
return undefined;
|
|
663
|
+
try {
|
|
664
|
+
const head = await worktrees.commitEverything(info.path, `nightshift(${programId}): salvaged work in progress for ${workstreamId}`);
|
|
665
|
+
if (head === undefined)
|
|
666
|
+
return undefined;
|
|
667
|
+
if (await worktrees.isAncestor(root, head, "HEAD"))
|
|
668
|
+
return undefined;
|
|
669
|
+
const ref = `${retainedRefName(programId, workstreamId)}-wip`;
|
|
670
|
+
await worktrees.updateRef(root, ref, head);
|
|
671
|
+
return ref;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const INSTALL_TRIGGER_NAMES = [
|
|
678
|
+
"package.json",
|
|
679
|
+
"package-lock.json",
|
|
680
|
+
"pnpm-lock.yaml",
|
|
681
|
+
"yarn.lock",
|
|
682
|
+
"bun.lockb",
|
|
683
|
+
"bun.lock",
|
|
684
|
+
];
|
|
685
|
+
/** True when any path in `paths` is (or ends with) a file that can change a worktree's installed dependencies (WS-02, §3.5 step 1). */
|
|
686
|
+
function touchesInstallFiles(paths) {
|
|
687
|
+
return paths.some((path) => INSTALL_TRIGGER_NAMES.some((name) => path === name || path.endsWith(`/${name}`)));
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Runs a worktree's dependency install command, when a lockfile is present
|
|
691
|
+
* in the worktree root itself (WS-02, §3.3) — attributed to the caller's
|
|
692
|
+
* open `worktree-setup` span. A worktree with no lockfile installs nothing
|
|
693
|
+
* and is not a failure.
|
|
694
|
+
*/
|
|
695
|
+
async function installWorktreeDependencies(worktreeRoot, setupRunner, log) {
|
|
696
|
+
const installCommand = worktreeInstallCommand(worktreeRoot);
|
|
697
|
+
if (installCommand === undefined) {
|
|
698
|
+
log("worktree: no lockfile — dependencies not installed");
|
|
699
|
+
return { ok: true };
|
|
700
|
+
}
|
|
701
|
+
let result;
|
|
702
|
+
try {
|
|
703
|
+
result = await setupRunner(installCommand, worktreeRoot);
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
return { ok: false, reason: `dependency install could not run: ${error.message}` };
|
|
707
|
+
}
|
|
708
|
+
if (result.exitCode !== 0) {
|
|
709
|
+
return {
|
|
710
|
+
ok: false,
|
|
711
|
+
reason: `dependency install (\`${installCommand}\`) failed:\n${tail(result.output, 1500)}`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
log(`worktree: installed dependencies (${installCommand})`);
|
|
715
|
+
return { ok: true };
|
|
716
|
+
}
|
|
717
|
+
export async function prepareWorktree(args) {
|
|
718
|
+
const { root, programId, workstreamId, worktrees, worktreeBase, setupRunner, log, programTip } = args;
|
|
719
|
+
const branch = worktreeBranchName(programId, workstreamId);
|
|
720
|
+
const path = worktreePathFor(worktreeBase.base, programId, workstreamId, worktreeBase.repoHash);
|
|
721
|
+
try {
|
|
722
|
+
// Idempotent belt-and-braces against a crash-leftover from this same
|
|
723
|
+
// run; cross-run leftovers are reaped at run start.
|
|
724
|
+
await worktrees.remove(root, path);
|
|
725
|
+
await worktrees.deleteBranch(root, branch);
|
|
726
|
+
// `remove` is a no-op for a directory git no longer lists (a prior
|
|
727
|
+
// `worktree prune` after a crash); `add` would then refuse with
|
|
728
|
+
// "already exists". The path is program-owned, so clearing it is safe.
|
|
729
|
+
await rm(path, { recursive: true, force: true });
|
|
730
|
+
await worktrees.add(root, path, branch, programTip);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
await worktrees.remove(root, path);
|
|
734
|
+
await worktrees.deleteBranch(root, branch);
|
|
735
|
+
return { ok: false, reason: `could not create the worktree: ${error.message}`, created: false };
|
|
736
|
+
}
|
|
737
|
+
log(`worktree: created at ${path}`);
|
|
738
|
+
const install = await installWorktreeDependencies(path, setupRunner, log);
|
|
739
|
+
if (!install.ok) {
|
|
740
|
+
return { ok: false, reason: install.reason, created: true, cwd: path, branch };
|
|
741
|
+
}
|
|
742
|
+
return { ok: true, cwd: path, branch };
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* One integration attempt: discard runner debris, merge the program branch
|
|
746
|
+
* forward when its tip has moved, re-verify the merged tree, then squash
|
|
747
|
+
* onto the program branch as one path-limited commit (WS-02, §3.5). Never
|
|
748
|
+
* throws; every failure mode is a typed result the caller decides what to do
|
|
749
|
+
* with (retain and recover, or report).
|
|
750
|
+
*/
|
|
751
|
+
async function attemptIntegration(args) {
|
|
752
|
+
const { root, programId, workstreamId, workstreamName, cwd, branch, finalCommit, worktrees, git, config, verifyRunner, setupRunner, scope, log, rootIndexMutex, rosterTaskFiles, } = args;
|
|
753
|
+
// The full refname, not the short branch name: `refs/nightshift/<p>/<ws>`
|
|
754
|
+
// (the retained ref) and `refs/heads/nightshift/<p>/<ws>` (this branch)
|
|
755
|
+
// are distinct refs that never collide, but git's own short-name
|
|
756
|
+
// disambiguation tries `refs/<name>` *before* `refs/heads/<name>` — a bare
|
|
757
|
+
// `nightshift/<p>/<ws>` passed to `git diff`/`git merge --squash` resolves
|
|
758
|
+
// ambiguously and can silently pick the retained ref instead of the
|
|
759
|
+
// branch. Every revision-parsing use below is spelled out in full; only
|
|
760
|
+
// `deleteBranch`, which operates on `refs/heads/` by construction, uses
|
|
761
|
+
// the short name.
|
|
762
|
+
const branchRef = `refs/heads/${branch}`;
|
|
763
|
+
// Steps 0-1: discard runner debris, then merge the program branch forward
|
|
764
|
+
// when its tip is not already an ancestor of this worktree's own commit.
|
|
765
|
+
const mergeSpan = scope.span({ stage: "integration", workstream: workstreamId });
|
|
766
|
+
const discarded = await worktrees.discardRunnerArtifacts(cwd);
|
|
767
|
+
if (discarded.length > 0) {
|
|
768
|
+
log(`integration: discarded agent edit(s) under docs/programs: ${discarded.join(", ")}`);
|
|
769
|
+
}
|
|
770
|
+
const tipNow = await worktrees.resolveCommit(root, "HEAD");
|
|
771
|
+
if (tipNow === undefined) {
|
|
772
|
+
mergeSpan.close({ outcome: "failed" });
|
|
773
|
+
return { ok: false, kind: "tip-unresolvable" };
|
|
774
|
+
}
|
|
775
|
+
let mergeRan = false;
|
|
776
|
+
if (!(await git.isAncestor(cwd, tipNow, finalCommit))) {
|
|
777
|
+
mergeRan = true;
|
|
778
|
+
const mergeResult = await worktrees.mergeInto(cwd, tipNow, `nightshift(${programId}): merge program branch into ${workstreamId}`);
|
|
779
|
+
if (!mergeResult.ok) {
|
|
780
|
+
await worktrees.abortMerge(cwd);
|
|
781
|
+
mergeSpan.close({ outcome: "failed" });
|
|
782
|
+
return { ok: false, kind: "conflict", output: mergeResult.output, tipNow };
|
|
783
|
+
}
|
|
784
|
+
log("integration: merged the program branch forward");
|
|
785
|
+
const changedByMerge = await worktrees.changedPaths(cwd, finalCommit, "HEAD");
|
|
786
|
+
if (touchesInstallFiles(changedByMerge)) {
|
|
787
|
+
const install = await installWorktreeDependencies(cwd, setupRunner, log);
|
|
788
|
+
if (!install.ok) {
|
|
789
|
+
mergeSpan.close({ outcome: "failed" });
|
|
790
|
+
return { ok: false, kind: "install", output: install.reason, tipNow };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
mergeSpan.close({ outcome: "success" });
|
|
795
|
+
// Step 2: re-verify, but only when the merge actually ran — an unmoved tip
|
|
796
|
+
// means the tree is byte-identical to the one that just verified green.
|
|
797
|
+
if (mergeRan) {
|
|
798
|
+
log("integration: verifying the merged tree");
|
|
799
|
+
const verifyFailure = await verifyAttempt(config, verifyRunner, cwd, root, 0, scope, "verification-command", {
|
|
800
|
+
programId,
|
|
801
|
+
phase: "integration",
|
|
802
|
+
workstream: workstreamId,
|
|
803
|
+
...(args.verifyCache ? { cache: args.verifyCache } : {}),
|
|
804
|
+
});
|
|
805
|
+
if (verifyFailure !== undefined) {
|
|
806
|
+
return { ok: false, kind: "verify", output: verifyFailure, tipNow };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// Step 3: squash onto the program branch, in the root checkout.
|
|
810
|
+
const squashSpan = scope.span({ stage: "integration", workstream: workstreamId });
|
|
811
|
+
const incoming = await worktrees.changedPaths(root, tipNow, branchRef);
|
|
812
|
+
if (incoming.length === 0) {
|
|
813
|
+
squashSpan.close({ outcome: "success" });
|
|
814
|
+
log("integration: nothing to integrate — the branch matches the tip");
|
|
815
|
+
return { ok: true };
|
|
816
|
+
}
|
|
817
|
+
// WS-05 §3.7.1: everything from here on runs inside `rootIndexMutex`,
|
|
818
|
+
// which (together with `integrationMutex`, already held by the caller for
|
|
819
|
+
// this whole flow) makes the re-resolved tip below stable for the rest of
|
|
820
|
+
// this step. The only thing that can have moved the program-branch tip
|
|
821
|
+
// since `tipNow` was resolved above is an authoring commit — it takes no
|
|
822
|
+
// `integrationMutex` and must not (§3.7) — so a movement here is always
|
|
823
|
+
// exactly that, never a sibling integration.
|
|
824
|
+
return await rootIndexMutex.runExclusive(async () => {
|
|
825
|
+
const tipAtSquash = await worktrees.resolveCommit(root, "HEAD");
|
|
826
|
+
if (tipAtSquash === undefined) {
|
|
827
|
+
squashSpan.close({ outcome: "failed" });
|
|
828
|
+
return { ok: false, kind: "tip-unresolvable" };
|
|
829
|
+
}
|
|
830
|
+
const movedPaths = tipAtSquash === tipNow ? [] : await worktrees.changedPaths(root, tipNow, tipAtSquash);
|
|
831
|
+
if (movedPaths.length > 0) {
|
|
832
|
+
// Branch 3: the movement collides with this workstream's own paths, or
|
|
833
|
+
// touches something an authoring commit has no business touching —
|
|
834
|
+
// never proceed past a movement like that; land it unverified would be
|
|
835
|
+
// exactly the unproven integration SC-02 forbids.
|
|
836
|
+
const intersecting = movedPaths.filter((path) => incoming.includes(path));
|
|
837
|
+
const disallowed = movedPaths.filter((path) => !isRunnerOwnedArtifactPath(path) && !rosterTaskFiles.has(path));
|
|
838
|
+
if (intersecting.length > 0 || disallowed.length > 0) {
|
|
839
|
+
squashSpan.close({ outcome: "failed" });
|
|
840
|
+
const parts = [];
|
|
841
|
+
if (intersecting.length > 0) {
|
|
842
|
+
parts.push(`path(s) this workstream also touched: ${intersecting.join(", ")}`);
|
|
843
|
+
}
|
|
844
|
+
if (disallowed.length > 0) {
|
|
845
|
+
parts.push(`path(s) outside the runner's own artifacts or a roster spec file: ${disallowed.join(", ")}`);
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
ok: false,
|
|
849
|
+
kind: "moved",
|
|
850
|
+
tipNow,
|
|
851
|
+
tipAtSquash,
|
|
852
|
+
output: `the program branch moved from \`${tipNow}\` to \`${tipAtSquash}\` while this ` +
|
|
853
|
+
`workstream was integrating (an authoring commit landed); ${parts.join("; ")}.`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Branch 2 (movedPaths non-empty but disjoint from `incoming` and every
|
|
858
|
+
// path runner-owned or a roster spec file) falls through to the same
|
|
859
|
+
// squash as branch 1 (no movement) — only the post-condition below
|
|
860
|
+
// differs, relaxed to the subset form §3.7.1 requires.
|
|
861
|
+
const dirtyAtRoot = new Set(await git.dirtyPaths(root));
|
|
862
|
+
const overlap = incoming.filter((path) => dirtyAtRoot.has(path));
|
|
863
|
+
if (overlap.length > 0) {
|
|
864
|
+
squashSpan.close({ outcome: "failed" });
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
kind: "refused",
|
|
868
|
+
output: `the workstream changed path(s) already uncommitted at the root checkout: ${overlap.join(", ")}`,
|
|
869
|
+
tipNow,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
const squash = await worktrees.squashIntegrate(root, branchRef, `nightshift(${programId}): ${workstreamId} ${workstreamName}`, incoming);
|
|
873
|
+
if (!squash.ok) {
|
|
874
|
+
squashSpan.close({ outcome: "failed" });
|
|
875
|
+
return { ok: false, kind: "refused", output: squash.output, tipNow };
|
|
876
|
+
}
|
|
877
|
+
if (squash.commit !== undefined) {
|
|
878
|
+
// Post-condition: the integrated tree equals the branch's tree, up to
|
|
879
|
+
// paths the concurrent tip movement (if any) already explains — a
|
|
880
|
+
// path list that produced the commit was incomplete, or a movement
|
|
881
|
+
// that collided, is worse than a failed integration.
|
|
882
|
+
const remaining = await worktrees.changedPaths(root, squash.commit, branchRef);
|
|
883
|
+
const unexplained = remaining.filter((path) => !movedPaths.includes(path));
|
|
884
|
+
if (unexplained.length > 0) {
|
|
885
|
+
squashSpan.close({ outcome: "failed" });
|
|
886
|
+
return {
|
|
887
|
+
ok: false,
|
|
888
|
+
kind: "refused",
|
|
889
|
+
output: `the integration commit's tree does not match the branch's: ${unexplained.join(", ")}`,
|
|
890
|
+
tipNow,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
squashSpan.close({ outcome: "success" });
|
|
895
|
+
log(squash.commit !== undefined
|
|
896
|
+
? `integration: integrated as \`${squash.commit}\``
|
|
897
|
+
: "integration: nothing to commit");
|
|
898
|
+
return { ok: true, ...(squash.commit === undefined ? {} : { commit: squash.commit }) };
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function integrationFailureText(result, finalCommit) {
|
|
902
|
+
if (result.kind === "tip-unresolvable") {
|
|
903
|
+
return "cannot resolve the program-branch tip";
|
|
904
|
+
}
|
|
905
|
+
// Every branch below names both commits the recovery brief and the
|
|
906
|
+
// outcome reason need: the verified commit this worktree built, and the
|
|
907
|
+
// program-branch tip it could not be reconciled with (WS-02 §3.6 step 6).
|
|
908
|
+
// "conflict" was the only kind that did this until the coverage review
|
|
909
|
+
// caught that a red integration verify, a failed post-merge reinstall, or
|
|
910
|
+
// a refused squash all left a diagnosis that named neither.
|
|
911
|
+
const verb = result.kind === "conflict"
|
|
912
|
+
? `merging the program branch (now at \`${result.tipNow}\`) into the verified commit \`${finalCommit}\` conflicted`
|
|
913
|
+
: result.kind === "verify"
|
|
914
|
+
? `the merged tree (program branch now at \`${result.tipNow}\`, verified commit \`${finalCommit}\`) failed re-verification`
|
|
915
|
+
: result.kind === "install"
|
|
916
|
+
? `reinstalling dependencies after merging the program branch (now at \`${result.tipNow}\`) into the verified commit \`${finalCommit}\` failed`
|
|
917
|
+
// "moved" (WS-05 §3.7.1): a concurrent authoring commit moved the
|
|
918
|
+
// tip during this integration's own squash step, colliding with
|
|
919
|
+
// this workstream's paths or touching something it should not
|
|
920
|
+
// have — named by both shas, never retried in place.
|
|
921
|
+
: result.kind === "moved"
|
|
922
|
+
? `landing the verified commit \`${finalCommit}\` (scheduled from tip \`${result.tipNow}\`) conflicted with an authoring commit that landed during integration (program branch now at \`${result.tipAtSquash}\`)`
|
|
923
|
+
: `the squash integration of the verified commit \`${finalCommit}\` onto the program branch (now at \`${result.tipNow}\`) failed`;
|
|
924
|
+
return `${verb}:\n${result.output}`;
|
|
925
|
+
}
|
|
926
|
+
function integrationRecoveryBrief(workstream, finalCommit, retainedRef, tipNow, failureText) {
|
|
927
|
+
return [
|
|
928
|
+
`# Integration recovery: ${workstream.id} ${workstream.name}`,
|
|
929
|
+
"",
|
|
930
|
+
"This workstream's build already verified green, as commit",
|
|
931
|
+
`\`${finalCommit}\`, reachable from this worktree as \`${retainedRef}\` — `,
|
|
932
|
+
"`git show`, `git diff`, and `git cherry-pick` against that ref all",
|
|
933
|
+
"work from here even though it is not checked out. Since then, the",
|
|
934
|
+
`program branch moved to a new tip, \`${tipNow}\`, and landing the`,
|
|
935
|
+
"verified work on top of it failed:",
|
|
936
|
+
"",
|
|
937
|
+
failureText,
|
|
938
|
+
"",
|
|
939
|
+
"Your job is to land the workstream's *intent* on top of the current",
|
|
940
|
+
"tip — not to reproduce the old commit byte for byte. Resolve whatever",
|
|
941
|
+
"conflicted, or fix whatever the merged tree's verification found,",
|
|
942
|
+
"however that takes: editing the code, editing the tests the failed",
|
|
943
|
+
"attempt wrote, or both.",
|
|
944
|
+
"",
|
|
945
|
+
"You are working in a dedicated git worktree on a per-workstream",
|
|
946
|
+
"branch. Never commit — the runner owns commits and verifies your fix",
|
|
947
|
+
"itself. Do not switch branches, and do not edit `docs/programs/` —",
|
|
948
|
+
"those are the runner's own artifacts and changes to them are",
|
|
949
|
+
"discarded.",
|
|
950
|
+
"",
|
|
951
|
+
summaryContract(),
|
|
952
|
+
].join("\n");
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Integrates one green worktree onto the program branch (WS-02, §3.5-§3.6):
|
|
956
|
+
* one attempt, and on failure, exactly one bounded recovery attempt in a
|
|
957
|
+
* fresh worktree from the current tip, briefed with both commits and the
|
|
958
|
+
* failure. The verified commit is retained under `refs/nightshift/` on
|
|
959
|
+
* every path, before any mutation, and deleted again only once this
|
|
960
|
+
* function returns success.
|
|
961
|
+
*/
|
|
962
|
+
export async function integrateWorkstream(args) {
|
|
963
|
+
const { root, programId, workstream, cwd, branch, finalCommit, worktrees, git, config, verifyRunner, setupRunner, agentRunner, permits, recoveryAgent, worktreeBase, scope, log, transcriptSink, rootIndexMutex, rosterTaskFiles, } = args;
|
|
964
|
+
const ref = retainedRefName(programId, workstream.id);
|
|
965
|
+
const cleanup = [{ path: cwd, branch }];
|
|
966
|
+
// Full refname, not the short branch name: see attemptIntegration's own
|
|
967
|
+
// note on why a bare short name can resolve ambiguously once a
|
|
968
|
+
// same-named `refs/nightshift/...` ref exists (e.g. left over from a
|
|
969
|
+
// prior run's failed integration of this same workstream).
|
|
970
|
+
await git.createRef(root, ref, `refs/heads/${branch}`);
|
|
971
|
+
const first = await attemptIntegration({
|
|
972
|
+
root,
|
|
973
|
+
programId,
|
|
974
|
+
workstreamId: workstream.id,
|
|
975
|
+
workstreamName: workstream.name,
|
|
976
|
+
cwd,
|
|
977
|
+
branch,
|
|
978
|
+
finalCommit,
|
|
979
|
+
worktrees,
|
|
980
|
+
git,
|
|
981
|
+
config,
|
|
982
|
+
verifyRunner,
|
|
983
|
+
setupRunner,
|
|
984
|
+
...(args.verifyCache ? { verifyCache: args.verifyCache } : {}),
|
|
985
|
+
scope,
|
|
986
|
+
log,
|
|
987
|
+
rootIndexMutex,
|
|
988
|
+
rosterTaskFiles,
|
|
989
|
+
});
|
|
990
|
+
if (first.ok) {
|
|
991
|
+
return { ok: true, ...(first.commit === undefined ? {} : { commit: first.commit }), transcripts: [], cleanup };
|
|
992
|
+
}
|
|
993
|
+
const firstReason = integrationFailureText(first, finalCommit);
|
|
994
|
+
log(`integration: ${firstReason}`);
|
|
995
|
+
const recoveryBranch = `${worktreeBranchName(programId, workstream.id)}-integration`;
|
|
996
|
+
const recoveryPath = worktreePathFor(worktreeBase.base, programId, `${workstream.id}-integration`, worktreeBase.repoHash);
|
|
997
|
+
const transcripts = [];
|
|
998
|
+
const recoveryTip = await worktrees.resolveCommit(root, "HEAD");
|
|
999
|
+
if (recoveryTip === undefined) {
|
|
1000
|
+
return {
|
|
1001
|
+
ok: false,
|
|
1002
|
+
reason: `${firstReason}\n\nRecovery could not start: the program-branch tip could not be resolved.`,
|
|
1003
|
+
retainedRef: ref,
|
|
1004
|
+
worktreePaths: [cwd],
|
|
1005
|
+
transcripts,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
await worktrees.remove(root, recoveryPath);
|
|
1010
|
+
await worktrees.deleteBranch(root, recoveryBranch);
|
|
1011
|
+
await worktrees.add(root, recoveryPath, recoveryBranch, recoveryTip);
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
reason: `${firstReason}\n\nRecovery could not start: the recovery worktree could not be ` +
|
|
1017
|
+
`created (${error.message}).`,
|
|
1018
|
+
retainedRef: ref,
|
|
1019
|
+
worktreePaths: [cwd],
|
|
1020
|
+
transcripts,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
cleanup.push({ path: recoveryPath, branch: recoveryBranch });
|
|
1024
|
+
const install = await installWorktreeDependencies(recoveryPath, setupRunner, log);
|
|
1025
|
+
if (!install.ok) {
|
|
1026
|
+
return {
|
|
1027
|
+
ok: false,
|
|
1028
|
+
reason: `${firstReason}\n\nRecovery could not start: ${install.reason}`,
|
|
1029
|
+
retainedRef: ref,
|
|
1030
|
+
worktreePaths: [cwd, recoveryPath],
|
|
1031
|
+
transcripts,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const cause = first.kind === "verify" || first.kind === "install" ? "integration-verify" : "integration-conflict";
|
|
1035
|
+
const failureTipNow = first.kind === "tip-unresolvable" ? recoveryTip : first.tipNow;
|
|
1036
|
+
const brief = integrationRecoveryBrief(workstream, finalCommit, ref, failureTipNow, firstReason);
|
|
1037
|
+
let invocation;
|
|
1038
|
+
try {
|
|
1039
|
+
invocation = await invokeAgent(agentRunner, recoveryAgent, brief, recoveryPath, permits, "recoveryAgent", transcriptSink("integration-recovery"), scope, { stage: "recovery", workstream: workstream.id, attemptSeat: "integration recovery", attemptReason: cause });
|
|
1040
|
+
if (invocation.transcript)
|
|
1041
|
+
transcripts.push(invocation.transcript);
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
return {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
reason: `${firstReason}\n\nThe recovery agent could not be spawned: ${error.message}`,
|
|
1047
|
+
retainedRef: ref,
|
|
1048
|
+
worktreePaths: [cwd, recoveryPath],
|
|
1049
|
+
transcripts,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const recoveryVerifyFailure = await verifyAttempt(config, verifyRunner, recoveryPath, root, invocation.exitCode, scope, "verification-command", {
|
|
1053
|
+
programId,
|
|
1054
|
+
phase: "integration-recovery",
|
|
1055
|
+
workstream: workstream.id,
|
|
1056
|
+
role: "recoveryAgent",
|
|
1057
|
+
attemptSeat: "integration recovery",
|
|
1058
|
+
});
|
|
1059
|
+
if (recoveryVerifyFailure !== undefined) {
|
|
1060
|
+
return {
|
|
1061
|
+
ok: false,
|
|
1062
|
+
reason: `${firstReason}\n\nThe recovery attempt failed verification: ${recoveryVerifyFailure}`,
|
|
1063
|
+
retainedRef: ref,
|
|
1064
|
+
worktreePaths: [cwd, recoveryPath],
|
|
1065
|
+
transcripts,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
const recoveryCommit = await git.commitAll(recoveryPath, `nightshift(${programId}): ${workstream.id} integration recovery`, ["docs/programs"]);
|
|
1069
|
+
if (recoveryCommit === undefined) {
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
reason: `${firstReason}\n\nThe recovery attempt verified green but produced no change.`,
|
|
1073
|
+
retainedRef: ref,
|
|
1074
|
+
worktreePaths: [cwd, recoveryPath],
|
|
1075
|
+
transcripts,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
await git.createRef(root, ref, `refs/heads/${recoveryBranch}`);
|
|
1079
|
+
const second = await attemptIntegration({
|
|
1080
|
+
root,
|
|
1081
|
+
programId,
|
|
1082
|
+
workstreamId: workstream.id,
|
|
1083
|
+
workstreamName: workstream.name,
|
|
1084
|
+
cwd: recoveryPath,
|
|
1085
|
+
branch: recoveryBranch,
|
|
1086
|
+
finalCommit: recoveryCommit,
|
|
1087
|
+
worktrees,
|
|
1088
|
+
git,
|
|
1089
|
+
config,
|
|
1090
|
+
verifyRunner,
|
|
1091
|
+
setupRunner,
|
|
1092
|
+
...(args.verifyCache ? { verifyCache: args.verifyCache } : {}),
|
|
1093
|
+
scope,
|
|
1094
|
+
log,
|
|
1095
|
+
rootIndexMutex,
|
|
1096
|
+
rosterTaskFiles,
|
|
1097
|
+
});
|
|
1098
|
+
if (second.ok) {
|
|
1099
|
+
return { ok: true, ...(second.commit === undefined ? {} : { commit: second.commit }), transcripts, cleanup };
|
|
1100
|
+
}
|
|
1101
|
+
// Do not recurse — one bounded recovery attempt only (§3.6).
|
|
1102
|
+
const secondReason = integrationFailureText(second, recoveryCommit);
|
|
1103
|
+
return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
reason: `${firstReason}\n\nThe recovery attempt also failed to integrate: ${secondReason}\n\n` +
|
|
1106
|
+
`The recovery's own verified commit is \`${recoveryCommit}\`, retained as \`${ref}\`.`,
|
|
1107
|
+
retainedRef: ref,
|
|
1108
|
+
worktreePaths: [cwd, recoveryPath],
|
|
1109
|
+
transcripts,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
530
1112
|
/**
|
|
531
1113
|
* The decide-and-continue runner.
|
|
532
1114
|
*
|
|
@@ -560,7 +1142,26 @@ async function runProgramWith(options, permits) {
|
|
|
560
1142
|
const agentRunner = options.agentRunner ?? defaultAgentRunner;
|
|
561
1143
|
const verifyRunner = options.verifyRunner ?? defaultVerifyRunner;
|
|
562
1144
|
const git = options.git ?? defaultGitOps;
|
|
563
|
-
|
|
1145
|
+
// One cache per run (WS-09, SC-14): an identical tree — everything the
|
|
1146
|
+
// verify commands can read except this program's own six bookkeeping
|
|
1147
|
+
// files (§3.3.2) — is never verified twice. No global, no module state: a
|
|
1148
|
+
// second `runProgram` in the same process gets its own cache.
|
|
1149
|
+
const verifyCache = createVerifyCache({
|
|
1150
|
+
git,
|
|
1151
|
+
commands: config.verify,
|
|
1152
|
+
exclude: runnerOwnedVerifyPaths(options.programId),
|
|
1153
|
+
});
|
|
1154
|
+
const worktrees = options.worktrees ?? defaultWorktreeOps;
|
|
1155
|
+
// A separate default from `verifyRunner` on purpose (WS-02, §3.3): tests
|
|
1156
|
+
// stub `verifyRunner` to always return green, and reusing it for the
|
|
1157
|
+
// worktree install step would make a failed install look like a success.
|
|
1158
|
+
const setupRunner = options.setupRunner ?? defaultVerifyRunner;
|
|
1159
|
+
// Resolved once, inside the isRepository branch below, and read from every
|
|
1160
|
+
// workstream flow via closure (WS-02) — undefined when this is not a git
|
|
1161
|
+
// repository, where no worktree is ever created (§3.9).
|
|
1162
|
+
let worktreeBase;
|
|
1163
|
+
const baseLog = options.log ?? ((line) => console.log(line));
|
|
1164
|
+
const log = baseLog;
|
|
564
1165
|
const now = options.now ?? (() => new Date());
|
|
565
1166
|
const monotonic = options.monotonic ?? (() => performance.now());
|
|
566
1167
|
// Read before anything durable happens (a plain read, safe before the
|
|
@@ -688,7 +1289,7 @@ async function runProgramWith(options, permits) {
|
|
|
688
1289
|
// interrupted, whose own leftover work is exactly what a resume needs.
|
|
689
1290
|
// The runner's own artifacts under docs/programs/ are exempt: they are
|
|
690
1291
|
// output, not work in progress.
|
|
691
|
-
const dirty = (await git.dirtyPaths(root)).filter((path) => !path
|
|
1292
|
+
const dirty = (await git.dirtyPaths(root)).filter((path) => !isRunnerOwnedArtifactPath(path));
|
|
692
1293
|
if (dirty.length > 0 && !resuming) {
|
|
693
1294
|
throw new CouldNotStartError(`The working tree has uncommitted changes the run would sweep into its commits:\n` +
|
|
694
1295
|
dirty.map((path) => ` ${path}`).join("\n") +
|
|
@@ -697,7 +1298,7 @@ async function runProgramWith(options, permits) {
|
|
|
697
1298
|
// A run always commits to the program's own branch — never the default
|
|
698
1299
|
// branch, and never another program's branch, since the runner commits
|
|
699
1300
|
// with `git add -A` and a mismatched worktree would sweep this program's
|
|
700
|
-
// work onto the wrong branch. These refuse before
|
|
1301
|
+
// work onto the wrong branch. These refuse before the pipeline below
|
|
701
1302
|
// spawns the first agent; --force is the explicit escape hatch.
|
|
702
1303
|
if (!options.force) {
|
|
703
1304
|
const currentBranch = await git.currentBranch(root);
|
|
@@ -720,6 +1321,26 @@ async function runProgramWith(options, permits) {
|
|
|
720
1321
|
`with \`${branchCommand}\`, or re-run with --force.`);
|
|
721
1322
|
}
|
|
722
1323
|
}
|
|
1324
|
+
// Every worktree and per-workstream branch this program left behind —
|
|
1325
|
+
// a crashed prior run, not only an ordinary resume — is reaped before
|
|
1326
|
+
// anything else runs (WS-02, §3.7). Retained `refs/nightshift/…` commits
|
|
1327
|
+
// are the forensic record a human needs to salvage a parked
|
|
1328
|
+
// integration and are never touched here.
|
|
1329
|
+
await reapProgramWorktrees(root, options.programId, worktrees, log, new Set(manifest.workstreams.filter((w) => w.status === "complete").map((w) => w.id)));
|
|
1330
|
+
worktreeBase = await resolveWorktreeBase(root, options.worktreeBaseDir);
|
|
1331
|
+
log(`worktrees: ${worktreeBase.base}` +
|
|
1332
|
+
(worktreeBase.usedFallback ? " (OS-temp fallback — the sibling location was not writable)" : ""));
|
|
1333
|
+
// A resumed run's own leftover work no longer sits in the root tree a
|
|
1334
|
+
// workstream's build reads from — that tree is now a fresh worktree from
|
|
1335
|
+
// the program-branch tip. Said once, plainly, rather than discovered
|
|
1336
|
+
// later as "my edit vanished" (WS-02, §3.7).
|
|
1337
|
+
if (resuming && dirty.length > 0) {
|
|
1338
|
+
log("warning: the working tree has uncommitted change(s) that a " +
|
|
1339
|
+
"worktree-isolated build will not see (it starts from the " +
|
|
1340
|
+
"program-branch tip), and an integration may refuse if a " +
|
|
1341
|
+
"workstream also touches the same path(s):\n" +
|
|
1342
|
+
dirty.map((path) => ` ${path}`).join("\n"));
|
|
1343
|
+
}
|
|
723
1344
|
}
|
|
724
1345
|
// HEAD as it stood before the run's first agent ran — the whole-program
|
|
725
1346
|
// review stage's fallback diff base (see resolveProgramDiffBase) when a
|
|
@@ -768,7 +1389,7 @@ async function runProgramWith(options, permits) {
|
|
|
768
1389
|
let baselineFingerprint;
|
|
769
1390
|
if (Object.keys(config.verify).length > 0) {
|
|
770
1391
|
log("baseline: running the verify commands on the untouched tree");
|
|
771
|
-
const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline" });
|
|
1392
|
+
const baselineFailure = await verifyAttempt(config, verifyRunner, root, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline", cache: verifyCache });
|
|
772
1393
|
if (baselineFailure === undefined) {
|
|
773
1394
|
log("baseline: clean");
|
|
774
1395
|
}
|
|
@@ -841,11 +1462,28 @@ async function runProgramWith(options, permits) {
|
|
|
841
1462
|
throw error;
|
|
842
1463
|
}
|
|
843
1464
|
async function runProgramBody() {
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
1465
|
+
// WS-05 §3.7: the total lock order for this whole run. `integrationMutex`
|
|
1466
|
+
// (WS-04's, unchanged) is held across a whole integration attempt,
|
|
1467
|
+
// including its re-verify pass, deliberately for minutes at a time.
|
|
1468
|
+
// `rootIndexMutex` (new) is held only for the git-index operations at
|
|
1469
|
+
// `root`: authoring's spec commits (inside `prepareAuthoring`), an
|
|
1470
|
+
// integration's step-3 tip re-resolve and squash (§3.7.1), and the final
|
|
1471
|
+
// report commit below. Beneath both sits the WS-01 write queue
|
|
1472
|
+
// (`enqueueProgramWrite`). Never taken in any other order, and never
|
|
1473
|
+
// nested with `src/author.ts`'s own `graphMutex` (its merge critical
|
|
1474
|
+
// section), which takes no git operation at all.
|
|
1475
|
+
const integrationMutex = createMutex();
|
|
1476
|
+
const rootIndexMutex = createMutex();
|
|
1477
|
+
// WS-05 §3.1: one dependency graph with two kinds of node instead of a
|
|
1478
|
+
// barrier between two loops. `prepareAuthoring` throws for exactly the
|
|
1479
|
+
// reasons `authorProgram` threw before this workstream — no author agent
|
|
1480
|
+
// configured, a cyclic manifest, an unknown `--from` id — and does so
|
|
1481
|
+
// before any agent spawns. The manifest object is this run's single
|
|
1482
|
+
// shared one (§3.4): authoring mutates and persists *this* object, so the
|
|
1483
|
+
// build scheduler sees a discovered dependency edge or a `specStatus`
|
|
1484
|
+
// transition the instant an author makes it, and no post-authoring reload
|
|
1485
|
+
// is needed or performed.
|
|
1486
|
+
const pipeline = await prepareAuthoring({
|
|
849
1487
|
cwd: options.cwd,
|
|
850
1488
|
programId: options.programId,
|
|
851
1489
|
config,
|
|
@@ -857,65 +1495,90 @@ async function runProgramWith(options, permits) {
|
|
|
857
1495
|
reviewed,
|
|
858
1496
|
triaged,
|
|
859
1497
|
recorder,
|
|
1498
|
+
runSpan,
|
|
1499
|
+
monotonic,
|
|
1500
|
+
manifest,
|
|
1501
|
+
rootIndexMutex,
|
|
1502
|
+
...(options.maxParallel === undefined ? {} : { maxParallel: options.maxParallel }),
|
|
860
1503
|
});
|
|
861
|
-
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
const
|
|
866
|
-
const
|
|
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);
|
|
1504
|
+
// Every workstream's `taskFile` — one of the two path shapes a concurrent
|
|
1505
|
+
// authoring commit may safely have touched while an integration was in
|
|
1506
|
+
// flight (§3.7.1); fixed for the whole run, since only `dependencies`
|
|
1507
|
+
// mutate during authoring, never a workstream's own `taskFile`.
|
|
1508
|
+
const rosterTaskFiles = new Set(manifest.workstreams.map((workstream) => workstream.taskFile));
|
|
1509
|
+
const rosterIds = new Set(manifest.workstreams.map((workstream) => workstream.id));
|
|
876
1510
|
// The awaiting cone: every workstream directly referencing an unmet
|
|
877
1511
|
// prerequisite, plus everything transitively downstream of one — the same
|
|
878
1512
|
// downstream-cone machinery failure isolation uses, with the semantics
|
|
879
|
-
// inverted (no agent spawned, no retry, no diagnosis; see
|
|
1513
|
+
// inverted (no agent spawned, no retry, no diagnosis; see `dispositionOf`).
|
|
1514
|
+
// `directlyAwaiting` is fixed for the run (authoring never mutates
|
|
1515
|
+
// `prerequisites`); its downstream cone is recomputed live on every call
|
|
1516
|
+
// (§3.5) — a dependency edge an author discovers mid-run can still route a
|
|
1517
|
+
// workstream into this cone after the run started.
|
|
880
1518
|
const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
|
|
881
1519
|
const directlyAwaiting = new Set(manifest.workstreams
|
|
882
1520
|
.filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
|
|
883
1521
|
.map((workstream) => workstream.id));
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1522
|
+
function isAwaiting(id) {
|
|
1523
|
+
return (directlyAwaiting.has(id) ||
|
|
1524
|
+
downstreamCone(manifest.workstreams, [...directlyAwaiting]).has(id));
|
|
1525
|
+
}
|
|
1526
|
+
// Every workstream whose spec authoring failed or parked, and every
|
|
1527
|
+
// workstream whose *build* failed — grown live as author and build items
|
|
1528
|
+
// settle (§3.5), never frozen at a barrier that no longer exists.
|
|
1529
|
+
// `isBlocked` recomputes the downstream cone of both sets on every call:
|
|
1530
|
+
// by the time `build:X` is admissible, every ancestor's `author` item
|
|
1531
|
+
// *and* `build` item has already settled (`build:X` depends on `build:D`
|
|
1532
|
+
// for every roster dependency `D`, which itself depends on `author:D`), so
|
|
1533
|
+
// the live sets contain everything that could possibly block `X` at that
|
|
1534
|
+
// instant — the correctness lemma this replaces WS-04's frozen `blocked`
|
|
1535
|
+
// set with.
|
|
1536
|
+
const authoringFailed = new Set();
|
|
1537
|
+
const buildFailed = new Set();
|
|
1538
|
+
function isBlocked(id) {
|
|
1539
|
+
return (authoringFailed.has(id) ||
|
|
1540
|
+
downstreamCone(manifest.workstreams, [...authoringFailed, ...buildFailed]).has(id));
|
|
1541
|
+
}
|
|
888
1542
|
// Set when a workstream's verify failure reproduced the pre-run baseline:
|
|
889
1543
|
// the environment is broken, so every remaining workstream parks instead
|
|
890
|
-
// of spending its budget on the same crash.
|
|
1544
|
+
// of spending its budget on the same crash — in *both* phases (§3.6):
|
|
1545
|
+
// in-flight authoring finishes and commits its spec, in-flight builds
|
|
1546
|
+
// finish and integrate, but no not-yet-dispatched author or build
|
|
1547
|
+
// launches. Mutated only from the scheduler's `launchBuild`, after a
|
|
1548
|
+
// result is in hand — the same place today's serial loop mutated it.
|
|
891
1549
|
let environmentalHalt = false;
|
|
892
|
-
|
|
1550
|
+
/**
|
|
1551
|
+
* WS-04's five-branch cascade (minus the `runWorkstream` branch, which is
|
|
1552
|
+
* the absence of a short-circuit), extracted so the scheduler's `classify`
|
|
1553
|
+
* can consult it on every pass without re-implementing it. Precedence is
|
|
1554
|
+
* load-bearing and unchanged: complete, then environmental halt, then
|
|
1555
|
+
* awaiting (guarded against repainting an authoring failure), then
|
|
1556
|
+
* blocked. Evaluated at dispatch time, when every dependency's `build`
|
|
1557
|
+
* item has already settled (admissibility requires it), so `isBlocked` is
|
|
1558
|
+
* as authoritative here as it was over WS-04's frozen `blocked` set
|
|
1559
|
+
* (WS-05 §3.5).
|
|
1560
|
+
*/
|
|
1561
|
+
function dispositionOf(workstream) {
|
|
893
1562
|
if (workstream.status === "complete") {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
name: workstream.name,
|
|
1563
|
+
return {
|
|
1564
|
+
kind: "short-circuit",
|
|
897
1565
|
outcome: { status: "skipped", reason: "already complete" },
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
});
|
|
901
|
-
continue;
|
|
1566
|
+
persist: false,
|
|
1567
|
+
};
|
|
902
1568
|
}
|
|
903
1569
|
if (environmentalHalt) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
name: workstream.name,
|
|
1570
|
+
return {
|
|
1571
|
+
kind: "short-circuit",
|
|
1572
|
+
status: "parked",
|
|
908
1573
|
outcome: {
|
|
909
1574
|
status: "parked",
|
|
910
1575
|
reason: "the run halted on an environmental verification failure; parked, not attempted",
|
|
911
1576
|
},
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
});
|
|
915
|
-
continue;
|
|
1577
|
+
persist: false,
|
|
1578
|
+
};
|
|
916
1579
|
}
|
|
917
1580
|
// Authoring failures take precedence over awaiting: a workstream whose
|
|
918
|
-
// spec authoring failed or parked must fall through to the `
|
|
1581
|
+
// spec authoring failed or parked must fall through to the `isBlocked`
|
|
919
1582
|
// branch below and render as the genuine failure it is, never be
|
|
920
1583
|
// repainted `awaiting_human` — an authoring failure has no other
|
|
921
1584
|
// representation in `results` than that branch's own `parked` result, so
|
|
@@ -925,46 +1588,218 @@ async function runProgramWith(options, permits) {
|
|
|
925
1588
|
// resolves to awaiting_human here; that is safe because the build
|
|
926
1589
|
// failure self-reports `failed` in `results`, so the run is classified a
|
|
927
1590
|
// partial regardless (see the intermission classification below).
|
|
928
|
-
if (
|
|
929
|
-
workstream.status = "awaiting_human";
|
|
930
|
-
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1591
|
+
if (isAwaiting(workstream.id) && !authoringFailed.has(workstream.id)) {
|
|
931
1592
|
const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
|
|
932
1593
|
const reason = ownUnmet.length > 0
|
|
933
1594
|
? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
|
|
934
1595
|
: "an upstream workstream is awaiting a human prerequisite; not attempted.";
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1596
|
+
return {
|
|
1597
|
+
kind: "short-circuit",
|
|
1598
|
+
status: "awaiting_human",
|
|
938
1599
|
outcome: { status: "awaiting_human", reason },
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
});
|
|
942
|
-
continue;
|
|
1600
|
+
persist: true,
|
|
1601
|
+
};
|
|
943
1602
|
}
|
|
944
|
-
if (
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
name: workstream.name,
|
|
1603
|
+
if (isBlocked(workstream.id)) {
|
|
1604
|
+
return {
|
|
1605
|
+
kind: "short-circuit",
|
|
1606
|
+
status: "parked",
|
|
949
1607
|
outcome: {
|
|
950
1608
|
status: "parked",
|
|
951
1609
|
reason: "an upstream dependency failed; parked, not attempted",
|
|
952
1610
|
},
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
});
|
|
956
|
-
continue;
|
|
1611
|
+
persist: false,
|
|
1612
|
+
};
|
|
957
1613
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1614
|
+
return { kind: "build" };
|
|
1615
|
+
}
|
|
1616
|
+
// `classify` and `settle` are two separate scheduler calls, and `settle`
|
|
1617
|
+
// runs on a later microtask — a naive `settle` that recomputes
|
|
1618
|
+
// `dispositionOf` could observe a different answer than the one that sent
|
|
1619
|
+
// it there (a sibling can flip `environmentalHalt` in between). Memoising
|
|
1620
|
+
// the dispatching pass's answer and having `settle` read it back avoids
|
|
1621
|
+
// the hazard entirely rather than reasoning about it (WS-04, §3.2).
|
|
1622
|
+
const dispositionCache = new Map();
|
|
1623
|
+
function dispositionFor(workstream) {
|
|
1624
|
+
const fresh = dispositionOf(workstream);
|
|
1625
|
+
dispositionCache.set(workstream.id, fresh);
|
|
1626
|
+
return fresh;
|
|
1627
|
+
}
|
|
1628
|
+
async function applyShortCircuit(workstream) {
|
|
1629
|
+
const disposition = dispositionCache.get(workstream.id);
|
|
1630
|
+
if (disposition === undefined || disposition.kind !== "short-circuit") {
|
|
1631
|
+
// Unreachable: the scheduler only calls `settle` for an item whose
|
|
1632
|
+
// `classify` call (via `dispositionFor`) just returned "settle".
|
|
1633
|
+
throw new Error(`applyShortCircuit called for ${workstream.id} without a cached short-circuit disposition`);
|
|
1634
|
+
}
|
|
1635
|
+
if (disposition.status !== undefined)
|
|
1636
|
+
workstream.status = disposition.status;
|
|
1637
|
+
if (disposition.persist) {
|
|
1638
|
+
await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1639
|
+
}
|
|
1640
|
+
return {
|
|
1641
|
+
id: workstream.id,
|
|
1642
|
+
name: workstream.name,
|
|
1643
|
+
outcome: disposition.outcome,
|
|
1644
|
+
decisionIds: [],
|
|
1645
|
+
decisionErrors: [],
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
const byId = new Map();
|
|
1649
|
+
const cap = normalizeMaxParallel(options.maxParallel);
|
|
1650
|
+
const schedLog = schedulerLog(baseLog);
|
|
1651
|
+
schedLog(`pipelining authoring and building over ${manifest.workstreams.length} workstream(s)` +
|
|
1652
|
+
(cap === Infinity ? "" : `, at most ${cap} flow(s) at a time`));
|
|
1653
|
+
const authorItems = manifest.workstreams.map((workstream) => ({
|
|
1654
|
+
id: `author:${workstream.id}`,
|
|
1655
|
+
phase: "author",
|
|
1656
|
+
workstream,
|
|
1657
|
+
get dependencies() {
|
|
1658
|
+
return workstream.dependencies.map((id) => `author:${id}`);
|
|
1659
|
+
},
|
|
1660
|
+
}));
|
|
1661
|
+
const buildItems = manifest.workstreams.map((workstream) => ({
|
|
1662
|
+
id: `build:${workstream.id}`,
|
|
1663
|
+
phase: "build",
|
|
1664
|
+
workstream,
|
|
1665
|
+
get dependencies() {
|
|
1666
|
+
return [`author:${workstream.id}`, ...workstream.dependencies.map((id) => `build:${id}`)];
|
|
1667
|
+
},
|
|
1668
|
+
}));
|
|
1669
|
+
// Authors first, then builds, both in manifest order (§3.1): `runReadySet`
|
|
1670
|
+
// breaks ties by this input order, so at `cap: 1` every author drains
|
|
1671
|
+
// before any build is even considered — the pipeline rebuilds today's
|
|
1672
|
+
// barrier exactly rather than approximating it (§3.10).
|
|
1673
|
+
const items = [...authorItems, ...buildItems];
|
|
1674
|
+
// Settle instants, keyed by phased-item id, for the `spec-pending` wait
|
|
1675
|
+
// measurement below (§3.8); the scheduler does not expose these itself.
|
|
1676
|
+
const settledAt = new Map();
|
|
1677
|
+
const schedulerStart = monotonic();
|
|
1678
|
+
async function launchAuthor(item) {
|
|
1679
|
+
const result = await pipeline.authorWorkstream(item.workstream);
|
|
1680
|
+
settledAt.set(item.id, monotonic());
|
|
1681
|
+
if (result.outcome.status === "failed" || result.outcome.status === "parked") {
|
|
1682
|
+
authoringFailed.add(item.workstream.id);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// The only path by which an author item settles without running (§3.6): a
|
|
1686
|
+
// halt-parked author never ran, so `specStatus` is left exactly as it
|
|
1687
|
+
// stood, and it does not enter `authoringFailed` — a downstream workstream
|
|
1688
|
+
// reports the halt reason, never "an upstream dependency's authoring
|
|
1689
|
+
// failed".
|
|
1690
|
+
async function settleHaltedAuthor(item) {
|
|
1691
|
+
pipeline.haltAuthorWorkstream(item.workstream, "the run halted on an environmental verification failure; parked, not attempted");
|
|
1692
|
+
settledAt.set(item.id, monotonic());
|
|
1693
|
+
}
|
|
1694
|
+
async function settleBuild(item) {
|
|
1695
|
+
byId.set(item.workstream.id, await applyShortCircuit(item.workstream));
|
|
1696
|
+
settledAt.set(item.id, monotonic());
|
|
1697
|
+
}
|
|
1698
|
+
async function launchBuild(item) {
|
|
1699
|
+
const workstream = item.workstream;
|
|
1700
|
+
// The scheduling instant (WS-04, §3.5): resolved first, with no
|
|
1701
|
+
// `await` between the read and the `workstream-scheduled` emission, so
|
|
1702
|
+
// no sibling can integrate in between — the point and the sha
|
|
1703
|
+
// therefore describe the same instant by construction. The per-flow
|
|
1704
|
+
// ledger read (§3.4) goes strictly after this emission, and nothing
|
|
1705
|
+
// else is inserted between them, so WS-04's
|
|
1706
|
+
// `scheduled − ready === concurrency-cap` identity stays exact.
|
|
1707
|
+
const scheduledFrom = isRepository
|
|
1708
|
+
? await worktrees.resolveCommit(root, "HEAD")
|
|
1709
|
+
: undefined;
|
|
1710
|
+
recordWorkstreamScheduled(recorder, workstream.id);
|
|
1711
|
+
const ledger = await readDecisionLedger(root, options.programId);
|
|
1712
|
+
const result = await runWorkstream(workstream, ledger, scheduledFrom === undefined ? {} : { scheduledFrom });
|
|
1713
|
+
byId.set(workstream.id, result);
|
|
1714
|
+
settledAt.set(item.id, monotonic());
|
|
1715
|
+
if (result.environmental === true && !environmentalHalt) {
|
|
961
1716
|
environmentalHalt = true;
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1717
|
+
schedLog("environmental verification failure — halting further dispatch");
|
|
1718
|
+
schedLog("[run] the halt stops dispatch in both phases — in-flight authoring finishes " +
|
|
1719
|
+
"and commits its spec, in-flight builds finish and integrate, and every " +
|
|
1720
|
+
"not-yet-dispatched author or build parks with the halt reason");
|
|
1721
|
+
}
|
|
1722
|
+
if (result.outcome.status === "failed" || result.outcome.status === "parked") {
|
|
1723
|
+
// A parked build (no spec on disk) leaves its dependents without a
|
|
1724
|
+
// producer just as a failure does; both seed the downstream cone.
|
|
1725
|
+
buildFailed.add(workstream.id);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/** Emits `spec-pending` (§3.8): the wait between when this workstream's
|
|
1729
|
+
* build *could* have become ready — the latest settle of any roster
|
|
1730
|
+
* dependency's build item, or the scheduler's start when it has none —
|
|
1731
|
+
* and the instant it actually did. Authors emit no `workstream-ready`;
|
|
1732
|
+
* that point means "a build is ready" and must stay unique per
|
|
1733
|
+
* workstream. */
|
|
1734
|
+
function onBuildReady(item) {
|
|
1735
|
+
const readyAt = monotonic();
|
|
1736
|
+
const depsReadyAt = item.workstream.dependencies
|
|
1737
|
+
.filter((id) => rosterIds.has(id))
|
|
1738
|
+
.reduce((latest, id) => Math.max(latest, settledAt.get(`build:${id}`) ?? schedulerStart), schedulerStart);
|
|
1739
|
+
recordWorkstreamReady(recorder, item.workstream.id);
|
|
1740
|
+
const waited = readyAt - depsReadyAt;
|
|
1741
|
+
if (waited > 0) {
|
|
1742
|
+
recordWorkstreamWaiting(recorder, item.workstream.id, { cause: "spec-pending", waitedMs: waited });
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
/** Emits `author-concurrency-cap` (§3.8) — the authoring half of the
|
|
1746
|
+
* shared `--max-parallel` budget, distinct from `concurrency-cap` so
|
|
1747
|
+
* per-(workstream, cause) uniqueness and WS-04's
|
|
1748
|
+
* `scheduled − ready === concurrency-cap` identity both stay exact. */
|
|
1749
|
+
function onAuthorLaunch(item, cappedMs) {
|
|
1750
|
+
if (cappedMs > 0) {
|
|
1751
|
+
recordWorkstreamWaiting(recorder, item.workstream.id, {
|
|
1752
|
+
cause: "author-concurrency-cap",
|
|
1753
|
+
waitedMs: cappedMs,
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
await runReadySet({
|
|
1758
|
+
items,
|
|
1759
|
+
cap,
|
|
1760
|
+
monotonic,
|
|
1761
|
+
classify: (item) => item.phase === "author"
|
|
1762
|
+
// The one short-circuit an author item has: the environmental halt
|
|
1763
|
+
// (§3.6). Authoring has no other short-circuit — the cone parks are
|
|
1764
|
+
// WS-03's own, inside `handle`.
|
|
1765
|
+
? environmentalHalt
|
|
1766
|
+
? "settle"
|
|
1767
|
+
: "launch"
|
|
1768
|
+
: dispositionFor(item.workstream).kind === "build"
|
|
1769
|
+
? "launch"
|
|
1770
|
+
: "settle",
|
|
1771
|
+
launch: (item) => (item.phase === "author" ? launchAuthor(item) : launchBuild(item)),
|
|
1772
|
+
settle: (item) => (item.phase === "author" ? settleHaltedAuthor(item) : settleBuild(item)),
|
|
1773
|
+
onAdmissible: (item) => {
|
|
1774
|
+
if (item.phase === "build")
|
|
1775
|
+
onBuildReady(item);
|
|
1776
|
+
},
|
|
1777
|
+
onLaunch: (item, cappedMs) => {
|
|
1778
|
+
if (item.phase === "author") {
|
|
1779
|
+
onAuthorLaunch(item, cappedMs);
|
|
1780
|
+
}
|
|
1781
|
+
else if (cappedMs > 0) {
|
|
1782
|
+
recordWorkstreamWaiting(recorder, item.workstream.id, { cause: "concurrency-cap", waitedMs: cappedMs });
|
|
966
1783
|
}
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
const authorResult = pipeline.finish();
|
|
1787
|
+
// Wall-clock completion order is not deterministic; `ordered` is.
|
|
1788
|
+
// Computed only now, after the drain (WS-05 §3.9) — never before it, since
|
|
1789
|
+
// an author discovering a dependency mid-run can still change the graph
|
|
1790
|
+
// this walks. Rebuilding `results` from it is what keeps every existing
|
|
1791
|
+
// positional assertion (`results[0]`, `results[1]`, …) green under an
|
|
1792
|
+
// unbounded default. `runReadySet`'s settle-in-`finally` makes a missing
|
|
1793
|
+
// id impossible; filtered defensively anyway per SC-12's fail-open rule.
|
|
1794
|
+
const ordered = stableTopologicalOrder(manifest.workstreams);
|
|
1795
|
+
const results = [];
|
|
1796
|
+
for (const workstream of ordered) {
|
|
1797
|
+
const result = byId.get(workstream.id);
|
|
1798
|
+
if (result === undefined) {
|
|
1799
|
+
schedLog(`warning: ${workstream.id} produced no result — omitted from the report`);
|
|
1800
|
+
continue;
|
|
967
1801
|
}
|
|
1802
|
+
results.push(result);
|
|
968
1803
|
}
|
|
969
1804
|
const complete = results.every((result) => result.outcome.status === "complete" ||
|
|
970
1805
|
result.outcome.status === "skipped");
|
|
@@ -1030,6 +1865,10 @@ async function runProgramWith(options, permits) {
|
|
|
1030
1865
|
recorder,
|
|
1031
1866
|
});
|
|
1032
1867
|
const reportPath = runReportPath(root, options.programId);
|
|
1868
|
+
// Under concurrency (WS-02+) this is what guarantees no manifest or
|
|
1869
|
+
// ledger write is still in flight while docs/programs is staged below;
|
|
1870
|
+
// today, with nothing concurrent, it is a no-op that must exist anyway.
|
|
1871
|
+
await drainProgramWrites(programWriteKey(root, options.programId));
|
|
1033
1872
|
// Rendering is the last stage the recorder can actually observe (SC-03):
|
|
1034
1873
|
// captured as its own span, nested under the run span, rather than folded
|
|
1035
1874
|
// silently into it. Kept in its own try/catch, separate from the write and
|
|
@@ -1118,7 +1957,12 @@ async function runProgramWith(options, permits) {
|
|
|
1118
1957
|
try {
|
|
1119
1958
|
await writeFile(reportPath, reportContent, "utf8");
|
|
1120
1959
|
if (isRepository) {
|
|
1121
|
-
|
|
1960
|
+
// Guarded by `rootIndexMutex` (WS-05 §3.7) like every other git-index
|
|
1961
|
+
// operation at root; by this point the unified scheduler has fully
|
|
1962
|
+
// drained, so nothing else can be contending for it, but the lock
|
|
1963
|
+
// discipline is stated once and applied everywhere rather than
|
|
1964
|
+
// special-cased at the one call site that "happens to" run alone.
|
|
1965
|
+
await rootIndexMutex.runExclusive(() => git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]));
|
|
1122
1966
|
}
|
|
1123
1967
|
// A best-effort forensic breadcrumb for the one boundary the
|
|
1124
1968
|
// already-committed canonical artifact can never describe: `point()`
|
|
@@ -1151,7 +1995,22 @@ async function runProgramWith(options, permits) {
|
|
|
1151
1995
|
wholeProgramReview: wholeProgram,
|
|
1152
1996
|
causalAnalysis,
|
|
1153
1997
|
};
|
|
1154
|
-
async function runWorkstream(workstream
|
|
1998
|
+
async function runWorkstream(workstream,
|
|
1999
|
+
/**
|
|
2000
|
+
* The decision ledger, read once per build flow (WS-05 §3.4) rather
|
|
2001
|
+
* than once for the whole run — a build's brief then sees every
|
|
2002
|
+
* decision journalled before it started, including its own
|
|
2003
|
+
* dependencies' authoring, without breaking WS-04's
|
|
2004
|
+
* `scheduled − ready === concurrency-cap` identity (the read happens
|
|
2005
|
+
* strictly after the `workstream-scheduled` emission, in the caller).
|
|
2006
|
+
*/
|
|
2007
|
+
ledger, flowOptions = {}) {
|
|
2008
|
+
// Explicit parenting (WS-01 §3.3): every span/point this flow opens
|
|
2009
|
+
// parents to the run span itself, never to whatever happens to be open
|
|
2010
|
+
// on the implicit stack — the seam future concurrent workstreams (WS-04)
|
|
2011
|
+
// stand on. `[WS-xx] `-prefixes every line this flow logs.
|
|
2012
|
+
const scope = runSpan.scope({ workstream: workstream.id });
|
|
2013
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
1155
2014
|
const base = {
|
|
1156
2015
|
id: workstream.id,
|
|
1157
2016
|
name: workstream.name,
|
|
@@ -1182,13 +2041,78 @@ async function runProgramWith(options, permits) {
|
|
|
1182
2041
|
return base;
|
|
1183
2042
|
}
|
|
1184
2043
|
workstream.status = "in_progress";
|
|
1185
|
-
await timed(
|
|
1186
|
-
|
|
1187
|
-
|
|
2044
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2045
|
+
// One read, not two (WS-04, §3.5): the program-branch tip is the
|
|
2046
|
+
// worktree's base *and* the decision anchor (`baseCommit`) — the same
|
|
2047
|
+
// fact under two names, resolved through `WorktreeOps`, never
|
|
2048
|
+
// `GitOps.currentCommit`. Concurrently, a second independent read here
|
|
2049
|
+
// could observe a sibling's integration land in between, giving this
|
|
2050
|
+
// workstream a `baseCommit` newer than the tree its worktree was
|
|
2051
|
+
// actually built from — the reviewer and decider would then see a
|
|
2052
|
+
// sibling's change as though this workstream had deleted it. Preferring
|
|
2053
|
+
// `flowOptions.scheduledFrom` when present means the scheduler's own
|
|
2054
|
+
// read (taken with no `await` before its `workstream-scheduled`
|
|
2055
|
+
// emission) is the only read on every scheduled path; the fallback here
|
|
2056
|
+
// exists only for the one caller with no scheduling instant to offer.
|
|
2057
|
+
const programTip = isRepository
|
|
2058
|
+
? (flowOptions.scheduledFrom ?? (await worktrees.resolveCommit(root, "HEAD")))
|
|
1188
2059
|
: undefined;
|
|
2060
|
+
const baseCommit = programTip;
|
|
2061
|
+
// The worktree lifecycle (WS-02, §3.3): every in-progress workstream
|
|
2062
|
+
// builds in its own dedicated worktree on its own branch, created from
|
|
2063
|
+
// the program-branch tip, never in the root checkout. `cwd` is what
|
|
2064
|
+
// every build-role spawn, verify command, and workstream-owned git
|
|
2065
|
+
// operation below uses from here on; `root` stays reserved for the
|
|
2066
|
+
// manifest, ledger, transcripts, and verify-output evidence.
|
|
2067
|
+
let cwd = root;
|
|
2068
|
+
let worktreeBranchOfThis;
|
|
2069
|
+
if (isRepository) {
|
|
2070
|
+
if (worktreeBase === undefined) {
|
|
2071
|
+
// Unreachable in production (worktreeBase is always resolved inside
|
|
2072
|
+
// the isRepository branch above, before any workstream runs) —
|
|
2073
|
+
// guarded rather than asserted so a future refactor fails loudly.
|
|
2074
|
+
throw new Error("worktree base was never resolved for a git repository run");
|
|
2075
|
+
}
|
|
2076
|
+
// Routed through the same `PrepareWorktreeResult` shape and the same
|
|
2077
|
+
// failure handling below as a real `prepareWorktree` failure, rather
|
|
2078
|
+
// than a second early-return with its own `saveManifest` call —
|
|
2079
|
+
// the tip could not resolve, so there is nothing to build from, but
|
|
2080
|
+
// that is still exactly one more reason a worktree could not be
|
|
2081
|
+
// prepared (WS-04, §3.5).
|
|
2082
|
+
const prepared = programTip === undefined
|
|
2083
|
+
? { ok: false, reason: "cannot resolve the program-branch tip", created: false }
|
|
2084
|
+
: await prepareWorktree({
|
|
2085
|
+
root,
|
|
2086
|
+
programId: options.programId,
|
|
2087
|
+
workstreamId: workstream.id,
|
|
2088
|
+
worktrees,
|
|
2089
|
+
worktreeBase,
|
|
2090
|
+
setupRunner,
|
|
2091
|
+
log,
|
|
2092
|
+
programTip,
|
|
2093
|
+
});
|
|
2094
|
+
if (!prepared.ok) {
|
|
2095
|
+
workstream.status = "failed";
|
|
2096
|
+
if (prepared.created)
|
|
2097
|
+
base.worktreePath = prepared.cwd;
|
|
2098
|
+
base.outcome = { status: "failed", reason: prepared.reason };
|
|
2099
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2100
|
+
return base;
|
|
2101
|
+
}
|
|
2102
|
+
cwd = prepared.cwd;
|
|
2103
|
+
worktreeBranchOfThis = prepared.branch;
|
|
2104
|
+
base.worktreePath = cwd;
|
|
2105
|
+
}
|
|
1189
2106
|
let priorFailure;
|
|
1190
2107
|
let priorDiagnosis;
|
|
1191
2108
|
let implementerFingerprint;
|
|
2109
|
+
// WS-08: last-wins across every attempt in this workstream's build —
|
|
2110
|
+
// the fix seats resume whichever attempt most recently reported a
|
|
2111
|
+
// session id, which is the attempt whose commit they are fixing.
|
|
2112
|
+
// Capturing on every attempt (including recovery) is not resuming: the
|
|
2113
|
+
// recovery seat still spawns fresh; its handle is only ever a candidate
|
|
2114
|
+
// for the fix seats, subject to invokeAgent's provider/invocation checks.
|
|
2115
|
+
let buildSession;
|
|
1192
2116
|
const attempts = [
|
|
1193
2117
|
{ agent, label: "implementer", role: "agent" },
|
|
1194
2118
|
];
|
|
@@ -1204,10 +2128,10 @@ async function runProgramWith(options, permits) {
|
|
|
1204
2128
|
attempts.push({ agent, label: "informed retry", role: "agent" });
|
|
1205
2129
|
}
|
|
1206
2130
|
for (const [index, attempt] of attempts.entries()) {
|
|
1207
|
-
log(`${workstream.
|
|
1208
|
-
const { brief, components } = implementerBrief(manifest, workstream, spec,
|
|
2131
|
+
log(`${workstream.name}: ${attempt.label} attempt`);
|
|
2132
|
+
const { brief, components } = implementerBrief(manifest, workstream, spec, ledger, config.permits?.policy, priorFailure, priorDiagnosis);
|
|
1209
2133
|
for (const point of promptComponentSizePoints(components, { role: attempt.role })) {
|
|
1210
|
-
|
|
2134
|
+
scope.point(point);
|
|
1211
2135
|
}
|
|
1212
2136
|
// Why this repetition happened (WS-02, SC-05/SC-06): the first attempt
|
|
1213
2137
|
// is always `initial`; a later seat is `informed-by-diagnosis` when the
|
|
@@ -1224,7 +2148,7 @@ async function runProgramWith(options, permits) {
|
|
|
1224
2148
|
* kept. */
|
|
1225
2149
|
let dispositionOutcome;
|
|
1226
2150
|
const emitAttemptOutcome = (outcome, reasonOverride) => {
|
|
1227
|
-
|
|
2151
|
+
scope.point({
|
|
1228
2152
|
kind: "attempt-outcome",
|
|
1229
2153
|
coverage: "observed",
|
|
1230
2154
|
dimensions: {
|
|
@@ -1241,7 +2165,9 @@ async function runProgramWith(options, permits) {
|
|
|
1241
2165
|
let invocation;
|
|
1242
2166
|
let spawnFailure;
|
|
1243
2167
|
try {
|
|
1244
|
-
invocation = await invokeAgent(agentRunner, attempt.agent, brief,
|
|
2168
|
+
invocation = await invokeAgent(agentRunner, attempt.agent, brief, cwd, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")), scope, { stage: seatStage, workstream: workstream.id, attemptSeat: attempt.label, attemptIndex: index, attemptReason });
|
|
2169
|
+
if (invocation.session)
|
|
2170
|
+
buildSession = invocation.session;
|
|
1245
2171
|
}
|
|
1246
2172
|
catch (error) {
|
|
1247
2173
|
// The agent process never started — its command is wrong, missing,
|
|
@@ -1257,18 +2183,19 @@ async function runProgramWith(options, permits) {
|
|
|
1257
2183
|
recordTranscript(invocation.transcript);
|
|
1258
2184
|
const summary = resolveSummary(invocation.output);
|
|
1259
2185
|
base.summary = summary.text;
|
|
1260
|
-
await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
|
|
2186
|
+
await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission, scope);
|
|
1261
2187
|
const parsed = extractDecisions(invocation.output);
|
|
1262
2188
|
base.decisionErrors.push(...parsed.errors);
|
|
1263
|
-
await journalDecisions(workstream, parsed.decisions, baseCommit);
|
|
2189
|
+
await journalDecisions(workstream, parsed.decisions, baseCommit, scope);
|
|
1264
2190
|
base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
|
|
1265
|
-
let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode,
|
|
2191
|
+
let failure = await verifyAttempt(config, verifyRunner, cwd, root, invocation.exitCode, scope, "verification-command", {
|
|
1266
2192
|
programId: options.programId,
|
|
1267
2193
|
phase: `post-${attempt.label.replaceAll(" ", "-")}`,
|
|
1268
2194
|
workstream: workstream.id,
|
|
1269
2195
|
role: attempt.role,
|
|
1270
2196
|
attemptSeat: attempt.label,
|
|
1271
2197
|
attemptIndex: index,
|
|
2198
|
+
cache: verifyCache,
|
|
1272
2199
|
});
|
|
1273
2200
|
// Deterministic protocol failures the runner can see without a model:
|
|
1274
2201
|
// no diagnosis is spent on them, because no read of the tree could add
|
|
@@ -1300,8 +2227,8 @@ async function runProgramWith(options, permits) {
|
|
|
1300
2227
|
// commit and walks through the gate as "complete". A reply that never
|
|
1301
2228
|
// produces the work is a protocol failure, not an empty result.
|
|
1302
2229
|
if (failure === undefined && isRepository) {
|
|
1303
|
-
const dirty = await git.dirtyPaths(
|
|
1304
|
-
const meaningful = dirty.some((path) => !path
|
|
2230
|
+
const dirty = await git.dirtyPaths(cwd);
|
|
2231
|
+
const meaningful = dirty.some((path) => !isRunnerOwnedArtifactPath(path));
|
|
1305
2232
|
if (!meaningful) {
|
|
1306
2233
|
failure =
|
|
1307
2234
|
"the attempt verified green but changed nothing outside " +
|
|
@@ -1326,11 +2253,18 @@ async function runProgramWith(options, permits) {
|
|
|
1326
2253
|
// files the work required, and that set is exactly what the
|
|
1327
2254
|
// runner cannot know in advance. c0 is the green anchor the test
|
|
1328
2255
|
// critique's fix loop resets to on a failing fix (SC-07).
|
|
1329
|
-
c0 = await timed(
|
|
2256
|
+
c0 = await timed(scope, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(cwd, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`, ["docs/programs"]), commitEvidence);
|
|
1330
2257
|
}
|
|
2258
|
+
// Captured before the pass runs so a fallback's report line can name
|
|
2259
|
+
// the producer's command (§3.11) — an approximation good for the
|
|
2260
|
+
// common one-round case; a later round's true producer is whichever
|
|
2261
|
+
// round most recently resumed, which the pass itself tracks
|
|
2262
|
+
// internally but does not need to surface here.
|
|
2263
|
+
const criticEnteringSession = buildSession;
|
|
1331
2264
|
const critique = isRepository && c0 !== undefined
|
|
1332
2265
|
? await runTestCritique({
|
|
1333
2266
|
root,
|
|
2267
|
+
cwd,
|
|
1334
2268
|
manifest,
|
|
1335
2269
|
workstream,
|
|
1336
2270
|
spec,
|
|
@@ -1341,83 +2275,134 @@ async function runProgramWith(options, permits) {
|
|
|
1341
2275
|
git,
|
|
1342
2276
|
reviewer,
|
|
1343
2277
|
agent,
|
|
2278
|
+
deciderConfigured: decider !== undefined,
|
|
1344
2279
|
baseCommit,
|
|
1345
2280
|
greenCommit: c0,
|
|
1346
2281
|
now,
|
|
1347
2282
|
log,
|
|
1348
2283
|
transcriptSink,
|
|
1349
|
-
recorder,
|
|
2284
|
+
recorder: scope,
|
|
2285
|
+
verifyCache,
|
|
2286
|
+
triage: (events, triageBaseCommit) => reviewWorkstreamFindings(workstream.id, cwd, events, triageBaseCommit, scope),
|
|
2287
|
+
...(buildSession ? { buildSession } : {}),
|
|
1350
2288
|
})
|
|
1351
2289
|
: undefined;
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
2290
|
+
if (critique?.continuity) {
|
|
2291
|
+
(base.sessionContinuity ??= []).push(...critique.continuity.map((continuity) => ({
|
|
2292
|
+
seat: "test critique fix",
|
|
2293
|
+
continuity,
|
|
2294
|
+
producerCommand: criticEnteringSession?.command,
|
|
2295
|
+
seatCommand: agent.command,
|
|
2296
|
+
})));
|
|
2297
|
+
}
|
|
2298
|
+
// The workstream's own final verified commit, in its worktree —
|
|
2299
|
+
// after any kept fix, that is the fix commit, not the earlier green
|
|
2300
|
+
// one; already includes any triaged fix-now commit, since the merged
|
|
2301
|
+
// fix runs inside the critique loop itself (WS-09). Not what lands
|
|
2302
|
+
// in the manifest: under WS-02 that is the *integration* commit,
|
|
2303
|
+
// recorded only after integration succeeds, below.
|
|
2304
|
+
const finalCommit = critique?.finalCommit ?? c0;
|
|
1356
2305
|
if (critique && critique.stageErrors.length > 0) {
|
|
1357
2306
|
(base.stageErrors ??= []).push(...critique.stageErrors);
|
|
1358
2307
|
}
|
|
1359
2308
|
for (const transcript of critique?.transcripts ?? [])
|
|
1360
2309
|
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
2310
|
{
|
|
1380
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
2311
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
|
|
1381
2312
|
if (spawnErrors.length > 0)
|
|
1382
2313
|
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1383
2314
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
2315
|
+
if (critique)
|
|
2316
|
+
base.testCritique = critique.outcome;
|
|
2317
|
+
base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
|
|
2318
|
+
// Integration (WS-02, §3.5-§3.6): the last thing this flow does on
|
|
2319
|
+
// the green path. Not reached when this is not a git repository —
|
|
2320
|
+
// there is nothing to integrate, and `finalCommit`/`cwd` are already
|
|
2321
|
+
// the root's own.
|
|
2322
|
+
if (!isRepository || finalCommit === undefined) {
|
|
2323
|
+
workstream.status = "complete";
|
|
2324
|
+
if (finalCommit !== undefined)
|
|
2325
|
+
workstream.commit = finalCommit;
|
|
2326
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2327
|
+
base.outcome = {
|
|
2328
|
+
status: "complete",
|
|
2329
|
+
...(finalCommit === undefined ? {} : { commit: finalCommit }),
|
|
2330
|
+
};
|
|
2331
|
+
emitAttemptOutcome("success");
|
|
2332
|
+
return base;
|
|
1391
2333
|
}
|
|
1392
|
-
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
2334
|
+
const recoveryAgentForIntegration = recovery && !recovery.borrowedImplementer ? recovery.agent : agent;
|
|
2335
|
+
// Serialised through the one run-scoped integration mutex (WS-04,
|
|
2336
|
+
// §3.5): the mutex spans the whole call, including the merge-forward
|
|
2337
|
+
// and its re-verify inside `integrateWorkstream` — not just the
|
|
2338
|
+
// final squash — because the merge-forward is the proof that the
|
|
2339
|
+
// squash is a fast-forward, and a sibling landing between them would
|
|
2340
|
+
// void that proof.
|
|
2341
|
+
const mutexWaitStart = monotonic();
|
|
2342
|
+
const integration = await integrationMutex.runExclusive(async () => {
|
|
2343
|
+
const mutexWaitedMs = monotonic() - mutexWaitStart;
|
|
2344
|
+
if (mutexWaitedMs > 0) {
|
|
2345
|
+
recordWorkstreamWaiting(recorder, workstream.id, {
|
|
2346
|
+
cause: "integration-mutex",
|
|
2347
|
+
waitedMs: mutexWaitedMs,
|
|
2348
|
+
});
|
|
1402
2349
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
2350
|
+
return integrateWorkstream({
|
|
2351
|
+
root,
|
|
2352
|
+
programId: options.programId,
|
|
2353
|
+
workstream,
|
|
2354
|
+
cwd,
|
|
2355
|
+
branch: worktreeBranchOfThis ?? worktreeBranchName(options.programId, workstream.id),
|
|
2356
|
+
finalCommit,
|
|
2357
|
+
worktrees,
|
|
2358
|
+
git,
|
|
2359
|
+
config,
|
|
2360
|
+
verifyRunner,
|
|
2361
|
+
setupRunner,
|
|
2362
|
+
verifyCache,
|
|
2363
|
+
agentRunner,
|
|
2364
|
+
permits,
|
|
2365
|
+
recoveryAgent: recoveryAgentForIntegration,
|
|
2366
|
+
worktreeBase: worktreeBase,
|
|
2367
|
+
scope,
|
|
2368
|
+
log,
|
|
2369
|
+
transcriptSink,
|
|
2370
|
+
rootIndexMutex,
|
|
2371
|
+
rosterTaskFiles,
|
|
2372
|
+
});
|
|
2373
|
+
});
|
|
2374
|
+
for (const transcript of integration.transcripts)
|
|
2375
|
+
recordTranscript(transcript);
|
|
2376
|
+
if (integration.ok) {
|
|
2377
|
+
workstream.status = "complete";
|
|
2378
|
+
if (integration.commit !== undefined)
|
|
2379
|
+
workstream.commit = integration.commit;
|
|
2380
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2381
|
+
for (const { path, branch: cleanupBranch } of integration.cleanup) {
|
|
2382
|
+
await worktrees.remove(root, path);
|
|
2383
|
+
await worktrees.deleteBranch(root, cleanupBranch);
|
|
1407
2384
|
}
|
|
2385
|
+
await git.deleteRef(root, retainedRefName(options.programId, workstream.id));
|
|
2386
|
+
delete base.worktreePath;
|
|
2387
|
+
base.outcome = {
|
|
2388
|
+
status: "complete",
|
|
2389
|
+
...(integration.commit === undefined ? {} : { commit: integration.commit }),
|
|
2390
|
+
};
|
|
2391
|
+
emitAttemptOutcome("success");
|
|
2392
|
+
return base;
|
|
1408
2393
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
base.
|
|
2394
|
+
workstream.status = "failed";
|
|
2395
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2396
|
+
base.retainedRef = integration.retainedRef;
|
|
1412
2397
|
base.outcome = {
|
|
1413
|
-
status: "
|
|
1414
|
-
|
|
2398
|
+
status: "failed",
|
|
2399
|
+
reason: `${integration.reason}\n\nWorktree(s) retained: ${integration.worktreePaths.join(", ")}.`,
|
|
1415
2400
|
};
|
|
1416
2401
|
emitAttemptOutcome("success");
|
|
1417
2402
|
return base;
|
|
1418
2403
|
}
|
|
1419
2404
|
priorFailure = failure;
|
|
1420
|
-
log(`${
|
|
2405
|
+
log(`${attempt.label} attempt failed — ${failure}`);
|
|
1421
2406
|
// A failure identical to the pre-run baseline (up to counts and
|
|
1422
2407
|
// timings) was there before any workstream ran: environmental, not
|
|
1423
2408
|
// this workstream's work. No retry or diagnosis is spent on it, and
|
|
@@ -1428,9 +2413,9 @@ async function runProgramWith(options, permits) {
|
|
|
1428
2413
|
!resumedIds.has(workstream.id) &&
|
|
1429
2414
|
failureFingerprint(failure) === baselineFingerprint) {
|
|
1430
2415
|
workstream.status = "failed";
|
|
1431
|
-
await timed(
|
|
2416
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
1432
2417
|
{
|
|
1433
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
2418
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
|
|
1434
2419
|
if (spawnErrors.length > 0)
|
|
1435
2420
|
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1436
2421
|
}
|
|
@@ -1456,11 +2441,12 @@ async function runProgramWith(options, permits) {
|
|
|
1456
2441
|
failureFingerprint(failure) === implementerFingerprint;
|
|
1457
2442
|
if (index === attempts.length - 1 || stuck) {
|
|
1458
2443
|
workstream.status = "failed";
|
|
1459
|
-
await timed(
|
|
1460
|
-
// The work stays in the
|
|
1461
|
-
//
|
|
2444
|
+
await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
|
|
2445
|
+
// The work stays in the retained worktree (a resume salvages it to a
|
|
2446
|
+
// `refs/nightshift/…-wip` ref before reaping); decisions made on the
|
|
2447
|
+
// way to a failure are still journaled and still reviewable.
|
|
1462
2448
|
{
|
|
1463
|
-
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
2449
|
+
const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
|
|
1464
2450
|
if (spawnErrors.length > 0)
|
|
1465
2451
|
(base.stageErrors ??= []).push(...spawnErrors);
|
|
1466
2452
|
}
|
|
@@ -1486,7 +2472,7 @@ async function runProgramWith(options, permits) {
|
|
|
1486
2472
|
// and a reviewer read of an empty diff could only muddy it.
|
|
1487
2473
|
if (reviewer && !deterministicFailure) {
|
|
1488
2474
|
const diagnosed = await diagnoseFailure({
|
|
1489
|
-
|
|
2475
|
+
cwd,
|
|
1490
2476
|
manifest,
|
|
1491
2477
|
workstream,
|
|
1492
2478
|
spec,
|
|
@@ -1499,14 +2485,14 @@ async function runProgramWith(options, permits) {
|
|
|
1499
2485
|
attemptSeat: attempt.label,
|
|
1500
2486
|
attemptIndex: index,
|
|
1501
2487
|
observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
|
|
1502
|
-
recorder,
|
|
2488
|
+
recorder: scope,
|
|
1503
2489
|
});
|
|
1504
2490
|
priorDiagnosis = diagnosed.diagnosis;
|
|
1505
2491
|
if (diagnosed.spawnError !== undefined) {
|
|
1506
2492
|
(base.stageErrors ??= []).push(diagnosed.spawnError);
|
|
1507
|
-
log(
|
|
2493
|
+
log(diagnosed.spawnError);
|
|
1508
2494
|
}
|
|
1509
|
-
await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
|
|
2495
|
+
await journalDenials(workstream.id, "reviewer", diagnosed.transcript, [], scope);
|
|
1510
2496
|
}
|
|
1511
2497
|
else {
|
|
1512
2498
|
priorDiagnosis = undefined;
|
|
@@ -1516,13 +2502,16 @@ async function runProgramWith(options, permits) {
|
|
|
1516
2502
|
attempt: attempt.label,
|
|
1517
2503
|
verdict: priorDiagnosis,
|
|
1518
2504
|
});
|
|
1519
|
-
log(
|
|
2505
|
+
log(`reviewer diagnosed the ${attempt.label} failure`);
|
|
1520
2506
|
}
|
|
1521
2507
|
emitAttemptOutcome(dispositionOutcome ?? "failed");
|
|
1522
2508
|
}
|
|
1523
2509
|
return base;
|
|
1524
2510
|
}
|
|
1525
|
-
async function journalDecisions(workstream, decisions, baseCommit
|
|
2511
|
+
async function journalDecisions(workstream, decisions, baseCommit,
|
|
2512
|
+
/** The workstream flow's span scope (SC-09) — see reviewWorkstreamDecisions. */
|
|
2513
|
+
flowScope = recorder) {
|
|
2514
|
+
const log = workstreamLog(baseLog, workstream.id);
|
|
1526
2515
|
const events = decisions.map((decision) => ({
|
|
1527
2516
|
kind: "decision-recorded",
|
|
1528
2517
|
at: now().toISOString(),
|
|
@@ -1532,9 +2521,9 @@ async function runProgramWith(options, permits) {
|
|
|
1532
2521
|
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
1533
2522
|
decidedBy: "implementer",
|
|
1534
2523
|
}));
|
|
1535
|
-
await timed(
|
|
2524
|
+
await timed(flowScope, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
|
|
1536
2525
|
for (const decision of decisions) {
|
|
1537
|
-
log(
|
|
2526
|
+
log(`decision: ${decision.title} -> ${decision.chosen}`);
|
|
1538
2527
|
}
|
|
1539
2528
|
}
|
|
1540
2529
|
/**
|
|
@@ -1543,20 +2532,26 @@ async function runProgramWith(options, permits) {
|
|
|
1543
2532
|
* and (for build roles only) the agent's `needsPermission` self-reports.
|
|
1544
2533
|
* A no-op when the spawn produced neither.
|
|
1545
2534
|
*/
|
|
1546
|
-
async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
|
|
2535
|
+
async function journalDenials(workstreamId, attempt, transcript, needsPermission = [], flowScope = recorder) {
|
|
2536
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1547
2537
|
const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
|
|
1548
2538
|
if (events.length === 0)
|
|
1549
2539
|
return;
|
|
1550
|
-
await timed(
|
|
2540
|
+
await timed(flowScope, { stage: "ledger-persist", workstream: workstreamId }, () => appendLedgerEvents(root, options.programId, events));
|
|
1551
2541
|
for (const event of events) {
|
|
1552
2542
|
if (event.kind !== "permission-denied")
|
|
1553
2543
|
continue;
|
|
1554
|
-
log(`${
|
|
2544
|
+
log(`${attempt}: denied \`${event.command}\` (${event.source})`);
|
|
1555
2545
|
}
|
|
1556
2546
|
}
|
|
1557
|
-
function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit
|
|
2547
|
+
function reviewWorkstreamDecisions(workstreamId, cwd, decisions, baseCommit,
|
|
2548
|
+
/** The workstream flow's span scope (SC-09): decider spans parent here,
|
|
2549
|
+
* never to whatever span another concurrent flow happens to have open. */
|
|
2550
|
+
flowScope = recorder) {
|
|
2551
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1558
2552
|
return reviewDecisions({
|
|
1559
2553
|
root,
|
|
2554
|
+
cwd,
|
|
1560
2555
|
programId: options.programId,
|
|
1561
2556
|
manifest,
|
|
1562
2557
|
workstreamId,
|
|
@@ -1568,17 +2563,19 @@ async function runProgramWith(options, permits) {
|
|
|
1568
2563
|
git,
|
|
1569
2564
|
isRepository,
|
|
1570
2565
|
reviewed,
|
|
1571
|
-
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
|
|
2566
|
+
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript, [], flowScope),
|
|
1572
2567
|
triaged,
|
|
1573
2568
|
now,
|
|
1574
2569
|
log,
|
|
1575
2570
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
|
|
1576
|
-
recorder,
|
|
2571
|
+
recorder: flowScope,
|
|
1577
2572
|
});
|
|
1578
2573
|
}
|
|
1579
|
-
function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
|
|
2574
|
+
function reviewWorkstreamFindings(workstreamId, cwd, findings, baseCommit, flowScope = recorder) {
|
|
2575
|
+
const log = workstreamLog(baseLog, workstreamId);
|
|
1580
2576
|
return triageFindings({
|
|
1581
2577
|
root,
|
|
2578
|
+
cwd,
|
|
1582
2579
|
programId: options.programId,
|
|
1583
2580
|
manifest,
|
|
1584
2581
|
workstreamId,
|
|
@@ -1591,92 +2588,12 @@ async function runProgramWith(options, permits) {
|
|
|
1591
2588
|
isRepository,
|
|
1592
2589
|
reviewed,
|
|
1593
2590
|
observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
|
|
1594
|
-
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
|
|
2591
|
+
onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript, [], flowScope),
|
|
1595
2592
|
triaged,
|
|
1596
2593
|
now,
|
|
1597
2594
|
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,
|
|
2595
|
+
recorder: flowScope,
|
|
1651
2596
|
});
|
|
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
2597
|
}
|
|
1681
2598
|
}
|
|
1682
2599
|
}
|
|
@@ -1916,6 +2833,8 @@ function causalAnalysisTimeline(results) {
|
|
|
1916
2833
|
roundsRun: result.testCritique.roundsRun,
|
|
1917
2834
|
openSubjects: result.testCritique.open.map((finding) => finding.subject),
|
|
1918
2835
|
resolvedSubjects: result.testCritique.resolved.map((finding) => finding.subject),
|
|
2836
|
+
fixesRun: result.testCritique.fixesRun,
|
|
2837
|
+
cyclesAllotted: result.testCritique.cyclesAllotted,
|
|
1919
2838
|
},
|
|
1920
2839
|
}),
|
|
1921
2840
|
}));
|
|
@@ -2042,12 +2961,62 @@ async function writeVerifyOutputEvidence(root, programId, label, output) {
|
|
|
2042
2961
|
* dimensions (SC-05/SC-06) so a repeated verify pass can be uniquely
|
|
2043
2962
|
* correlated back to the attempt that provoked it, not just its phase.
|
|
2044
2963
|
*/
|
|
2045
|
-
async function verifyAttempt(config, verifyRunner,
|
|
2964
|
+
async function verifyAttempt(config, verifyRunner,
|
|
2965
|
+
/** The tree the verify commands actually run against (WS-02) — a
|
|
2966
|
+
* workstream's worktree during a build, `root` for the baseline. Also the
|
|
2967
|
+
* tree the verify cache keys on (WS-09) — see the `cwd-in-key` decision. */
|
|
2968
|
+
cwd,
|
|
2969
|
+
/** Where verify-output evidence is written (WS-02) — always `root`, so a
|
|
2970
|
+
* worktree never contains runner-owned transcripts. */
|
|
2971
|
+
evidenceRoot, agentExitCode, recorder = NOOP_RUN_RECORDER, stage = "verification-command", opts = {}) {
|
|
2046
2972
|
if (agentExitCode !== 0) {
|
|
2047
2973
|
return `agent exited with code ${agentExitCode}`;
|
|
2048
2974
|
}
|
|
2049
|
-
const { programId, phase, workstream, role, attemptSeat, attemptIndex } = opts;
|
|
2050
|
-
|
|
2975
|
+
const { programId, phase, workstream, role, attemptSeat, attemptIndex, cache } = opts;
|
|
2976
|
+
const commands = Object.entries(config.verify);
|
|
2977
|
+
if (commands.length === 0)
|
|
2978
|
+
return undefined;
|
|
2979
|
+
const cacheDims = {
|
|
2980
|
+
stage,
|
|
2981
|
+
...(phase === undefined ? {} : { attemptReason: phase }),
|
|
2982
|
+
...(workstream === undefined ? {} : { workstream }),
|
|
2983
|
+
};
|
|
2984
|
+
const key = await cache?.keyFor(cwd);
|
|
2985
|
+
const hit = key === undefined ? undefined : cache?.get(key);
|
|
2986
|
+
if (hit !== undefined) {
|
|
2987
|
+
for (const [name] of commands) {
|
|
2988
|
+
const span = recorder.span({
|
|
2989
|
+
stage,
|
|
2990
|
+
verifyCommand: name,
|
|
2991
|
+
...(phase === undefined ? {} : { attemptReason: phase }),
|
|
2992
|
+
...(workstream === undefined ? {} : { workstream }),
|
|
2993
|
+
...(role === undefined ? {} : { role }),
|
|
2994
|
+
...(attemptSeat === undefined ? {} : { attemptSeat }),
|
|
2995
|
+
...(attemptIndex === undefined ? {} : { attemptIndex }),
|
|
2996
|
+
});
|
|
2997
|
+
const cached = hit.evidence.get(name);
|
|
2998
|
+
const evidence = cached && cached.length > 0
|
|
2999
|
+
? cached.map((ref) => ({
|
|
3000
|
+
...ref,
|
|
3001
|
+
note: `verify output (cached from the ${hit.phase} pass)`,
|
|
3002
|
+
}))
|
|
3003
|
+
: undefined;
|
|
3004
|
+
span.close({ outcome: "skipped", ...(evidence ? { evidence } : {}) });
|
|
3005
|
+
}
|
|
3006
|
+
recorder.point({
|
|
3007
|
+
kind: "verify-cache",
|
|
3008
|
+
coverage: "observed",
|
|
3009
|
+
label: "hit",
|
|
3010
|
+
detail: hit.phase,
|
|
3011
|
+
dimensions: cacheDims,
|
|
3012
|
+
});
|
|
3013
|
+
return undefined;
|
|
3014
|
+
}
|
|
3015
|
+
if (key !== undefined) {
|
|
3016
|
+
recorder.point({ kind: "verify-cache", coverage: "observed", label: "miss", dimensions: cacheDims });
|
|
3017
|
+
}
|
|
3018
|
+
const evidenceByCommand = new Map();
|
|
3019
|
+
for (const [name, command] of commands) {
|
|
2051
3020
|
const span = recorder.span({
|
|
2052
3021
|
stage,
|
|
2053
3022
|
verifyCommand: name,
|
|
@@ -2059,7 +3028,7 @@ async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder
|
|
|
2059
3028
|
});
|
|
2060
3029
|
let result;
|
|
2061
3030
|
try {
|
|
2062
|
-
result = await verifyRunner(command,
|
|
3031
|
+
result = await verifyRunner(command, cwd);
|
|
2063
3032
|
}
|
|
2064
3033
|
catch (error) {
|
|
2065
3034
|
span.close({ outcome: "failed" });
|
|
@@ -2067,13 +3036,29 @@ async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder
|
|
|
2067
3036
|
}
|
|
2068
3037
|
const evidenceRef = programId === undefined
|
|
2069
3038
|
? undefined
|
|
2070
|
-
: await writeVerifyOutputEvidence(
|
|
3039
|
+
: await writeVerifyOutputEvidence(evidenceRoot, programId, `${phase ?? stage}-${name}`, result.output);
|
|
2071
3040
|
const evidence = evidenceRef ? [evidenceRef] : undefined;
|
|
2072
3041
|
if (result.exitCode !== 0) {
|
|
2073
3042
|
span.close({ outcome: "failed", ...(evidence ? { evidence } : {}) });
|
|
2074
3043
|
return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
|
|
2075
3044
|
}
|
|
2076
3045
|
span.close({ outcome: "success", ...(evidence ? { evidence } : {}) });
|
|
3046
|
+
evidenceByCommand.set(name, evidence ?? []);
|
|
3047
|
+
}
|
|
3048
|
+
if (key !== undefined && cache !== undefined) {
|
|
3049
|
+
const settled = await cache.keyFor(cwd);
|
|
3050
|
+
if (settled === key) {
|
|
3051
|
+
cache.set(key, { evidence: evidenceByCommand, phase: phase ?? stage });
|
|
3052
|
+
}
|
|
3053
|
+
else {
|
|
3054
|
+
recorder.point({
|
|
3055
|
+
kind: "verify-cache",
|
|
3056
|
+
coverage: "observed",
|
|
3057
|
+
label: "not-cached",
|
|
3058
|
+
detail: "commands-changed-the-tree",
|
|
3059
|
+
dimensions: cacheDims,
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
2077
3062
|
}
|
|
2078
3063
|
return undefined;
|
|
2079
3064
|
}
|
|
@@ -2157,10 +3142,10 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
|
|
|
2157
3142
|
* undefined and the retry proceeds on the raw verify output alone.
|
|
2158
3143
|
*/
|
|
2159
3144
|
async function diagnoseFailure(options) {
|
|
2160
|
-
const {
|
|
3145
|
+
const { cwd, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
|
|
2161
3146
|
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
2162
3147
|
const rawDiff = baseCommit !== undefined
|
|
2163
|
-
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(
|
|
3148
|
+
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(cwd, baseCommit))
|
|
2164
3149
|
: "";
|
|
2165
3150
|
const clippedSpec = clipForReview(spec, "spec");
|
|
2166
3151
|
const clippedDiff = clipForReview(rawDiff, "diff");
|
|
@@ -2169,7 +3154,7 @@ async function diagnoseFailure(options) {
|
|
|
2169
3154
|
const brief = failureDiagnosisBrief(manifest, workstream, clippedSpec.text, clippedDiff.text, failure);
|
|
2170
3155
|
let invocation;
|
|
2171
3156
|
try {
|
|
2172
|
-
invocation = await invokeAgent(agentRunner, reviewer, brief,
|
|
3157
|
+
invocation = await invokeAgent(agentRunner, reviewer, brief, cwd, permits, "reviewerAgent", observe, recorder, {
|
|
2173
3158
|
stage: "failure-diagnosis",
|
|
2174
3159
|
workstream: workstream.id,
|
|
2175
3160
|
...(options.attemptSeat === undefined ? {} : { attemptSeat: options.attemptSeat }),
|
|
@@ -2305,27 +3290,61 @@ function findingEvidenceLine(finding) {
|
|
|
2305
3290
|
.join("; ");
|
|
2306
3291
|
return evidence === "" ? "" : ` Evidence: ${evidence}`;
|
|
2307
3292
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
* inviting judgment rather than demanding compliance — the manual workflow's
|
|
2311
|
-
* own framing (WS-03 design §2). Never asks the implementer to commit or to
|
|
2312
|
-
* echo a block back.
|
|
2313
|
-
*/
|
|
2314
|
-
function testCritiqueFixBrief(workstream, spec, findings) {
|
|
2315
|
-
const findingsList = findings.map((finding) => [
|
|
3293
|
+
function findingsListLines(findings) {
|
|
3294
|
+
return findings.map((finding) => [
|
|
2316
3295
|
`- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
|
|
2317
3296
|
findingEvidenceLine(finding),
|
|
2318
3297
|
]
|
|
2319
3298
|
.filter((line) => line !== "")
|
|
2320
3299
|
.join("\n"));
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* The implementer's fix re-brief (WS-09 §3.5.1): `binding` is the decider's
|
|
3303
|
+
* `fix-now` triage — a required correction — and `advisory` is everything
|
|
3304
|
+
* else the reviewer raised this cycle, left to the fixer's judgment. Never
|
|
3305
|
+
* asks the implementer to commit or to echo a block back.
|
|
3306
|
+
*/
|
|
3307
|
+
function testCritiqueFixBrief(workstream, spec, binding, advisory, resumed, deciderConfigured) {
|
|
3308
|
+
const bindingSection = binding.length > 0
|
|
3309
|
+
? [
|
|
3310
|
+
"## The decider ruled: fix these now",
|
|
3311
|
+
"",
|
|
3312
|
+
"An independent reviewer raised these and the decider triaged them",
|
|
3313
|
+
"as worth one bounded fix attempt before the run proceeds. Apply",
|
|
3314
|
+
"them.",
|
|
3315
|
+
"",
|
|
3316
|
+
...findingsListLines(binding),
|
|
3317
|
+
"",
|
|
3318
|
+
]
|
|
3319
|
+
: [];
|
|
3320
|
+
const advisorySection = advisory.length > 0
|
|
3321
|
+
? [
|
|
3322
|
+
"## Also raised, for your judgment",
|
|
3323
|
+
"",
|
|
3324
|
+
...(deciderConfigured
|
|
3325
|
+
? [
|
|
3326
|
+
"The reviewer raised these too. The decider did not mark them",
|
|
3327
|
+
"for an immediate fix. Weigh them and apply what you agree",
|
|
3328
|
+
"with.",
|
|
3329
|
+
]
|
|
3330
|
+
: [
|
|
3331
|
+
"No decider is configured for this run, so none of these were",
|
|
3332
|
+
"triaged. Weigh them and apply what you agree with.",
|
|
3333
|
+
]),
|
|
3334
|
+
"",
|
|
3335
|
+
...findingsListLines(advisory),
|
|
3336
|
+
"",
|
|
3337
|
+
]
|
|
3338
|
+
: [];
|
|
2321
3339
|
return [
|
|
2322
3340
|
`# Your tests for ${workstream.id}: ${workstream.name} were reviewed`,
|
|
2323
3341
|
"",
|
|
3342
|
+
...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
|
|
2324
3343
|
"An independent reviewer read the diff you produced — implementation",
|
|
2325
|
-
"and tests together
|
|
2326
|
-
"",
|
|
2327
|
-
...findingsList,
|
|
3344
|
+
"and tests together.",
|
|
2328
3345
|
"",
|
|
3346
|
+
...bindingSection,
|
|
3347
|
+
...advisorySection,
|
|
2329
3348
|
"## Specification",
|
|
2330
3349
|
"",
|
|
2331
3350
|
spec.trim(),
|
|
@@ -2339,12 +3358,26 @@ function testCritiqueFixBrief(workstream, spec, findings) {
|
|
|
2339
3358
|
"",
|
|
2340
3359
|
"Never commit — the runner owns commits, verifies your fix itself, and",
|
|
2341
3360
|
"either keeps it or discards it depending on whether it actually",
|
|
2342
|
-
"verifies.
|
|
2343
|
-
"
|
|
3361
|
+
"verifies. Run only the tests that cover what you changed; the runner",
|
|
3362
|
+
"runs the project's full verify suite after you and gates on it, which",
|
|
3363
|
+
"overrides AGENTS.md's \"verify before claiming completion\" directive",
|
|
3364
|
+
"for this spawn. Reply in prose, not a block: what you fixed and what",
|
|
3365
|
+
"you declined, and why.",
|
|
3366
|
+
"",
|
|
3367
|
+
"You are working in a dedicated git worktree on a per-workstream",
|
|
3368
|
+
"branch. Do not switch branches, and do not edit `docs/programs/` —",
|
|
3369
|
+
"those are the runner's own artifacts and changes to them are",
|
|
3370
|
+
"discarded.",
|
|
2344
3371
|
"",
|
|
2345
3372
|
summaryContract(),
|
|
2346
3373
|
].join("\n");
|
|
2347
3374
|
}
|
|
3375
|
+
/** Sorted `dirtyPaths` + `untrackedPaths`, joined, for the §3.6
|
|
3376
|
+
* before/after comparison a fix seat's resumed-spawn fallback guards on. */
|
|
3377
|
+
async function treeFingerprint(git, cwd) {
|
|
3378
|
+
const [dirty, untracked] = await Promise.all([git.dirtyPaths(cwd), git.untrackedPaths(cwd)]);
|
|
3379
|
+
return [...dirty].sort().join("\n") + "\u0000" + [...untracked].sort().join("\n");
|
|
3380
|
+
}
|
|
2348
3381
|
/**
|
|
2349
3382
|
* The fix seam, in full: one brief carrying every finding, one implementer
|
|
2350
3383
|
* invocation, one verification, one commit on green or one reset on
|
|
@@ -2352,21 +3385,58 @@ function testCritiqueFixBrief(workstream, spec, findings) {
|
|
|
2352
3385
|
* whether (and how many times) this is called.
|
|
2353
3386
|
*/
|
|
2354
3387
|
async function attemptFix(options) {
|
|
2355
|
-
const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent,
|
|
3388
|
+
const { root, cwd, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, binding, advisory, deciderConfigured, greenCommit, label, log, now, observe, stage, attemptReason, attemptIndex, } = options;
|
|
2356
3389
|
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
2357
|
-
const attemptReason = stage === "fix-now" ? "fix-now-finding" : "critique-finding";
|
|
2358
|
-
const brief = testCritiqueFixBrief(workstream, spec, findings);
|
|
2359
3390
|
let invocation;
|
|
3391
|
+
let session;
|
|
3392
|
+
let continuity;
|
|
3393
|
+
const preSpawnTree = options.buildSession ? await treeFingerprint(git, cwd) : undefined;
|
|
2360
3394
|
try {
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
3395
|
+
const spawned = await spawnWithSessionContinuity({
|
|
3396
|
+
resume: options.buildSession,
|
|
3397
|
+
spawn: (resume, resumed) => {
|
|
3398
|
+
const brief = testCritiqueFixBrief(workstream, spec, binding, advisory, resumed, deciderConfigured);
|
|
3399
|
+
return invokeAgent(agentRunner, agent, brief, cwd, permits, "agent", observe, recorder, { stage, workstream: workstream.id, attemptSeat: label, attemptIndex, attemptReason }, resume === undefined ? {} : { handle: resume });
|
|
3400
|
+
},
|
|
3401
|
+
untouched: async () => preSpawnTree !== undefined && (await treeFingerprint(git, cwd)) === preSpawnTree,
|
|
3402
|
+
log,
|
|
3403
|
+
observeContinuity: (result) => {
|
|
3404
|
+
continuity = result;
|
|
3405
|
+
try {
|
|
3406
|
+
recorder.point({
|
|
3407
|
+
kind: "session-continuity",
|
|
3408
|
+
coverage: "observed",
|
|
3409
|
+
label: result.mode,
|
|
3410
|
+
...(result.declineReason ? { detail: result.declineReason } : {}),
|
|
3411
|
+
dimensions: { stage, workstream: workstream.id, role: "agent", attemptSeat: label, attemptIndex, attemptReason },
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
catch (error) {
|
|
3415
|
+
log(`run-analytics: session continuity point failed: ${error.message}`);
|
|
3416
|
+
}
|
|
3417
|
+
},
|
|
2367
3418
|
});
|
|
3419
|
+
invocation = spawned.result;
|
|
3420
|
+
session = spawned.result.session;
|
|
3421
|
+
continuity = spawned.continuity;
|
|
2368
3422
|
}
|
|
2369
3423
|
catch (error) {
|
|
3424
|
+
// A rethrow out of `spawnWithSessionContinuity` (WS-08 §3.6) means a
|
|
3425
|
+
// resumed spawn threw *and* it had already touched the tree — the one
|
|
3426
|
+
// case where reaching this catch does not mean "nothing was built".
|
|
3427
|
+
// Reset defensively so a half-applied fix is never left standing; a
|
|
3428
|
+
// plain spawn failure (no resume attempted, or the tree is unchanged)
|
|
3429
|
+
// makes this a no-op reset to the state already there.
|
|
3430
|
+
if (preSpawnTree !== undefined) {
|
|
3431
|
+
try {
|
|
3432
|
+
await git.resetHard(cwd, greenCommit);
|
|
3433
|
+
}
|
|
3434
|
+
catch {
|
|
3435
|
+
// Best-effort: the green state may already be intact, or the reset
|
|
3436
|
+
// itself failed for an unrelated reason; either way this must not
|
|
3437
|
+
// mask the original spawn error being reported below.
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
2370
3440
|
const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
|
|
2371
3441
|
"the fix was not attempted and the green state was preserved.";
|
|
2372
3442
|
recorder.point({
|
|
@@ -2388,6 +3458,7 @@ async function attemptFix(options) {
|
|
|
2388
3458
|
summary: "(fix not attempted — the implementer could not be spawned)",
|
|
2389
3459
|
failure: spawnError,
|
|
2390
3460
|
spawnError,
|
|
3461
|
+
...(continuity ? { continuity } : {}),
|
|
2391
3462
|
};
|
|
2392
3463
|
}
|
|
2393
3464
|
const transcript = invocation.transcript;
|
|
@@ -2399,16 +3470,17 @@ async function attemptFix(options) {
|
|
|
2399
3470
|
for (const event of denialEvents) {
|
|
2400
3471
|
if (event.kind !== "permission-denied")
|
|
2401
3472
|
continue;
|
|
2402
|
-
log(`${
|
|
3473
|
+
log(`${label}: denied \`${event.command}\` (${event.source})`);
|
|
2403
3474
|
}
|
|
2404
3475
|
}
|
|
2405
|
-
const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
|
|
3476
|
+
const failure = await verifyAttempt(config, verifyRunner, cwd, root, invocation.exitCode, recorder, "verification-command", {
|
|
2406
3477
|
programId,
|
|
2407
3478
|
phase: stage,
|
|
2408
3479
|
workstream: workstream.id,
|
|
2409
3480
|
role: "agent",
|
|
2410
3481
|
attemptSeat: label,
|
|
2411
3482
|
attemptIndex,
|
|
3483
|
+
...(options.verifyCache ? { cache: options.verifyCache } : {}),
|
|
2412
3484
|
});
|
|
2413
3485
|
const emitOutcome = (outcome) => {
|
|
2414
3486
|
recorder.point({
|
|
@@ -2435,10 +3507,10 @@ async function attemptFix(options) {
|
|
|
2435
3507
|
// outside docs/programs counts as the implementer having done anything;
|
|
2436
3508
|
// with none, skip committing — the pending bookkeeping stays for a
|
|
2437
3509
|
// later commit to pick up, nothing is lost.
|
|
2438
|
-
const dirty = await git.dirtyPaths(
|
|
2439
|
-
const meaningfulChange = dirty.some((path) => !path
|
|
3510
|
+
const dirty = await git.dirtyPaths(cwd);
|
|
3511
|
+
const meaningfulChange = dirty.some((path) => !isRunnerOwnedArtifactPath(path));
|
|
2440
3512
|
const commit = meaningfulChange
|
|
2441
|
-
? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(
|
|
3513
|
+
? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(cwd, `nightshift(${programId}): ${workstream.id} ${label}`, ["docs/programs"]), commitEvidence)
|
|
2442
3514
|
: undefined;
|
|
2443
3515
|
emitOutcome(commit === undefined ? "discarded" : "success");
|
|
2444
3516
|
return {
|
|
@@ -2447,23 +3519,26 @@ async function attemptFix(options) {
|
|
|
2447
3519
|
...(commit === undefined ? {} : { commit }),
|
|
2448
3520
|
summary,
|
|
2449
3521
|
...(transcript === undefined ? {} : { transcript }),
|
|
3522
|
+
...(session === undefined ? {} : { session }),
|
|
3523
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
2450
3524
|
};
|
|
2451
3525
|
}
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2455
|
-
//
|
|
2456
|
-
//
|
|
2457
|
-
|
|
2458
|
-
// and the `finding-fix-attempted` event appended afterwards would project
|
|
2459
|
-
// against nothing, so the escalation would never reach the human (SC-15).
|
|
2460
|
-
// Preserve the journal across the rollback exactly as `decide`'s replay
|
|
2461
|
-
// does; the run's final commit picks it up.
|
|
2462
|
-
const snapshot = await snapshotProgramsDir(root);
|
|
2463
|
-
await git.resetHard(root, greenCommit);
|
|
2464
|
-
await restoreProgramsDir(root, snapshot);
|
|
3526
|
+
// A worktree hazard the pre-WS-02 code guarded against with a
|
|
3527
|
+
// snapshot/restore of docs/programs no longer exists: the runner writes
|
|
3528
|
+
// nothing under docs/programs in a worktree, so a whole-tree
|
|
3529
|
+
// `git reset --hard` here throws away nothing the run's journal at `root`
|
|
3530
|
+
// depends on.
|
|
3531
|
+
await git.resetHard(cwd, greenCommit);
|
|
2465
3532
|
emitOutcome("failed");
|
|
2466
|
-
return {
|
|
3533
|
+
return {
|
|
3534
|
+
outcome: "failed",
|
|
3535
|
+
greenCommit,
|
|
3536
|
+
summary,
|
|
3537
|
+
failure,
|
|
3538
|
+
...(transcript === undefined ? {} : { transcript }),
|
|
3539
|
+
...(session === undefined ? {} : { session }),
|
|
3540
|
+
...(continuity === undefined ? {} : { continuity }),
|
|
3541
|
+
};
|
|
2467
3542
|
}
|
|
2468
3543
|
/**
|
|
2469
3544
|
* The test-critique driver: wires WS-01's bounded review loop to the two
|
|
@@ -2473,12 +3548,16 @@ async function attemptFix(options) {
|
|
|
2473
3548
|
* failure), so a review can never turn green work red.
|
|
2474
3549
|
*/
|
|
2475
3550
|
async function runTestCritique(options) {
|
|
2476
|
-
const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
|
|
3551
|
+
const { root, cwd, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, deciderConfigured, baseCommit, now, log, transcriptSink, triage, } = options;
|
|
2477
3552
|
const recorder = options.recorder ?? NOOP_RUN_RECORDER;
|
|
2478
3553
|
let greenCommit = options.greenCommit;
|
|
2479
3554
|
let diffClipped = false;
|
|
2480
3555
|
const stageErrors = [];
|
|
2481
3556
|
const transcripts = [];
|
|
3557
|
+
// WS-08: last-wins across every fix round this pass runs — the next
|
|
3558
|
+
// round resumes whichever round most recently reported a session id.
|
|
3559
|
+
let buildSession = options.buildSession;
|
|
3560
|
+
const continuity = [];
|
|
2482
3561
|
if (!reviewer) {
|
|
2483
3562
|
return {
|
|
2484
3563
|
outcome: reviewerAbsentOutcome(),
|
|
@@ -2486,12 +3565,13 @@ async function runTestCritique(options) {
|
|
|
2486
3565
|
diffClipped,
|
|
2487
3566
|
stageErrors,
|
|
2488
3567
|
transcripts,
|
|
3568
|
+
...(buildSession ? { buildSession } : {}),
|
|
2489
3569
|
};
|
|
2490
3570
|
}
|
|
2491
|
-
const locate = (file) => locateInRepo(
|
|
3571
|
+
const locate = (file) => locateInRepo(cwd, file);
|
|
2492
3572
|
const review = async (round, priorOpen) => {
|
|
2493
3573
|
const rawDiff = baseCommit !== undefined
|
|
2494
|
-
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(
|
|
3574
|
+
? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(cwd, baseCommit))
|
|
2495
3575
|
: "";
|
|
2496
3576
|
const diff = clipForReview(rawDiff, "diff");
|
|
2497
3577
|
const clippedSpec = clipForReview(spec, "spec");
|
|
@@ -2502,7 +3582,7 @@ async function runTestCritique(options) {
|
|
|
2502
3582
|
const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
|
|
2503
3583
|
let invocation;
|
|
2504
3584
|
try {
|
|
2505
|
-
invocation = await invokeAgent(agentRunner, reviewer, brief,
|
|
3585
|
+
invocation = await invokeAgent(agentRunner, reviewer, brief, cwd, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"), recorder, { workstream: workstream.id, attemptIndex: round });
|
|
2506
3586
|
}
|
|
2507
3587
|
catch (error) {
|
|
2508
3588
|
// Fail open, matching the existing reviewer-error path: no findings
|
|
@@ -2518,7 +3598,7 @@ async function runTestCritique(options) {
|
|
|
2518
3598
|
for (const event of denialEvents) {
|
|
2519
3599
|
if (event.kind !== "permission-denied")
|
|
2520
3600
|
continue;
|
|
2521
|
-
log(
|
|
3601
|
+
log(`reviewer: denied \`${event.command}\` (${event.source})`);
|
|
2522
3602
|
}
|
|
2523
3603
|
}
|
|
2524
3604
|
}
|
|
@@ -2527,9 +3607,56 @@ async function runTestCritique(options) {
|
|
|
2527
3607
|
const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
|
|
2528
3608
|
return { findings, errors: parsed.errors, ran };
|
|
2529
3609
|
};
|
|
2530
|
-
const respond = async (
|
|
3610
|
+
const respond = async (cycle, findings) => {
|
|
3611
|
+
// Step 1: record. Every routable finding this cycle raised reaches the
|
|
3612
|
+
// ledger — not a post-fix residue (WS-09 §3.5), anchored to the same
|
|
3613
|
+
// durable pre-workstream commit the decider's own diff uses, so a
|
|
3614
|
+
// rendered reference still resolves after this workstream's worktree
|
|
3615
|
+
// and branch are gone (see the comment on `diffSince`'s docs/programs
|
|
3616
|
+
// exclusion for the same worktree-lifetime concern).
|
|
3617
|
+
const routable = findings.filter(hasRoutableEvidence);
|
|
3618
|
+
const events = findingsToLedgerEvents({
|
|
3619
|
+
workstreamId: workstream.id,
|
|
3620
|
+
findings: routable,
|
|
3621
|
+
...(baseCommit === undefined ? {} : { baseCommit }),
|
|
3622
|
+
now,
|
|
3623
|
+
});
|
|
3624
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, events));
|
|
3625
|
+
// Step 2: triage — one decider spawn per routed finding, before the fix.
|
|
3626
|
+
stageErrors.push(...(await triage(events, baseCommit)));
|
|
3627
|
+
// Step 3: split. Read the ledger projection *after* triage so this
|
|
3628
|
+
// cycle's `finding-triaged` events are visible. A `human-decided`
|
|
3629
|
+
// finding is dropped entirely — never handed to the fix seam
|
|
3630
|
+
// (`run-program.ts` §2.3/§3.6's invariant, preserved); a `fix-now`
|
|
3631
|
+
// finding with no fix attempt on it yet is binding; everything else
|
|
3632
|
+
// (open, accepted, escalated, or no ledger record at all — an
|
|
3633
|
+
// advisory-only or unverifiable finding) is advisory.
|
|
3634
|
+
const ledger = await readDecisionLedger(root, manifest.program.id);
|
|
3635
|
+
const recordsById = new Map(ledger.findings.map((record) => [record.id, record]));
|
|
3636
|
+
const binding = [];
|
|
3637
|
+
const bindingIds = [];
|
|
3638
|
+
const advisory = [];
|
|
3639
|
+
for (const finding of findings) {
|
|
3640
|
+
const id = fingerprint({ ...finding, workstreamId: workstream.id });
|
|
3641
|
+
const record = recordsById.get(id);
|
|
3642
|
+
if (record?.status === "human-decided")
|
|
3643
|
+
continue;
|
|
3644
|
+
if (record?.status === "fix-now" && record.fixAttempt === undefined) {
|
|
3645
|
+
binding.push(finding);
|
|
3646
|
+
bindingIds.push(id);
|
|
3647
|
+
}
|
|
3648
|
+
else {
|
|
3649
|
+
advisory.push(finding);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
// Step 4: fix. Skipped only when every finding this cycle raised was
|
|
3653
|
+
// already human-decided.
|
|
3654
|
+
if (binding.length === 0 && advisory.length === 0) {
|
|
3655
|
+
return { note: "every finding this cycle raised was already human-decided; no fix pass ran" };
|
|
3656
|
+
}
|
|
2531
3657
|
const fix = await attemptFix({
|
|
2532
3658
|
root,
|
|
3659
|
+
cwd,
|
|
2533
3660
|
programId: manifest.program.id,
|
|
2534
3661
|
workstream,
|
|
2535
3662
|
spec,
|
|
@@ -2539,30 +3666,83 @@ async function runTestCritique(options) {
|
|
|
2539
3666
|
verifyRunner,
|
|
2540
3667
|
git,
|
|
2541
3668
|
agent,
|
|
2542
|
-
|
|
3669
|
+
binding,
|
|
3670
|
+
advisory,
|
|
3671
|
+
deciderConfigured,
|
|
2543
3672
|
greenCommit,
|
|
2544
3673
|
label: "test critique fix",
|
|
2545
3674
|
log,
|
|
2546
3675
|
now,
|
|
2547
3676
|
observe: transcriptSink("test-critique-fix"),
|
|
2548
3677
|
stage: "test-critique-fix",
|
|
2549
|
-
|
|
3678
|
+
attemptReason: "triaged-findings",
|
|
3679
|
+
attemptIndex: cycle,
|
|
2550
3680
|
recorder,
|
|
3681
|
+
...(options.verifyCache ? { verifyCache: options.verifyCache } : {}),
|
|
3682
|
+
...(buildSession ? { buildSession } : {}),
|
|
2551
3683
|
});
|
|
2552
3684
|
greenCommit = fix.greenCommit;
|
|
2553
3685
|
if (fix.transcript)
|
|
2554
3686
|
transcripts.push(fix.transcript);
|
|
2555
3687
|
if (fix.spawnError !== undefined)
|
|
2556
3688
|
stageErrors.push(fix.spawnError);
|
|
3689
|
+
if (fix.session)
|
|
3690
|
+
buildSession = fix.session;
|
|
3691
|
+
if (fix.continuity)
|
|
3692
|
+
continuity.push(fix.continuity);
|
|
3693
|
+
// Step 5: journal the fix-now outcome. One `finding-fix-attempted` per
|
|
3694
|
+
// binding id, exactly `driveFixNowFindings`'s old rule: "kept" requires
|
|
3695
|
+
// both a clean verify and a landed commit — a clean-but-empty attempt is
|
|
3696
|
+
// a decline, not a fix. The merged fix also covers `advisory` findings
|
|
3697
|
+
// in the same spawn, so a commit does not prove any one binding finding
|
|
3698
|
+
// was addressed (the ledger projection already disclaims per-finding
|
|
3699
|
+
// proof); the note is prefixed with the binding subjects so a human
|
|
3700
|
+
// reading the ledger sees both the instruction and the reply, never
|
|
3701
|
+
// inferred from the fixer's prose.
|
|
3702
|
+
if (bindingIds.length > 0) {
|
|
3703
|
+
const kept = fix.outcome === "kept" && fix.commit !== undefined;
|
|
3704
|
+
const bindingSubjects = binding.map((finding) => finding.subject).join(", ");
|
|
3705
|
+
const note = kept
|
|
3706
|
+
? `(fix-now: ${bindingSubjects}) ${fix.summary}`
|
|
3707
|
+
: fix.outcome === "kept"
|
|
3708
|
+
? `(fix-now: ${bindingSubjects}) ${fix.summary} (the attempt verified clean but made no change; no fix landed)`
|
|
3709
|
+
: `(fix-now: ${bindingSubjects}) ${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
|
|
3710
|
+
const fixNowEvents = bindingIds.map((id) => ({
|
|
3711
|
+
kind: "finding-fix-attempted",
|
|
3712
|
+
at: now().toISOString(),
|
|
3713
|
+
id,
|
|
3714
|
+
outcome: kept ? "kept" : "failed",
|
|
3715
|
+
note,
|
|
3716
|
+
...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
|
|
3717
|
+
attemptedBy: "implementer",
|
|
3718
|
+
}));
|
|
3719
|
+
await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, fixNowEvents));
|
|
3720
|
+
log(kept
|
|
3721
|
+
? "fix-now finding(s) verified and committed"
|
|
3722
|
+
: `fix-now finding(s) failed and were escalated — ${note}`);
|
|
3723
|
+
}
|
|
2557
3724
|
if (fix.outcome === "kept") {
|
|
2558
|
-
log(
|
|
3725
|
+
log("test critique fix verified and committed");
|
|
2559
3726
|
return { note: `${fix.summary} (fix verified and committed)` };
|
|
2560
3727
|
}
|
|
2561
|
-
log(
|
|
3728
|
+
log(`test critique fix failed verification and was discarded — ${fix.failure}`);
|
|
2562
3729
|
return { note: `${fix.summary} (fix failed verification and was discarded; the green state was preserved)` };
|
|
2563
3730
|
};
|
|
2564
|
-
const outcome = await runReviewPass({
|
|
2565
|
-
|
|
3731
|
+
const outcome = await runReviewPass({
|
|
3732
|
+
cycles: TEST_CRITIQUE_CYCLES,
|
|
3733
|
+
cycleNote: TEST_CRITIQUE_CYCLE_NOTE,
|
|
3734
|
+
review,
|
|
3735
|
+
respond,
|
|
3736
|
+
});
|
|
3737
|
+
return {
|
|
3738
|
+
outcome,
|
|
3739
|
+
finalCommit: greenCommit,
|
|
3740
|
+
diffClipped,
|
|
3741
|
+
stageErrors,
|
|
3742
|
+
transcripts,
|
|
3743
|
+
...(buildSession ? { buildSession } : {}),
|
|
3744
|
+
...(continuity.length > 0 ? { continuity } : {}),
|
|
3745
|
+
};
|
|
2566
3746
|
}
|
|
2567
3747
|
function renderSpecsSection(authorResult) {
|
|
2568
3748
|
if (authorResult.results.length === 0)
|
|
@@ -2597,7 +3777,7 @@ function renderSpecsSection(authorResult) {
|
|
|
2597
3777
|
lines.push(...notes.map((note) => `- ${note}`), "");
|
|
2598
3778
|
}
|
|
2599
3779
|
if (entry.specCritique) {
|
|
2600
|
-
lines.push(...renderPassReport("Spec critique", entry.specCritique), "");
|
|
3780
|
+
lines.push(...renderPassReport("Spec critique", entry.specCritique, "runner spec check"), "");
|
|
2601
3781
|
}
|
|
2602
3782
|
}
|
|
2603
3783
|
return lines;
|
|
@@ -2844,6 +4024,25 @@ function renderWorkstreamCommands(result) {
|
|
|
2844
4024
|
}
|
|
2845
4025
|
return lines;
|
|
2846
4026
|
}
|
|
4027
|
+
/**
|
|
4028
|
+
* States a fix seat's session-resume fallback where a human will see it
|
|
4029
|
+
* (WS-08 §3.11) — nothing is added when every requesting seat resumed
|
|
4030
|
+
* cleanly, so a clean run's report is unchanged.
|
|
4031
|
+
*/
|
|
4032
|
+
function renderSessionContinuityFallbacks(result) {
|
|
4033
|
+
const lines = [];
|
|
4034
|
+
for (const entry of result.sessionContinuity ?? []) {
|
|
4035
|
+
if (entry.continuity.mode !== "fresh" || entry.continuity.declineReason === undefined)
|
|
4036
|
+
continue;
|
|
4037
|
+
const reason = describeSessionDecline(entry.continuity.declineReason, {
|
|
4038
|
+
producerCommand: entry.producerCommand,
|
|
4039
|
+
seatCommand: entry.seatCommand,
|
|
4040
|
+
});
|
|
4041
|
+
lines.push(` - The ${entry.seat} could not continue the implementer's session (${reason}); ` +
|
|
4042
|
+
"a fresh agent applied the findings instead.");
|
|
4043
|
+
}
|
|
4044
|
+
return lines;
|
|
4045
|
+
}
|
|
2847
4046
|
/**
|
|
2848
4047
|
* The program-level `## Commands denied` section (SC-12): every
|
|
2849
4048
|
* `permission-denied` ledger record, grouped by workstream in the run's own
|
|
@@ -2929,6 +4128,12 @@ causalAnalysisSection) {
|
|
|
2929
4128
|
for (const diagnosis of result.failureDiagnoses ?? []) {
|
|
2930
4129
|
lines.push(`**Reviewer diagnosis (after the ${diagnosis.attempt} attempt):**`, "", diagnosis.verdict, "");
|
|
2931
4130
|
}
|
|
4131
|
+
if (result.worktreePath !== undefined) {
|
|
4132
|
+
lines.push(`Worktree retained at \`${result.worktreePath}\`.`, "");
|
|
4133
|
+
}
|
|
4134
|
+
if (result.retainedRef !== undefined) {
|
|
4135
|
+
lines.push(`Verified commit retained at \`${result.retainedRef}\`.`, "");
|
|
4136
|
+
}
|
|
2932
4137
|
const workstreamDenials = ledger.denials.filter((denial) => denial.workstream === result.id);
|
|
2933
4138
|
if (workstreamDenials.length > 0) {
|
|
2934
4139
|
lines.push("Commands denied: " +
|
|
@@ -2962,6 +4167,7 @@ causalAnalysisSection) {
|
|
|
2962
4167
|
if (result.outcome.status === "complete" || result.outcome.status === "failed") {
|
|
2963
4168
|
lines.push(...renderWorkstreamCommands(result));
|
|
2964
4169
|
}
|
|
4170
|
+
lines.push(...renderSessionContinuityFallbacks(result));
|
|
2965
4171
|
}
|
|
2966
4172
|
lines.push("");
|
|
2967
4173
|
if (wholeProgram !== undefined) {
|