forgedock 1.36.0 → 1.37.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/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
6
|
+
import { normaliseDecision, renderDecision, parseDecisionComment, foldDecisions, anchorMatches, deadAnchors, staleDecisions, historyRows, withDecisionRows, supersede, readDecisionIndex, recordDecisions, HISTORY_ROWS_MAX } from "./decisions.mjs";
|
|
7
|
+
|
|
8
|
+
const D = (o) => normaliseDecision({ choice: "use A not B", why: "B leaks", anchors: ["src/a/**"], ...o }, { issue: 10, n: 1 });
|
|
9
|
+
|
|
10
|
+
describe("decisions (#4695)", () => {
|
|
11
|
+
it("round-trips through the marker line and ignores prose FORGE:DECISION comments", () => {
|
|
12
|
+
const d = D({ symbols: ["foo"], anchors: ["src/a/x.js", "lib/-->evil"] });
|
|
13
|
+
const m = renderDecision(d);
|
|
14
|
+
const body = `<!-- ${m.marker} -->\n${m.body}`;
|
|
15
|
+
assert.deepEqual(parseDecisionComment(body), d);
|
|
16
|
+
assert.equal(parseDecisionComment("<!-- FORGE:DECISION -->\n## Decision (batch 1)\n**What**: x"), null);
|
|
17
|
+
assert.equal(parseDecisionComment("<!-- FORGE:DECISION {nope} -->"), null);
|
|
18
|
+
assert.equal(normaliseDecision({ choice: "x", anchors: [] }), null);
|
|
19
|
+
});
|
|
20
|
+
it("matches files, directory prefixes and globs", () => {
|
|
21
|
+
assert.ok(anchorMatches("src/a.js", "src/a.js"));
|
|
22
|
+
assert.ok(anchorMatches("src/a/", "src/a/b/c.js"));
|
|
23
|
+
assert.ok(anchorMatches("src/**", "src/a/b.js"));
|
|
24
|
+
assert.ok(anchorMatches("src/*.js", "src/a.js"));
|
|
25
|
+
assert.ok(!anchorMatches("src/*.js", "src/a/b.js"));
|
|
26
|
+
assert.deepEqual(deadAnchors(["src/a.js", "gone/**"], ["src/a.js"]), ["gone/**"]);
|
|
27
|
+
});
|
|
28
|
+
it("history rows: only anchored deliverables, capped at 5, deterministic", () => {
|
|
29
|
+
const recs = Array.from({ length: 8 }, (_, i) => normaliseDecision({ id: `d${i}`, choice: `c${i}`, anchors: ["src/**"], issue: i + 1 }));
|
|
30
|
+
const a = historyRows(recs, ["src/x.js"]), b = historyRows([...recs].reverse(), ["src/x.js"]);
|
|
31
|
+
assert.equal(a.length, HISTORY_ROWS_MAX);
|
|
32
|
+
assert.deepEqual(a, b);
|
|
33
|
+
assert.deepEqual(a.map((r) => r.id), ["H1", "H2", "H3", "H4", "H5"]);
|
|
34
|
+
assert.equal(a[0].decision, "d7");
|
|
35
|
+
assert.ok(a.every((r) => r.source === "decision" && r.what.includes("supersedes")));
|
|
36
|
+
assert.deepEqual(historyRows(recs, ["docs/x.md"]), []);
|
|
37
|
+
assert.deepEqual(historyRows([], ["src/x.js"]), []);
|
|
38
|
+
});
|
|
39
|
+
it("withDecisionRows leaves a contract with no hit untouched (same object)", () => {
|
|
40
|
+
const c = { preserve: [{ id: "P1", what: "w" }], deliverables: [{ id: "D1", path: "docs/x.md" }] };
|
|
41
|
+
assert.equal(withDecisionRows(c, [D()]), c);
|
|
42
|
+
const c2 = withDecisionRows({ ...c, deliverables: [{ id: "D1", path: "src/a/x.js" }] }, [D()]);
|
|
43
|
+
assert.deepEqual(c2.preserve.map((p) => p.id), ["P1", "H1"]);
|
|
44
|
+
});
|
|
45
|
+
it("supersession links both records and the old one stops binding", () => {
|
|
46
|
+
const old = D({ id: "d10-1" }), next = D({ id: "d11-1", choice: "use B" });
|
|
47
|
+
const r = supersede(old, next);
|
|
48
|
+
assert.equal(r.old.status, "superseded");
|
|
49
|
+
assert.equal(r.old.superseded_by, "d11-1");
|
|
50
|
+
assert.equal(r.next.supersedes, "d10-1");
|
|
51
|
+
assert.deepEqual(historyRows([old, r.old], ["src/a/x.js"]), []);
|
|
52
|
+
assert.equal(foldDecisions([old, r.old]).length, 1);
|
|
53
|
+
});
|
|
54
|
+
it("lists stale decisions with dead anchors, never superseded ones", () => {
|
|
55
|
+
const live = D({ id: "a" }), dead = D({ id: "b", anchors: ["src/a/**", "old/x.js"] }), gone = D({ id: "c", anchors: ["old/y.js"], status: "superseded", superseded_by: "z" });
|
|
56
|
+
const s = staleDecisions([live, dead, gone], ["src/a/q.js"]);
|
|
57
|
+
assert.deepEqual(s.map((x) => [x.decision.id, x.dead]), [["b", ["old/x.js"]]]);
|
|
58
|
+
});
|
|
59
|
+
it("recordDecisions posts the new and the superseded old record and updates the index", async () => {
|
|
60
|
+
process.env.FORGEDOCK_DECISIONS_DIR = tmpDir("dec-");
|
|
61
|
+
const posts = [], post = async (marker, body, on) => { posts.push({ marker, on }); };
|
|
62
|
+
const first = await recordDecisions([{ choice: "use A", anchors: ["src/**"], why: "w" }], { issue: 10, repo: "o/r", post });
|
|
63
|
+
assert.equal(first[0].id, "d10-1");
|
|
64
|
+
const second = await recordDecisions([{ choice: "use B", anchors: ["src/**"], why: "w2", supersedes: "d10-1" }], { issue: 11, repo: "o/r", post });
|
|
65
|
+
assert.equal(second[0].supersedes, "d10-1");
|
|
66
|
+
assert.deepEqual(posts.map((p) => p.on), [10, 11, 10]);
|
|
67
|
+
const idx = foldDecisions(readDecisionIndex("o/r"));
|
|
68
|
+
assert.equal(idx.find((d) => d.id === "d10-1").status, "superseded");
|
|
69
|
+
assert.equal(idx.find((d) => d.id === "d11-1").supersedes, "d10-1");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { gatherContextFacts } from "./context.mjs";
|
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
20
|
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
-
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
21
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
|
|
22
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
23
23
|
import { englishCopies } from "./i18n.mjs";
|
|
24
24
|
import { scopedChecks } from "./checks.mjs";
|
|
@@ -30,9 +30,9 @@ import { detectApiShape } from "./apishape.mjs";
|
|
|
30
30
|
const pexec = promisify(execFile);
|
|
31
31
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
32
32
|
/** Argv-only child with stdin input; resolves stdout, rejects on a non-zero exit with stderr attached. */
|
|
33
|
-
export function runWithInput(bin, args, input, timeout = 120000) {
|
|
33
|
+
export function runWithInput(bin, args, input, timeout = 120000, env = undefined) {
|
|
34
34
|
return new Promise((res, rej) => {
|
|
35
|
-
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
35
|
+
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024, ...(env ? { env } : {}) }, (err, stdout, stderr) => {
|
|
36
36
|
if (err) { err.stdout = stdout; err.stderr = stderr; rej(err); } else res(String(stdout));
|
|
37
37
|
});
|
|
38
38
|
// A child that exits before reading stdin closes the pipe: EPIPE must reject via the exit callback, never crash the lane.
|
|
@@ -50,14 +50,25 @@ export function systemdRunAvailable() {
|
|
|
50
50
|
}
|
|
51
51
|
return systemdProbe;
|
|
52
52
|
}
|
|
53
|
-
const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d
|
|
54
|
-
/**
|
|
55
|
-
export
|
|
53
|
+
const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d+%$/, TASKS_RE = /^\d+$/;
|
|
54
|
+
/** #4690: default `TasksMax` of a lane scope; `tasks_max: 0|off` (or any non-count) turns the cap off. */
|
|
55
|
+
export const DEFAULT_LANE_TASKS_MAX = 1024;
|
|
56
|
+
let scopeSeq = 0;
|
|
57
|
+
/**
|
|
58
|
+
* Cap the whole lane process tree: spawn under `systemd-run --user --scope --unit=.. -p TasksMax=.. [-p MemoryMax=..] [-p CPUQuota=..]`.
|
|
59
|
+
* #4690: `TasksMax` is on by default (one runaway lane cannot fork-bomb the host); unset limits use the default, invalid ones are dropped.
|
|
60
|
+
* Returns `unit` (the scope name) when wrapped, so the runner can read the scope's limit events.
|
|
61
|
+
*/
|
|
62
|
+
export function wrapLaneLimits(bin, args, limits, unit = `forgedock-lane-${process.pid}-${++scopeSeq}`) {
|
|
56
63
|
const mem = limits?.memory_max && MEM_RE.test(String(limits.memory_max)) ? String(limits.memory_max) : null;
|
|
57
64
|
const cpu = limits?.cpu_quota && CPU_RE.test(String(limits.cpu_quota)) ? String(limits.cpu_quota) : null;
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
const raw = limits?.tasks_max === undefined || limits?.tasks_max === null || limits?.tasks_max === "" ? DEFAULT_LANE_TASKS_MAX : String(limits.tasks_max).trim();
|
|
66
|
+
const tasks = TASKS_RE.test(raw) && Number(raw) > 0 ? Number(raw) : null;
|
|
67
|
+
if (!mem && !cpu && !tasks) return { bin, args };
|
|
68
|
+
return { bin: "systemd-run", unit, args: ["--user", "--scope", "-q", `--unit=${unit}`, ...(tasks ? ["-p", `TasksMax=${tasks}`] : []), ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
|
|
60
69
|
}
|
|
70
|
+
/** #4690: the one line a run logs when lanes get no scope (systemd-run --user --scope unavailable); null when they are capped. */
|
|
71
|
+
export const laneCapNotice = (probe = systemdRunAvailable) => (probe() ? null : "lanes are uncapped: `systemd-run --user --scope` is unavailable here, so no TasksMax/MemoryMax/CPUQuota applies to lane process trees");
|
|
61
72
|
|
|
62
73
|
/** #4211: a judgment call over data in the prompt only (the refactor:split grouping call): no tool can read a file. */
|
|
63
74
|
export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
@@ -81,7 +92,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
|
|
|
81
92
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
82
93
|
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
83
94
|
logger: { log() {} },
|
|
84
|
-
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap:
|
|
95
|
+
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
85
96
|
// #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
|
|
86
97
|
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default",
|
|
87
98
|
baselineTokens: resume ? null : (r.baselineTokens ?? null), tools: toolStats(r.toolUses) }; // #4410: tool calls per turn, re-reads
|
|
@@ -328,11 +339,14 @@ export function makeLeanIo({ repo }) {
|
|
|
328
339
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
329
340
|
/**
|
|
330
341
|
* File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
|
|
331
|
-
* append, create, claim marker, duplicate re-check). items: [{key, why}]
|
|
342
|
+
* append, create, claim marker, duplicate re-check; one issue per class, #4664). items: [{key, why, class?}]; the key is the
|
|
343
|
+
* item's identity (column 5, column 1 is `-`). milestone / defaultMilestone: the parent's and forge.yaml followups.default_milestone; lane: the release lane of the post-deploy rollup (#4724).
|
|
344
|
+
* Returns {key: issueNumber}; throws on exit 2/3/4.
|
|
332
345
|
*/
|
|
333
|
-
async fileFollowups({ parent, items }) {
|
|
334
|
-
const tsv = items.map((i) =>
|
|
335
|
-
const
|
|
346
|
+
async fileFollowups({ parent, items, milestone = null, defaultMilestone = null, lane = null }) {
|
|
347
|
+
const tsv = items.map((i) => `-\t${i.why}\t\t${i.class ?? ""}\t${i.key}`).join("\n") + "\n";
|
|
348
|
+
const env = { ...process.env, ...(milestone ? { FORGE_FOLLOWUP_MILESTONE: String(milestone) } : {}), ...(defaultMilestone ? { FORGE_FOLLOWUPS_DEFAULT_MILESTONE: String(defaultMilestone) } : {}), ...(lane ? { FORGE_RELEASE_LANE: String(lane) } : {}) }; // #4724: the post-deploy rollup is one per release lane
|
|
349
|
+
const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000, env);
|
|
336
350
|
return followupMap(out, items);
|
|
337
351
|
},
|
|
338
352
|
/**
|
|
@@ -381,7 +395,8 @@ export function makeLeanIo({ repo }) {
|
|
|
381
395
|
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
382
396
|
sequenceLease: makeSequenceLeaseIo(),
|
|
383
397
|
/** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
|
|
384
|
-
|
|
398
|
+
/** #4661: plus journal `when` stamps not above the base max (newest-only migrators skip them). */
|
|
399
|
+
migrationGenerator: async ({ cwd, base }) => { const git = (a) => run("git")(a, { cwd }); return [...await generatorProblems({ git, base }), ...await journalOrderProblems({ git, base })]; },
|
|
385
400
|
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|
|
386
401
|
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
387
402
|
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -62,6 +62,16 @@ const safeRegExp = (src) => { try { return new RegExp(src, "i"); } catch { retur
|
|
|
62
62
|
const ENV_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
63
63
|
const IMAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/:@-]*$/;
|
|
64
64
|
const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
65
|
+
const TRANSPORT_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "EPIPE", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
66
|
+
/** #4672: a failure of the link to the provider (connection refused/reset, `fetch failed`, timeout, 502/503/504), not of the lane. */
|
|
67
|
+
export function isTransportError(e) {
|
|
68
|
+
if (!e || e.unreachable) return false;
|
|
69
|
+
if (e.transient === true || [502, 503, 504].includes(e.status)) return true;
|
|
70
|
+
const code = e.code ?? e.cause?.code;
|
|
71
|
+
if (TRANSPORT_CODES.has(code)) return true;
|
|
72
|
+
if (e.name === "AbortError" || e.name === "TimeoutError") return true;
|
|
73
|
+
return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up|network error|timed? ?out/i.test(String(e.message ?? ""));
|
|
74
|
+
}
|
|
65
75
|
const errText = (e) => String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 300);
|
|
66
76
|
|
|
67
77
|
/**
|
|
@@ -98,7 +108,9 @@ export function parseExecution(sec = {}) {
|
|
|
98
108
|
const patRaw = Array.isArray(s.host_capability_patterns) ? s.host_capability_patterns : str(s.host_capability_patterns) ? str(s.host_capability_patterns).split(",") : [];
|
|
99
109
|
const hostCapabilityPatterns = patRaw.map((x) => { const t = str(x), i = t.indexOf(":"); return { name: t.slice(0, i).trim().toLowerCase(), pattern: t.slice(i + 1).trim() }; })
|
|
100
110
|
.filter((x) => CAPABILITY_RE.test(x.name) && x.pattern && safeRegExp(x.pattern));
|
|
101
|
-
|
|
111
|
+
const uw = Number(str(s.unreachable_window_min));
|
|
112
|
+
const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
|
|
113
|
+
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}) };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
const round1 = (x) => Math.round(x * 10) / 10;
|
|
@@ -429,7 +441,8 @@ export function providerByName(name, opts) {
|
|
|
429
441
|
try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; }
|
|
430
442
|
// The router's token is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
|
|
431
443
|
const tokenEnv = exec.tokenEnv ?? (router?.token ? "FORGEDOCK_POOL_TOKEN" : undefined);
|
|
432
|
-
|
|
444
|
+
// #4685: an empty env/keychain lookup of a configured token_env falls back to the router's token file.
|
|
445
|
+
const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }) ?? router?.token ?? null);
|
|
433
446
|
const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}) };
|
|
434
447
|
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool", env, credentials, transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
435
448
|
}
|
|
@@ -443,7 +456,7 @@ export function providerByName(name, opts) {
|
|
|
443
456
|
* returns that sandbox's ledger (ledger.mjs `makeLedger`).
|
|
444
457
|
*/
|
|
445
458
|
export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
446
|
-
costPerMinute = 0, now = () => Date.now() }) {
|
|
459
|
+
costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000 }) {
|
|
447
460
|
const cpm = nonNeg(Number(costPerMinute)) ?? 0;
|
|
448
461
|
// #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
|
|
449
462
|
// clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
|
|
@@ -463,20 +476,38 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
463
476
|
if (ok) { try { led?.release("sandbox", h.id); led?.remove(); } catch { /* the reaper retries */ } }
|
|
464
477
|
return ok;
|
|
465
478
|
};
|
|
466
|
-
|
|
479
|
+
// #4672: a transport failure (router restart, refused/reset connection, 502/503/504) is retried with backoff until the lane has
|
|
480
|
+
// waited `unreachableWindowMs` in a row without one successful poll; then it ends sandbox.unreachable with the sandbox kept.
|
|
481
|
+
const guard = (st) => async (fn) => {
|
|
482
|
+
for (;;) {
|
|
483
|
+
try { const v = await fn(); st.waited = 0; st.tries = 0; return v; } catch (e) {
|
|
484
|
+
if (!isTransportError(e)) throw e;
|
|
485
|
+
if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true });
|
|
486
|
+
const d = Math.min(pollMs * 2 ** Math.min(st.tries++, 10), 30000, Math.max(1, unreachableWindowMs - st.waited));
|
|
487
|
+
st.waited += d;
|
|
488
|
+
await sleep(d);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const unreachable = (h, e) => ({ terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
|
|
493
|
+
detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
|
|
494
|
+
const wait = async (h, g = guard({ waited: 0, tries: 0 })) => {
|
|
467
495
|
for (;;) {
|
|
468
|
-
const r = await provider.fetchResult(h);
|
|
496
|
+
const r = await g(() => provider.fetchResult(h));
|
|
469
497
|
if (r) return r;
|
|
470
|
-
const st = await provider.status(h);
|
|
498
|
+
const st = await g(() => provider.status(h));
|
|
471
499
|
if (st !== "running") {
|
|
472
|
-
const last = await provider.fetchResult(h); // it may have written the result just before exiting
|
|
500
|
+
const last = await g(() => provider.fetchResult(h)); // it may have written the result just before exiting
|
|
473
501
|
return last ?? { terminalReason: "engine-error", detail: `sandbox ${h.id} ${st} without a result` };
|
|
474
502
|
}
|
|
475
503
|
await sleep(pollMs);
|
|
476
504
|
}
|
|
477
505
|
};
|
|
478
506
|
// A start failure repeats on a relaunch: typed, never engine-error (#4254). Retryable (capacity) defers with backoff.
|
|
479
|
-
|
|
507
|
+
// #4685: a refused or missing credential defers with its own code (a fixed token is picked up on the retry), never needs-human.
|
|
508
|
+
const startFailed = (id, e) => ([401, 403].includes(e?.status)
|
|
509
|
+
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)` }
|
|
510
|
+
: e?.retryable === true
|
|
480
511
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
|
|
481
512
|
: { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
|
|
482
513
|
return {
|
|
@@ -486,8 +517,12 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
486
517
|
const led = ledgerFor(n);
|
|
487
518
|
const base = { provider: provider.name, id: provider.idFor(n), issue: n, startedAt: now() };
|
|
488
519
|
// A sandbox an earlier attempt left behind (same id) goes first, or the new one could not start.
|
|
489
|
-
|
|
490
|
-
|
|
520
|
+
const left = (led?.entries("sandbox") ?? []).find((e) => e.value === base.id);
|
|
521
|
+
if (left) {
|
|
522
|
+
// #4672: a sandbox kept as unreachable is adopted when the provider answers again; it is torn down only when it is gone.
|
|
523
|
+
const kept = await adoptLeft(n, left, ctx);
|
|
524
|
+
if (kept) return kept;
|
|
525
|
+
if (!(await tear(led, base))) return { terminalReason: "engine-error", detail: `sandbox ${base.id} from an earlier attempt could not be torn down` };
|
|
491
526
|
}
|
|
492
527
|
led?.register("sandbox", base.id, { provider: provider.name, handle: base }); // before it exists
|
|
493
528
|
let h = base, r, started = false;
|
|
@@ -498,12 +533,15 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
498
533
|
ctx.onStart?.({ ...(Number.isInteger(h.pid) ? { pid: h.pid } : {}), handle: h, remote: true });
|
|
499
534
|
r = await wait(h);
|
|
500
535
|
} catch (e) {
|
|
536
|
+
if (started && e?.unreachable) return withUsage(unreachable(h, e), h); // #4672: no tear, the ledger keeps it
|
|
501
537
|
r = started ? { terminalReason: "engine-error", detail: `sandbox ${base.id}: ${errText(e)}` } : startFailed(base.id, e);
|
|
502
538
|
}
|
|
503
539
|
await tear(led, h); // every path, after the result is in (tear never throws)
|
|
504
540
|
return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
|
|
505
541
|
},
|
|
506
|
-
adopt:
|
|
542
|
+
adopt: (n, rec, ctx = {}) => adoptSaved(n, rec, ctx),
|
|
543
|
+
};
|
|
544
|
+
async function adoptSaved(n, rec, ctx = {}) {
|
|
507
545
|
const saved = rec?.handle;
|
|
508
546
|
if (!saved?.id || saved.provider !== provider.name) return null;
|
|
509
547
|
// Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
|
|
@@ -511,15 +549,21 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
511
549
|
const led = ledgerFor(n);
|
|
512
550
|
led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
|
|
513
551
|
let r;
|
|
552
|
+
const g = guard({ waited: 0, tries: 0 });
|
|
514
553
|
try {
|
|
515
|
-
const ended = await provider.fetchResult(h);
|
|
516
|
-
if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
|
|
517
|
-
else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
|
|
554
|
+
const ended = await g(() => provider.fetchResult(h));
|
|
555
|
+
if (!ended && (await g(() => provider.status(h))) !== "running") r = null; // relaunched
|
|
556
|
+
else { if (!ended) ctx.onLive?.(); r = await wait(h, g); } // #4504: a live sandbox is awaited in the background
|
|
518
557
|
} catch (e) {
|
|
558
|
+
if (e?.unreachable) return withUsage(unreachable(h, e), h); // #4672: same tolerance; no tear, left for the next adopt
|
|
519
559
|
r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
|
|
520
560
|
}
|
|
521
561
|
await tear(led, h);
|
|
522
562
|
return withUsage(r, h);
|
|
523
|
-
|
|
524
|
-
|
|
563
|
+
}
|
|
564
|
+
// A leftover ledger entry of this lane's sandbox: its result if it can still be had, else null (torn down by the caller).
|
|
565
|
+
async function adoptLeft(n, left, ctx) {
|
|
566
|
+
const r = await adoptSaved(n, { handle: left.handle }, ctx).catch(() => null);
|
|
567
|
+
return r ?? null;
|
|
568
|
+
}
|
|
525
569
|
}
|
|
@@ -6,7 +6,7 @@ import assert from "node:assert/strict";
|
|
|
6
6
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
9
|
+
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
10
10
|
import { makeLedger, countLiveLanes } from "./ledger.mjs";
|
|
11
11
|
import { openBatch } from "./batch.mjs";
|
|
12
12
|
import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
|
|
@@ -24,7 +24,7 @@ function fakeProvider(opts = {}) {
|
|
|
24
24
|
async start({ issue, env, secrets }) {
|
|
25
25
|
p.calls.push(["start", issue]);
|
|
26
26
|
p.ledgerAtStart = opts.ledger?.entries("sandbox").map((e) => e.value) ?? null;
|
|
27
|
-
if (opts.startThrows) throw new Error("boom: no capacity");
|
|
27
|
+
if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), opts.startThrows === 401 ? { status: 401 } : {});
|
|
28
28
|
p.live.add(issue);
|
|
29
29
|
p.env = env; p.secrets = secrets;
|
|
30
30
|
if (opts.finish) { const r = opts.finish(issue); if (r !== undefined) { p.results[issue] = r; p.live.delete(issue); } }
|
|
@@ -81,6 +81,56 @@ describe("execution config (#4253)", () => {
|
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
describe("providerLanes: an unreachable provider is retried, then deferred without teardown (#4672)", () => {
|
|
85
|
+
const flaky = (failPolls) => {
|
|
86
|
+
const prov = fakeProvider(); let polls = 0;
|
|
87
|
+
prov.live.add(7);
|
|
88
|
+
const gate = () => { if (polls++ < failPolls) throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); };
|
|
89
|
+
prov.fetchResult = async (h) => { gate(); return prov.results[h.issue] ?? null; };
|
|
90
|
+
prov.status = async (h) => { gate(); prov.calls.push(["status", h.issue]); return prov.live.has(h.issue) ? "running" : "exited"; };
|
|
91
|
+
return prov;
|
|
92
|
+
};
|
|
93
|
+
const lanes = (prov, extra = {}) => { const root = tmpDir("exec-unreach-"); const ledgerFor = ledgerIn(root); const slept = [];
|
|
94
|
+
return { slept, ledgerFor, l: providerLanes({ provider: prov, laneArgs: () => [], ledgerFor, pollMs: 1000, now: () => 1000, sleep: async (ms) => { slept.push(ms); if (slept.length === 6) { prov.results[7] = { terminalReason: "merged", pr: 9 }; prov.live.delete(7); } }, ...extra }) }; };
|
|
95
|
+
|
|
96
|
+
it("unreachable: N failed polls then recovery yields the real result", async () => {
|
|
97
|
+
const prov = flaky(3); prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
|
|
98
|
+
const { l, slept } = lanes(prov);
|
|
99
|
+
const r = await l.runOne(7, "staging");
|
|
100
|
+
assert.equal(r.terminalReason, "merged");
|
|
101
|
+
assert.ok(slept.length >= 3 && slept[1] > slept[0]); // backoff
|
|
102
|
+
assert.equal(prov.calls.filter((c) => c[0] === "teardown").length, 1); // only after the result
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("unreachable: down past the window yields sandbox.unreachable, no teardown, ledger kept", async () => {
|
|
106
|
+
const prov = flaky(1e9); prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
|
|
107
|
+
const { l, ledgerFor } = lanes(prov, { unreachableWindowMs: 5000 });
|
|
108
|
+
const r = await l.runOne(7, "staging");
|
|
109
|
+
assert.deepEqual([r.terminalReason, r.code], ["deferred", "sandbox.unreachable"]);
|
|
110
|
+
assert.equal(prov.calls.some((c) => c[0] === "teardown"), false);
|
|
111
|
+
assert.deepEqual(ledgerFor(7).entries("sandbox").map((e) => e.value), ["fake-7"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("unreachable: adopt applies the same tolerance, and a non-transport error is still engine-error", async () => {
|
|
115
|
+
const down = flaky(1e9);
|
|
116
|
+
const { l } = lanes(down, { unreachableWindowMs: 3000 });
|
|
117
|
+
const r = await l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } });
|
|
118
|
+
assert.equal(r.code, "sandbox.unreachable");
|
|
119
|
+
assert.equal(down.calls.some((c) => c[0] === "teardown"), false);
|
|
120
|
+
const ok = flaky(2); ok.results[7] = { terminalReason: "merged", pr: 1 };
|
|
121
|
+
assert.equal((await lanes(ok).l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } })).terminalReason, "merged");
|
|
122
|
+
const bad = fakeProvider(); bad.fetchResult = async () => { throw new Error("boom"); };
|
|
123
|
+
assert.equal((await lanes(bad).l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } })).terminalReason, "engine-error");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("unreachable: classifies transport errors and parses the window key", () => {
|
|
127
|
+
for (const e of [new TypeError("fetch failed"), Object.assign(new Error("x"), { code: "ECONNRESET" }), Object.assign(new Error("x"), { status: 502 }), Object.assign(new Error("x"), { name: "AbortError" })]) assert.equal(isTransportError(e), true);
|
|
128
|
+
for (const e of [new Error("boom"), Object.assign(new Error("HTTP 404"), { status: 404 })]) assert.equal(isTransportError(e), false);
|
|
129
|
+
assert.equal(parseExecution({}).unreachableWindowMin, undefined); // providerLanes defaults it to 5 min
|
|
130
|
+
assert.equal(parseExecution({ unreachable_window_min: "2" }).unreachableWindowMin, 2);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
84
134
|
describe("providerLanes: sandbox ledger and teardown on every exit path (#4253)", () => {
|
|
85
135
|
const run = async ({ finish, startThrows, teardownOk, pollMs = 1 } = {}) => {
|
|
86
136
|
const root = tmpDir("exec-ledger-");
|
|
@@ -118,6 +168,13 @@ describe("providerLanes: sandbox ledger and teardown on every exit path (#4253)"
|
|
|
118
168
|
assert.equal(led.read(), null);
|
|
119
169
|
});
|
|
120
170
|
|
|
171
|
+
it("#4685 start refused with HTTP 401: deferred sandbox.auth with a long retry, never needs-human", async () => {
|
|
172
|
+
const { r } = await run({ startThrows: 401 });
|
|
173
|
+
assert.equal(r.terminalReason, "deferred");
|
|
174
|
+
assert.equal(r.code, "sandbox.auth");
|
|
175
|
+
assert.equal(r.retryInSeconds, 300);
|
|
176
|
+
});
|
|
177
|
+
|
|
121
178
|
it("start throws: typed needs-human sandbox.start_failed row (#4254), teardown still runs", async () => {
|
|
122
179
|
const { r, prov, led } = await run({ startThrows: true });
|
|
123
180
|
assert.equal(r.terminalReason, "needs-human");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge#4656: ship-gate watchdog. A gate (a settle pass, a hold list, `/milestone ship` pre-flight) that waits on an issue the
|
|
3
|
+
* scheduler will not admit never resolves; this pure module names those waits so the batch reports `GATE_DEADLOCK` within one pass
|
|
4
|
+
* instead of polling silently. No I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `gates`: [{ name, waitsOn: [issue...], since?: ms }]; `admissible(n)`: true when the scheduler could still run or finish `n`;
|
|
9
|
+
* `now`: ms. Returns one { issue, gate, ageMs } per (gate, issue) wait that cannot resolve, in gate order.
|
|
10
|
+
*/
|
|
11
|
+
export function detectGateDeadlock({ gates, admissible, now = Date.now(), firstSeen = null }) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const g of Array.isArray(gates) ? gates : []) {
|
|
14
|
+
for (const n of Array.isArray(g?.waitsOn) ? g.waitsOn : []) {
|
|
15
|
+
let ok = true;
|
|
16
|
+
try { ok = admissible(n) !== false; } catch { ok = true; }
|
|
17
|
+
if (ok) continue;
|
|
18
|
+
const key = `${g.name}#${n}`;
|
|
19
|
+
if (firstSeen && !firstSeen.has(key)) firstSeen.set(key, Number.isFinite(g.since) ? g.since : now);
|
|
20
|
+
const since = firstSeen?.get(key) ?? (Number.isFinite(g.since) ? g.since : now);
|
|
21
|
+
out.push({ issue: n, gate: String(g.name), ageMs: Math.max(0, now - since) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One `pipeline-status` line: `GATE_DEADLOCK issue=#N gate=G age=12m`. */
|
|
28
|
+
export function formatGateDeadlock(d) {
|
|
29
|
+
const m = Math.round((Number(d.ageMs) || 0) / 60000);
|
|
30
|
+
return `GATE_DEADLOCK issue=#${d.issue} gate=${d.gate} age=${m}m`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
|
|
4
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
5
|
+
import { classifyNeedsHuman, classifyLegacyNeedsHuman, parkedLabel } from "../engine/outcome-codes.mjs";
|
|
6
|
+
|
|
7
|
+
describe("#4656 gate watchdog", () => {
|
|
8
|
+
it("a milestone ship gate waiting on a parked issue yields GATE_DEADLOCK with issue, gate and age", () => {
|
|
9
|
+
const firstSeen = new Map([["ship#7", 0]]);
|
|
10
|
+
const d = detectGateDeadlock({ gates: [{ name: "ship", waitsOn: [7, 8] }], admissible: (n) => n !== 7, now: 120000, firstSeen });
|
|
11
|
+
assert.deepEqual(d, [{ issue: 7, gate: "ship", ageMs: 120000 }]);
|
|
12
|
+
assert.equal(formatGateDeadlock(d[0]), "GATE_DEADLOCK issue=#7 gate=ship age=2m");
|
|
13
|
+
});
|
|
14
|
+
it("an admissible wait is not a deadlock", () => {
|
|
15
|
+
assert.deepEqual(detectGateDeadlock({ gates: [{ name: "ship", waitsOn: [8] }], admissible: () => true }), []);
|
|
16
|
+
});
|
|
17
|
+
it("the batch runner reports GATE_DEADLOCK once per (gate, issue) for a parked issue on the gate, instead of waiting", async () => {
|
|
18
|
+
const rows = [], decisions = [];
|
|
19
|
+
const runOne = async (n) => n === 1
|
|
20
|
+
? { terminalReason: "needs-human", code: "conformance.unimplemented", detail: "unknown requirement", pr: 9 }
|
|
21
|
+
: { terminalReason: "merged", pr: 10 };
|
|
22
|
+
const _unused = async (n) => n === 1
|
|
23
|
+
? { terminalReason: "needs-human", code: "ci.red", detail: "lint failed", pr: 9 }
|
|
24
|
+
: { terminalReason: "merged", pr: 10 };
|
|
25
|
+
const relabels = [];
|
|
26
|
+
const res = await orchestrate({ issues: [1, 2], parallel: 1, runOne, readBody: async () => "", onUpdate: (r) => rows.push(r), decide: (d) => decisions.push(d),
|
|
27
|
+
getGates: () => [{ name: "ship", waitsOn: [1] }], recovery: { maxAttempts: 1 }, relabelNeedsHuman: async (a) => { relabels.push(a); return true; }, pollMs: 1, sleep: async () => {} });
|
|
28
|
+
const dl = rows.filter((r) => r.outcome === "GATE_DEADLOCK");
|
|
29
|
+
assert.equal(dl.length, 1);
|
|
30
|
+
assert.match(dl[0].detail, /GATE_DEADLOCK issue=#1 gate=ship/);
|
|
31
|
+
assert.ok(Array.isArray(res));
|
|
32
|
+
assert.deepEqual(relabels.map((r) => r.parked?.code), ["conformance.unimplemented"]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("#4656 parked class and migration", () => {
|
|
37
|
+
it("parked is opt-in and leaves the default classes untouched", () => {
|
|
38
|
+
const row = { outcome: "needs-human", code: "conformance.unimplemented" };
|
|
39
|
+
assert.equal(classifyNeedsHuman(row), "terminal");
|
|
40
|
+
assert.equal(classifyNeedsHuman(row, { parked: true }), "parked");
|
|
41
|
+
assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: "builder.blocked" }, { parked: true }), "human");
|
|
42
|
+
assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: "ci.red" }, { parked: true }), "recoverable");
|
|
43
|
+
assert.equal(classifyNeedsHuman({ outcome: "needs-human" }, { parked: true }), "terminal");
|
|
44
|
+
});
|
|
45
|
+
it("legacy needs-human classifies from the terminal annotation and defaults to external", () => {
|
|
46
|
+
assert.deepEqual(classifyLegacyNeedsHuman("terminal: ci.red after final round"), { kind: "parked", code: "ci.red" });
|
|
47
|
+
assert.deepEqual(classifyLegacyNeedsHuman("waiting for the AWS account"), { kind: "external" });
|
|
48
|
+
assert.deepEqual(classifyLegacyNeedsHuman(null), { kind: "external" });
|
|
49
|
+
assert.equal(parkedLabel("ci.red"), "parked:ci.red");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("#4656 report routing", () => {
|
|
54
|
+
it("a parked row past recovery is listed on the remediation queue line, not unrecovered or escalations", async () => {
|
|
55
|
+
const { formatOutcomeLists } = await import("./orchestrate.mjs");
|
|
56
|
+
const lines = formatOutcomeLists([{ issue: 1, outcome: "needs-human", code: "conformance.unimplemented", pr: 9, parkedQueued: true }]);
|
|
57
|
+
assert.deepEqual(lines, ["remediation queue (/work-on:remediate): #1 (parked:conformance.unimplemented PR #9)"]);
|
|
58
|
+
});
|
|
59
|
+
});
|