forgedock 1.28.0 → 1.29.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/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +71 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +171 -35
- package/bin/lean/lean.test.mjs +413 -7
- package/bin/lean/orchestrate.mjs +80 -3
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/render.mjs +25 -5
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/orchestrate.md +52 -6
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -50,8 +50,18 @@
|
|
|
50
50
|
* With `remote.fallbackLocal`, a lane placed remote runs on the host path instead of waiting when `max` remote lanes run,
|
|
51
51
|
* and instead of stopping when the budget refuses. The final `{where, reason}` is kept on the run record and the row as
|
|
52
52
|
* `placement` and passed to `runOne` as `ctx.placement` (the lane records it in its FORGE:STATE).
|
|
53
|
+
*
|
|
54
|
+
* In-batch recovery (#4267), off unless `recovery: {maxAttempts}` is passed: a row `classifyNeedsHuman` calls
|
|
55
|
+
* recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
56
|
+
* edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts) is not final while its persisted `recoveries` stay
|
|
57
|
+
* under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt}`) under the same admission as any
|
|
58
|
+
* lane, so its dependents stay queued. A merged recovery row carries `recovered: <code>`; any other end is final with
|
|
59
|
+
* `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
|
|
60
|
+
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
61
|
+
* fire-and-forget: the runner never waits on it.
|
|
53
62
|
*/
|
|
54
63
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
64
|
+
import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
|
|
55
65
|
|
|
56
66
|
// A dependency that finished like this no longer blocks its dependents.
|
|
57
67
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -144,7 +154,8 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
144
154
|
*/
|
|
145
155
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
146
156
|
store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
147
|
-
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null
|
|
157
|
+
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
158
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null }) {
|
|
148
159
|
const saved = store?.load?.() ?? null;
|
|
149
160
|
const order = saved ? [...saved.order] : [];
|
|
150
161
|
const set = new Set(order);
|
|
@@ -193,13 +204,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
193
204
|
const sandboxUse = toMap(saved?.sandbox); // issue -> {minutes, cost_usd, attempts} (#4255)
|
|
194
205
|
const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
|
|
195
206
|
let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
|
|
207
|
+
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
208
|
+
const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
|
|
209
|
+
const recovering = toMap(saved?.recovering); // issue -> {code, attempt} of the recovery lane queued or running
|
|
210
|
+
// #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
|
|
211
|
+
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
212
|
+
const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
|
|
196
213
|
|
|
197
214
|
const save = () => {
|
|
198
215
|
if (!store) return;
|
|
199
216
|
store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
200
217
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
201
218
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
202
|
-
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {})
|
|
219
|
+
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
220
|
+
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}) });
|
|
203
221
|
};
|
|
204
222
|
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
205
223
|
const classifyOne = async (n) => {
|
|
@@ -341,6 +359,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
341
359
|
// Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
|
|
342
360
|
const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
|
|
343
361
|
requeue(n, allDone ? DEP_REQUEUE_DELAY_S * 1000 : 0); done(); save();
|
|
362
|
+
decided({ issue: n, what: `requeued behind ${depList.map((d) => `#${d}`).join(", ")} (Depends on edge added)`, why: firstLine(row.detail) || "the lane reported an open dependency",
|
|
363
|
+
alternative: "skip the issue and its dependents" });
|
|
344
364
|
update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
|
|
345
365
|
return;
|
|
346
366
|
}
|
|
@@ -365,15 +385,50 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
365
385
|
if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
|
|
366
386
|
engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
|
|
367
387
|
requeue(n, 0); done(); save();
|
|
388
|
+
decided({ issue: n, what: "relaunch once after engine-error", why: firstLine(row.detail) || "engine-error", alternative: "end the issue engine-error and skip its dependents" });
|
|
368
389
|
update({ ...row, detail: `relaunching once: ${row.detail ?? ""}` });
|
|
369
390
|
return;
|
|
370
391
|
}
|
|
371
392
|
}
|
|
372
393
|
if (row.outcome === "decomposed" && subs.length) {
|
|
373
394
|
await addIssues(subs);
|
|
395
|
+
decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
|
|
396
|
+
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
374
397
|
for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
|
|
375
398
|
noteCrossLane();
|
|
376
399
|
}
|
|
400
|
+
// #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
|
|
401
|
+
const prior = recovering.get(n) ?? null;
|
|
402
|
+
if (prior) {
|
|
403
|
+
recovering.delete(n);
|
|
404
|
+
// #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
|
|
405
|
+
row.cost_usd = Math.round(((Number(row.cost_usd) || 0) + (Number(prior.cost_usd) || 0)) * 1e6) / 1e6;
|
|
406
|
+
row.minutes = Math.round(((Number(row.minutes) || 0) + (Number(prior.minutes) || 0)) * 10) / 10;
|
|
407
|
+
if (row.outcome === "merged") row.recovered = prior.code;
|
|
408
|
+
else row.recovery = { code: prior.code, attempts: recoveries.get(n) ?? prior.attempt, outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), detail: firstLine(row.detail) };
|
|
409
|
+
// A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
|
|
410
|
+
// relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
|
|
411
|
+
if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
412
|
+
try { await relabelNeedsHuman({ issue: n, evidence: `Recovery pass for \`${prior.code}\` (attempt ${prior.attempt}) did not merge: it ended \`${row.outcome}\`${row.code ? ` (\`${row.code}\`)` : ""}${firstLine(row.detail) ? `: ${firstLine(row.detail)}` : ""}. The issue stays \`needs-human\`.` }); } catch { /* best effort */ }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
|
|
416
|
+
let isConflicting = false;
|
|
417
|
+
if (row.outcome === "ci-pending" && typeof conflicting === "function" && Number.isInteger(row.pr)) {
|
|
418
|
+
try { isConflicting = (await conflicting(n, row.pr)) === true; } catch { isConflicting = false; }
|
|
419
|
+
}
|
|
420
|
+
const facts = { ...row, issueEdited: r?.issueEdited === true, conflicting: isConflicting };
|
|
421
|
+
const used = recoveries.get(n) ?? 0;
|
|
422
|
+
if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
|
|
423
|
+
const code = recoveryCodeOf(facts), attempt = used + 1;
|
|
424
|
+
recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0 });
|
|
425
|
+
requeue(n, 0); done(); save();
|
|
426
|
+
decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
|
|
427
|
+
alternative: "leave needs-human for the operator and skip the dependents" });
|
|
428
|
+
update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${recoveryMax}: ${firstLine(row.detail)}` });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
377
432
|
results.set(n, row);
|
|
378
433
|
if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
|
|
379
434
|
save(); update(row);
|
|
@@ -406,7 +461,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
406
461
|
if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
407
462
|
else {
|
|
408
463
|
try {
|
|
409
|
-
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), onStart: (info) => {
|
|
464
|
+
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt } } : {}), onStart: (info) => {
|
|
410
465
|
Object.assign(rec, info ?? {}); save();
|
|
411
466
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
412
467
|
} });
|
|
@@ -521,6 +576,28 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
521
576
|
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run") });
|
|
522
577
|
}
|
|
523
578
|
|
|
579
|
+
/** Max chars of one row's detail in the batch-report outcome lists (#4298). */
|
|
580
|
+
export const OUTCOME_LINE_MAX = 200;
|
|
581
|
+
/**
|
|
582
|
+
* #4267 batch-report lines after the table: `recovered: #A (ci.red) #B (base.conflict)`, one `unrecovered:` line per
|
|
583
|
+
* mechanical needs-human row (failed recovery or a terminal code), then one escalation list holding only human codes,
|
|
584
|
+
* asked once at the end of the batch. Empty when there is nothing to list.
|
|
585
|
+
*/
|
|
586
|
+
export function formatOutcomeLists(rows) {
|
|
587
|
+
const out = [];
|
|
588
|
+
const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
|
|
589
|
+
const rec = (rows ?? []).filter((r) => r?.recovered);
|
|
590
|
+
if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
|
|
591
|
+
for (const r of rows ?? []) {
|
|
592
|
+
if (!r || r.recovered) continue;
|
|
593
|
+
const human = r.outcome === "needs-human" && classifyNeedsHuman(r) === "human";
|
|
594
|
+
if (!human && (r.outcome === "needs-human" || r.recovery)) out.push(`unrecovered: #${r.issue} (${r.code ?? r.recovery?.code ?? "unclassified"}: ${one(r.recovery ? `recovery ${r.recovery.code} ended ${r.recovery.outcome}: ${r.recovery.detail}` : r.detail)})`);
|
|
595
|
+
}
|
|
596
|
+
const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
|
|
597
|
+
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
|
|
524
601
|
export function formatTable(rows) {
|
|
525
602
|
// #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
|
|
526
603
|
const sbx = rows.some((r) => r?.sandbox);
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
11
11
|
* All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
|
|
12
12
|
*/
|
|
13
|
-
import { capComment, sanitizeMd } from "./render.mjs";
|
|
13
|
+
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
14
14
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
@@ -38,7 +38,7 @@ export function ciFailureRun(iss) {
|
|
|
38
38
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
39
39
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
40
40
|
export function namedPaths(text) {
|
|
41
|
-
const s = sanitizeMd(text)
|
|
41
|
+
const s = noComments(sanitizeMd(text), " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
|
|
42
42
|
return [...new Set([...s.matchAll(PATH_RE)].map((m) => m[1].replace(/^\.\//, "")).filter((p) => !p.startsWith("/") && !p.startsWith("../")))];
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -112,6 +112,9 @@ describe("pre-filter (#4123)", () => {
|
|
|
112
112
|
|
|
113
113
|
it("every named path missing on the lane is a candidate; any surviving path is not", async () => {
|
|
114
114
|
assert.deepEqual(namedPaths("see `src/old/foo.js:12` and lib/bar.py, not https://x.io/a/b.js <!-- FORGE:STATE {\"a\":\"c/d.js\"} -->"), ["src/old/foo.js", "lib/bar.py"]);
|
|
115
|
+
// #4298: the scan-based noComments: a `--!>` closer ends a comment and an unclosed one runs to the end (the old
|
|
116
|
+
// one-pass regex kept both hidden paths).
|
|
117
|
+
assert.deepEqual(namedPaths("fix lib/a.js <!-- x/hidden.js --!> visible/e.js and <!-- unclosed c/d.js"), ["lib/a.js", "visible/e.js"]);
|
|
115
118
|
const gone = world({ issues: { 5: { body: "crash in src/old/foo.js:12" } }, verdict: { verdict: "INVALID", evidence: ["src/old/foo.js removed in abc123"] } });
|
|
116
119
|
assert.equal((await pf(gone, 5)).closed, true);
|
|
117
120
|
assert.match(gone.runner[0].prompt, /do not exist on origin\/staging: src\/old\/foo\.js/);
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -50,14 +50,17 @@ export function ciUnavailablePrompt() {
|
|
|
50
50
|
/** #4163: the extra host-rules line when the engine pinned the repo's toolchain on PATH; "" otherwise (prompt unchanged). */
|
|
51
51
|
const toolchainRule = (t) => (t ? `\nThe engine pinned this repo's toolchain on PATH (${t}). Use it as is: do not install global shims or other versions and do not edit PATH; if the pinned tool itself fails, report it as a toolchain/environment problem instead of working around it.` : "");
|
|
52
52
|
|
|
53
|
+
/** #4272: the extra line citing the repo-facts cache by path (never its content) when it exists; "" otherwise (prompt unchanged). */
|
|
54
|
+
const factsRule = (f) => (f ? `\nRepo facts (toolchain pins, slow suites, layer rules, hotspots) are cached in ${f}: read it before choosing checks or placing code.` : "");
|
|
55
|
+
|
|
53
56
|
/** #4221: the extra host-rules line when the lane worktree is sparse; "" otherwise (prompt unchanged). */
|
|
54
57
|
const sparseRule = (s) => (s?.excluded ? `\nThis worktree is a sparse checkout: ${s.excluded} tracked file(s) of ${s.maxKb} KB or more are not on disk (\`git ls-files -t | grep '^S'\` lists them). They are not deleted: never commit their removal. If you need one, run \`git sparse-checkout add /<path>\`.` : "");
|
|
55
58
|
|
|
56
59
|
/** Builder call 1: investigate and write the contract. No code edits. */
|
|
57
|
-
export function builderPlanPrompt(n, { toolchain = null, sparse = null } = {}) {
|
|
60
|
+
export function builderPlanPrompt(n, { toolchain = null, sparse = null, facts = null } = {}) {
|
|
58
61
|
return `Resolve issue #${n} (read it with gh issue view ${n} --comments). This first step is PLANNING ONLY: do not edit code, do not commit.
|
|
59
62
|
|
|
60
|
-
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}
|
|
63
|
+
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
61
64
|
|
|
62
65
|
1. Write ${INVESTIGATION_FILE} as markdown with exactly these headings, concise, file:line wherever you cite code (no HTML comments):
|
|
63
66
|
**Verdict**: CONFIRMED|ALREADY-FIXED|INVALID|BLOCKED
|
|
@@ -79,7 +82,7 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
|
|
|
79
82
|
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>"}],
|
|
80
83
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
81
84
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
82
|
-
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}],
|
|
85
|
+
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
|
|
83
86
|
"decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
|
|
84
87
|
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
|
|
85
88
|
A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
|
|
@@ -88,11 +91,11 @@ ${BLOCK_RULES}`;
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/** Builder call 2, same session: implement the frozen contract. */
|
|
91
|
-
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null } = {}) {
|
|
94
|
+
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
|
|
92
95
|
const remote = mode === "ci" || mode === "hybrid";
|
|
93
96
|
return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
|
|
94
97
|
|
|
95
|
-
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
98
|
+
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
96
99
|
|
|
97
100
|
The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
|
|
98
101
|
- {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
|
|
@@ -115,6 +118,15 @@ If you started Docker containers or volumes that are still there, list their nam
|
|
|
115
118
|
If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/**
|
|
122
|
+
* forge#4275: the compiled context pack for the plan prompt (context_pack: on); "" for an empty pack. Labelled as hints:
|
|
123
|
+
* the issue outranks the code, which outranks the pack, and contract quotes never come from it.
|
|
124
|
+
*/
|
|
125
|
+
export function contextPackNote(pack) {
|
|
126
|
+
if (!pack?.text) return "";
|
|
127
|
+
return `\n\nContext pack (hints, not proof; compiled by the engine from GitHub and git). The issue outranks the code, which outranks this pack: verify anything you rely on. Contract requirements quote only the issue and its trusted comments, never this pack. Open a link (gh issue view N / gh pr view N) yourself when you need more.\n${pack.text}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
|
|
119
131
|
export function reproPlanNote(on) {
|
|
120
132
|
if (!on) return "";
|
|
@@ -140,8 +152,11 @@ export function contractCorrectionPrompt(problems) {
|
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
/** The one bounded correction round, in the builder's own session. */
|
|
143
|
-
export function conformanceResumePrompt(failures, mode = "local") {
|
|
144
|
-
|
|
155
|
+
export function conformanceResumePrompt(failures, mode = "local", lane = "") {
|
|
156
|
+
const baseMoved = lane && failures.some((f) => f?.kind === "validate_failed")
|
|
157
|
+
? `\n\nIf a validate command fails only because origin/${lane} moved on after this branch started (the change itself is fine), bring the branch up to date with \`git merge --no-edit origin/${lane}\` (already fetched; never rebase or force), resolve any conflicts, then re-run every validate command and record the results.`
|
|
158
|
+
: "";
|
|
159
|
+
return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}${baseMoved}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
/** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4267: in-batch recovery of mechanical needs-human (orchestrate, run-issue/orchestrate CLI, /orchestrate spec).
|
|
4
|
+
// The runCore side (--recover label/decision/prompt, issueEdited) is in lean.test.mjs next to its harness.
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
|
|
10
|
+
import { RECOVER_HOW } from "./lean.mjs";
|
|
11
|
+
import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
12
|
+
import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
|
|
13
|
+
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
14
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("recovery-sched-");
|
|
15
|
+
|
|
16
|
+
describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
17
|
+
// runOne: `script[n]` is a list of results, one per launch of issue n (the last repeats); ctx is recorded.
|
|
18
|
+
const batch = (script, bodies = {}, extra = {}) => {
|
|
19
|
+
const calls = [], decisions = [];
|
|
20
|
+
const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: 1 },
|
|
21
|
+
readBody: async (n) => bodies[n] ?? "",
|
|
22
|
+
runOne: async (n, _lane, ctx = {}) => {
|
|
23
|
+
calls.push({ n, recover: ctx.recover ?? null });
|
|
24
|
+
await new Promise((r) => setImmediate(r));
|
|
25
|
+
const list = script[n] ?? [{ terminalReason: "merged" }];
|
|
26
|
+
const k = calls.filter((c) => c.n === n).length - 1;
|
|
27
|
+
return list[Math.min(k, list.length - 1)];
|
|
28
|
+
},
|
|
29
|
+
decide: (d) => { decisions.push(d); }, ...extra });
|
|
30
|
+
return p.then((rows) => ({ rows, calls, decisions }));
|
|
31
|
+
};
|
|
32
|
+
const NH = (code, detail = `ended ${code}`, more = {}) => ({ terminalReason: "needs-human", code, detail, ...more });
|
|
33
|
+
const byIssue = (rows) => Object.fromEntries(rows.map((r) => [r.issue, r]));
|
|
34
|
+
|
|
35
|
+
it("ci.red: one recovery lane on the PR; green re-queues the issue and its dependent in the same batch, no operator action", async () => {
|
|
36
|
+
const { rows, calls, decisions } = await batch({ 1: [NH("ci.red", "Required check(s) failed on PR #101: test"), { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "merged", pr: 102 }] }, { 2: "**Depends on**: #1" });
|
|
37
|
+
const r = byIssue(rows);
|
|
38
|
+
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].recovered, "ci.red");
|
|
39
|
+
assert.equal(r[2].outcome, "merged", "the dependent was not skipped");
|
|
40
|
+
assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1 }]);
|
|
41
|
+
assert.ok(calls.findIndex((c) => c.n === 2) > calls.findLastIndex((c) => c.n === 1), "the dependent ran after the recovered merge");
|
|
42
|
+
const d = decisions.find((x) => x.issue === 1);
|
|
43
|
+
assert.match(d.what, /recovery lane for ci\.red/); assert.match(d.why, /Required check/); assert.ok(d.alternative);
|
|
44
|
+
assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red)"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("a recovery that stays red is final with its evidence; dependents stay skipped; no attempt beyond max_attempts", async () => {
|
|
48
|
+
const { rows, calls } = await batch({ 1: [NH("ci.red"), NH("ci.red", "CI verification failed again", { recovery: true })], 2: [] }, { 2: "**Depends on**: #1" });
|
|
49
|
+
const r = byIssue(rows);
|
|
50
|
+
assert.equal(calls.filter((c) => c.n === 1).length, 2, "the first run plus exactly one recovery");
|
|
51
|
+
assert.equal(r[1].outcome, "needs-human");
|
|
52
|
+
assert.deepEqual(r[1].recovery, { code: "ci.red", attempts: 1, outcome: "needs-human", result_code: "ci.red", detail: "CI verification failed again" });
|
|
53
|
+
assert.equal(r[2].outcome, "skipped"); assert.ok(!calls.some((c) => c.n === 2));
|
|
54
|
+
const lines = formatOutcomeLists(rows);
|
|
55
|
+
assert.equal(lines.length, 1); assert.match(lines[0], /^unrecovered: #1 \(ci\.red: recovery ci\.red ended needs-human: CI verification failed again\)$/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("human codes (product-decision, spend) are never recovered, still skip dependents and are the only escalations, asked once while other lanes keep running", async () => {
|
|
59
|
+
const { rows, calls, decisions } = await batch({
|
|
60
|
+
1: [NH("builder.blocked", "Builder is blocked (product-decision): pick a pricing model")],
|
|
61
|
+
2: [],
|
|
62
|
+
3: [NH("builder.blocked", "Builder is blocked (spend): needs a paid plan")],
|
|
63
|
+
4: [NH("conformance.omission", "R2 not covered")],
|
|
64
|
+
5: [{ terminalReason: "merged" }] }, { 2: "**Depends on**: #1" });
|
|
65
|
+
const r = byIssue(rows);
|
|
66
|
+
assert.equal(calls.filter((c) => c.n === 1).length, 1); assert.equal(calls.filter((c) => c.n === 3).length, 1);
|
|
67
|
+
assert.equal(calls.filter((c) => c.n === 4).length, 1, "terminal mechanical codes are not recovered either");
|
|
68
|
+
assert.ok(!decisions.some((d) => [1, 3].includes(d.issue)));
|
|
69
|
+
assert.equal(r[2].outcome, "skipped"); assert.equal(r[5].outcome, "merged");
|
|
70
|
+
const lines = formatOutcomeLists(rows);
|
|
71
|
+
assert.equal(lines.filter((l) => l.startsWith("escalations")).length, 1, "one escalation list");
|
|
72
|
+
const esc = lines.find((l) => l.startsWith("escalations"));
|
|
73
|
+
assert.match(esc, /#1 \(builder\.blocked\): Builder is blocked \(product-decision\)/);
|
|
74
|
+
assert.match(esc, /#3 \(builder\.blocked\): Builder is blocked \(spend\)/);
|
|
75
|
+
assert.doesNotMatch(esc, /#4/, "the needs-human list holds only human codes");
|
|
76
|
+
assert.ok(lines.some((l) => /^unrecovered: #4 \(conformance\.omission: R2 not covered\)$/.test(l)));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("base.conflict: a recovery base-sync with a clean result re-queues the issue; the report lists recovered issues separately", async () => {
|
|
80
|
+
const { rows, calls } = await batch({ 1: [NH("ci.red"), { terminalReason: "merged" }], 2: [NH("base.conflict", "PR #102 still conflicts with staging after 2 base-syncs"), { terminalReason: "merged" }] });
|
|
81
|
+
assert.deepEqual(calls.filter((c) => c.n === 2).map((c) => c.recover?.code ?? null), [null, "base.conflict"]);
|
|
82
|
+
assert.equal(byIssue(rows)[2].recovered, "base.conflict");
|
|
83
|
+
assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red) #2 (base.conflict)"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ci-pending recovers only when its PR conflicts (as base.conflict); a slow-CI ci-pending is left as is", async () => {
|
|
87
|
+
const seen = [];
|
|
88
|
+
const conflicting = async (n, pr) => { seen.push([n, pr]); return n === 1; };
|
|
89
|
+
const { rows, calls } = await batch({ 1: [{ terminalReason: "ci-pending", pr: 101 }, { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "ci-pending", pr: 102 }] }, {}, { conflicting });
|
|
90
|
+
const r = byIssue(rows);
|
|
91
|
+
assert.equal(r[1].recovered, "base.conflict");
|
|
92
|
+
assert.equal(r[2].outcome, "ci-pending"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
|
|
93
|
+
assert.deepEqual(seen.sort(), [[1, 101], [2, 102]]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("contract.invalid recovers only when the issue was edited mid-run", async () => {
|
|
97
|
+
const { rows, calls } = await batch({ 1: [NH("contract.invalid", "contract invalid", { issueEdited: true }), { terminalReason: "merged" }], 2: [NH("contract.invalid")] });
|
|
98
|
+
assert.equal(byIssue(rows)[1].recovered, "contract.invalid");
|
|
99
|
+
assert.equal(byIssue(rows)[2].outcome, "needs-human"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("relabelNeedsHuman (SPEC-7/8): only a recovery that ends final engine-error/deferred relabels; invalid/merged never; a requeued deferred waits", async () => {
|
|
103
|
+
const relabels = [];
|
|
104
|
+
const rl = { relabelNeedsHuman: async (x) => { relabels.push(x); }, pollMs: 5 };
|
|
105
|
+
// engine-error after the recovery lane's own relaunch: final -> exactly one relabel with the recovery evidence
|
|
106
|
+
const a = await batch({ 1: [NH("ci.red"), { terminalReason: "engine-error", detail: "boom" }] }, {}, rl);
|
|
107
|
+
assert.equal(a.rows[0].outcome, "engine-error"); assert.equal(relabels.length, 1);
|
|
108
|
+
assert.equal(relabels[0].issue, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `engine-error`/);
|
|
109
|
+
// invalid / merged / decomposed: never
|
|
110
|
+
relabels.length = 0;
|
|
111
|
+
await batch({ 1: [NH("ci.red"), { terminalReason: "invalid", detail: "not a bug" }], 2: [NH("ci.red"), { terminalReason: "merged" }],
|
|
112
|
+
3: [NH("ci.red"), { terminalReason: "decomposed", subissues: [] }] }, {}, rl);
|
|
113
|
+
assert.deepEqual(relabels, []);
|
|
114
|
+
// a deferred recovery that is requeued is not relabelled until it is final (capped)
|
|
115
|
+
const defer = { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 0.01, detail: "gh down" };
|
|
116
|
+
const seen = [];
|
|
117
|
+
const d = await batch({ 1: [NH("ci.red"), defer] }, {}, { ...rl, maxDeferrals: 2, onUpdate: (r) => seen.push([r.outcome, relabels.length, String(r.detail)]) });
|
|
118
|
+
assert.equal(d.rows[0].outcome, "deferred");
|
|
119
|
+
const rq = seen.filter(([o, , det]) => o === "deferred" && /^requeued in/.test(det));
|
|
120
|
+
assert.ok(rq.length >= 1 && rq.every(([, n]) => n === 0), "a requeued deferral is not relabelled");
|
|
121
|
+
assert.equal(relabels.filter((x) => x.issue === 1).length, 1, "once, only when final");
|
|
122
|
+
// without a recovery pass nothing is relabelled
|
|
123
|
+
relabels.length = 0;
|
|
124
|
+
await batch({ 1: [{ terminalReason: "engine-error" }] }, {}, rl);
|
|
125
|
+
assert.deepEqual(relabels, []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("without the recovery option the runner behaves as before: needs-human is final and dependents skip", async () => {
|
|
129
|
+
const calls = [];
|
|
130
|
+
const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "**Depends on**: #1" : ""),
|
|
131
|
+
runOne: async (n) => { calls.push(n); return NH("ci.red"); } });
|
|
132
|
+
assert.deepEqual(calls, [1]); assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("the attempt count survives a resume: a persisted recovery lane is launched with ctx.recover and is not retried again", async () => {
|
|
136
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } } };
|
|
137
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
138
|
+
const ctxs = [];
|
|
139
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 1 }, readBody: async () => "",
|
|
140
|
+
runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return NH("ci.red"); } });
|
|
141
|
+
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1 }]);
|
|
142
|
+
assert.equal(rows[0].outcome, "needs-human"); assert.equal(rows[0].recovery.code, "ci.red");
|
|
143
|
+
assert.deepEqual(state.recovering, {});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("other runner decisions are recorded too (engine-error relaunch, dependency requeue, sub-issues), and a throwing recorder never blocks the run", async () => {
|
|
147
|
+
const { decisions } = await batch({ 1: [{ terminalReason: "engine-error", detail: "boom" }, { terminalReason: "merged" }], 2: [{ terminalReason: "decomposed", subissues: [8] }] });
|
|
148
|
+
assert.ok(decisions.some((d) => d.issue === 1 && /relaunch/.test(d.what) && d.why === "boom" && d.alternative));
|
|
149
|
+
assert.ok(decisions.some((d) => d.issue === 2 && /#8/.test(d.what)));
|
|
150
|
+
const rows = await orchestrate({ issues: [1], recovery: { maxAttempts: 1 }, readBody: async () => "", decide: () => { throw new Error("gh down"); },
|
|
151
|
+
runOne: async (n, l, ctx) => (ctx.recover ? { terminalReason: "merged" } : NH("ci.red")) });
|
|
152
|
+
assert.equal(rows[0].recovered, "ci.red");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
it("run-issue --recover reaches runCore; a batch relaunches a ci.red lane with recover and reports it", async () => {
|
|
157
|
+
const got = [];
|
|
158
|
+
const l = console.log; console.log = () => {};
|
|
159
|
+
try {
|
|
160
|
+
await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "base.conflict", "--recover-attempt", "1"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
161
|
+
leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
|
|
162
|
+
} finally { console.log = l; }
|
|
163
|
+
assert.deepEqual(got, [{ code: "base.conflict", attempt: 1 }]);
|
|
164
|
+
// The batch: a ci.red lane is relaunched in-process with recover, the decision is recorded and the report lists it.
|
|
165
|
+
const leanIo = {
|
|
166
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
167
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
168
|
+
};
|
|
169
|
+
const runs = [], decisions = [], logs = [];
|
|
170
|
+
console.log = (x) => logs.push(String(x));
|
|
171
|
+
let res;
|
|
172
|
+
try {
|
|
173
|
+
res = await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
|
|
174
|
+
io: { gh: async () => "o/r\n" }, leanIo, sched: null, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: (d) => decisions.push(d),
|
|
175
|
+
leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return o.recover ? { terminalReason: "merged", pr: 55 } : { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
|
|
176
|
+
} finally { console.log = l; }
|
|
177
|
+
assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1 }]);
|
|
178
|
+
assert.equal(res.ok, true);
|
|
179
|
+
assert.ok(decisions.some((d) => d.issue === 5 && /recovery lane for ci\.red/.test(d.what)));
|
|
180
|
+
assert.ok(logs.includes("recovered: #5 (ci.red)"), logs.join("\n"));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("/orchestrate spec step 3 lists the recoverable codes, asks no confirmation on them, and escalates once at the end", async () => {
|
|
184
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
185
|
+
const step3 = spec.slice(spec.indexOf("## 3. Launch lanes"), spec.indexOf("## 4. Report"));
|
|
186
|
+
for (const c of ["`ci.red`", "`base.conflict`", "`ci-pending` on a conflicting", "`contract.invalid` caused by an issue edited mid-run"]) assert.ok(step3.includes(c), c);
|
|
187
|
+
assert.match(step3, /\*\*Do not ask for confirmation on them\.\*\*/);
|
|
188
|
+
assert.match(step3, /FORGE:DECISION/);
|
|
189
|
+
assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
|
|
190
|
+
assert.match(spec, /recovered: #A \(ci\.red\) #B \(base\.conflict\)/);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("#4298 staging-review follow-ups: recovery", () => {
|
|
195
|
+
it("RECOVER_HOW has a prompt for exactly the RECOVERABLE_CODES", () => {
|
|
196
|
+
assert.deepEqual(Object.keys(RECOVER_HOW).sort(), [...RECOVERABLE_CODES].sort());
|
|
197
|
+
for (const c of RECOVERABLE_CODES) assert.ok(String(RECOVER_HOW[c]).length > 20, c);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt}", async () => {
|
|
201
|
+
let t = 0; const seen = [];
|
|
202
|
+
const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 0.5 }, { terminalReason: "merged", pr: 9, cost_usd: 0.25 }],
|
|
203
|
+
2: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 2 }] };
|
|
204
|
+
const k = { 1: 0, 2: 0 };
|
|
205
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => t,
|
|
206
|
+
runOne: async (n, _l, ctx = {}) => { seen.push(ctx.recover ?? null); t += 60000 * (n === 1 ? 3 : 1); return script[n][k[n]++]; } });
|
|
207
|
+
const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
|
|
208
|
+
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].cost_usd, 0.75); assert.equal(r[1].minutes, 6);
|
|
209
|
+
assert.equal(r[2].outcome, "needs-human"); assert.equal(r[2].cost_usd, 3); assert.equal(r[2].minutes, 2);
|
|
210
|
+
assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1 }, { code: "ci.red", attempt: 1 }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("formatOutcomeLists caps a detail at OUTCOME_LINE_MAX", () => {
|
|
214
|
+
const [l] = formatOutcomeLists([{ issue: 3, outcome: "needs-human", code: "conformance.omission", detail: "x".repeat(500) }]);
|
|
215
|
+
assert.ok(l.includes("x".repeat(OUTCOME_LINE_MAX)) && !l.includes("x".repeat(OUTCOME_LINE_MAX + 1)));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("run-issue rejects --recover on the legacy --path full", async () => {
|
|
219
|
+
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "full", "--recover", "ci.red"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" } }),
|
|
220
|
+
/--recover is not supported with --path full/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("/orchestrate spec describes terminal non-human needs-human rows, --recover scope and the schedd resume scope", () => {
|
|
224
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
225
|
+
assert.match(spec, /\*\*terminal code\*\* that is neither recoverable nor human/);
|
|
226
|
+
assert.match(spec, /`unrecovered: #N/);
|
|
227
|
+
assert.match(spec, /core path only:\s+`--path full` rejects `--recover`/);
|
|
228
|
+
assert.match(spec, /resumes every daemon-hosted batch that has no report yet/);
|
|
229
|
+
});
|
|
230
|
+
});
|
package/bin/lean/render.mjs
CHANGED
|
@@ -20,6 +20,25 @@ const bullets = (v) => {
|
|
|
20
20
|
const objs = (v, key) => arr(v).map((x) => (x && typeof x === "object" ? x : { [key]: x }));
|
|
21
21
|
const norm = (r) => r ?? {};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Untrusted issue/comment text with HTML comments removed and no comment opener left: comments are cut out by plain
|
|
25
|
+
* scanning (closer `-->` or `--!>`; an unclosed one runs to the end), then every `<` that starts `<!` becomes `‹`.
|
|
26
|
+
* With no `<!` in the output, no HTML comment and no FORGE marker can open, whatever the input nesting. Pack text is
|
|
27
|
+
* posted back in a trusted bot comment, so this is the guarantee that matters.
|
|
28
|
+
*/
|
|
29
|
+
export function noComments(s, repl = "") {
|
|
30
|
+
const t = String(s ?? "");
|
|
31
|
+
let out = "", i = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const a = t.indexOf("<!--", i);
|
|
34
|
+
if (a < 0) { out += t.slice(i); break; }
|
|
35
|
+
out += t.slice(i, a) + repl;
|
|
36
|
+
const ends = [[t.indexOf("-->", a + 4), 3], [t.indexOf("--!>", a + 4), 4]].filter(([k]) => k >= 0).sort((x, y) => x[0] - y[0]);
|
|
37
|
+
if (!ends.length) break;
|
|
38
|
+
i = ends[0][0] + ends[0][1];
|
|
39
|
+
}
|
|
40
|
+
return out.replace(/<(?=!)/g, "\u2039");
|
|
41
|
+
}
|
|
23
42
|
/** Builder-written markdown is untrusted: drop any FORGE marker comment (even unterminated ones) and normalise newlines. */
|
|
24
43
|
export function sanitizeMd(md) {
|
|
25
44
|
return String(md ?? "").replace(/\r\n/g, "\n").replace(/<!--\s*FORGE:[\s\S]*?(?:-->|$)/gi, "").trim();
|
|
@@ -86,11 +105,12 @@ const esc = (v) => cell(v).replace(/</g, "<");
|
|
|
86
105
|
const escl = (v) => line(v).replace(/</g, "<");
|
|
87
106
|
/** Canonical contract JSON: stable, and `<` escaped so builder text can never open an HTML/FORGE comment. */
|
|
88
107
|
export const contractJson = (c) => JSON.stringify(c, null, 1).replace(/</g, "\\u003c");
|
|
89
|
-
|
|
108
|
+
// #4265: an optional ", issue S" records the hash of the issue scope (conformance.mjs scopeText) the contract was planned from.
|
|
109
|
+
const CONTRACT_BLOCK_RE = /<summary>contract\.json \(sha256 ([0-9a-f]{12})(?:, issue ([0-9a-f]{12}))?\)<\/summary>\s*```json\n([\s\S]*?)\n```/;
|
|
90
110
|
|
|
91
111
|
/** FORGE:CONTRACT body: readable tables plus the frozen JSON and its hash (read back on a rerun). */
|
|
92
|
-
export function renderContract(c, hash, { compact = false } = {}) {
|
|
93
|
-
const json = `<details><summary>contract.json (sha256 ${hash})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
|
|
112
|
+
export function renderContract(c, hash, { compact = false, scope = null } = {}) {
|
|
113
|
+
const json = `<details><summary>contract.json (sha256 ${hash}${scope ? `, issue ${scope}` : ""})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
|
|
94
114
|
if (compact) return `## Builder Contract (frozen \`${hash}\`)\n\n${c.requirements.length} requirement(s), ${(c.deliverables ?? []).length} deliverable(s); the tables are omitted because the contract is large.\n\n${json}`;
|
|
95
115
|
const disp = (r) => `${r.disposition}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
|
|
96
116
|
const req = c.requirements.map((r) => `| ${esc(r.id)} | ${esc(r.quote)} | ${esc(disp(r))} |`).join("\n");
|
|
@@ -99,11 +119,11 @@ export function renderContract(c, hash, { compact = false } = {}) {
|
|
|
99
119
|
? `\n\n### Decisions\n| Id | Choice | Justification | Source |\n| --- | --- | --- | --- |\n${c.decisions.map((k) => `| ${esc(k?.id)} | ${esc(k?.choice)} | ${esc(k?.justification)} | ${esc(k?.source)} |`).join("\n")}` : "";
|
|
100
120
|
return `## Builder Contract (frozen \`${hash}\`)\n\n### Requirements\n| Id | From the issue | Disposition |\n| --- | --- | --- |\n${req}\n\n### Deliverables\n${del ? `| Id | Path | Change | Requirements |\n| --- | --- | --- | --- |\n${del}` : "None."}\n\n### Preserve\n${(c.preserve ?? []).map((p) => `- ${escl(p.id)}: ${escl(p.what)}`).join("\n") || "None."}\n\n### Validate\n${(c.validate ?? []).map((v) => `- ${escl(v.id)}: \`${escl(v.command).replace(/`/g, "'")}\``).join("\n") || NR}${dec}\n\n${json}`;
|
|
101
121
|
}
|
|
102
|
-
/** {contract, hash, text} from a FORGE:CONTRACT comment body, or null. */
|
|
122
|
+
/** {contract, hash, scope, text} from a FORGE:CONTRACT comment body (scope null for a pre-#4265 comment), or null. */
|
|
103
123
|
export function parseContractComment(body) {
|
|
104
124
|
const m = CONTRACT_BLOCK_RE.exec(String(body ?? ""));
|
|
105
125
|
if (!m) return null;
|
|
106
|
-
try { return { hash: m[1],
|
|
126
|
+
try { return { hash: m[1], scope: m[2] ?? null, text: m[3], contract: JSON.parse(m[3]) }; } catch { return null; }
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
/** Typed amendments, posted visibly before review. */
|