forgedock 1.49.0 → 1.51.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 +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/orchestrate.md +2 -1
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- package/scripts/review-staging-post.test.sh +40 -8
package/bin/lean/lean.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
15
15
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
17
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
18
|
-
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows,
|
|
18
|
+
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, reaskable, criterionLines, closePlan, followupItems, renderPendingFollowups, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
19
19
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
20
20
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
21
21
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
@@ -91,6 +91,8 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
91
91
|
[OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]: "an earlier run deferred a requirement without a sufficient why after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record a `defer` amendment with a concrete why so the engine files a tracked follow-up instead of stopping",
|
|
92
92
|
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
93
93
|
});
|
|
94
|
+
/** #5168: retry-in for a `backend.auth` deferral; the batch holds the account for it (an operator `--set-env` fix runs under a new identity). */
|
|
95
|
+
const AUTH_RETRY_SECONDS = 900;
|
|
94
96
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
95
97
|
const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND]);
|
|
96
98
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
@@ -319,7 +321,8 @@ export async function runCore(o) {
|
|
|
319
321
|
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
320
322
|
// forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
|
|
321
323
|
const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort(), ...(reason === "merged" && mergedTo ? { mergedTo } : {}) } : {}; // #4711: where the change landed
|
|
322
|
-
|
|
324
|
+
const pending = pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}; // #5141: unfiled follow-ups, filed later by the marker
|
|
325
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed, ...pending } : pending); } catch { /* best effort */ }
|
|
323
326
|
}
|
|
324
327
|
|
|
325
328
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
@@ -357,6 +360,8 @@ export async function runCore(o) {
|
|
|
357
360
|
let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
|
|
358
361
|
const waits = {};
|
|
359
362
|
const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
|
|
363
|
+
const pendingFollowups = []; // #5141: {req, why} whose filing kept failing in-lane; filed after the merge, else recorded
|
|
364
|
+
const pendingRecord = () => pendingFollowups.filter((p) => !p.req.tracked).map((p) => ({ id: p.req.id, quote: String(p.req.quote ?? ""), disposition: p.req.disposition, why: p.why, marker: `FORGE:DEFERRED_FROM: #${issue}` }));
|
|
360
365
|
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
361
366
|
const addWait = (phase, ms, queued = 0) => {
|
|
362
367
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
@@ -485,7 +490,7 @@ export async function runCore(o) {
|
|
|
485
490
|
}
|
|
486
491
|
if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
|
|
487
492
|
if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
|
|
488
|
-
else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
493
|
+
else if (reason === "merged") T.close = [extra.keptOpen ? "left open (follow-up pending)" : extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"]; // #5159
|
|
489
494
|
else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
|
|
490
495
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
491
496
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
@@ -494,7 +499,7 @@ export async function runCore(o) {
|
|
|
494
499
|
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
495
500
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs, phases: phaseLedger.snapshot(),
|
|
496
501
|
...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
497
|
-
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
502
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
498
503
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
499
504
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
500
505
|
...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
|
|
@@ -633,6 +638,9 @@ export async function runCore(o) {
|
|
|
633
638
|
const par = verifyParallelism();
|
|
634
639
|
return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
|
|
635
640
|
}
|
|
641
|
+
/** #5168: a failed claude login ends the lane `deferred` `backend.auth`, held per account by the batch (never the relaunch or the breaker). */
|
|
642
|
+
const authDeferral = (e) => Object.assign(e, { deferCode: OUTCOME_CODES.BACKEND_AUTH, retryInSeconds: AUTH_RETRY_SECONDS, backendId: laneBackendId(process.env, rotator),
|
|
643
|
+
deferDetail: `claude CLI authentication failed (login expired or invalid); held until the login is fixed: ${String(e?.message ?? e).split("\n")[0].slice(0, 300)}` });
|
|
636
644
|
/** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
637
645
|
async function callRunner(args) {
|
|
638
646
|
let pauses = 0, rotations = 0;
|
|
@@ -646,6 +654,7 @@ export async function runCore(o) {
|
|
|
646
654
|
try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
|
|
647
655
|
catch (e) {
|
|
648
656
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
657
|
+
if (e?.authFailure === true && e.usageLimit !== true && epoch === null) throw authDeferral(e); // #5168: an account condition, never engine-error
|
|
649
658
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
650
659
|
if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
|
|
651
660
|
const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
|
|
@@ -999,9 +1008,10 @@ export async function runCore(o) {
|
|
|
999
1008
|
// #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
|
|
1000
1009
|
if (await label("decomposed") && marked && closeParent) {
|
|
1001
1010
|
try {
|
|
1002
|
-
|
|
1011
|
+
// #5152: the scope moved to the sub-issues, it was not dropped: `completed`, never `not planned`
|
|
1012
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed", "--comment",
|
|
1003
1013
|
`Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
|
|
1004
|
-
T.decisions.push(`Closed the decomposed parent (
|
|
1014
|
+
T.decisions.push(`Closed the decomposed parent (completed; scope moved to sub-issues); tracked by ${refs(filed)}`);
|
|
1005
1015
|
} catch { /* best effort: the parent stays open, as before #4337 */ }
|
|
1006
1016
|
}
|
|
1007
1017
|
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
|
|
@@ -1083,7 +1093,7 @@ export async function runCore(o) {
|
|
|
1083
1093
|
try {
|
|
1084
1094
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
1085
1095
|
} catch (e) {
|
|
1086
|
-
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode); // #4662
|
|
1096
|
+
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #4662; #5168 backendId
|
|
1087
1097
|
return finish("engine-error", `cannot read issue: ${e.message}`);
|
|
1088
1098
|
}
|
|
1089
1099
|
const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
|
|
@@ -1404,25 +1414,54 @@ export async function runCore(o) {
|
|
|
1404
1414
|
}
|
|
1405
1415
|
return block ? await endBlocked(block, result.summary) : null;
|
|
1406
1416
|
}
|
|
1407
|
-
/**
|
|
1408
|
-
|
|
1409
|
-
|
|
1417
|
+
/**
|
|
1418
|
+
* File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open.
|
|
1419
|
+
* #5141: the whole step is retried in-lane (3 attempts, 5s then 15s): the script is idempotent (claim marker /
|
|
1420
|
+
* FORGE:DEFERRED_FROM lookup, checklist dedup, duplicate close), so only a persistent failure defers the run.
|
|
1421
|
+
* A malformed TSV (DEFERRED_TSV=INVALID) is deterministic and never retried. #5141: when the retries are spent the run
|
|
1422
|
+
* keeps going (keep): the untracked requirements join pendingFollowups, recorded in FORGE:STATE and filed after the merge.
|
|
1423
|
+
*/
|
|
1424
|
+
async function track(reqs, why, { keep = true } = {}) {
|
|
1425
|
+
const fail = (detail, final = true) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry`, final });
|
|
1410
1426
|
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
1411
1427
|
const items = followupItems(reqs, { now: now() });
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1428
|
+
const attempt = async () => {
|
|
1429
|
+
let filed;
|
|
1430
|
+
try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) {
|
|
1431
|
+
throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`, /DEFERRED_TSV=INVALID/.test(`${e?.stdout ?? ""}${e?.message ?? ""}`));
|
|
1432
|
+
}
|
|
1433
|
+
for (const [i, r] of reqs.entries()) {
|
|
1434
|
+
if (recorded.has(i)) continue; // tracked in an earlier attempt: keeps its r.tracked, never re-counted
|
|
1435
|
+
const n = Number(filed?.[items[i].key]);
|
|
1436
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`, false);
|
|
1437
|
+
let st = null;
|
|
1438
|
+
try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
|
|
1439
|
+
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`, false);
|
|
1440
|
+
recorded.add(i);
|
|
1441
|
+
r.tracked = `#${n}`;
|
|
1442
|
+
const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
|
|
1443
|
+
if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
const recorded = new Set();
|
|
1447
|
+
const waits = [5000, 15000];
|
|
1448
|
+
for (let k = 0; ; k++) {
|
|
1449
|
+
try { await attempt(); break; } catch (e) {
|
|
1450
|
+
if (e.final || (k >= waits.length && !keep)) throw e;
|
|
1451
|
+
if (k >= waits.length) { // #5141: keep the merge-capable work; the close step files these after the merge
|
|
1452
|
+
const left = reqs.filter((_, i) => !recorded.has(i));
|
|
1453
|
+
for (const r of left) if (!pendingFollowups.some((p) => p.req === r)) pendingFollowups.push({ req: r, why });
|
|
1454
|
+
T.anomalies.push(`follow-up filing failed ${k + 1} times (${String(e.message).slice(0, 160)}): ${left.map((r) => r.id).join(", ")} pending, filed after the merge (FORGE:DEFERRED_FROM: #${issue})`);
|
|
1455
|
+
break;
|
|
1456
|
+
}
|
|
1457
|
+
T.decisions.push(`Follow-up filing attempt ${k + 1} failed (${String(e.message).slice(0, 160)}); retrying in-lane in ${waits[k] / 1000}s`);
|
|
1458
|
+
await sleep(waits[k]);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const done = reqs.filter((r) => r.tracked);
|
|
1462
|
+
if (!done.length) return;
|
|
1463
|
+
const buckets = Object.entries(bucketTally(done)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
|
|
1464
|
+
T.decisions.push(`Tracked ${done.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(done.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
|
|
1426
1465
|
}
|
|
1427
1466
|
/**
|
|
1428
1467
|
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
@@ -1689,9 +1728,10 @@ export async function runCore(o) {
|
|
|
1689
1728
|
if (closes) {
|
|
1690
1729
|
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
1691
1730
|
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
1692
|
-
|
|
1731
|
+
// #5152: already-fixed is delivered behaviour (`completed`); only invalid is dropped scope (`not planned`)
|
|
1732
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", result.status === "already-fixed" ? "completed" : "not planned"]);
|
|
1693
1733
|
await label("invalid");
|
|
1694
|
-
return await finish("invalid", result.summary ?? result.status);
|
|
1734
|
+
return await finish("invalid", result.summary ?? result.status, result.status === "already-fixed" ? { close: "already-fixed" } : {});
|
|
1695
1735
|
}
|
|
1696
1736
|
const blocked = await endIfBlocked();
|
|
1697
1737
|
if (blocked) return blocked;
|
|
@@ -2204,16 +2244,16 @@ export async function runCore(o) {
|
|
|
2204
2244
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE, e2e: { checks: vcfg.e2e?.checks ?? [], isSpec: (x) => e2eSpecs([x], vcfg.e2e).length > 0 } };
|
|
2205
2245
|
let d = null, first = null;
|
|
2206
2246
|
for (let t = 0; t < 3; t++) {
|
|
2207
|
-
// #4327: a third run only when the repaired reply is incomplete solely by evidence paths
|
|
2247
|
+
// #4327/#5164: a third run only when the repaired reply is incomplete solely by reviewer slips (unresolved evidence paths, coverage misquotes).
|
|
2208
2248
|
if (t === 2) {
|
|
2209
|
-
if (d.state !== "review.incomplete" || !
|
|
2210
|
-
T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
2249
|
+
if (d.state !== "review.incomplete" || !reaskable(d.failures.map((f) => f.detail))) break;
|
|
2250
|
+
T.decisions.push(`Review incomplete only by unresolved evidence paths or coverage misquotes (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
2211
2251
|
}
|
|
2212
2252
|
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
2213
2253
|
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
2214
2254
|
await renew();
|
|
2215
2255
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
2216
|
-
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
2256
|
+
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first, criteria: (d.missing ?? []).includes("issue_coverage") ? criterionLines(issueText) : [] })
|
|
2217
2257
|
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2218
2258
|
await renew();
|
|
2219
2259
|
await attempt("review", ++reviewN, rr, null, "review");
|
|
@@ -2257,7 +2297,7 @@ export async function runCore(o) {
|
|
|
2257
2297
|
run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
|
|
2258
2298
|
run.cost_usd = num(rr?.costUsd) ?? 0;
|
|
2259
2299
|
} catch (e) {
|
|
2260
|
-
if (e?.deferUsageLimit) throw e;
|
|
2300
|
+
if (e?.deferUsageLimit || e?.deferCode === OUTCOME_CODES.BACKEND_AUTH) throw e; // #5168: a login failure (backend.auth) is not swallowed as a verifier error
|
|
2261
2301
|
run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
|
|
2262
2302
|
T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
|
|
2263
2303
|
}
|
|
@@ -3015,10 +3055,27 @@ export async function runCore(o) {
|
|
|
3015
3055
|
}
|
|
3016
3056
|
try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
|
|
3017
3057
|
await deleteMergedBranch();
|
|
3058
|
+
let keepOpen = false;
|
|
3059
|
+
if (pendingRecord().length) { // #5141: the close step files what the lane could not track before the PR (idempotent by the marker)
|
|
3060
|
+
const left = pendingFollowups.filter((p) => !p.req.tracked);
|
|
3061
|
+
for (const why of new Set(left.map((p) => p.why))) {
|
|
3062
|
+
try { await track(left.filter((p) => p.why === why).map((p) => p.req), `${why}; filed after the merge`); } catch (e) { T.anomalies.push(`follow-up filing after the merge failed: ${String(e?.message ?? e).slice(0, 160)}`); }
|
|
3063
|
+
}
|
|
3064
|
+
for (const p of left) if (p.req.tracked && !closing.tracked.includes(p.req.tracked)) closing.tracked.push(p.req.tracked);
|
|
3065
|
+
const still = pendingRecord();
|
|
3066
|
+
if (still.length) {
|
|
3067
|
+
keepOpen = true; // release review #5155 (#5156): nothing re-files a pending item, so the issue stays open as the durable record
|
|
3068
|
+
T.decisions.push(`Follow-up still unfiled after the merge: ${still.map((x) => x.id).join(", ")}; issue left open (FORGE:FOLLOWUP_PENDING, FORGE:STATE pendingFollowups) until it is tracked`);
|
|
3069
|
+
try { await post("FORGE:FOLLOWUP_PENDING", renderPendingFollowups(still, pr)); } catch { /* FORGE:STATE and the lane result carry them too */ }
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3018
3072
|
const partial = closing.reason === "partial";
|
|
3019
|
-
|
|
3020
|
-
if (
|
|
3021
|
-
|
|
3073
|
+
// #5152: a merged change is `completed` even when partial: the remainder is tracked (FORGE:TRACKED), not dropped
|
|
3074
|
+
if (!keepOpen) await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
3075
|
+
if (keepOpen) { // #5159: the issue is not closed while a follow-up is pending, so the note must not say it is
|
|
3076
|
+
if (partial || closing.tracked.length) try { await post("FORGE:TRACKED", `## Left open: follow-up pending\nMerged by PR #${pr}; the issue stays open until the pending follow-up is filed (FORGE:FOLLOWUP_PENDING).${partial ? " The investigation's stated question is not answered in this change." : ""}${closing.tracked.length ? ` Already tracked in ${closing.tracked.join(", ")}.` : ""}`); } catch { /* the PR body links them too */ }
|
|
3077
|
+
} else if (partial) {
|
|
3078
|
+
try { await post("FORGE:TRACKED", `## Closed as partial\nClosed as completed by PR #${pr}; the investigation's stated question is not answered in this change and is carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
3022
3079
|
} else if (closing.tracked.length) {
|
|
3023
3080
|
try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
3024
3081
|
}
|
|
@@ -3043,13 +3100,13 @@ export async function runCore(o) {
|
|
|
3043
3100
|
await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
|
|
3044
3101
|
let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
|
|
3045
3102
|
if (lane) try { const sha = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeCommit"])) || "{}").mergeCommit?.oid; if (typeof sha === "string" && sha) mergedTo = { lane, pr, sha }; } catch { /* the PR-ancestry rule still applies */ }
|
|
3046
|
-
return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
3103
|
+
return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), ...(keepOpen ? { keptOpen: true } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
3047
3104
|
} catch (e) {
|
|
3048
3105
|
if (e?.deferUsageLimit) {
|
|
3049
3106
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
3050
3107
|
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env, rotator) }, OUTCOME_CODES.RATE_LIMITED);
|
|
3051
3108
|
}
|
|
3052
|
-
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
|
|
3109
|
+
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}), ...(e.backendId ? { backendId: e.backendId } : {}) }, e.deferCode); // #5168: backendId keys the account hold
|
|
3053
3110
|
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|
|
3054
3111
|
if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
|
|
3055
3112
|
return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -186,7 +186,9 @@ function world(o = {}) {
|
|
|
186
186
|
transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
|
|
187
187
|
...(o.procs ? { proc: { inDir: async () => o.procs, alive: (pid) => w.alive.has(pid), kill: (pid, sig) => { w.kills.push([pid, sig]); w.seq.push(`kill-${sig}`); if (sig === "SIGKILL" || !o.stubborn) w.alive.delete(pid); } } } : {}),
|
|
188
188
|
fs: { ...(o.wtExists ? { exists: async (p) => p.startsWith("/wt/lean-7") } : {}), stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; }, write: async (p, t) => { w.files[p] = t; }},
|
|
189
|
-
fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw new Error(o.followupError)
|
|
189
|
+
fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw Object.assign(new Error(o.followupError), { stdout: o.followupStdout ?? "" });
|
|
190
|
+
if (o.followupRet) return o.followupRet(w.followups.length, items);
|
|
191
|
+
if (w.followups.length <= (o.followupFailTimes ?? 0)) throw new Error("DEFERRED_LOOKUP=FAILED"); if (w.followups.length <= (o.followupMissingTimes ?? 0)) return {}; return Object.fromEntries(items.filter((i) => !(o.followupSkip ?? []).some((q) => JSON.stringify(i).includes(q))).map((i) => [i.key, o.followupIssue ?? 900])); },
|
|
190
192
|
};
|
|
191
193
|
return w;
|
|
192
194
|
}
|
|
@@ -279,13 +281,16 @@ describe("core work-on", () => {
|
|
|
279
281
|
const t = traj(w)[0];
|
|
280
282
|
for (const needle of ["| Validate | INVALID |", "| Plan | skipped |", "| Implement | skipped |", "| Review | skipped |", "| PR | none |", "| Close | issue closed (not planned) |"]) assert.ok(t.includes(needle), needle);
|
|
281
283
|
assert.equal(w.closed.length, 1);
|
|
284
|
+
assert.equal(w.closed[0][w.closed[0].indexOf("--reason") + 1], "not planned", "#5152: invalid keeps not planned");
|
|
282
285
|
assert.equal(w.prs.length, 0);
|
|
283
286
|
assert.equal(reviewers(w).length, 0);
|
|
284
287
|
});
|
|
285
288
|
|
|
286
|
-
it("already-fixed with evidence also closes", async () => {
|
|
289
|
+
it("already-fixed with evidence also closes, as completed (#5152)", async () => {
|
|
287
290
|
const w = world({ changed: [], result: { status: "already-fixed", summary: "done upstream", evidence: ["a.js:1"], tests: [] } });
|
|
288
291
|
assert.equal((await run(w)).terminalReason, "invalid");
|
|
292
|
+
assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"], "#5152: already-fixed is delivered, never not planned");
|
|
293
|
+
assert.ok(traj(w)[0].includes("| Close | issue closed (completed) |"));
|
|
289
294
|
});
|
|
290
295
|
|
|
291
296
|
it("a failed needs-human comment is recorded as an anomaly and the lane still ends needs-human (#4905)", async () => {
|
|
@@ -381,6 +386,21 @@ describe("core work-on", () => {
|
|
|
381
386
|
assert.equal(r2.code, "review.incomplete"); assert.equal(reviewers(w2).length, 3, "at most three reviewer runs"); assert.equal(w2.merges.length, 0);
|
|
382
387
|
});
|
|
383
388
|
|
|
389
|
+
it("#5164 a coverage misquote gets one more targeted re-ask with the issue's criterion lines; a near-verbatim one is requoted without a re-ask", async () => {
|
|
390
|
+
const mis = () => conf({ issue_coverage: [{ quote: "foo copes when nothing at all is passed", contract_ids: ["R1"], status: "covered" }] });
|
|
391
|
+
const w = world({ reviews: [mis(), mis(), ok()] });
|
|
392
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
393
|
+
const rv = reviewers(w);
|
|
394
|
+
assert.equal(rv.length, 3); assert.equal(builders(w).length, 2, "never charged to the builder");
|
|
395
|
+
for (const i of [1, 2]) { assert.match(rv[i][4], /<issue_criteria>/); assert.match(rv[i][4], /- foo must handle a null argument without throwing\./); }
|
|
396
|
+
assert.match(rv[1][4], /answer ONLY for these ids or sections: issue_coverage/);
|
|
397
|
+
const w2 = world({ reviews: [mis(), mis(), mis(), ok()] });
|
|
398
|
+
const r2 = await run(w2);
|
|
399
|
+
assert.equal(r2.code, "review.incomplete"); assert.equal(reviewers(w2).length, 3, "at most three reviewer runs"); assert.equal(w2.merges.length, 0);
|
|
400
|
+
const w3 = world({ reviews: [conf({ issue_coverage: [{ quote: "foo must handle null arguments without throwing", contract_ids: ["R1"], status: "covered" }] })] });
|
|
401
|
+
assert.equal((await run(w3)).terminalReason, "merged"); assert.equal(reviewers(w3).length, 1);
|
|
402
|
+
});
|
|
403
|
+
|
|
384
404
|
it("a CONFORMS verdict whose rows fail is not conforming (the engine computes the verdict)", async () => {
|
|
385
405
|
const w = world({ reviews: [bad("half done", "CONFORMS"), ok()] });
|
|
386
406
|
assert.equal((await run(w)).terminalReason, "merged");
|
|
@@ -687,13 +707,13 @@ describe("core work-on", () => {
|
|
|
687
707
|
assert.ok(w.labels.includes("decomposed"));
|
|
688
708
|
});
|
|
689
709
|
|
|
690
|
-
it("#4337 with decomposition.close_parent a split closes the parent
|
|
710
|
+
it("#4337 #5152 with decomposition.close_parent a split closes the parent completed after its FORGE:DECOMPOSED trail and label, listing every sub-issue", async () => {
|
|
691
711
|
const w = depWorld({ changed: [], result: splitBlock }, {});
|
|
692
712
|
const r = await run(w, { closeParent: true });
|
|
693
713
|
assert.equal(r.terminalReason, "decomposed");
|
|
694
714
|
assert.equal(w.closed.length, 1);
|
|
695
715
|
const c = w.closed[0];
|
|
696
|
-
assert.equal(c[c.indexOf("--reason") + 1], "not
|
|
716
|
+
assert.equal(c[c.indexOf("--reason") + 1], "completed", "#5152: the scope moved to the sub-issues, it was not dropped");
|
|
697
717
|
const note = c[c.indexOf("--comment") + 1];
|
|
698
718
|
assert.match(note, /#201/); assert.match(note, /#202/);
|
|
699
719
|
assert.ok(w.labels.includes("decomposed"));
|
|
@@ -982,21 +1002,100 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
|
|
|
982
1002
|
});
|
|
983
1003
|
}
|
|
984
1004
|
|
|
985
|
-
it("a follow-up that cannot be filed
|
|
1005
|
+
it("#5141 a follow-up that cannot be filed keeps the work: PR and merge proceed, the issue stays open with the unfiled item recorded, never needs-human (#5156)", async () => {
|
|
986
1006
|
const k = two("defer", { why: "w" });
|
|
987
1007
|
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "DEFERRED_LOOKUP=FAILED" });
|
|
1008
|
+
w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
|
|
988
1009
|
const r = await run(w);
|
|
989
|
-
assert.equal(r.terminalReason, "
|
|
990
|
-
assert.
|
|
991
|
-
assert.equal(w.
|
|
1010
|
+
assert.equal(r.terminalReason, "merged"); assert.notEqual(r.code, "followup.unavailable");
|
|
1011
|
+
assert.equal(w.prs.length, 1); assert.equal(w.merges.length, 1);
|
|
1012
|
+
assert.equal(w.closed.length, 0, "release review #5155 (#5156): nothing re-files a pending item, so the issue is not closed over it");
|
|
1013
|
+
assert.equal(w.followups.length, 6, "3 in-lane attempts before the PR, 3 more in the close step");
|
|
1014
|
+
assert.deepEqual(r.pendingFollowups, [{ id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "accepted by the conformance review", marker: "FORGE:DEFERRED_FROM: #7" }]);
|
|
1015
|
+
assert.deepEqual(w.leaseWrites.at(-1).pendingFollowups, r.pendingFollowups, "the issue's FORGE:STATE carries the unfiled item");
|
|
1016
|
+
const note = w.comments.find((c) => c.includes("FORGE:FOLLOWUP_PENDING"));
|
|
1017
|
+
assert.match(note, /R2 \(defer\): Existing callers of foo keep working\..*`FORGE:DEFERRED_FROM: #7`/);
|
|
992
1018
|
assert.ok(!w.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")));
|
|
1019
|
+
assert.ok(!w.comments.some((c) => /Closed as/.test(c)), "#5159: no note says the kept-open issue was closed");
|
|
1020
|
+
assert.match(traj(w).join("\n"), /left open \(follow-up pending\)/, "#5159: the trajectory Close row says left open");
|
|
1021
|
+
assert.doesNotMatch(traj(w).join("\n"), /issue closed \(/);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("#5141 a follow-up that keeps failing before the PR is filed by the close step after the merge", async () => {
|
|
1025
|
+
const k = two("defer", { why: "w" });
|
|
1026
|
+
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 3 });
|
|
1027
|
+
const r = await run(w);
|
|
1028
|
+
assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 4); assert.equal(r.pendingFollowups, undefined);
|
|
1029
|
+
assert.ok(w.comments.some((c) => c.includes("FORGE:TRACKED") && c.includes("#900")));
|
|
1030
|
+
assert.ok(!w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
|
|
1031
|
+
assert.match(traj(w).join("\n"), /Tracked R2 \(defer\) in #900 \(accepted by the conformance review; filed after the merge/);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("#5141 a follow-up filing that fails once is retried in-lane, not requeued: the run proceeds and tracks once", async () => {
|
|
1035
|
+
const k = two("defer", { why: "w" });
|
|
1036
|
+
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupFailTimes: 1 });
|
|
1037
|
+
const r = await run(w);
|
|
1038
|
+
assert.notEqual(r.code, "followup.unavailable");
|
|
1039
|
+
assert.equal(w.followups.length, 2, "one retry of the whole filing step");
|
|
1040
|
+
assert.equal(w.prs.length, 1);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("#5141 a follow-up filing that returns no tracking issue is retried in-lane before deferring", async () => {
|
|
1044
|
+
const k = two("defer", { why: "w" });
|
|
1045
|
+
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 2 });
|
|
1046
|
+
const r = await run(w);
|
|
1047
|
+
assert.notEqual(r.code, "followup.unavailable");
|
|
1048
|
+
assert.equal(w.followups.length, 3);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("#5141 a requirement tracked in an earlier attempt keeps its tracking issue; the retry tracks only the rest, once each", async () => {
|
|
1052
|
+
const base = two("defer", { why: "w" });
|
|
1053
|
+
const k = { ...base, requirements: [...base.requirements, { id: "R3", quote: "Old callers log a warning.", disposition: "defer", bucket: "post-deploy", why: "w" }] };
|
|
1054
|
+
let views900 = 0;
|
|
1055
|
+
const w = world({ contract: k, body: `${ISSUE_BODY}\n- Old callers log a warning.`, issueStates: { get 900() { views900 += 1; return "OPEN"; } }, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)],
|
|
1056
|
+
followupRet: (call, items) => (call === 1 ? { [items[0].key]: 900 } : { [items[0].key]: 900, [items[1].key]: 901 }) });
|
|
1057
|
+
const r = await run(w);
|
|
1058
|
+
assert.notEqual(r.code, "followup.unavailable");
|
|
1059
|
+
assert.equal(w.followups.length, 2);
|
|
1060
|
+
const t = traj(w).join("\n");
|
|
1061
|
+
assert.match(t, /Follow-up filing attempt 1 failed \(no tracking issue was returned for R3\); retrying in-lane in 5s/);
|
|
1062
|
+
assert.match(t, /Tracked R2 \(defer\), R3 \(defer\) in #900, #901/);
|
|
1063
|
+
assert.equal(views900, 1, "R2, verified open in attempt 1, is not re-verified or re-counted by the retry");
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it("#5141 a persistent follow-up failure is retried 3 times in-lane, then the run keeps going; a malformed TSV is not retried and defers", async () => {
|
|
1067
|
+
const k = two("defer", { why: "w" });
|
|
1068
|
+
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 99 });
|
|
1069
|
+
const r = await run(w);
|
|
1070
|
+
assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 6); assert.equal(w.prs.length, 1);
|
|
1071
|
+
assert.equal(r.pendingFollowups[0].id, "R2");
|
|
1072
|
+
const w2 = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "Command failed", followupStdout: "DEFERRED_TSV=INVALID\n" });
|
|
1073
|
+
const r2 = await run(w2);
|
|
1074
|
+
assert.equal(r2.code, "followup.unavailable"); assert.equal(w2.followups.length, 1); assert.equal(w2.prs.length, 0);
|
|
993
1075
|
});
|
|
994
1076
|
|
|
995
|
-
it("
|
|
1077
|
+
it("#5159 one follow-up tracked, another still pending: FORGE:TRACKED says left open, never 'Closed as', and the trajectory says left open", async () => {
|
|
1078
|
+
const k0 = two("defer", { why: "w" });
|
|
1079
|
+
const k = { ...k0, requirements: [...k0.requirements, { id: "R3", quote: "foo crashes on null input.", disposition: "defer", bucket: "post-deploy", why: "w" }] };
|
|
1080
|
+
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupSkip: ["foo crashes on null input."] });
|
|
1081
|
+
const r = await run(w);
|
|
1082
|
+
assert.equal(r.terminalReason, "merged");
|
|
1083
|
+
assert.deepEqual((r.pendingFollowups ?? []).map((p) => p.id), ["R3"]);
|
|
1084
|
+
assert.equal(w.closed.length, 0);
|
|
1085
|
+
const tracked = w.comments.find((c) => c.includes("FORGE:TRACKED"));
|
|
1086
|
+
assert.ok(tracked, "the tracked item is still recorded");
|
|
1087
|
+
assert.match(tracked, /Left open: follow-up pending/); assert.match(tracked, /#900/);
|
|
1088
|
+
assert.ok(!w.comments.some((c) => /Closed as/.test(c)));
|
|
1089
|
+
assert.match(traj(w).join("\n"), /left open \(follow-up pending\)/);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it("a tracking issue that is not open is not a tracking issue: the item stays pending and is recorded", async () => {
|
|
996
1093
|
const k = two("defer", { why: "w" });
|
|
997
1094
|
const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], issueStates: { 900: "CLOSED" } });
|
|
998
1095
|
const r = await run(w);
|
|
999
|
-
assert.equal(r.
|
|
1096
|
+
assert.equal(r.terminalReason, "merged"); assert.equal(r.pendingFollowups[0].id, "R2");
|
|
1097
|
+
assert.ok(w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
|
|
1098
|
+
assert.equal(w.closed.length, 0, "left open while R2 is untracked (#5156)");
|
|
1000
1099
|
});
|
|
1001
1100
|
|
|
1002
1101
|
it("an existing PR gets the tracking links in its body before merge", async () => {
|
|
@@ -1649,13 +1748,13 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1649
1748
|
assert.deepEqual(rs.conformance, { mode: "shadow", would: "nonconforming", risk: false });
|
|
1650
1749
|
});
|
|
1651
1750
|
|
|
1652
|
-
it("#4398 an investigation whose question is deferred closes partial (
|
|
1751
|
+
it("#4398 #5152 an investigation whose question is deferred closes partial (completed), carried forward in the tracking issue", async () => {
|
|
1653
1752
|
const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w", bucket: "new-issue" }] };
|
|
1654
1753
|
const w = world({ title: "investigate(stealth): which signal is rejected", contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
|
|
1655
1754
|
const r = await runShadow(w);
|
|
1656
1755
|
assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
|
|
1657
|
-
assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["
|
|
1658
|
-
assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && /Closed as partial[\s\S]*carried forward in #900/.test(c)));
|
|
1756
|
+
assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"], "#5152: merged with a tracked remainder is completed");
|
|
1757
|
+
assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && /Closed as partial[\s\S]*Closed as completed by PR #[\s\S]*carried forward in #900/.test(c)));
|
|
1659
1758
|
assert.ok(traj(w)[0].includes("| Close | issue closed (partial) |"));
|
|
1660
1759
|
});
|
|
1661
1760
|
|
|
@@ -1684,7 +1783,7 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1684
1783
|
assert.equal(r.terminalReason, "merged"); assert.equal(r.close, "partial");
|
|
1685
1784
|
assert.equal(w.followups.length, 1); assert.match(w.followups[0].items[0].key, /^R1: /); assert.match(w.followups[0].items[0].why, /investigation question not answered/);
|
|
1686
1785
|
assert.match(w.prs[0].join(" "), /#900/);
|
|
1687
|
-
assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["
|
|
1786
|
+
assert.deepEqual(w.closed.map((a) => a[a.indexOf("--reason") + 1]), ["completed"]);
|
|
1688
1787
|
});
|
|
1689
1788
|
|
|
1690
1789
|
it("#4398 an answered investigation and a non-investigation with a deferral both close completed", async () => {
|
|
@@ -2586,10 +2685,10 @@ describe("orchestrate", () => {
|
|
|
2586
2685
|
assert.ok(h.log.ends.indexOf(1) < h.log.starts.indexOf(2), JSON.stringify(h.log));
|
|
2587
2686
|
});
|
|
2588
2687
|
|
|
2589
|
-
it("a dependent is
|
|
2688
|
+
it("a dependent is held, without running, when its dependency ends needs-human (transitively)", async () => {
|
|
2590
2689
|
const h = harness({ 2: "**Depends on**: #1", 3: "**Depends on**: #2" }, { 1: "needs-human" });
|
|
2591
2690
|
const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
|
|
2592
|
-
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "
|
|
2691
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "held", "held", "merged"]);
|
|
2593
2692
|
assert.ok(!h.log.starts.includes(2) && !h.log.starts.includes(3));
|
|
2594
2693
|
assert.match(rows[1].detail, /#1/);
|
|
2595
2694
|
});
|
|
@@ -2670,7 +2769,7 @@ describe("orchestrate", () => {
|
|
|
2670
2769
|
const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), ...(dispatchCheck ? { dispatchCheck } : {}),
|
|
2671
2770
|
runOne: async (n) => { log.push(n); return { terminalReason: "needs-human", detail: "ci.red" }; } });
|
|
2672
2771
|
assert.deepEqual(log, [9]);
|
|
2673
|
-
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "
|
|
2772
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "held"]]);
|
|
2674
2773
|
}
|
|
2675
2774
|
});
|
|
2676
2775
|
|
|
@@ -2681,7 +2780,7 @@ describe("orchestrate", () => {
|
|
|
2681
2780
|
onUpdate: (r) => rowsSeen.push(r),
|
|
2682
2781
|
dispatchCheck: async (n) => (n === 9 && merged ? { outcome: "merged", pr: 90, detail: "PR #90 already merged" } : null),
|
|
2683
2782
|
runOne: async (n) => { if (n === 9) { merged = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 101 }; } });
|
|
2684
|
-
const skipRow = rowsSeen.find((r) => r.issue === 1 && r.outcome === "
|
|
2783
|
+
const skipRow = rowsSeen.find((r) => r.issue === 1 && r.outcome === "held");
|
|
2685
2784
|
assert.equal(skipRow.waiting, true);
|
|
2686
2785
|
assert.equal(skipRow.trigger, 9);
|
|
2687
2786
|
assert.ok(rowsSeen.some((r) => r.issue === 1 && r.outcome === "readmitted" && r.trigger === 9));
|
|
@@ -2693,7 +2792,7 @@ describe("orchestrate", () => {
|
|
|
2693
2792
|
const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""),
|
|
2694
2793
|
dispatchCheck: async (n) => { if (n === 9 && ran) { checks++; return { outcome: "invalid", detail: "issue closed (not planned)", final: true }; } return null; },
|
|
2695
2794
|
runOne: async () => { ran = true; return { terminalReason: "needs-human", detail: "ci.red" }; } });
|
|
2696
|
-
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "needs-human", undefined], [1, "
|
|
2795
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.waiting]), [[9, "needs-human", undefined], [1, "held", undefined]]);
|
|
2697
2796
|
assert.equal(checks, 1, "a final blocker is not re-checked");
|
|
2698
2797
|
});
|
|
2699
2798
|
|
|
@@ -3347,6 +3446,31 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
|
|
|
3347
3446
|
assert.equal(w.merges.length, 0);
|
|
3348
3447
|
});
|
|
3349
3448
|
|
|
3449
|
+
it("#5168 a claude login failure ends the lane deferred/backend.auth with the account, not engine-error", async () => {
|
|
3450
|
+
const w = world(); projector(w, null); const sleeps = [];
|
|
3451
|
+
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
|
|
3452
|
+
const authErr = Object.assign(new Error("claude CLI exited with status 1. Failed to authenticate. API Error: 401"), { code: "CLI_BACKEND_FAILED", authFailure: true });
|
|
3453
|
+
let tries = 0; const orig = w.io.runner; w.io.runner = async (c) => { if (c.role === "builder") { tries++; throw authErr; } return orig(c); };
|
|
3454
|
+
const r = await run(w);
|
|
3455
|
+
assert.equal(r.terminalReason, "deferred");
|
|
3456
|
+
assert.equal(r.code, "backend.auth");
|
|
3457
|
+
assert.equal(r.retryInSeconds, 900);
|
|
3458
|
+
assert.equal(r.backendId, laneBackendId(process.env), "the hold names the account whose login failed");
|
|
3459
|
+
assert.match(r.detail, /claude CLI authentication failed/);
|
|
3460
|
+
assert.equal(tries, 1, "no in-lane retry or pause on a dead login");
|
|
3461
|
+
assert.equal(sleeps.length, 0);
|
|
3462
|
+
assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
|
|
3463
|
+
assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
|
|
3464
|
+
});
|
|
3465
|
+
|
|
3466
|
+
it("#5168 an error that is both a usage limit and auth-like stays rate_limited", async () => {
|
|
3467
|
+
const w = world();
|
|
3468
|
+
failFirstBuilder(w, limitErr({ authFailure: true, resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }));
|
|
3469
|
+
const r = await run(w);
|
|
3470
|
+
assert.equal(r.terminalReason, "deferred");
|
|
3471
|
+
assert.equal(r.code, "rate_limited");
|
|
3472
|
+
});
|
|
3473
|
+
|
|
3350
3474
|
it("#4101 unknown reset pauses a bounded number of times, then defers", async () => {
|
|
3351
3475
|
const w = world(); let sleeps = 0;
|
|
3352
3476
|
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s0(ms); };
|