forgedock 1.29.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine-cli.mjs +12 -6
- package/bin/lean/context.mjs +27 -4
- package/bin/lean/context.test.mjs +40 -12
- package/bin/lean/lean.mjs +23 -18
- package/bin/lean/lean.test.mjs +31 -3
- package/bin/lean/orchestrate.mjs +41 -11
- package/bin/lean/prefilter.mjs +79 -14
- package/bin/lean/prefilter.test.mjs +88 -2
- package/bin/lean/recovery.test.mjs +196 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/testmap.mjs +17 -0
- package/bin/lean/testmap.test.mjs +16 -1
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +4 -3
- package/commands/review-pr-staging.md +6 -1
- package/docs/OUTCOME-CODES.md +1 -1
- package/package.json +1 -1
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +139 -33
- package/scripts/test-map.test.sh +195 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
package/bin/engine-cli.mjs
CHANGED
|
@@ -637,7 +637,7 @@ export function lastLocalRun(dir) {
|
|
|
637
637
|
*/
|
|
638
638
|
export async function runFromCli(argv, deps = {}) {
|
|
639
639
|
const issue = parseInt(argv[0], 10);
|
|
640
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
|
|
640
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n] [--recover-nonfinal]] [--retry] [--wait-lease]");
|
|
641
641
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
642
642
|
const lane = flag(argv, "--lane");
|
|
643
643
|
if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
|
|
@@ -667,10 +667,11 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
667
667
|
const placementWhere = flag(argv, "--placement");
|
|
668
668
|
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
669
669
|
? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
|
|
670
|
-
// #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`)
|
|
670
|
+
// #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`); #4302: `--recover-nonfinal`
|
|
671
|
+
// marks an attempt the runner may follow with another, so the lane leaves the needs-human relabel to the runner.
|
|
671
672
|
const recoverCode = flag(argv, "--recover");
|
|
672
673
|
const recoverAttempt = Number(flag(argv, "--recover-attempt"));
|
|
673
|
-
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
|
|
674
|
+
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1, final: !argv.includes("--recover-nonfinal") } : null;
|
|
674
675
|
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
675
676
|
try {
|
|
676
677
|
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
@@ -1098,7 +1099,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1098
1099
|
// #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
|
|
1099
1100
|
const placementOf = new Map(), recoverOf = new Map();
|
|
1100
1101
|
const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
|
|
1101
|
-
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
|
|
1102
|
+
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1), ...(rc.final === false ? ["--recover-nonfinal"] : [])] : [])]; };
|
|
1102
1103
|
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
|
|
1103
1104
|
if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
|
|
1104
1105
|
if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
|
|
@@ -1153,6 +1154,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1153
1154
|
}
|
|
1154
1155
|
}
|
|
1155
1156
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1157
|
+
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
1158
|
+
const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
|
|
1159
|
+
if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
|
|
1156
1160
|
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1157
1161
|
const limitsMod = await import("./lean/limits.mjs");
|
|
1158
1162
|
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
@@ -1231,7 +1235,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1231
1235
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1232
1236
|
...(remote ? { remote } : {}),
|
|
1233
1237
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1234
|
-
...(
|
|
1238
|
+
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
1235
1239
|
decide: deps.decide ?? (async (d) => {
|
|
1236
1240
|
if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
|
|
1237
1241
|
const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
|
|
@@ -1240,7 +1244,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1240
1244
|
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1241
1245
|
relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
|
|
1242
1246
|
try {
|
|
1243
|
-
|
|
1247
|
+
// #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
|
|
1248
|
+
const { capComment } = await import("./lean/render.mjs");
|
|
1249
|
+
await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
|
|
1244
1250
|
await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
1245
1251
|
} catch { /* best effort: the batch row keeps the evidence */ }
|
|
1246
1252
|
}),
|
package/bin/lean/context.mjs
CHANGED
|
@@ -102,12 +102,35 @@ export function priorAttempts(body, comments = []) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298) the
|
|
106
|
-
* `
|
|
105
|
+
* Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
|
|
106
|
+
* issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
|
|
107
|
+
* fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
|
|
107
108
|
*/
|
|
108
109
|
export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
|
|
109
110
|
`context-${issue}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
|
|
110
|
-
...(stamp?.
|
|
111
|
+
...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
|
|
112
|
+
...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
|
|
113
|
+
|
|
114
|
+
/** Max referenced issues whose state is read for the cache stamp (one REST call each). */
|
|
115
|
+
export const LINK_STAMP_MAX = 20;
|
|
116
|
+
/**
|
|
117
|
+
* #4316: the linked-issue part of the cache stamp: a hash of `n:STATE` for each issue the body references (parent, Depends
|
|
118
|
+
* on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. Timeline cross-refs are
|
|
119
|
+
* left to the gather. An unreadable issue counts as `n:?`; never throws; '' when the body references nothing.
|
|
120
|
+
*/
|
|
121
|
+
export async function linkStateStamp({ gh, repo, issue, body }) {
|
|
122
|
+
const parent = parentOf(body);
|
|
123
|
+
const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
|
|
124
|
+
.filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(0, LINK_STAMP_MAX);
|
|
125
|
+
if (!nums.length) return "";
|
|
126
|
+
const states = [];
|
|
127
|
+
for (const n of nums) {
|
|
128
|
+
let st = "?";
|
|
129
|
+
try { st = String(await gh(["api", `repos/${repo}/issues/${n}`, "--jq", ".state"])).trim().toUpperCase() || "?"; } catch { /* unreadable: n:? */ }
|
|
130
|
+
states.push(`${n}:${st}`);
|
|
131
|
+
}
|
|
132
|
+
return hash12(states.join(","));
|
|
133
|
+
}
|
|
111
134
|
|
|
112
135
|
// ---- gather (I/O) ----
|
|
113
136
|
|
|
@@ -298,7 +321,7 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
/**
|
|
301
|
-
* The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp`) or gathered
|
|
324
|
+
* The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp` {scope, attempts}) or gathered
|
|
302
325
|
* and compiled, then cached. `gather` returns the facts; `fs` is {read, write, rename?, list?, rm?}. A gather that
|
|
303
326
|
* reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
|
|
304
327
|
* cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
5
|
+
import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
6
6
|
|
|
7
7
|
const REPO = "o/r";
|
|
8
8
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -212,26 +212,54 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
212
212
|
list: async () => [...store.keys()].map((k) => k.slice("/runs/".length)), rm: async (p) => { ops.push(["rm", p]); store.delete(p); } } };
|
|
213
213
|
};
|
|
214
214
|
|
|
215
|
-
it("the key changes with the
|
|
215
|
+
it("#4304: the key changes with the scope hash and attempt count, not updatedAt; no stamp keeps the old shape", () => {
|
|
216
216
|
const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
|
|
217
217
|
assert.equal(k(), contextPackCacheKey(50, "abc", cfg));
|
|
218
|
-
assert.equal(k({
|
|
219
|
-
assert.
|
|
220
|
-
assert.notEqual(k({
|
|
218
|
+
assert.equal(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 1 }));
|
|
219
|
+
assert.equal(k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T10:00:00Z" }), k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T11:00:00Z" }), "the lane's own posts do not miss the cache");
|
|
220
|
+
assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "bbb222", attempts: 1 }));
|
|
221
|
+
assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
|
|
225
|
+
const states = { 7: "open", 8: "open", 9: "closed" };
|
|
226
|
+
const calls = [];
|
|
227
|
+
const gh = async (args) => { calls.push(args); const n = Number(/issues\/(\d+)$/.exec(args[1])?.[1]); if (n === 9 && states.fail9) throw new Error("HTTP 502"); return `${states[n]}\n`; };
|
|
228
|
+
const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
|
|
229
|
+
const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
|
|
230
|
+
assert.match(s1, /^[0-9a-f]{12}$/);
|
|
231
|
+
assert.deepEqual(calls.map((a) => a[1]), ["repos/o/r/issues/7", "repos/o/r/issues/8", "repos/o/r/issues/9"], "parent, depends-on and links once each, never self");
|
|
232
|
+
assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
|
|
233
|
+
states[8] = "closed";
|
|
234
|
+
const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
|
|
235
|
+
assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
|
|
236
|
+
states.fail9 = true;
|
|
237
|
+
assert.match(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), /^[0-9a-f]{12}$/, "an unreadable link never throws");
|
|
238
|
+
assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
|
|
239
|
+
const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
|
|
240
|
+
assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
|
|
241
|
+
assert.notEqual(k({ scope: "s", attempts: 0, links: s1 }), k({ scope: "s", attempts: 0, links: s2 }));
|
|
242
|
+
const { fs } = memFs();
|
|
243
|
+
let n = 0;
|
|
244
|
+
const run = (links) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "s", attempts: 0, links }, gather: async () => { n++; return facts(); } });
|
|
245
|
+
await run(s1);
|
|
246
|
+
assert.equal((await run(s1)).cached, true);
|
|
247
|
+
assert.equal((await run(s2)).cached, false, "the pack recompiles after a linked issue closed");
|
|
248
|
+
assert.equal(n, 2);
|
|
221
249
|
});
|
|
222
250
|
|
|
223
251
|
it("an issue edit recompiles; a gather with source errors is not cached", async () => {
|
|
224
252
|
const { fs, store } = memFs();
|
|
225
253
|
let n = 0;
|
|
226
254
|
const run = (stamp, errors) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp, gather: async () => { n++; return facts(errors); } });
|
|
227
|
-
await run({
|
|
228
|
-
assert.equal((await run({
|
|
229
|
-
await run({
|
|
255
|
+
await run({ scope: "s1", attempts: 0 });
|
|
256
|
+
assert.equal((await run({ scope: "s1", attempts: 0, updatedAt: "later" })).cached, true);
|
|
257
|
+
await run({ scope: "s2", attempts: 0 });
|
|
230
258
|
assert.equal(n, 2, "the edit recompiled");
|
|
231
259
|
const before = store.size;
|
|
232
|
-
const r = await run({
|
|
260
|
+
const r = await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"]);
|
|
233
261
|
assert.deepEqual([r.cached, r.errors, store.size], [false, ["graph: timeline HTTP 502"], before]);
|
|
234
|
-
assert.equal((await run({
|
|
262
|
+
assert.equal((await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"])).cached, false, "errors are retried, not served from cache");
|
|
235
263
|
});
|
|
236
264
|
|
|
237
265
|
it("writes through a temp file and a rename, then prunes the issue's other packs only", async () => {
|
|
@@ -239,8 +267,8 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
239
267
|
store.set("/runs/context-50-old-aaaaaaaaaaaa.json", "{}");
|
|
240
268
|
store.set("/runs/context-500-x-bbbbbbbbbbbb.json", "{}");
|
|
241
269
|
store.set("/runs/50-run.jsonl", "");
|
|
242
|
-
await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: {
|
|
243
|
-
const file = `/runs/${contextPackCacheKey(50, "abc", cfg, {
|
|
270
|
+
await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "t", attempts: 0 }, gather: async () => facts() });
|
|
271
|
+
const file = `/runs/${contextPackCacheKey(50, "abc", cfg, { scope: "t", attempts: 0 })}`;
|
|
244
272
|
assert.equal(ops[0][0], "write"); assert.match(ops[0][1], /\.tmp$/);
|
|
245
273
|
assert.deepEqual(ops[1], ["rename", ops[0][1], file]);
|
|
246
274
|
assert.deepEqual([...store.keys()].sort(), ["/runs/50-run.jsonl", "/runs/context-500-x-bbbbbbbbbbbb.json", file].sort());
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -19,9 +19,9 @@ import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
|
19
19
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
20
|
import { repoKey } from "../runner.mjs";
|
|
21
21
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
22
|
-
import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
22
|
+
import { readTestMap, readTestMapAtRef, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
23
23
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
24
|
-
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
24
|
+
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
25
25
|
import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
26
26
|
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
27
27
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
@@ -30,7 +30,7 @@ import { laneBackendId } from "./limits.mjs";
|
|
|
30
30
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
31
31
|
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
32
32
|
import { priorityFromLabels } from "./sched.mjs";
|
|
33
|
-
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
|
|
33
|
+
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
@@ -129,8 +129,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
129
129
|
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
130
130
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
131
131
|
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
132
|
-
* @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
|
-
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
|
|
132
|
+
* @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
|
+
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
|
|
134
|
+
* (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
|
|
134
135
|
*/
|
|
135
136
|
export async function runCore(o) {
|
|
136
137
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
@@ -143,8 +144,8 @@ export async function runCore(o) {
|
|
|
143
144
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
144
145
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
145
146
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
146
|
-
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
|
|
147
|
-
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
|
|
147
|
+
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
|
|
148
|
+
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
|
|
148
149
|
const branch = `feat/issue-${issue}-lean`;
|
|
149
150
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
150
151
|
const t0 = now();
|
|
@@ -322,10 +323,11 @@ export async function runCore(o) {
|
|
|
322
323
|
await emit(term);
|
|
323
324
|
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
324
325
|
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
325
|
-
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
|
|
326
|
-
|
|
326
|
+
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
|
|
327
|
+
// either launches the next attempt or relabels in settle.
|
|
328
|
+
if (recoverUnlabelled && recover.final && reason === "ci-pending") {
|
|
327
329
|
try {
|
|
328
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${
|
|
330
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
329
331
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
330
332
|
} catch { /* best effort */ }
|
|
331
333
|
}
|
|
@@ -344,11 +346,11 @@ export async function runCore(o) {
|
|
|
344
346
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
345
347
|
return done;
|
|
346
348
|
}
|
|
347
|
-
/** #4297:
|
|
348
|
-
const
|
|
349
|
+
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
350
|
+
const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
|
|
349
351
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
350
|
-
try {
|
|
351
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${
|
|
352
|
+
if (!recover || recover.final) try {
|
|
353
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
352
354
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
353
355
|
} catch { /* best effort */ }
|
|
354
356
|
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
@@ -894,7 +896,9 @@ export async function runCore(o) {
|
|
|
894
896
|
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
895
897
|
try {
|
|
896
898
|
const headSha = String(await git(["rev-parse", base])).trim();
|
|
897
|
-
|
|
899
|
+
// #4316: linked-issue states are part of the key; a failed lookup only drops them from it
|
|
900
|
+
const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
|
|
901
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
|
|
898
902
|
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
899
903
|
const p = r.pack;
|
|
900
904
|
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
@@ -1117,11 +1121,12 @@ export async function runCore(o) {
|
|
|
1117
1121
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1118
1122
|
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1119
1123
|
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1120
|
-
|
|
1124
|
+
// #4315: the map's selection is merged into the learned commands (deduped): a partial map never skips a learned suite
|
|
1125
|
+
const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return { ...mergeChecks(m, learnedCommands), selected: m.length }; };
|
|
1121
1126
|
let picked = pickChecks();
|
|
1122
1127
|
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1123
1128
|
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1124
|
-
if (picked.source
|
|
1129
|
+
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
|
|
1125
1130
|
if (picked.commands.length && io.runCommand) {
|
|
1126
1131
|
for (let round = 0; ; round++) {
|
|
1127
1132
|
await renew();
|
|
@@ -1133,7 +1138,7 @@ export async function runCore(o) {
|
|
|
1133
1138
|
try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1134
1139
|
results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
|
|
1135
1140
|
}
|
|
1136
|
-
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source
|
|
1141
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source !== "learned" ? { source: picked.source } : {}) });
|
|
1137
1142
|
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1138
1143
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1139
1144
|
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -787,6 +787,24 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
787
787
|
assert.equal((await run(plain, ends["ci-pending"][1])).terminalReason, "ci-pending");
|
|
788
788
|
assert.equal(add(plain), 0, "no recovery: a ci-pending run is not labelled");
|
|
789
789
|
});
|
|
790
|
+
|
|
791
|
+
it("#4302: a non-final recovery attempt (recover.final false) never relabels needs-human; final true or absent relabels once", async () => {
|
|
792
|
+
const add = (w) => w.edits.filter((e) => e.join(" ") === "issue edit 7 -R o/r --add-label needs-human").length;
|
|
793
|
+
const nh = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:NEEDS_HUMAN -->")).length;
|
|
794
|
+
const ends = {
|
|
795
|
+
"ci-pending": [{ waits: Array(500).fill({ rc: 2, text: "" }) }, { ciMaxMinutes: 10, mergeWait: "all" }],
|
|
796
|
+
"needs-human": [{ waits: [{ rc: 1, text: "lint failed" }] }, { mergeWait: "all" }],
|
|
797
|
+
};
|
|
798
|
+
for (const [reason, [wo, ro]] of Object.entries(ends)) {
|
|
799
|
+
for (const [final, want] of [[false, 0], [true, 1], [undefined, 1]]) {
|
|
800
|
+
const w = world(wo);
|
|
801
|
+
const r = await run(w, { recover: { code: "ci.red", attempt: 1, ...(final === undefined ? {} : { final }) }, ...ro });
|
|
802
|
+
assert.equal(r.terminalReason, reason);
|
|
803
|
+
assert.equal(add(w), want, `${reason} final=${final}: label`); assert.equal(nh(w), want, `${reason} final=${final}: comment`);
|
|
804
|
+
if (reason === "needs-human") assert.equal(r.recovery, true);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
});
|
|
790
808
|
});
|
|
791
809
|
|
|
792
810
|
describe("devdocs freshness gate (the repo's own scripts/check-devdocs-freshness.sh)", () => {
|
|
@@ -3547,10 +3565,20 @@ describe("test map and repo facts caches (#4272)", () => {
|
|
|
3547
3565
|
{ globs: ["**/*.py"], command: "uv run pytest", cwd: ".", source: "pyproject.toml" }, { globs: ["test/**"], command: "npm test", cwd: "pkg", source: "pkg/package.json" }]);
|
|
3548
3566
|
const dir = tmpDir("core-");
|
|
3549
3567
|
assert.equal((await run(w, { dir, precheck: { commands: ["make lint"] } })).terminalReason, "merged");
|
|
3550
|
-
|
|
3568
|
+
// #4315: the map adds to learned.test_commands, never replaces them
|
|
3569
|
+
assert.deepEqual(ran, ["make lint", "npm run lint", "cd pkg && npm test"]);
|
|
3551
3570
|
const pre = readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS");
|
|
3552
|
-
assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map", ["npm run lint", "cd pkg && npm test"]]]);
|
|
3553
|
-
assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries/);
|
|
3571
|
+
assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map+learned", ["make lint", "npm run lint", "cd pkg && npm test"]]]);
|
|
3572
|
+
assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries, plus 1 learned/);
|
|
3573
|
+
});
|
|
3574
|
+
|
|
3575
|
+
it("#4315 a partial test map still runs the learned commands it does not cover, each once", async () => {
|
|
3576
|
+
const w = world(); const ran = recorder(w);
|
|
3577
|
+
w.baseFiles = { [MAPF]: map([{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["**/*.go"], command: "go test ./...", cwd: "." }]) };
|
|
3578
|
+
const dir = tmpDir("core-");
|
|
3579
|
+
assert.equal((await run(w, { dir, precheck: { commands: ["npm test", "make lint"] } })).terminalReason, "merged");
|
|
3580
|
+
assert.deepEqual(ran, ["npm test", "make lint"]);
|
|
3581
|
+
assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS").map((e) => Object.keys(e).includes("source")), [false]);
|
|
3554
3582
|
});
|
|
3555
3583
|
|
|
3556
3584
|
it("the main checkout's test map is used when the base ref has none; a map alone enables the step", async () => {
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -51,17 +51,22 @@
|
|
|
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
53
|
*
|
|
54
|
-
* In-batch recovery (#4267),
|
|
54
|
+
* In-batch recovery (#4267), on by default: this module recovers only when `recovery: {maxAttempts}` is passed, and the
|
|
55
|
+
* CLI always passes it (forge.yaml `orchestration.recovery.max_attempts`, default 1; 0 disables): a row `classifyNeedsHuman` calls
|
|
55
56
|
* recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
56
57
|
* 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}`
|
|
58
|
-
* lane, so its dependents stay queued.
|
|
59
|
-
*
|
|
58
|
+
* under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
|
|
59
|
+
* `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
|
|
60
|
+
* itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
|
|
61
|
+
* still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
|
|
62
|
+
* full persisted lane reason (#4315).
|
|
63
|
+
* A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
|
|
60
64
|
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
61
65
|
* fire-and-forget: the runner never waits on it.
|
|
62
66
|
*/
|
|
63
67
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
64
68
|
import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
|
|
69
|
+
import { recoveryEvidence } from "./render.mjs";
|
|
65
70
|
|
|
66
71
|
// A dependency that finished like this no longer blocks its dependents.
|
|
67
72
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -206,7 +211,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
206
211
|
let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
|
|
207
212
|
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
208
213
|
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
|
|
214
|
+
const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
|
|
215
|
+
const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
|
|
210
216
|
// #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
|
|
211
217
|
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
212
218
|
const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
|
|
@@ -217,7 +223,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
217
223
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
218
224
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
219
225
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
220
|
-
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {})
|
|
226
|
+
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
227
|
+
...(relabelled.size ? { relabelled: [...relabelled] } : {}) });
|
|
221
228
|
};
|
|
222
229
|
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
223
230
|
const classifyOne = async (n) => {
|
|
@@ -338,7 +345,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
338
345
|
|
|
339
346
|
// Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
|
|
340
347
|
const settle = async (n, r, rec, pf) => {
|
|
341
|
-
|
|
348
|
+
// #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
|
|
349
|
+
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
350
|
+
const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
|
|
342
351
|
...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
343
352
|
...(rec?.placement ? { placement: rec.placement } : {}) };
|
|
344
353
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
@@ -399,6 +408,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
399
408
|
}
|
|
400
409
|
// #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
|
|
401
410
|
const prior = recovering.get(n) ?? null;
|
|
411
|
+
const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail });
|
|
402
412
|
if (prior) {
|
|
403
413
|
recovering.delete(n);
|
|
404
414
|
// #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
|
|
@@ -409,7 +419,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
409
419
|
// A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
|
|
410
420
|
// relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
|
|
411
421
|
if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
412
|
-
try { await relabelNeedsHuman({ issue: n, evidence:
|
|
422
|
+
try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
|
|
413
423
|
}
|
|
414
424
|
}
|
|
415
425
|
if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
|
|
@@ -421,7 +431,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
421
431
|
const used = recoveries.get(n) ?? 0;
|
|
422
432
|
if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
|
|
423
433
|
const code = recoveryCodeOf(facts), attempt = used + 1;
|
|
424
|
-
|
|
434
|
+
// #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
|
|
435
|
+
recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
|
|
436
|
+
detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
|
|
425
437
|
requeue(n, 0); done(); save();
|
|
426
438
|
decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
|
|
427
439
|
alternative: "leave needs-human for the operator and skip the dependents" });
|
|
@@ -429,6 +441,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
429
441
|
return;
|
|
430
442
|
}
|
|
431
443
|
}
|
|
444
|
+
// #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
|
|
445
|
+
if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
446
|
+
try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
|
|
447
|
+
}
|
|
432
448
|
results.set(n, row);
|
|
433
449
|
if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
|
|
434
450
|
save(); update(row);
|
|
@@ -449,6 +465,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
449
465
|
return;
|
|
450
466
|
}
|
|
451
467
|
const lane = ln?.lane;
|
|
468
|
+
if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
|
|
452
469
|
const pl = placed.get(n);
|
|
453
470
|
placed.delete(n);
|
|
454
471
|
const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
|
|
@@ -461,7 +478,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
461
478
|
if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
462
479
|
else {
|
|
463
480
|
try {
|
|
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) => {
|
|
481
|
+
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, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
|
|
465
482
|
Object.assign(rec, info ?? {}); save();
|
|
466
483
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
467
484
|
} });
|
|
@@ -572,8 +589,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
572
589
|
}
|
|
573
590
|
}
|
|
574
591
|
if (reg) withdrawExcept(new Set());
|
|
592
|
+
const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
593
|
+
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
594
|
+
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
595
|
+
for (const [n, q] of recovering) {
|
|
596
|
+
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
597
|
+
const cause = heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n);
|
|
598
|
+
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
599
|
+
queued: q.attempt, cause, recoveryMax });
|
|
600
|
+
try { await relabelNeedsHuman({ issue: n, evidence }); } catch { /* best effort */ }
|
|
601
|
+
relabelled.add(n);
|
|
602
|
+
}
|
|
575
603
|
save();
|
|
576
|
-
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail:
|
|
604
|
+
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
605
|
+
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
606
|
+
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
577
607
|
}
|
|
578
608
|
|
|
579
609
|
/** Max chars of one row's detail in the batch-report outcome lists (#4298). */
|