@yagni-app/code-staging 1.2.6-staging.1952.1 → 1.2.6-staging.1956.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension/branding.d.ts +20 -0
- package/dist/extension/branding.js +49 -8
- package/dist/extension/index.js +5 -0
- package/dist/extension/pipeline/changeScan.d.ts +62 -0
- package/dist/extension/pipeline/changeScan.js +307 -0
- package/dist/extension/pipeline/cloud.d.ts +205 -0
- package/dist/extension/pipeline/cloud.js +382 -0
- package/dist/extension/pipeline/continuation.d.ts +7 -3
- package/dist/extension/pipeline/continuation.js +29 -13
- package/dist/extension/pipeline/findings.d.ts +9 -4
- package/dist/extension/pipeline/findings.js +25 -10
- package/dist/extension/pipeline/finish.d.ts +11 -1
- package/dist/extension/pipeline/finish.js +51 -5
- package/dist/extension/pipeline/headlessGo.d.ts +20 -1
- package/dist/extension/pipeline/headlessGo.js +205 -33
- package/dist/extension/pipeline/orchestrator.d.ts +8 -2
- package/dist/extension/pipeline/orchestrator.js +249 -68
- package/dist/extension/pipeline/personas.d.ts +8 -3
- package/dist/extension/pipeline/personas.js +42 -7
- package/dist/extension/pipeline/planOutput.d.ts +42 -0
- package/dist/extension/pipeline/planOutput.js +94 -0
- package/dist/extension/pipeline/stages.d.ts +34 -5
- package/dist/extension/pipeline/stages.js +136 -7
- package/dist/extension/pipeline/tierCap.d.ts +22 -1
- package/dist/extension/pipeline/tierCap.js +48 -1
- package/dist/extension/pipeline/types.d.ts +31 -0
- package/dist/extension/pipeline/types.js +2 -0
- package/dist/extension/pipeline/verify.d.ts +33 -0
- package/dist/extension/pipeline/verify.js +101 -9
- package/package.json +2 -2
|
@@ -26,14 +26,15 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, DEFAULT_STAGE_ESTIMATE_MS, EMPTY_RUN_USAGE, exceedsBudget, stageWall, stageWontFit, } from "./budget.js";
|
|
28
28
|
import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
|
|
29
|
-
import { auditClaims, parsePartition } from "./fanout.js";
|
|
30
|
-
import { extractFindingsBlock, hasBlockingFindings, isBlockingFinding, parseFindings, shouldStop, unionFindings } from "./findings.js";
|
|
29
|
+
import { auditClaims, parsePartition, } from "./fanout.js";
|
|
30
|
+
import { extractFindingsBlock, hasBlockingFindings, isBlockingFinding, parseFindings, shouldStop, unionFindings, } from "./findings.js";
|
|
31
31
|
import { limitedMapFiles, readTouchedFiles } from "./fixRound.js";
|
|
32
32
|
import { missionDesignContext, missionSeed, missionSkippedStages, normalizeMission, PINNED_DESIGN_MARKER, } from "./mission.js";
|
|
33
33
|
import { withResilience } from "./resilience.js";
|
|
34
34
|
import { runStage as defaultRunStage } from "./runner.js";
|
|
35
35
|
import { makeRunVerify, makeWorkstreamCheck, parseChangedPaths, } from "./verify.js";
|
|
36
|
-
import {
|
|
36
|
+
import { extractPlanJson } from "./planOutput.js";
|
|
37
|
+
import { builderStage, fixerStage, fixRoundImplementStage, fixRoundMapStage, FULL_LANE, orchestratorStage, partitionReaskStage, PARTITION_CALLER_LABEL, planJsonReaskStage, REQUIRED_LENSES, reaskStage, reviewStage, synthesizerFixStage, synthesizerStage, SYNTHESIZER_CALLER_LABEL, workstreamCallerLabel, } from "./stages.js";
|
|
37
38
|
import { snapshotWorkspace as defaultSnapshotWorkspace, workspaceChanged } from "./workspace.js";
|
|
38
39
|
import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MAX_FANOUT_CONCURRENCY, MAX_FANOUT_CONCURRENCY_ULTRA, MAX_FIX_TURNS, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
|
|
39
40
|
/** Each review round's findings as the host stores them: bounded, messages clipped. */
|
|
@@ -95,7 +96,10 @@ function toolFailureReason(r) {
|
|
|
95
96
|
* that exits 0 while most of its edits failed is not trusted on its prose alone.
|
|
96
97
|
*/
|
|
97
98
|
function isFailed(r) {
|
|
98
|
-
return r.exitCode !== 0 ||
|
|
99
|
+
return (r.exitCode !== 0 ||
|
|
100
|
+
r.stopReason === "error" ||
|
|
101
|
+
r.stopReason === "aborted" ||
|
|
102
|
+
toolFailureExceeded(r));
|
|
99
103
|
}
|
|
100
104
|
/**
|
|
101
105
|
* A non-final build stage threads its `finalOutput` forward as the NEXT stage's
|
|
@@ -168,6 +172,10 @@ function creditExhaustionReason(r) {
|
|
|
168
172
|
return undefined;
|
|
169
173
|
return "the workspace is out of YAGNI credits (the model proxy returned 402). Top up credits, then re-run /go.";
|
|
170
174
|
}
|
|
175
|
+
/** How a reviewer is named in the round's record: its lens, or the lane's one reviewer. */
|
|
176
|
+
function reviewerName(lens) {
|
|
177
|
+
return lens ?? "reviewer";
|
|
178
|
+
}
|
|
171
179
|
/** Render only the blocking findings as the fix stage's `{previous}` handoff. */
|
|
172
180
|
function serializeBlocking(findings) {
|
|
173
181
|
return findings
|
|
@@ -203,7 +211,11 @@ function composeFanHandoff(decision, workstreams, results, violations) {
|
|
|
203
211
|
parts.push(`## ${w.name} (${w.tier})\nClaimed: ${w.files.join(", ")}\n\n${body}`);
|
|
204
212
|
});
|
|
205
213
|
if (violations.length > 0) {
|
|
206
|
-
parts.push([
|
|
214
|
+
parts.push([
|
|
215
|
+
"## Out-of-claim edits",
|
|
216
|
+
"These paths changed outside every workstream's claims:",
|
|
217
|
+
...violations.map((p) => `- ${p}`),
|
|
218
|
+
].join("\n"));
|
|
207
219
|
}
|
|
208
220
|
return parts.join("\n\n");
|
|
209
221
|
}
|
|
@@ -231,7 +243,9 @@ async function mapWithConcurrencyLimit(items, concurrency, fn) {
|
|
|
231
243
|
* floor and run past the deadline, so it fails instead of retrying.
|
|
232
244
|
*/
|
|
233
245
|
export function stageRunnerPolicy(budget) {
|
|
234
|
-
return budget.deadlineMs === undefined
|
|
246
|
+
return budget.deadlineMs === undefined
|
|
247
|
+
? DEFAULT_RESILIENCE_POLICY
|
|
248
|
+
: { ...DEFAULT_RESILIENCE_POLICY, retryWallTimeout: false };
|
|
235
249
|
}
|
|
236
250
|
/**
|
|
237
251
|
* The pipeline's default stage runner: `base` under the R1 resilience wrapper
|
|
@@ -286,7 +300,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
286
300
|
// `go:implement:<workstream>`), so per-workstream spend attributes for free the
|
|
287
301
|
// way review lenses do. Every other stage leaves it undefined and keeps the
|
|
288
302
|
// runner's own `go:<stage>` default.
|
|
289
|
-
// `writes` marks a
|
|
303
|
+
// `writes` marks a child that gets `extraArgs` (the Team's skills): a build or
|
|
304
|
+
// fix child, and on a lane with `skillsEveryStage` (the cloud session) its
|
|
305
|
+
// plan and review children too (spec decision 4).
|
|
290
306
|
const stageDepsFor = (tag, callerLabel, writes = false) => {
|
|
291
307
|
const onEvent = deps.onEvent;
|
|
292
308
|
return {
|
|
@@ -311,9 +327,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
311
327
|
// The text the stages plan against: the resolved ticket brief (verbatim title +
|
|
312
328
|
// body) when /go could fetch it, else the raw ticket reference (honest blind).
|
|
313
329
|
const promptTicket = deps.ticketBrief ?? ticket;
|
|
314
|
-
// M6: default grounded + the
|
|
330
|
+
// M6: default grounded + the lane's stage list; the blind eval lane overrides both.
|
|
315
331
|
const grounded = deps.grounded !== false;
|
|
316
|
-
const
|
|
332
|
+
const lane = deps.lane ?? FULL_LANE;
|
|
333
|
+
const all = deps.stages ?? lane.stages;
|
|
317
334
|
// R3-b: aggregate usage ceiling, checked at each stage boundary.
|
|
318
335
|
const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
|
|
319
336
|
// Mission mode (spec: sandbox harness parity). The factory produced the plan
|
|
@@ -376,7 +393,8 @@ export async function runPipeline(ticket, deps) {
|
|
|
376
393
|
let reviewInput = resume ? resume.reviewInput : "";
|
|
377
394
|
const designMarker = PINNED_DESIGN_MARKER;
|
|
378
395
|
const resumedDesignOffset = reviewInput.indexOf(designMarker);
|
|
379
|
-
let designContext = missionDesignContext(mission) ??
|
|
396
|
+
let designContext = missionDesignContext(mission) ??
|
|
397
|
+
(resumedDesignOffset >= 0 ? reviewInput.slice(resumedDesignOffset) : undefined);
|
|
380
398
|
let stopReason = "clean";
|
|
381
399
|
// P4: honest note carried out when the verify gate could not give a verdict on
|
|
382
400
|
// the final round (no command / unrunnable), so /go never claims a build it did
|
|
@@ -402,7 +420,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
402
420
|
const observedMs = {};
|
|
403
421
|
const wontFit = (kind) => stageWontFit(budget, kind, observedMs[kind] ?? DEFAULT_STAGE_ESTIMATE_MS[kind], clock());
|
|
404
422
|
if (deps.continuation) {
|
|
405
|
-
log("continuation", {
|
|
423
|
+
log("continuation", {
|
|
424
|
+
stageAtStop: deps.continuation.stageAtStop,
|
|
425
|
+
resumedAtRound: resume ? resume.round : null,
|
|
426
|
+
});
|
|
406
427
|
}
|
|
407
428
|
// The map stage's brief, kept for the partitioner's repo context. Absent on a
|
|
408
429
|
// mission run (map is skipped there — the injected memo stands in) and on any
|
|
@@ -470,7 +491,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
470
491
|
const runImplementChecker = async (args) => {
|
|
471
492
|
const { stage, workstreams, handoff } = args;
|
|
472
493
|
const fanned = workstreams.length > 0;
|
|
473
|
-
const targets = workstreams.map((w) => ({
|
|
494
|
+
const targets = workstreams.map((w) => ({
|
|
495
|
+
name: w.name,
|
|
496
|
+
files: w.files,
|
|
497
|
+
}));
|
|
474
498
|
let report = await runChecker(targets);
|
|
475
499
|
let body = handoff;
|
|
476
500
|
// Defects the SYNTHESIZER named in its latest summary, kept apart from the
|
|
@@ -513,7 +537,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
513
537
|
const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
|
|
514
538
|
const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
|
|
515
539
|
progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
|
|
516
|
-
const fixed = await runStageFn({
|
|
540
|
+
const fixed = await runStageFn({
|
|
541
|
+
...fixerStage(a.workstream, renderFindings(a.findings), siblings),
|
|
542
|
+
tools: stage.tools,
|
|
543
|
+
}, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name), true));
|
|
517
544
|
progress({
|
|
518
545
|
kind: "stage_done",
|
|
519
546
|
stageId: stage.id,
|
|
@@ -527,7 +554,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
527
554
|
// A fix turn that CRASHED does not fail the stage: the merged candidate is
|
|
528
555
|
// still real work, the re-check below will simply still see the findings,
|
|
529
556
|
// and the cap bounds how long that can go on. Recorded, never silent.
|
|
530
|
-
const brokenFixes = fixes
|
|
557
|
+
const brokenFixes = fixes
|
|
558
|
+
.map((r, i) => (isFailed(r) ? assigned[i].workstream.name : null))
|
|
559
|
+
.filter(Boolean);
|
|
531
560
|
if (brokenFixes.length > 0)
|
|
532
561
|
log("fix_turn_child_failed", { turn, workstreams: brokenFixes });
|
|
533
562
|
}
|
|
@@ -537,7 +566,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
537
566
|
// is the one child with no claims, so it must not write while they do.
|
|
538
567
|
if (unassigned.length > 0) {
|
|
539
568
|
const findingsText = renderFindings(unassigned);
|
|
540
|
-
const fixStage = fanned
|
|
569
|
+
const fixStage = fanned
|
|
570
|
+
? synthesizerFixStage(findingsText)
|
|
571
|
+
: fixerStage(undefined, findingsText);
|
|
541
572
|
const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined, true));
|
|
542
573
|
stages.push(fixed);
|
|
543
574
|
if (fanned) {
|
|
@@ -575,6 +606,29 @@ export async function runPipeline(ticket, deps) {
|
|
|
575
606
|
return `${body}\n\n${composeFixNote(turn)}`;
|
|
576
607
|
return body;
|
|
577
608
|
};
|
|
609
|
+
/**
|
|
610
|
+
* One writer builds the plan, then the implement checker's verify and
|
|
611
|
+
* bounded fix turns run on its tree. The diamond's single-writer verdict
|
|
612
|
+
* runs this, and so does a lane with no fan-out (the cloud lane), where no
|
|
613
|
+
* partitioner runs at all.
|
|
614
|
+
*/
|
|
615
|
+
const runOneWriter = async (stage, previous) => {
|
|
616
|
+
const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, true));
|
|
617
|
+
stages.push(written);
|
|
618
|
+
// A failed or credit-starved writer never reaches the checker: the build
|
|
619
|
+
// loop's own guard turns it into `partial` or the honest stage error, and
|
|
620
|
+
// verifying a tree nobody finished writing would only add noise.
|
|
621
|
+
if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
|
|
622
|
+
return written;
|
|
623
|
+
// Decision 7: the fix loop applies identically here: the same checker gate,
|
|
624
|
+
// the same one builder re-engaged, the same cap.
|
|
625
|
+
const text = await runImplementChecker({
|
|
626
|
+
stage,
|
|
627
|
+
workstreams: [],
|
|
628
|
+
handoff: written.finalOutput,
|
|
629
|
+
});
|
|
630
|
+
return text === written.finalOutput ? written : { ...written, finalOutput: text };
|
|
631
|
+
};
|
|
578
632
|
/**
|
|
579
633
|
* The implement diamond (spec "Diamond execution"). Returns the StageResult the
|
|
580
634
|
* build loop treats as "the implement stage". The diamond owns `stages` for the
|
|
@@ -606,17 +660,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
606
660
|
const partitionTag = { stageId: stage.id };
|
|
607
661
|
const runSingleWriter = async (decision) => {
|
|
608
662
|
recordDecision(decision, beforeBuild);
|
|
609
|
-
|
|
610
|
-
stages.push(written);
|
|
611
|
-
// A failed or credit-starved writer never reaches the checker: the build
|
|
612
|
-
// loop's own guard turns it into `partial` or the honest stage error, and
|
|
613
|
-
// verifying a tree nobody finished writing would only add noise.
|
|
614
|
-
if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
|
|
615
|
-
return written;
|
|
616
|
-
// Decision 7: the fix loop applies identically here — the same checker gate,
|
|
617
|
-
// the same one builder re-engaged, the same cap.
|
|
618
|
-
const text = await runImplementChecker({ stage, workstreams: [], handoff: written.finalOutput });
|
|
619
|
-
return text === written.finalOutput ? written : { ...written, finalOutput: text };
|
|
663
|
+
return runOneWriter(stage, previous);
|
|
620
664
|
};
|
|
621
665
|
const partition = await runStageFn(partitionStage, { ticket: promptTicket, previous, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
|
|
622
666
|
stages.push(partition);
|
|
@@ -682,7 +726,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
682
726
|
// actually runs at once, so a width-8 partition runs 4-at-a-time outside ultra
|
|
683
727
|
// rather than being refused.
|
|
684
728
|
const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
|
|
685
|
-
log("fanout_start", {
|
|
729
|
+
log("fanout_start", {
|
|
730
|
+
width: workstreams.length,
|
|
731
|
+
concurrency: Math.min(workstreams.length, ceiling),
|
|
732
|
+
});
|
|
686
733
|
const builders = await mapWithConcurrencyLimit(workstreams, ceiling, async (w) => {
|
|
687
734
|
const siblings = workstreams.filter((s) => s.name !== w.name);
|
|
688
735
|
// Per-builder lifecycle, exactly the review fan-out's shape: each child
|
|
@@ -701,7 +748,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
701
748
|
return built;
|
|
702
749
|
});
|
|
703
750
|
stages.push(...builders);
|
|
704
|
-
const builderCredit = builders
|
|
751
|
+
const builderCredit = builders
|
|
752
|
+
.map((r) => creditExhaustionReason(r))
|
|
753
|
+
.find((v) => Boolean(v));
|
|
705
754
|
if (builderCredit) {
|
|
706
755
|
log("pipeline_failed", { stageId: stage.id, reason: builderCredit });
|
|
707
756
|
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${builderCredit}`, stages);
|
|
@@ -740,12 +789,19 @@ export async function runPipeline(ticket, deps) {
|
|
|
740
789
|
// Summed for honesty in logs and handoffs only: this object is NEVER pushed
|
|
741
790
|
// into `stages` (its children already are), so the run's usage is counted once.
|
|
742
791
|
usage: sumStageUsage(builders),
|
|
743
|
-
stderr: builders
|
|
792
|
+
stderr: builders
|
|
793
|
+
.map((r) => r.stderr)
|
|
794
|
+
.filter(Boolean)
|
|
795
|
+
.join("\n"),
|
|
744
796
|
toolCalls: builders.reduce((n, r) => n + r.toolCalls, 0),
|
|
745
797
|
toolErrors: builders.reduce((n, r) => n + r.toolErrors, 0),
|
|
746
798
|
...(failed.length > 0 ? { stopReason: "error", errorMessage: failureNote } : {}),
|
|
747
799
|
};
|
|
748
|
-
log("fanout_done", {
|
|
800
|
+
log("fanout_done", {
|
|
801
|
+
width: workstreams.length,
|
|
802
|
+
failed: failed.map((f) => f.name),
|
|
803
|
+
violations: violations.length,
|
|
804
|
+
});
|
|
749
805
|
return summary;
|
|
750
806
|
};
|
|
751
807
|
/** Emit + journal the diamond's verdict the moment it resolves (decision 2). */
|
|
@@ -806,7 +862,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
806
862
|
try {
|
|
807
863
|
const read = await (deps.readTouchedFiles ?? readTouchedFiles)(deps.cwd, fixInputs.baseRef, deps.signal);
|
|
808
864
|
if (read === null) {
|
|
809
|
-
log("fix_round_touched_unread", {
|
|
865
|
+
log("fix_round_touched_unread", {
|
|
866
|
+
baseRef: fixInputs.baseRef ?? null,
|
|
867
|
+
error: "git could not list the touched files",
|
|
868
|
+
});
|
|
810
869
|
}
|
|
811
870
|
touched = read ?? [];
|
|
812
871
|
}
|
|
@@ -853,9 +912,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
853
912
|
return done("budget_exceeded");
|
|
854
913
|
}
|
|
855
914
|
}
|
|
856
|
-
// One builder at the
|
|
915
|
+
// One builder at the lane's fix tier: the partition decision is recorded as
|
|
857
916
|
// single so the run's record still says how it was built.
|
|
858
|
-
const implementDef = fixRoundImplementStage(all.find((s) => s.id === "implement"));
|
|
917
|
+
const implementDef = fixRoundImplementStage(all.find((s) => s.id === "implement"), fix.model);
|
|
859
918
|
const previous = [
|
|
860
919
|
brief || "(No map brief: work from the findings, files and lines the ticket lists.)",
|
|
861
920
|
designContext,
|
|
@@ -870,9 +929,15 @@ export async function runPipeline(ticket, deps) {
|
|
|
870
929
|
log("stage_done", { stageId: "implement", exitCode: written.exitCode });
|
|
871
930
|
const credit = creditExhaustionReason(written);
|
|
872
931
|
if (isFailed(written)) {
|
|
873
|
-
const why = credit ??
|
|
874
|
-
|
|
875
|
-
|
|
932
|
+
const why = credit ??
|
|
933
|
+
toolFailureReason(written) ??
|
|
934
|
+
(written.errorMessage || written.stderr || written.finalOutput || "(no output)");
|
|
935
|
+
const killedMidWork = written.exitCode !== 0 ||
|
|
936
|
+
written.stopReason === "aborted" ||
|
|
937
|
+
written.stopReason === "error";
|
|
938
|
+
if (killedMidWork &&
|
|
939
|
+
!deps.signal?.aborted &&
|
|
940
|
+
workspaceChanged(beforeBuild, await snapshotFn(deps.cwd, deps.signal))) {
|
|
876
941
|
log("pipeline_partial", { stageId: "implement", reason: why });
|
|
877
942
|
return done("partial");
|
|
878
943
|
}
|
|
@@ -887,7 +952,11 @@ export async function runPipeline(ticket, deps) {
|
|
|
887
952
|
return done("aborted");
|
|
888
953
|
// Verify: the plan's checks (and the pull request's CI jobs) on the tree,
|
|
889
954
|
// with the same bounded fix turns as any implement stage.
|
|
890
|
-
await runImplementChecker({
|
|
955
|
+
await runImplementChecker({
|
|
956
|
+
stage: implementDef,
|
|
957
|
+
workstreams: [],
|
|
958
|
+
handoff: written.finalOutput,
|
|
959
|
+
});
|
|
891
960
|
progress({ kind: "stage_done", stageId: "implement" });
|
|
892
961
|
stageEvent("implement", "finish");
|
|
893
962
|
if (deps.signal?.aborted)
|
|
@@ -905,7 +974,11 @@ export async function runPipeline(ticket, deps) {
|
|
|
905
974
|
verifyCommand = undefined;
|
|
906
975
|
}
|
|
907
976
|
const open = lastChecker?.open ?? [];
|
|
908
|
-
writeCheckpoint(mkRecord("build_done", {
|
|
977
|
+
writeCheckpoint(mkRecord("build_done", {
|
|
978
|
+
snapshot: afterBuild,
|
|
979
|
+
reviewInput: written.finalOutput,
|
|
980
|
+
usage: currentUsage(),
|
|
981
|
+
}));
|
|
909
982
|
return done(open.length > 0 ? "round_cap" : "clean", open);
|
|
910
983
|
};
|
|
911
984
|
const fixRound = !resume && deps.fixRound && !deps.fixRound.replan ? deps.fixRound : undefined;
|
|
@@ -920,7 +993,13 @@ export async function runPipeline(ticket, deps) {
|
|
|
920
993
|
if (over) {
|
|
921
994
|
log("pipeline_stop", { reason: "budget_exceeded", where: "resume", detail: over });
|
|
922
995
|
progress({ kind: "done", stopReason: "budget_exceeded" });
|
|
923
|
-
return {
|
|
996
|
+
return {
|
|
997
|
+
stages,
|
|
998
|
+
rounds,
|
|
999
|
+
stopReason: "budget_exceeded",
|
|
1000
|
+
findings: rounds[rounds.length - 1]?.findings ?? [],
|
|
1001
|
+
decisionsRecorded,
|
|
1002
|
+
};
|
|
924
1003
|
}
|
|
925
1004
|
}
|
|
926
1005
|
else if (fixRound) {
|
|
@@ -944,8 +1023,16 @@ export async function runPipeline(ticket, deps) {
|
|
|
944
1023
|
baselinePaths = parseChangedPaths(beforeBuild.status);
|
|
945
1024
|
// Sequential build half — stop at the first failure (mirrors the chain).
|
|
946
1025
|
for (const stage of buildStages) {
|
|
947
|
-
if (stage.id === "plan" &&
|
|
948
|
-
|
|
1026
|
+
if (stage.id === "plan" &&
|
|
1027
|
+
!mission &&
|
|
1028
|
+
deps.prepareDesign &&
|
|
1029
|
+
!deps.signal?.aborted &&
|
|
1030
|
+
!overBudget()) {
|
|
1031
|
+
const prepared = await deps.prepareDesign({
|
|
1032
|
+
ticket: promptTicket,
|
|
1033
|
+
exploration: previous ?? "",
|
|
1034
|
+
signal: deps.signal,
|
|
1035
|
+
});
|
|
949
1036
|
designContext = prepared ? `${designMarker}\n${prepared}` : undefined;
|
|
950
1037
|
if (designContext)
|
|
951
1038
|
previous = `${previous ?? ""}\n\n${designContext}`;
|
|
@@ -958,10 +1045,12 @@ export async function runPipeline(ticket, deps) {
|
|
|
958
1045
|
// boundary, so nothing is pushed for it here.
|
|
959
1046
|
let r;
|
|
960
1047
|
if (stage.id === "implement") {
|
|
961
|
-
r =
|
|
1048
|
+
r = lane.fanout
|
|
1049
|
+
? await runImplementDiamond(stage, previous, beforeBuild)
|
|
1050
|
+
: await runOneWriter(stage, previous);
|
|
962
1051
|
}
|
|
963
1052
|
else {
|
|
964
|
-
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
1053
|
+
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, lane.skillsEveryStage));
|
|
965
1054
|
stages.push(r);
|
|
966
1055
|
}
|
|
967
1056
|
log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
|
|
@@ -973,16 +1062,23 @@ export async function runPipeline(ticket, deps) {
|
|
|
973
1062
|
// The final build stage (implement) is exempt: its handoff is the diff, not
|
|
974
1063
|
// prose, so `incompleteHandoff` never flags it.
|
|
975
1064
|
if (!isFailed(r) && stage.id !== finalBuildId && incompleteHandoff(r)) {
|
|
976
|
-
log("stage_retry", {
|
|
1065
|
+
log("stage_retry", {
|
|
1066
|
+
stageId: stage.id,
|
|
1067
|
+
attempt: 2,
|
|
1068
|
+
reason: "incomplete_handoff",
|
|
1069
|
+
stopReason: r.stopReason,
|
|
1070
|
+
});
|
|
977
1071
|
progress({ kind: "stage_start", stageId: stage.id });
|
|
978
1072
|
stageEvent(stage.id, "start");
|
|
979
|
-
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
1073
|
+
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, lane.skillsEveryStage));
|
|
980
1074
|
stages.push(r);
|
|
981
1075
|
log("stage_done", { stageId: stage.id, exitCode: r.exitCode, attempt: 2 });
|
|
982
1076
|
}
|
|
983
1077
|
const credit = creditExhaustionReason(r);
|
|
984
1078
|
if (isFailed(r)) {
|
|
985
|
-
const why = credit ??
|
|
1079
|
+
const why = credit ??
|
|
1080
|
+
toolFailureReason(r) ??
|
|
1081
|
+
(r.errorMessage || r.stderr || r.finalOutput || "(no output)");
|
|
986
1082
|
// A WRITE stage (implement/fix) KILLED mid-work — a timeout or transient blip
|
|
987
1083
|
// that aborted/errored the child once it had begun editing — leaves a real
|
|
988
1084
|
// partial diff in the working tree. R1 deliberately does NOT retry such a stage
|
|
@@ -1037,10 +1133,36 @@ export async function runPipeline(ticket, deps) {
|
|
|
1037
1133
|
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${problem}`, stages);
|
|
1038
1134
|
}
|
|
1039
1135
|
}
|
|
1136
|
+
// A lane whose plan goes to its gate stops here, with the plan as the
|
|
1137
|
+
// run's result. The plan must end on its ```plan-json block; one cheap
|
|
1138
|
+
// re-ask recovers a plan that left it off.
|
|
1139
|
+
if (lane.stopAfterPlan && stage.id === "plan") {
|
|
1140
|
+
let plan = r.finalOutput;
|
|
1141
|
+
if (plan.trim() && extractPlanJson(plan) === null && !deps.signal?.aborted) {
|
|
1142
|
+
const reask = await runStageFn(planJsonReaskStage(stage), { ticket: promptTicket, previous: plan, grounded }, stageDepsFor({ stageId: "plan" }));
|
|
1143
|
+
stages.push(reask);
|
|
1144
|
+
const recovered = !isFailed(reask) && extractPlanJson(reask.finalOutput) !== null;
|
|
1145
|
+
log("plan_json_reask", { recovered });
|
|
1146
|
+
if (recovered)
|
|
1147
|
+
plan = `${plan}\n\n${reask.finalOutput}`;
|
|
1148
|
+
}
|
|
1149
|
+
progress({ kind: "stage_done", stageId: "plan" });
|
|
1150
|
+
stageEvent("plan", "finish", { output: plan });
|
|
1151
|
+
const stop = deps.signal?.aborted
|
|
1152
|
+
? "aborted"
|
|
1153
|
+
: plan.trim()
|
|
1154
|
+
? "clean"
|
|
1155
|
+
: "failed";
|
|
1156
|
+
log("pipeline_stop", { reason: stop, where: "plan" });
|
|
1157
|
+
progress({ kind: "done", stopReason: stop });
|
|
1158
|
+
return { stages, rounds, stopReason: stop, findings: [], decisionsRecorded, plan };
|
|
1159
|
+
}
|
|
1040
1160
|
progress({ kind: "stage_done", stageId: stage.id });
|
|
1041
1161
|
// The plan finish carries its finalOutput so /go can record the plan onto the
|
|
1042
1162
|
// work item; no other boundary streams the full stage text.
|
|
1043
|
-
stageEvent(stage.id, "finish", stage.id === "plan"
|
|
1163
|
+
stageEvent(stage.id, "finish", stage.id === "plan"
|
|
1164
|
+
? { output: designContext ? `${r.finalOutput}\n\n${designContext}` : r.finalOutput }
|
|
1165
|
+
: undefined);
|
|
1044
1166
|
// The map brief is the partitioner's repo context on an interactive run
|
|
1045
1167
|
// (mission runs skip map and hand it the injected memo instead).
|
|
1046
1168
|
if (stage.id === "map")
|
|
@@ -1077,11 +1199,19 @@ export async function runPipeline(ticket, deps) {
|
|
|
1077
1199
|
// resume here instead of re-running map→plan→implement.
|
|
1078
1200
|
writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput, usage: currentUsage() }));
|
|
1079
1201
|
}
|
|
1202
|
+
// Each round's reviewers: the lane's lenses fanned out, or its one reviewer
|
|
1203
|
+
// (no lens), who judges through every angle.
|
|
1204
|
+
const reviewers = lane.reviewLenses.length > 0 ? [...lane.reviewLenses] : [undefined];
|
|
1080
1205
|
while (true) {
|
|
1081
1206
|
const reviewWontFit = wontFit("review");
|
|
1082
1207
|
if (reviewWontFit) {
|
|
1083
1208
|
stopReason = "budget_exceeded";
|
|
1084
|
-
log("pipeline_stop", {
|
|
1209
|
+
log("pipeline_stop", {
|
|
1210
|
+
reason: "budget_exceeded",
|
|
1211
|
+
phase: "review",
|
|
1212
|
+
round,
|
|
1213
|
+
detail: reviewWontFit,
|
|
1214
|
+
});
|
|
1085
1215
|
break;
|
|
1086
1216
|
}
|
|
1087
1217
|
const reviewStartedAt = clock();
|
|
@@ -1102,12 +1232,12 @@ export async function runPipeline(ticket, deps) {
|
|
|
1102
1232
|
verifyError = err ?? new Error("verify failed");
|
|
1103
1233
|
return null;
|
|
1104
1234
|
});
|
|
1105
|
-
const lensResults = await mapWithConcurrencyLimit(
|
|
1235
|
+
const lensResults = await mapWithConcurrencyLimit(reviewers, MAX_CONCURRENCY, async (lens) => {
|
|
1106
1236
|
// Per-lens lifecycle: each lens child is its own agent on the desktop, so it
|
|
1107
1237
|
// starts and finishes on its OWN timing rather than the whole fan-out's.
|
|
1108
1238
|
// The terminal panel ignores lens-tagged signals and paints one review row.
|
|
1109
1239
|
progress({ kind: "stage_start", stageId: "review", lens, round });
|
|
1110
|
-
const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
|
|
1240
|
+
const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }, undefined, lane.skillsEveryStage));
|
|
1111
1241
|
progress({ kind: "stage_done", stageId: "review", lens, round });
|
|
1112
1242
|
return lensResult;
|
|
1113
1243
|
});
|
|
@@ -1115,7 +1245,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
1115
1245
|
// which parses to zero findings — the loop would stop a false "clean" while
|
|
1116
1246
|
// every request was failing. Checked on ALL lens results (not just failed
|
|
1117
1247
|
// ones) for exactly that reason.
|
|
1118
|
-
const lensCredit = lensResults
|
|
1248
|
+
const lensCredit = lensResults
|
|
1249
|
+
.map((r) => creditExhaustionReason(r))
|
|
1250
|
+
.find((v) => Boolean(v));
|
|
1119
1251
|
if (lensCredit) {
|
|
1120
1252
|
stopReason = "failed";
|
|
1121
1253
|
log("pipeline_failed", { phase: "review", round, reason: lensCredit });
|
|
@@ -1133,13 +1265,22 @@ export async function runPipeline(ticket, deps) {
|
|
|
1133
1265
|
const perLens = [];
|
|
1134
1266
|
for (let i = 0; i < lensResults.length; i++) {
|
|
1135
1267
|
const r = lensResults[i];
|
|
1136
|
-
const lens =
|
|
1268
|
+
const lens = reviewers[i];
|
|
1137
1269
|
if (isFailed(r)) {
|
|
1138
1270
|
failedLenses.push(lens);
|
|
1139
|
-
progress({
|
|
1271
|
+
progress({
|
|
1272
|
+
kind: "stage_done",
|
|
1273
|
+
stageId: "review",
|
|
1274
|
+
lens,
|
|
1275
|
+
round,
|
|
1276
|
+
findings: 0,
|
|
1277
|
+
blocking: 0,
|
|
1278
|
+
degraded: "failed",
|
|
1279
|
+
});
|
|
1140
1280
|
continue;
|
|
1141
1281
|
}
|
|
1142
|
-
|
|
1282
|
+
// The lane's one reviewer names each finding's lens; a lens fan child is its lens.
|
|
1283
|
+
let parsed = parseFindings(r.finalOutput, lens, { lensColumn: lens === undefined });
|
|
1143
1284
|
// Visible signal (not silent): a non-empty reviewer output that parses to
|
|
1144
1285
|
// zero findings is a likely format miss, not a guaranteed clean review.
|
|
1145
1286
|
if (parsed.length === 0 && r.finalOutput.trim())
|
|
@@ -1158,7 +1299,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
1158
1299
|
!deps.signal?.aborted) {
|
|
1159
1300
|
const reask = await runStageFn(reaskStage(review), { ticket: promptTicket, previous: r.finalOutput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
|
|
1160
1301
|
if (!isFailed(reask))
|
|
1161
|
-
parsed = parseFindings(reask.finalOutput, lens);
|
|
1302
|
+
parsed = parseFindings(reask.finalOutput, lens, { lensColumn: lens === undefined });
|
|
1162
1303
|
log("review_reask", { lens, findings: parsed.length });
|
|
1163
1304
|
if (parsed.length === 0)
|
|
1164
1305
|
unparseableLenses.push(lens);
|
|
@@ -1195,7 +1336,12 @@ export async function runPipeline(ticket, deps) {
|
|
|
1195
1336
|
// check unrunnable) survives honestly — absent reason clears it.
|
|
1196
1337
|
verifyNote = verify.reason;
|
|
1197
1338
|
verifyCommand = verify.command;
|
|
1198
|
-
log("verify_done", {
|
|
1339
|
+
log("verify_done", {
|
|
1340
|
+
round,
|
|
1341
|
+
ok: verify.ok,
|
|
1342
|
+
command: verify.command,
|
|
1343
|
+
findings: verify.findings.length,
|
|
1344
|
+
});
|
|
1199
1345
|
}
|
|
1200
1346
|
else if (verify.reason) {
|
|
1201
1347
|
verifyNote = verify.reason;
|
|
@@ -1206,7 +1352,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
1206
1352
|
const findings = unionFindings([...perLens, verifyFindings]);
|
|
1207
1353
|
// Two degradation flavors, one honest list (§3e): a failed lens was skipped
|
|
1208
1354
|
// outright; an unparseable lens proceeded as zero findings after the re-ask.
|
|
1209
|
-
const degraded = [
|
|
1355
|
+
const degraded = [
|
|
1356
|
+
...failedLenses.map(reviewerName),
|
|
1357
|
+
...unparseableLenses.map((l) => `${reviewerName(l)} (unparseable)`),
|
|
1358
|
+
];
|
|
1210
1359
|
const degradedLenses = degraded.length > 0 ? degraded : undefined;
|
|
1211
1360
|
rounds.push({ round, lensResults, findings, ...(degradedLenses ? { degradedLenses } : {}) });
|
|
1212
1361
|
log("review_round", {
|
|
@@ -1215,8 +1364,17 @@ export async function runPipeline(ticket, deps) {
|
|
|
1215
1364
|
blocking: hasBlockingFindings(findings),
|
|
1216
1365
|
...(degradedLenses ? { degradedLenses } : {}),
|
|
1217
1366
|
});
|
|
1218
|
-
progress({
|
|
1219
|
-
|
|
1367
|
+
progress({
|
|
1368
|
+
kind: "findings",
|
|
1369
|
+
round,
|
|
1370
|
+
total: findings.length,
|
|
1371
|
+
blocking: blockingCount(findings),
|
|
1372
|
+
});
|
|
1373
|
+
stageEvent("review", "finish", {
|
|
1374
|
+
round,
|
|
1375
|
+
findings: findings.length,
|
|
1376
|
+
items: roundFindingItems(findings),
|
|
1377
|
+
});
|
|
1220
1378
|
// Honest health check BEFORE trusting the verdict: an aborted or crashed
|
|
1221
1379
|
// review must NOT be read as a clean review (empty output → [] findings →
|
|
1222
1380
|
// would otherwise stop 'clean'). Mirrors the build half's isFailed guard.
|
|
@@ -1231,8 +1389,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
1231
1389
|
// the loop cannot proceed without, default: correctness) is the one that
|
|
1232
1390
|
// failed. Otherwise the surviving lenses carry the round and `degradedLenses`
|
|
1233
1391
|
// already records the skip honestly.
|
|
1234
|
-
|
|
1235
|
-
|
|
1392
|
+
// A lane's only reviewer (no lens) is required by definition.
|
|
1393
|
+
const requiredFailed = failedLenses.filter((l) => l === undefined || REQUIRED_LENSES.includes(l));
|
|
1394
|
+
if (failedLenses.length === reviewers.length || requiredFailed.length > 0) {
|
|
1236
1395
|
stopReason = "failed";
|
|
1237
1396
|
log("pipeline_failed", { phase: "review", round, failedLenses, requiredFailed });
|
|
1238
1397
|
break;
|
|
@@ -1243,7 +1402,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
1243
1402
|
observedMs.review = clock() - reviewStartedAt;
|
|
1244
1403
|
// The verdict first: a clean review is clean and a capped one is capped, even
|
|
1245
1404
|
// past a ceiling, since neither spends anything more.
|
|
1246
|
-
const decision = shouldStop(findings, round);
|
|
1405
|
+
const decision = shouldStop(findings, round, lane.maxReviewRounds);
|
|
1247
1406
|
if (decision.stop && decision.reason) {
|
|
1248
1407
|
stopReason = decision.reason;
|
|
1249
1408
|
break;
|
|
@@ -1253,7 +1412,12 @@ export async function runPipeline(ticket, deps) {
|
|
|
1253
1412
|
const overAfterReview = overBudget();
|
|
1254
1413
|
if (overAfterReview) {
|
|
1255
1414
|
stopReason = "budget_exceeded";
|
|
1256
|
-
log("pipeline_stop", {
|
|
1415
|
+
log("pipeline_stop", {
|
|
1416
|
+
reason: "budget_exceeded",
|
|
1417
|
+
phase: "review",
|
|
1418
|
+
round,
|
|
1419
|
+
detail: overAfterReview,
|
|
1420
|
+
});
|
|
1257
1421
|
break;
|
|
1258
1422
|
}
|
|
1259
1423
|
// The review's findings are recorded; a fix that cannot finish in time would
|
|
@@ -1267,7 +1431,13 @@ export async function runPipeline(ticket, deps) {
|
|
|
1267
1431
|
const fixStartedAt = clock();
|
|
1268
1432
|
progress({ kind: "stage_start", stageId: "fix", round });
|
|
1269
1433
|
stageEvent("fix", "start", { round });
|
|
1270
|
-
const fixResult = await runStageFn(fix, {
|
|
1434
|
+
const fixResult = await runStageFn(fix, {
|
|
1435
|
+
ticket: promptTicket,
|
|
1436
|
+
previous: designContext
|
|
1437
|
+
? `${serializeBlocking(findings)}\n\n${designContext}`
|
|
1438
|
+
: serializeBlocking(findings),
|
|
1439
|
+
grounded,
|
|
1440
|
+
}, stageDepsFor({ stageId: "fix", round }, undefined, true));
|
|
1271
1441
|
observedMs.fix = clock() - fixStartedAt;
|
|
1272
1442
|
stages.push(fixResult);
|
|
1273
1443
|
log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
|
|
@@ -1285,7 +1455,8 @@ export async function runPipeline(ticket, deps) {
|
|
|
1285
1455
|
break;
|
|
1286
1456
|
}
|
|
1287
1457
|
if (isFailed(fixResult)) {
|
|
1288
|
-
const why = toolFailureReason(fixResult) ??
|
|
1458
|
+
const why = toolFailureReason(fixResult) ??
|
|
1459
|
+
(fixResult.errorMessage || fixResult.stderr || "(no output)");
|
|
1289
1460
|
// Symmetric with the build-half guard: a fix stage KILLED mid-work (timeout /
|
|
1290
1461
|
// transient abort/error) leaves the run's reviewed diff (implement + any prior
|
|
1291
1462
|
// accepted rounds) plus a partial fix in the tree. By here the build half has
|
|
@@ -1293,7 +1464,9 @@ export async function runPipeline(ticket, deps) {
|
|
|
1293
1464
|
// preservable work — report `partial`, not a bare `failed`. A clean exit-0 fix
|
|
1294
1465
|
// failed only by the R3-a tool-health gate is the silent landmine (edits never
|
|
1295
1466
|
// landed) and keeps the hard `failed`.
|
|
1296
|
-
const fixKilledMidWork = fixResult.exitCode !== 0 ||
|
|
1467
|
+
const fixKilledMidWork = fixResult.exitCode !== 0 ||
|
|
1468
|
+
fixResult.stopReason === "aborted" ||
|
|
1469
|
+
fixResult.stopReason === "error";
|
|
1297
1470
|
if (fixKilledMidWork) {
|
|
1298
1471
|
stopReason = "partial";
|
|
1299
1472
|
log("pipeline_partial", { phase: "fix", round, reason: why });
|
|
@@ -1314,8 +1487,16 @@ export async function runPipeline(ticket, deps) {
|
|
|
1314
1487
|
// crash now can resume at the NEXT round against this fix's output. Snapshot
|
|
1315
1488
|
// the post-fix tree as the resume consistency key.
|
|
1316
1489
|
const afterFix = await snapshotFn(deps.cwd, deps.signal);
|
|
1317
|
-
reviewInput = designContext
|
|
1318
|
-
|
|
1490
|
+
reviewInput = designContext
|
|
1491
|
+
? `${fixResult.finalOutput}\n\n${designContext}`
|
|
1492
|
+
: fixResult.finalOutput;
|
|
1493
|
+
writeCheckpoint(mkRecord("round_done", {
|
|
1494
|
+
round,
|
|
1495
|
+
reviewInput,
|
|
1496
|
+
findings,
|
|
1497
|
+
snapshot: afterFix,
|
|
1498
|
+
usage: currentUsage(),
|
|
1499
|
+
}));
|
|
1319
1500
|
round += 1;
|
|
1320
1501
|
}
|
|
1321
1502
|
log("pipeline_stop", { reason: stopReason, round });
|