@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.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/LICENSE.md +55 -0
- package/README.md +166 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/claudeCompat.d.ts +78 -0
- package/dist/claudeCompat.js +198 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +326 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +28 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +75 -0
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.js +45 -0
- package/dist/doctor.d.ts +94 -0
- package/dist/doctor.js +343 -0
- package/dist/extension/advisor.d.ts +84 -0
- package/dist/extension/advisor.js +101 -0
- package/dist/extension/askAdvisorTool.d.ts +83 -0
- package/dist/extension/askAdvisorTool.js +188 -0
- package/dist/extension/askYagniTool.d.ts +39 -0
- package/dist/extension/askYagniTool.js +63 -0
- package/dist/extension/bless.d.ts +45 -0
- package/dist/extension/bless.js +74 -0
- package/dist/extension/branding.d.ts +48 -0
- package/dist/extension/branding.js +119 -0
- package/dist/extension/chipEditor.d.ts +117 -0
- package/dist/extension/chipEditor.js +369 -0
- package/dist/extension/config.d.ts +112 -0
- package/dist/extension/config.js +108 -0
- package/dist/extension/costHud.d.ts +73 -0
- package/dist/extension/costHud.js +123 -0
- package/dist/extension/decisionCapture.d.ts +52 -0
- package/dist/extension/decisionCapture.js +66 -0
- package/dist/extension/decisions.d.ts +83 -0
- package/dist/extension/decisions.js +200 -0
- package/dist/extension/diagnostics.d.ts +41 -0
- package/dist/extension/diagnostics.js +110 -0
- package/dist/extension/index.d.ts +116 -0
- package/dist/extension/index.js +388 -0
- package/dist/extension/initDone.d.ts +28 -0
- package/dist/extension/initDone.js +66 -0
- package/dist/extension/initPass.d.ts +152 -0
- package/dist/extension/initPass.js +394 -0
- package/dist/extension/nextWorkTool.d.ts +51 -0
- package/dist/extension/nextWorkTool.js +80 -0
- package/dist/extension/permission.d.ts +91 -0
- package/dist/extension/permission.js +236 -0
- package/dist/extension/pipeline/activity.d.ts +37 -0
- package/dist/extension/pipeline/activity.js +151 -0
- package/dist/extension/pipeline/activityFeed.d.ts +59 -0
- package/dist/extension/pipeline/activityFeed.js +175 -0
- package/dist/extension/pipeline/budget.d.ts +48 -0
- package/dist/extension/pipeline/budget.js +68 -0
- package/dist/extension/pipeline/checkpoint.d.ts +31 -0
- package/dist/extension/pipeline/checkpoint.js +176 -0
- package/dist/extension/pipeline/eval.d.ts +168 -0
- package/dist/extension/pipeline/eval.js +182 -0
- package/dist/extension/pipeline/events.d.ts +56 -0
- package/dist/extension/pipeline/events.js +147 -0
- package/dist/extension/pipeline/findings.d.ts +42 -0
- package/dist/extension/pipeline/findings.js +144 -0
- package/dist/extension/pipeline/finish.d.ts +128 -0
- package/dist/extension/pipeline/finish.js +307 -0
- package/dist/extension/pipeline/goCommand.d.ts +128 -0
- package/dist/extension/pipeline/goCommand.js +972 -0
- package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
- package/dist/extension/pipeline/goCompareCommand.js +184 -0
- package/dist/extension/pipeline/goFlags.d.ts +40 -0
- package/dist/extension/pipeline/goFlags.js +46 -0
- package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
- package/dist/extension/pipeline/goStatusCommands.js +188 -0
- package/dist/extension/pipeline/invocation.d.ts +45 -0
- package/dist/extension/pipeline/invocation.js +64 -0
- package/dist/extension/pipeline/orchestrator.d.ts +131 -0
- package/dist/extension/pipeline/orchestrator.js +636 -0
- package/dist/extension/pipeline/personas.d.ts +44 -0
- package/dist/extension/pipeline/personas.js +248 -0
- package/dist/extension/pipeline/resilience.d.ts +85 -0
- package/dist/extension/pipeline/resilience.js +166 -0
- package/dist/extension/pipeline/resume.d.ts +18 -0
- package/dist/extension/pipeline/resume.js +106 -0
- package/dist/extension/pipeline/runRegistry.d.ts +112 -0
- package/dist/extension/pipeline/runRegistry.js +202 -0
- package/dist/extension/pipeline/runSession.d.ts +152 -0
- package/dist/extension/pipeline/runSession.js +167 -0
- package/dist/extension/pipeline/runState.d.ts +158 -0
- package/dist/extension/pipeline/runState.js +264 -0
- package/dist/extension/pipeline/runner.d.ts +79 -0
- package/dist/extension/pipeline/runner.js +298 -0
- package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
- package/dist/extension/pipeline/scrubSecrets.js +32 -0
- package/dist/extension/pipeline/stages.d.ts +51 -0
- package/dist/extension/pipeline/stages.js +113 -0
- package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
- package/dist/extension/pipeline/ticketResolution.js +75 -0
- package/dist/extension/pipeline/types.d.ts +436 -0
- package/dist/extension/pipeline/types.js +48 -0
- package/dist/extension/pipeline/verify.d.ts +277 -0
- package/dist/extension/pipeline/verify.js +758 -0
- package/dist/extension/pipeline/workspace.d.ts +32 -0
- package/dist/extension/pipeline/workspace.js +73 -0
- package/dist/extension/pipeline/worktree.d.ts +107 -0
- package/dist/extension/pipeline/worktree.js +200 -0
- package/dist/extension/provider.d.ts +11 -0
- package/dist/extension/provider.js +26 -0
- package/dist/extension/recall.d.ts +93 -0
- package/dist/extension/recall.js +187 -0
- package/dist/extension/recordContextTool.d.ts +38 -0
- package/dist/extension/recordContextTool.js +85 -0
- package/dist/extension/recordDecisionTool.d.ts +52 -0
- package/dist/extension/recordDecisionTool.js +102 -0
- package/dist/extension/repoDocs.d.ts +81 -0
- package/dist/extension/repoDocs.js +260 -0
- package/dist/extension/resilientFetch.d.ts +60 -0
- package/dist/extension/resilientFetch.js +133 -0
- package/dist/extension/reviewTool.d.ts +34 -0
- package/dist/extension/reviewTool.js +81 -0
- package/dist/extension/spool.d.ts +92 -0
- package/dist/extension/spool.js +266 -0
- package/dist/extension/stateHome.d.ts +2 -0
- package/dist/extension/stateHome.js +6 -0
- package/dist/extension/subagents.d.ts +135 -0
- package/dist/extension/subagents.js +281 -0
- package/dist/extension/surface.d.ts +10 -0
- package/dist/extension/surface.js +12 -0
- package/dist/extension/todos.d.ts +110 -0
- package/dist/extension/todos.js +217 -0
- package/dist/extension/tokenProvider.d.ts +93 -0
- package/dist/extension/tokenProvider.js +234 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.js +111 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +87 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +222 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/dist/upgrade.d.ts +97 -0
- package/dist/upgrade.js +284 -0
- package/package.json +39 -3
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pipeline driver.
|
|
3
|
+
*
|
|
4
|
+
* Sequences `runStage` for the build stages (map → plan → implement), threading
|
|
5
|
+
* each stage's `finalOutput` into the next as `{previous}` (mirrors the subagent
|
|
6
|
+
* example's chain). Then it runs the bounded review→fix LOOP: each round fans the
|
|
7
|
+
* review stage out across the 3 lenses (parallel, bounded by MAX_CONCURRENCY),
|
|
8
|
+
* parses each lens with `parseFindings` (with ONE cheap format-recovery re-ask
|
|
9
|
+
* for a healthy lens that broke the findings contract — spec §3e), unions them,
|
|
10
|
+
* and asks the PURE `shouldStop`. If not stopped it runs the fix stage against
|
|
11
|
+
* ONLY the blocking findings, increments the round, and re-reviews.
|
|
12
|
+
*
|
|
13
|
+
* ALL branching is delegated to the pure `shouldStop` / `parseFindings`; the
|
|
14
|
+
* orchestrator is just sequencing + an injectable `runStage`, so it is
|
|
15
|
+
* unit-tested with a fake `runStage`. The explicit stop reason is always logged
|
|
16
|
+
* (spec §7.3 — no silent truncation). An early build-stage failure short-circuits
|
|
17
|
+
* the pipeline by throwing (the /go handler catches and reports it). In the
|
|
18
|
+
* review→fix loop, a user abort or a crashed review/fix child is surfaced as an
|
|
19
|
+
* honest `aborted` / `failed` stopReason rather than being mis-read as `clean`
|
|
20
|
+
* (an aborted/crashed child has empty output, which otherwise parses to zero
|
|
21
|
+
* findings — i.e. a false "reviewed and clean").
|
|
22
|
+
*/
|
|
23
|
+
import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, EMPTY_RUN_USAGE, exceedsBudget } from "./budget.js";
|
|
24
|
+
import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
|
|
25
|
+
import { withResilience } from "./resilience.js";
|
|
26
|
+
import { runStage as defaultRunStage } from "./runner.js";
|
|
27
|
+
import { makeRunVerify, parseChangedPaths } from "./verify.js";
|
|
28
|
+
import { REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages } from "./stages.js";
|
|
29
|
+
import { snapshotWorkspace as defaultSnapshotWorkspace, workspaceChanged } from "./workspace.js";
|
|
30
|
+
import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
|
|
31
|
+
/** Error thrown when a build stage fails; carries the partial run for the caller. */
|
|
32
|
+
export class PipelineStageError extends Error {
|
|
33
|
+
stageId;
|
|
34
|
+
partial;
|
|
35
|
+
constructor(stageId, message, partial) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "PipelineStageError";
|
|
38
|
+
this.stageId = stageId;
|
|
39
|
+
this.partial = partial;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const BUILD_STAGE_IDS = ["map", "plan", "implement"];
|
|
43
|
+
/**
|
|
44
|
+
* Stages whose whole value is in landed edits, so a high tool-error rate there is
|
|
45
|
+
* a real failure (R3-a). Read-only recon/review stages are intentionally exempt:
|
|
46
|
+
* a grep/read that misses is normal navigation, not a broken stage.
|
|
47
|
+
*/
|
|
48
|
+
const WRITE_STAGE_IDS = ["implement", "fix"];
|
|
49
|
+
/**
|
|
50
|
+
* R3-a: true when a WRITE stage exited cleanly yet most of its tool calls errored
|
|
51
|
+
* — the silent partial-implement landmine, where the model narrates a tidy
|
|
52
|
+
* summary while its edits never landed. Conservative on purpose: only write
|
|
53
|
+
* stages are gated, and BOTH a meaningful sample (MIN_TOOL_CALLS_FOR_HEALTH) and
|
|
54
|
+
* a clear-majority error rate (above TOOL_ERROR_FAIL_RATE) must hold, so a couple
|
|
55
|
+
* of expected errors never fail a stage.
|
|
56
|
+
*/
|
|
57
|
+
function toolFailureExceeded(r) {
|
|
58
|
+
if (!WRITE_STAGE_IDS.includes(r.stageId))
|
|
59
|
+
return false;
|
|
60
|
+
if (r.toolCalls < MIN_TOOL_CALLS_FOR_HEALTH)
|
|
61
|
+
return false;
|
|
62
|
+
return r.toolErrors / r.toolCalls > TOOL_ERROR_FAIL_RATE;
|
|
63
|
+
}
|
|
64
|
+
/** The honest reason string for a tool-failure-flagged stage, else undefined. */
|
|
65
|
+
function toolFailureReason(r) {
|
|
66
|
+
if (!toolFailureExceeded(r))
|
|
67
|
+
return undefined;
|
|
68
|
+
return `tool failures: ${r.toolErrors} of ${r.toolCalls} tool calls errored, so its edits likely did not land despite a clean-looking summary. Re-run /go.`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A stage outcome counts as a failure on a non-zero exit, an error/abort stop, or
|
|
72
|
+
* (for a write stage) a tool-error rate over the threshold (R3-a) — so a stage
|
|
73
|
+
* that exits 0 while most of its edits failed is not trusted on its prose alone.
|
|
74
|
+
*/
|
|
75
|
+
function isFailed(r) {
|
|
76
|
+
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || toolFailureExceeded(r);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A non-final build stage threads its `finalOutput` forward as the NEXT stage's
|
|
80
|
+
* only input, so an empty or truncated handoff silently starves it — the bug: a
|
|
81
|
+
* cut-off plan fed to implement, which then writes nothing and bottoms out at a
|
|
82
|
+
* confusing `no_changes`. This converts that silent fragment into an honest,
|
|
83
|
+
* actionable stop the user sees BEFORE implement runs on nothing. Returns the
|
|
84
|
+
* stop reason when the handoff is unusable, else undefined.
|
|
85
|
+
*
|
|
86
|
+
* `length` is pi's max_tokens truncation (the literal "plan appears to be cut
|
|
87
|
+
* off" case) — a deterministic smoking gun. An empty body is the other half.
|
|
88
|
+
* We deliberately do NOT content-match a "plan shape" (unreliable, and would
|
|
89
|
+
* false-positive on legitimately short stages); persona hardening handles the
|
|
90
|
+
* interstitial-fragment flavor. The FINAL build stage (implement) is exempt: its
|
|
91
|
+
* real handoff is the working-tree diff, governed by the `no_changes` guard
|
|
92
|
+
* below, so an empty prose summary there is not a broken handoff.
|
|
93
|
+
*/
|
|
94
|
+
function incompleteHandoff(r) {
|
|
95
|
+
if (r.stopReason === "length") {
|
|
96
|
+
return "its output was cut off before it finished (the model reached its length limit), so the handoff to the next stage is incomplete. Re-run /go.";
|
|
97
|
+
}
|
|
98
|
+
if (r.finalOutput.trim().length === 0) {
|
|
99
|
+
return "it finished without producing any output to hand to the next stage. Re-run /go.";
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Credit-exhaustion shapes the model proxy emits when the workspace runs out of
|
|
105
|
+
* credits: the workspace-budget 402 body ("This workspace is out of credits."),
|
|
106
|
+
* the OpenAI-style `insufficient_quota` code, and the bare HTTP status. Matched
|
|
107
|
+
* against stderr/errorMessage only — never a stage's real output.
|
|
108
|
+
*/
|
|
109
|
+
const CREDIT_EXHAUSTION_RE = /out of credits|insufficient_quota|payment required|\b402\b/;
|
|
110
|
+
/**
|
|
111
|
+
* The silent-402 landmine: when the workspace runs out of credits mid-run, the
|
|
112
|
+
* proxy 402s every model request, the child pi exits 0 with an EMPTY transcript,
|
|
113
|
+
* and the run otherwise dies as a confusing `no_changes` / empty-handoff / false
|
|
114
|
+
* "clean" stop with the real cause buried in stderr. Detect it and stop with an
|
|
115
|
+
* actionable message instead. Gated on the stage having produced no real output
|
|
116
|
+
* (or having failed outright) so a healthy stage whose stderr happens to contain
|
|
117
|
+
* a matching token can never trip it.
|
|
118
|
+
*/
|
|
119
|
+
function creditExhaustionReason(r) {
|
|
120
|
+
if (r.finalOutput.trim().length > 0 && !isFailed(r))
|
|
121
|
+
return undefined;
|
|
122
|
+
const hay = `${r.errorMessage ?? ""}\n${r.stderr}`;
|
|
123
|
+
if (!CREDIT_EXHAUSTION_RE.test(hay))
|
|
124
|
+
return undefined;
|
|
125
|
+
return "the workspace is out of YAGNI credits (the model proxy returned 402). Top up credits, then re-run /go.";
|
|
126
|
+
}
|
|
127
|
+
/** Render only the blocking findings as the fix stage's `{previous}` handoff. */
|
|
128
|
+
function serializeBlocking(findings) {
|
|
129
|
+
return findings
|
|
130
|
+
.filter((f) => f.severity === "critical" || f.severity === "high")
|
|
131
|
+
.map((f) => {
|
|
132
|
+
const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "";
|
|
133
|
+
return `${f.severity} | ${loc} | ${f.message}`;
|
|
134
|
+
})
|
|
135
|
+
.join("\n");
|
|
136
|
+
}
|
|
137
|
+
/** Bounded-concurrency map preserving input order (copied from the subagent example). */
|
|
138
|
+
async function mapWithConcurrencyLimit(items, concurrency, fn) {
|
|
139
|
+
if (items.length === 0)
|
|
140
|
+
return [];
|
|
141
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
142
|
+
const results = new Array(items.length);
|
|
143
|
+
let nextIndex = 0;
|
|
144
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
145
|
+
while (true) {
|
|
146
|
+
const current = nextIndex++;
|
|
147
|
+
if (current >= items.length)
|
|
148
|
+
return;
|
|
149
|
+
results[current] = await fn(items[current], current);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
await Promise.all(workers);
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
export async function runPipeline(ticket, deps) {
|
|
156
|
+
const log = (event, data) => deps.logger?.(event, data);
|
|
157
|
+
// R1: wrap the real child runner with in-loop resilience (idle/wall timeout +
|
|
158
|
+
// transient-only retry) by DEFAULT, so every /go survives a transient blip
|
|
159
|
+
// across its up-to-8 spawns. A caller that injects its own `runStage` (the unit
|
|
160
|
+
// tests, a custom lane) bypasses the wrapper untouched. Per-attempt telemetry
|
|
161
|
+
// rides the existing logger seam, so resume + the checkpoint journal are unaffected.
|
|
162
|
+
const runStageFn = deps.runStage ??
|
|
163
|
+
withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY, {
|
|
164
|
+
telemetry: (rec) => log("resilience_attempt", rec),
|
|
165
|
+
});
|
|
166
|
+
const snapshotFn = deps.snapshotWorkspace ?? defaultSnapshotWorkspace;
|
|
167
|
+
const runVerifyFn = deps.runVerify ?? makeRunVerify();
|
|
168
|
+
const progress = (p) => deps.onProgress?.(p);
|
|
169
|
+
// Count of record_decision tool calls that completed without error across the
|
|
170
|
+
// whole run, tapped off the NDJSON stream (spec §3c: the FINISH trailer's
|
|
171
|
+
// YAGNI-Decisions line). COUNT only — decision ids are not observable without
|
|
172
|
+
// widening JsonEvent.
|
|
173
|
+
let decisionsRecorded = 0;
|
|
174
|
+
// Per-stage runner deps that tag each tapped event with the emitting stage
|
|
175
|
+
// (and, for the parallel review fan-out, that lens + round). Built fresh per
|
|
176
|
+
// call so every review lens captures its OWN tag — interleaved parallel events
|
|
177
|
+
// stay attributed to review/<lens>. The tap is ALWAYS installed (the
|
|
178
|
+
// decisionsRecorded count must not depend on a caller wiring onEvent);
|
|
179
|
+
// forwarding to the caller's onEvent stays optional.
|
|
180
|
+
const stageDepsFor = (tag) => {
|
|
181
|
+
const onEvent = deps.onEvent;
|
|
182
|
+
return {
|
|
183
|
+
cwd: deps.cwd,
|
|
184
|
+
signal: deps.signal,
|
|
185
|
+
...(deps.childEnv ? { env: deps.childEnv } : {}),
|
|
186
|
+
onEvent: (ev) => {
|
|
187
|
+
if (ev.type === "tool_execution_end" && ev.toolName === "record_decision" && !ev.isError) {
|
|
188
|
+
decisionsRecorded += 1;
|
|
189
|
+
}
|
|
190
|
+
onEvent?.(ev, tag);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const blockingCount = (findings) => findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
|
195
|
+
// Work-page run-tracking hook: fires at each stage boundary, independent of the
|
|
196
|
+
// terminal live feed's structured `progress`. Optional + fail-soft via onStage.
|
|
197
|
+
const stageEvent = (stage, phase, extra) => deps.onStage?.({ stage, phase, ...extra });
|
|
198
|
+
// The text the stages plan against: the resolved ticket brief (verbatim title +
|
|
199
|
+
// body) when /go could fetch it, else the raw ticket reference (honest blind).
|
|
200
|
+
const promptTicket = deps.ticketBrief ?? ticket;
|
|
201
|
+
// M6: default grounded + the full stage list; the blind eval lane overrides both.
|
|
202
|
+
const grounded = deps.grounded !== false;
|
|
203
|
+
const all = deps.stages ?? selectStages("full");
|
|
204
|
+
// R3-b: aggregate usage ceiling, checked at each stage boundary.
|
|
205
|
+
const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
|
|
206
|
+
const buildStages = all.filter((s) => BUILD_STAGE_IDS.includes(s.id));
|
|
207
|
+
// The last build stage's handoff is the working-tree diff (checked by the
|
|
208
|
+
// no_changes guard), not its prose; only the earlier stages thread text forward.
|
|
209
|
+
const finalBuildId = buildStages[buildStages.length - 1]?.id;
|
|
210
|
+
// Derive review + fix from `all` (not PIPELINE_V1) so the blind eval lane's
|
|
211
|
+
// grounding-stripped tool lists are honored on these stages too.
|
|
212
|
+
const review = all.find((s) => s.id === "review") ?? reviewStage();
|
|
213
|
+
const fix = all.find((s) => s.id === "fix");
|
|
214
|
+
if (!fix)
|
|
215
|
+
throw new Error("PIPELINE_V1 is missing the fix stage");
|
|
216
|
+
// Fail-soft checkpoint helpers (no-op when no store is injected). A checkpoint
|
|
217
|
+
// write must NEVER break the pipeline, so even a misbehaving store is caught.
|
|
218
|
+
const meta = deps.checkpointMeta;
|
|
219
|
+
const writeCheckpoint = (rec) => {
|
|
220
|
+
try {
|
|
221
|
+
deps.checkpoint?.record(rec);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
/* fail-soft */
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const mkRecord = (kind, extra) => ({
|
|
228
|
+
v: 1,
|
|
229
|
+
kind,
|
|
230
|
+
sessionKey: meta?.sessionKey ?? "",
|
|
231
|
+
ts: Date.now(),
|
|
232
|
+
ticket,
|
|
233
|
+
...(meta?.runId ? { runId: meta.runId } : {}),
|
|
234
|
+
...(meta?.workItemId ? { workItemId: meta.workItemId } : {}),
|
|
235
|
+
...(meta?.repo ? { repo: meta.repo } : {}),
|
|
236
|
+
...(meta?.branch ? { branch: meta.branch } : {}),
|
|
237
|
+
...extra,
|
|
238
|
+
});
|
|
239
|
+
const resume = deps.resumeFrom?.mode === "resume_loop" ? deps.resumeFrom : undefined;
|
|
240
|
+
const stages = [];
|
|
241
|
+
// Seed the loop state. On resume we re-enter the review→fix loop directly,
|
|
242
|
+
// carrying the journal's prior rounds for an honest final count; on a fresh run
|
|
243
|
+
// the build half below fills `reviewInput` from the implement output.
|
|
244
|
+
const rounds = resume ? [...resume.priorRounds] : [];
|
|
245
|
+
let round = resume ? resume.round : 1;
|
|
246
|
+
let reviewInput = resume ? resume.reviewInput : "";
|
|
247
|
+
let stopReason = "clean";
|
|
248
|
+
// P4: honest note carried out when the verify gate could not give a verdict on
|
|
249
|
+
// the final round (no command / unrunnable), so /go never claims a build it did
|
|
250
|
+
// not actually check. Cleared whenever a later round produces a real verdict.
|
|
251
|
+
let verifyNote;
|
|
252
|
+
// The FINAL round's verify command label(s) when it RAN — feeds the FINISH
|
|
253
|
+
// trailer's `YAGNI-Verify: passed (<cmds>)` honestly (spec §3c). Cleared when
|
|
254
|
+
// a later round's verify is skipped (the final gate did not actually run).
|
|
255
|
+
let verifyCommand;
|
|
256
|
+
// The tree's pre-run dirt (paths already changed before the build half), so the
|
|
257
|
+
// verify gate scopes to THIS run's own diff. Set on the fresh path once the
|
|
258
|
+
// baseline snapshot is taken; absent on resume (the build already ran), where the
|
|
259
|
+
// gate falls back to the whole-tree scope.
|
|
260
|
+
let baselinePaths;
|
|
261
|
+
const priorUsage = resume?.priorUsage ?? EMPTY_RUN_USAGE;
|
|
262
|
+
const currentUsage = () => addRunUsage(priorUsage, aggregateRunUsage(stages, rounds));
|
|
263
|
+
const overBudget = () => exceedsBudget(currentUsage(), budget);
|
|
264
|
+
if (resume) {
|
|
265
|
+
// Resume path: the build half already ran and its diff is in the working
|
|
266
|
+
// tree (the caller's planResume verified the tree still matches the
|
|
267
|
+
// checkpoint), so we skip map→plan→implement and the no-op guard entirely.
|
|
268
|
+
log("pipeline_resume", { round, priorRounds: resume.priorRounds.length });
|
|
269
|
+
const over = overBudget();
|
|
270
|
+
if (over) {
|
|
271
|
+
log("pipeline_stop", { reason: "budget_exceeded", where: "resume", detail: over });
|
|
272
|
+
progress({ kind: "done", stopReason: "budget_exceeded" });
|
|
273
|
+
return { stages, rounds, stopReason: "budget_exceeded", findings: rounds[rounds.length - 1]?.findings ?? [], decisionsRecorded };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
let previous;
|
|
278
|
+
// Baseline the working tree BEFORE the (read-only) recon stages so the no-op
|
|
279
|
+
// guard below can attribute any change to the build half. A git-less / non-repo
|
|
280
|
+
// cwd yields an untracked snapshot and the guard fails open (see workspace.ts).
|
|
281
|
+
const beforeBuild = await snapshotFn(deps.cwd, deps.signal);
|
|
282
|
+
writeCheckpoint(mkRecord("run_start", { snapshot: beforeBuild }));
|
|
283
|
+
// Record the pre-run dirt so the verify gate scopes to this run's OWN diff.
|
|
284
|
+
baselinePaths = parseChangedPaths(beforeBuild.status);
|
|
285
|
+
// Sequential build half — stop at the first failure (mirrors the chain).
|
|
286
|
+
for (const stage of buildStages) {
|
|
287
|
+
progress({ kind: "stage_start", stageId: stage.id });
|
|
288
|
+
stageEvent(stage.id, "start");
|
|
289
|
+
let r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
290
|
+
stages.push(r);
|
|
291
|
+
log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
|
|
292
|
+
// Retry a non-final build stage ONCE when it exits cleanly but hands off an
|
|
293
|
+
// incomplete fragment (length-truncated or empty). A reasoning model that
|
|
294
|
+
// over-explored and ran out of budget — e.g. a plan ending mid-"let me verify
|
|
295
|
+
// one more thing" — often converges on a fresh attempt; a task that
|
|
296
|
+
// systematically won't converge still stops honestly after the retry below.
|
|
297
|
+
// The final build stage (implement) is exempt: its handoff is the diff, not
|
|
298
|
+
// prose, so `incompleteHandoff` never flags it.
|
|
299
|
+
if (!isFailed(r) && stage.id !== finalBuildId && incompleteHandoff(r)) {
|
|
300
|
+
log("stage_retry", { stageId: stage.id, attempt: 2, reason: "incomplete_handoff", stopReason: r.stopReason });
|
|
301
|
+
progress({ kind: "stage_start", stageId: stage.id });
|
|
302
|
+
stageEvent(stage.id, "start");
|
|
303
|
+
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
304
|
+
stages.push(r);
|
|
305
|
+
log("stage_done", { stageId: stage.id, exitCode: r.exitCode, attempt: 2 });
|
|
306
|
+
}
|
|
307
|
+
const credit = creditExhaustionReason(r);
|
|
308
|
+
if (isFailed(r)) {
|
|
309
|
+
const why = credit ?? toolFailureReason(r) ?? (r.errorMessage || r.stderr || r.finalOutput || "(no output)");
|
|
310
|
+
// A WRITE stage (implement/fix) KILLED mid-work — a timeout or transient blip
|
|
311
|
+
// that aborted/errored the child once it had begun editing — leaves a real
|
|
312
|
+
// partial diff in the working tree. R1 deliberately does NOT retry such a stage
|
|
313
|
+
// (double-apply guard), so don't discard the work as a bare failure: stop with
|
|
314
|
+
// an honest `partial` outcome that points the user at their tree. Excluded on
|
|
315
|
+
// purpose: a clean exit-0 stage failed only by the R3-a tool-health gate is the
|
|
316
|
+
// silent partial-implement landmine (edits never landed), NOT real preservable
|
|
317
|
+
// work — it keeps the hard-fail. Read-only stages, write stages that changed
|
|
318
|
+
// nothing, and a user abort also keep the honest hard-fail below. Conservative:
|
|
319
|
+
// we never feed a possibly-truncated diff into the review loop.
|
|
320
|
+
const killedMidWork = r.exitCode !== 0 || r.stopReason === "aborted" || r.stopReason === "error";
|
|
321
|
+
if (killedMidWork &&
|
|
322
|
+
!deps.signal?.aborted &&
|
|
323
|
+
WRITE_STAGE_IDS.includes(stage.id)) {
|
|
324
|
+
const afterFail = await snapshotFn(deps.cwd, deps.signal);
|
|
325
|
+
if (workspaceChanged(beforeBuild, afterFail)) {
|
|
326
|
+
log("pipeline_partial", { stageId: stage.id, reason: why });
|
|
327
|
+
progress({ kind: "done", stopReason: "partial" });
|
|
328
|
+
return { stages, rounds, stopReason: "partial", findings: [], decisionsRecorded };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
log("pipeline_failed", { stageId: stage.id, reason: why });
|
|
332
|
+
// No structured progress on a build failure: the union has no failed
|
|
333
|
+
// variant, and the /go handler reports the thrown error + clears the feed.
|
|
334
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${why}`, stages);
|
|
335
|
+
}
|
|
336
|
+
// A 402-starved stage exits 0 with an empty transcript (not `isFailed`), so
|
|
337
|
+
// it would otherwise fall through to the generic empty-handoff / no_changes
|
|
338
|
+
// stops with the real cause buried. Stop with the actionable message.
|
|
339
|
+
if (credit) {
|
|
340
|
+
log("pipeline_failed", { stageId: stage.id, reason: credit });
|
|
341
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${credit}`, stages);
|
|
342
|
+
}
|
|
343
|
+
// Honest handoff-completeness guard: a build stage can exit 0 yet hand the
|
|
344
|
+
// next stage an empty / truncated fragment. Stop honestly here rather than
|
|
345
|
+
// letting implement run on garbage and bottom out at `no_changes`.
|
|
346
|
+
if (stage.id !== finalBuildId) {
|
|
347
|
+
const problem = incompleteHandoff(r);
|
|
348
|
+
if (problem && stage.id === "plan") {
|
|
349
|
+
// The plan's consumer — the worker — is explicitly built to implement from a
|
|
350
|
+
// partial or cut-off plan (WORKER_BODY: "if the plan is missing, partial, or
|
|
351
|
+
// appears cut off … implement the ticket directly"). GLM-5.2 reliably
|
|
352
|
+
// over-reasons this stage into a length-truncation; stopping here throws away
|
|
353
|
+
// real partial-plan work and never gives the worker its fallback. So hand the
|
|
354
|
+
// partial plan forward and let the worker fill the gaps from the ticket.
|
|
355
|
+
log("plan_incomplete_proceeding", { stageId: stage.id, reason: problem });
|
|
356
|
+
}
|
|
357
|
+
else if (problem) {
|
|
358
|
+
// Other build stages (map) have no such robust downstream consumer, so an
|
|
359
|
+
// incomplete handoff still stops honestly here.
|
|
360
|
+
log("pipeline_incomplete_handoff", { stageId: stage.id, reason: problem });
|
|
361
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${problem}`, stages);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
progress({ kind: "stage_done", stageId: stage.id });
|
|
365
|
+
// The plan finish carries its finalOutput so /go can record the plan onto the
|
|
366
|
+
// work item; no other boundary streams the full stage text.
|
|
367
|
+
stageEvent(stage.id, "finish", stage.id === "plan" ? { output: r.finalOutput } : undefined);
|
|
368
|
+
previous = r.finalOutput;
|
|
369
|
+
// R3-b: stop honestly if the build half alone blew the run budget.
|
|
370
|
+
const over = overBudget();
|
|
371
|
+
if (over) {
|
|
372
|
+
log("pipeline_stop", { reason: "budget_exceeded", where: stage.id, detail: over });
|
|
373
|
+
progress({ kind: "done", stopReason: "budget_exceeded" });
|
|
374
|
+
return { stages, rounds, stopReason: "budget_exceeded", findings: [], decisionsRecorded };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// No-op guard (spec §7.3 — no silent truncation): the review half is
|
|
378
|
+
// diff-driven, so an implement stage that exits 0 having changed nothing would
|
|
379
|
+
// sail through as `clean` (reviewers `git diff`, see nothing, find nothing).
|
|
380
|
+
// Stop HONESTLY with `no_changes` instead of reporting a false "reviewed and
|
|
381
|
+
// clean". Skipped (fails open) when the tree state is unknowable — see
|
|
382
|
+
// workspaceChanged. A user abort mid-build is the more truthful reason.
|
|
383
|
+
if (deps.signal?.aborted) {
|
|
384
|
+
log("pipeline_aborted", { phase: "build" });
|
|
385
|
+
progress({ kind: "done", stopReason: "aborted" });
|
|
386
|
+
return { stages, rounds, stopReason: "aborted", findings: [], decisionsRecorded };
|
|
387
|
+
}
|
|
388
|
+
const afterBuild = await snapshotFn(deps.cwd, deps.signal);
|
|
389
|
+
if (!workspaceChanged(beforeBuild, afterBuild)) {
|
|
390
|
+
log("pipeline_stop", { reason: "no_changes" });
|
|
391
|
+
progress({ kind: "done", stopReason: "no_changes" });
|
|
392
|
+
return { stages, rounds, stopReason: "no_changes", findings: [], decisionsRecorded };
|
|
393
|
+
}
|
|
394
|
+
reviewInput = previous ?? "";
|
|
395
|
+
// Build half complete + the tree changed: checkpoint the implement output as
|
|
396
|
+
// the round-1 review handoff so a crash in the (expensive) review loop can
|
|
397
|
+
// resume here instead of re-running map→plan→implement.
|
|
398
|
+
writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput, usage: currentUsage() }));
|
|
399
|
+
}
|
|
400
|
+
while (true) {
|
|
401
|
+
progress({ kind: "stage_start", stageId: "review", round });
|
|
402
|
+
stageEvent("review", "start", { round });
|
|
403
|
+
// P4 verify runs CONCURRENTLY with the lens fan-out: it is deterministic,
|
|
404
|
+
// independent of lens output, and the lenses are read-only — the tree it
|
|
405
|
+
// checks is exactly the tree they review. Run serially it added real wall
|
|
406
|
+
// time per round (a cold toolchain run costs minutes) that the slower lens
|
|
407
|
+
// fan-out absorbs entirely. The immediate `.catch` parks a rejection until
|
|
408
|
+
// the await below (which rethrows it at the same point the serial call
|
|
409
|
+
// threw), so a seam that rejects mid-fan-out can't crash the process as an
|
|
410
|
+
// unhandled rejection.
|
|
411
|
+
let verifyError;
|
|
412
|
+
const verifyPromise = deps.signal?.aborted
|
|
413
|
+
? null
|
|
414
|
+
: runVerifyFn(deps.cwd, deps.signal, baselinePaths).catch((err) => {
|
|
415
|
+
verifyError = err ?? new Error("verify failed");
|
|
416
|
+
return null;
|
|
417
|
+
});
|
|
418
|
+
const lensResults = await mapWithConcurrencyLimit(REVIEW_LENSES, MAX_CONCURRENCY, async (lens) => {
|
|
419
|
+
// Per-lens lifecycle: each lens child is its own agent on the desktop, so it
|
|
420
|
+
// starts and finishes on its OWN timing rather than the whole fan-out's.
|
|
421
|
+
// The terminal panel ignores lens-tagged signals and paints one review row.
|
|
422
|
+
progress({ kind: "stage_start", stageId: "review", lens, round });
|
|
423
|
+
const lensResult = await runStageFn({ ...review, lens }, { ticket: promptTicket, previous: reviewInput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
|
|
424
|
+
progress({ kind: "stage_done", stageId: "review", lens, round });
|
|
425
|
+
return lensResult;
|
|
426
|
+
});
|
|
427
|
+
// Credit exhaustion mid-review: a 402-starved lens exits 0 with EMPTY output,
|
|
428
|
+
// which parses to zero findings — the loop would stop a false "clean" while
|
|
429
|
+
// every request was failing. Checked on ALL lens results (not just failed
|
|
430
|
+
// ones) for exactly that reason.
|
|
431
|
+
const lensCredit = lensResults.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
|
|
432
|
+
if (lensCredit) {
|
|
433
|
+
stopReason = "failed";
|
|
434
|
+
log("pipeline_failed", { phase: "review", round, reason: lensCredit });
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
// R2 partial-failure tolerance: split the lens outcomes into healthy vs
|
|
438
|
+
// failed. A single transient lens failure must NOT discard the lenses that
|
|
439
|
+
// succeeded, so we parse findings only from the healthy lenses, proceed on
|
|
440
|
+
// those survivors, and record the skipped lenses as `degradedLenses`. The
|
|
441
|
+
// round fails only when EVERY lens failed or a REQUIRED lens is the one that
|
|
442
|
+
// failed (see the health check below). A failed lens has empty/garbage
|
|
443
|
+
// output, so it is never parsed for findings.
|
|
444
|
+
const failedLenses = [];
|
|
445
|
+
const unparseableLenses = [];
|
|
446
|
+
const perLens = [];
|
|
447
|
+
for (let i = 0; i < lensResults.length; i++) {
|
|
448
|
+
const r = lensResults[i];
|
|
449
|
+
const lens = REVIEW_LENSES[i];
|
|
450
|
+
if (isFailed(r)) {
|
|
451
|
+
failedLenses.push(lens);
|
|
452
|
+
progress({ kind: "stage_done", stageId: "review", lens, round, findings: 0, blocking: 0, degraded: "failed" });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
let parsed = parseFindings(r.finalOutput, lens);
|
|
456
|
+
// Visible signal (not silent): a non-empty reviewer output that parses to
|
|
457
|
+
// zero findings is a likely format miss, not a guaranteed clean review.
|
|
458
|
+
if (parsed.length === 0 && r.finalOutput.trim())
|
|
459
|
+
log("review_parse_warning", { lens });
|
|
460
|
+
// §3e findings-contract re-ask: a HEALTHY lens whose non-empty prose has
|
|
461
|
+
// NO ```findings fence broke the output contract (an explicit empty fence
|
|
462
|
+
// is a real clean review and is left alone). Re-ask that lens ONCE on the
|
|
463
|
+
// cheap efficient tier — a pure format-recovery pass whose `{previous}` is
|
|
464
|
+
// the lens's own output — and take whatever it re-emits. Still nothing →
|
|
465
|
+
// proceed (today's behavior) but record the lens as `<lens> (unparseable)`
|
|
466
|
+
// in degradedLenses so a zero-findings round built on an unreadable review
|
|
467
|
+
// is never silent.
|
|
468
|
+
if (parsed.length === 0 &&
|
|
469
|
+
r.finalOutput.trim() &&
|
|
470
|
+
extractFindingsBlock(r.finalOutput) == null &&
|
|
471
|
+
!deps.signal?.aborted) {
|
|
472
|
+
const reask = await runStageFn(reaskStage(review), { ticket: promptTicket, previous: r.finalOutput, lens, grounded }, stageDepsFor({ stageId: "review", lens, round }));
|
|
473
|
+
if (!isFailed(reask))
|
|
474
|
+
parsed = parseFindings(reask.finalOutput, lens);
|
|
475
|
+
log("review_reask", { lens, findings: parsed.length });
|
|
476
|
+
if (parsed.length === 0)
|
|
477
|
+
unparseableLenses.push(lens);
|
|
478
|
+
}
|
|
479
|
+
// The count exists only now: findings are parsed after the children return,
|
|
480
|
+
// so a lens row gains its count a beat after it goes ✔. Honest, not eager.
|
|
481
|
+
progress({
|
|
482
|
+
kind: "stage_done",
|
|
483
|
+
stageId: "review",
|
|
484
|
+
lens,
|
|
485
|
+
round,
|
|
486
|
+
findings: parsed.length,
|
|
487
|
+
blocking: blockingCount(parsed),
|
|
488
|
+
...(unparseableLenses.includes(lens) ? { degraded: "unparseable" } : {}),
|
|
489
|
+
});
|
|
490
|
+
perLens.push(parsed);
|
|
491
|
+
}
|
|
492
|
+
// P4: the deterministic verify gate runs every round (so a fix that breaks the
|
|
493
|
+
// build is caught next round) and folds any failure in as critical findings
|
|
494
|
+
// BEFORE shouldStop, so the loop cannot stop `clean` while the build is broken
|
|
495
|
+
// and the fix stage receives the real errors via serializeBlocking. Started
|
|
496
|
+
// above, concurrent with the lens fan-out; awaited (and a seam rejection
|
|
497
|
+
// rethrown) here. Skipped if the run was already aborting; fail-open (no
|
|
498
|
+
// findings, honest note) otherwise.
|
|
499
|
+
let verifyFindings = [];
|
|
500
|
+
if (verifyPromise) {
|
|
501
|
+
const verify = await verifyPromise;
|
|
502
|
+
if (verify === null)
|
|
503
|
+
throw verifyError;
|
|
504
|
+
verifyFindings = verify.findings;
|
|
505
|
+
if (verify.ran) {
|
|
506
|
+
// A real verdict supersedes any earlier skip note, but a PARTIAL note
|
|
507
|
+
// from the ran gate (spec §3d: "tests could not run", one package's
|
|
508
|
+
// check unrunnable) survives honestly — absent reason clears it.
|
|
509
|
+
verifyNote = verify.reason;
|
|
510
|
+
verifyCommand = verify.command;
|
|
511
|
+
log("verify_done", { round, ok: verify.ok, command: verify.command, findings: verify.findings.length });
|
|
512
|
+
}
|
|
513
|
+
else if (verify.reason) {
|
|
514
|
+
verifyNote = verify.reason;
|
|
515
|
+
verifyCommand = undefined; // the FINAL round's gate did not run
|
|
516
|
+
log("verify_skipped", { round, reason: verify.reason });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const findings = unionFindings([...perLens, verifyFindings]);
|
|
520
|
+
// Two degradation flavors, one honest list (§3e): a failed lens was skipped
|
|
521
|
+
// outright; an unparseable lens proceeded as zero findings after the re-ask.
|
|
522
|
+
const degraded = [...failedLenses, ...unparseableLenses.map((l) => `${l} (unparseable)`)];
|
|
523
|
+
const degradedLenses = degraded.length > 0 ? degraded : undefined;
|
|
524
|
+
rounds.push({ round, lensResults, findings, ...(degradedLenses ? { degradedLenses } : {}) });
|
|
525
|
+
log("review_round", {
|
|
526
|
+
round,
|
|
527
|
+
findings: findings.length,
|
|
528
|
+
blocking: hasBlockingFindings(findings),
|
|
529
|
+
...(degradedLenses ? { degradedLenses } : {}),
|
|
530
|
+
});
|
|
531
|
+
progress({ kind: "findings", round, total: findings.length, blocking: blockingCount(findings) });
|
|
532
|
+
stageEvent("review", "finish", { round, findings: findings.length });
|
|
533
|
+
// Honest health check BEFORE trusting the verdict: an aborted or crashed
|
|
534
|
+
// review must NOT be read as a clean review (empty output → [] findings →
|
|
535
|
+
// would otherwise stop 'clean'). Mirrors the build half's isFailed guard.
|
|
536
|
+
// The unhealthy paths intentionally skip the review `stage_done` so the feed
|
|
537
|
+
// can mark the in-flight stage failed off the closing `done` signal.
|
|
538
|
+
if (deps.signal?.aborted) {
|
|
539
|
+
stopReason = "aborted";
|
|
540
|
+
log("pipeline_aborted", { phase: "review", round });
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
// Fail the round ONLY when every lens failed, or a REQUIRED lens (the one
|
|
544
|
+
// the loop cannot proceed without, default: correctness) is the one that
|
|
545
|
+
// failed. Otherwise the surviving lenses carry the round and `degradedLenses`
|
|
546
|
+
// already records the skip honestly.
|
|
547
|
+
const requiredFailed = failedLenses.filter((l) => REQUIRED_LENSES.includes(l));
|
|
548
|
+
if (failedLenses.length === REVIEW_LENSES.length || requiredFailed.length > 0) {
|
|
549
|
+
stopReason = "failed";
|
|
550
|
+
log("pipeline_failed", { phase: "review", round, failedLenses, requiredFailed });
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
if (degradedLenses)
|
|
554
|
+
log("review_degraded", { round, degradedLenses });
|
|
555
|
+
progress({ kind: "stage_done", stageId: "review", round });
|
|
556
|
+
// R3-b: stop honestly if the review fan-out pushed the run over budget, before
|
|
557
|
+
// spending another fix + round. The round's findings are already recorded.
|
|
558
|
+
const overAfterReview = overBudget();
|
|
559
|
+
if (overAfterReview) {
|
|
560
|
+
stopReason = "budget_exceeded";
|
|
561
|
+
log("pipeline_stop", { reason: "budget_exceeded", phase: "review", round, detail: overAfterReview });
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
const decision = shouldStop(findings, round);
|
|
565
|
+
if (decision.stop && decision.reason) {
|
|
566
|
+
stopReason = decision.reason;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
progress({ kind: "stage_start", stageId: "fix", round });
|
|
570
|
+
stageEvent("fix", "start", { round });
|
|
571
|
+
const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
|
|
572
|
+
stages.push(fixResult);
|
|
573
|
+
log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
|
|
574
|
+
if (deps.signal?.aborted) {
|
|
575
|
+
stopReason = "aborted";
|
|
576
|
+
log("pipeline_aborted", { phase: "fix", round });
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
// Same silent-402 guard as the lenses: a credit-starved fix exits 0 empty and
|
|
580
|
+
// would hand the next round an empty reviewInput instead of stopping honestly.
|
|
581
|
+
const fixCredit = creditExhaustionReason(fixResult);
|
|
582
|
+
if (fixCredit) {
|
|
583
|
+
stopReason = "failed";
|
|
584
|
+
log("pipeline_failed", { phase: "fix", round, reason: fixCredit });
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
if (isFailed(fixResult)) {
|
|
588
|
+
const why = toolFailureReason(fixResult) ?? (fixResult.errorMessage || fixResult.stderr || "(no output)");
|
|
589
|
+
// Symmetric with the build-half guard: a fix stage KILLED mid-work (timeout /
|
|
590
|
+
// transient abort/error) leaves the run's reviewed diff (implement + any prior
|
|
591
|
+
// accepted rounds) plus a partial fix in the tree. By here the build half has
|
|
592
|
+
// already changed the tree (we passed the no_changes guard), so there is always
|
|
593
|
+
// preservable work — report `partial`, not a bare `failed`. A clean exit-0 fix
|
|
594
|
+
// failed only by the R3-a tool-health gate is the silent landmine (edits never
|
|
595
|
+
// landed) and keeps the hard `failed`.
|
|
596
|
+
const fixKilledMidWork = fixResult.exitCode !== 0 || fixResult.stopReason === "aborted" || fixResult.stopReason === "error";
|
|
597
|
+
if (fixKilledMidWork) {
|
|
598
|
+
stopReason = "partial";
|
|
599
|
+
log("pipeline_partial", { phase: "fix", round, reason: why });
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
stopReason = "failed";
|
|
603
|
+
log("pipeline_failed", { phase: "fix", round, reason: why });
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
progress({ kind: "stage_done", stageId: "fix", round });
|
|
608
|
+
stageEvent("fix", "finish", { round });
|
|
609
|
+
// R3-b: stop honestly if this round crossed the budget, before the next round.
|
|
610
|
+
const overAfterFix = overBudget();
|
|
611
|
+
if (overAfterFix) {
|
|
612
|
+
stopReason = "budget_exceeded";
|
|
613
|
+
log("pipeline_stop", { reason: "budget_exceeded", phase: "fix", round, detail: overAfterFix });
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
// This whole round (review + fix) completed and the loop will continue, so a
|
|
617
|
+
// crash now can resume at the NEXT round against this fix's output. Snapshot
|
|
618
|
+
// the post-fix tree as the resume consistency key.
|
|
619
|
+
const afterFix = await snapshotFn(deps.cwd, deps.signal);
|
|
620
|
+
writeCheckpoint(mkRecord("round_done", { round, reviewInput: fixResult.finalOutput, findings, snapshot: afterFix, usage: currentUsage() }));
|
|
621
|
+
reviewInput = fixResult.finalOutput;
|
|
622
|
+
round += 1;
|
|
623
|
+
}
|
|
624
|
+
log("pipeline_stop", { reason: stopReason, round });
|
|
625
|
+
progress({ kind: "done", stopReason });
|
|
626
|
+
return {
|
|
627
|
+
stages,
|
|
628
|
+
rounds,
|
|
629
|
+
stopReason,
|
|
630
|
+
findings: rounds[rounds.length - 1]?.findings ?? [],
|
|
631
|
+
decisionsRecorded,
|
|
632
|
+
...(verifyNote ? { verifyNote } : {}),
|
|
633
|
+
...(verifyCommand ? { verifyCommand } : {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=orchestrator.js.map
|