forgedock 1.42.0 → 1.43.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-cli.mjs +76 -17
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
* A seam calls `onLive()` before it waits on a live lane (#4504); an adopt that resolves first is settled (or requeued on null)
|
|
20
20
|
* before scheduling, so a live lane does not hold up the others.
|
|
21
21
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
22
|
-
* limit holds admissions for that account until its reset, and deferrals other than a
|
|
23
|
-
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788) stop after `maxDeferrals`.
|
|
22
|
+
* limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
|
|
23
|
+
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
|
|
24
24
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
25
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
26
|
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
|
|
27
27
|
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
28
28
|
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
29
|
+
* `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
|
|
30
|
+
* it reads one `{list, elsewhere}` and adds the open children (`**Parent**: #P`, sub-issues) of every batch issue and `epics` through the same
|
|
31
|
+
* path as `getAdded` (one decision and one `added` row listing the edges); mode `off` also stops the decomposed-lane join.
|
|
29
32
|
* `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
|
|
30
33
|
* join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
|
|
31
34
|
* batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
|
|
@@ -112,13 +115,14 @@ import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
|
112
115
|
import { laneSize, SIZES } from "./budget.mjs";
|
|
113
116
|
import { requeueKey, REQUEUABLE } from "./batch.mjs";
|
|
114
117
|
import { makeCapWait } from "./capwait.mjs";
|
|
118
|
+
import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_POLL_S } from "./autochildren.mjs";
|
|
115
119
|
|
|
116
120
|
// A dependency that finished like this no longer blocks its dependents.
|
|
117
121
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
118
122
|
const WATCH_EVERY_MS = 60_000; // #4595
|
|
119
123
|
// Times one issue may be requeued behind a dependency before its dependency-open row is final.
|
|
120
124
|
export const MAX_REQUEUES = 2;
|
|
121
|
-
// Deferrals other than a
|
|
125
|
+
// Deferrals other than a wait (UNCAPPED_DEFERRALS, usage limit) one issue may take before its deferred row is final.
|
|
122
126
|
export const DEFAULT_MAX_DEFERRALS = 3;
|
|
123
127
|
// Relaunches of an issue whose lane ended engine-error.
|
|
124
128
|
export const ENGINE_ERROR_RELAUNCHES = 1;
|
|
@@ -176,18 +180,19 @@ export function parseDeps(body, issue) {
|
|
|
176
180
|
const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
177
181
|
|
|
178
182
|
/**
|
|
179
|
-
* When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS).
|
|
183
|
+
* When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS or a usage limit).
|
|
180
184
|
* @returns {{delayMs:number, host?:true, hold?:true} | {capped:true}}
|
|
181
185
|
*/
|
|
182
186
|
export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_DEFERRALS }) {
|
|
183
187
|
const code = r?.code ?? null;
|
|
184
188
|
if (UNCAPPED_DEFERRALS.has(code)) return { delayMs: (posNum(r?.retryInSeconds) ?? HOST_BUSY_RETRY_S) * 1000, host: true };
|
|
185
|
-
|
|
186
|
-
if (k > maxDeferrals) return { capped: true };
|
|
189
|
+
// #4784: a usage limit is a known-time wait gated by the account hold, never capped.
|
|
187
190
|
if (code === "rate_limited") {
|
|
188
191
|
const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
|
|
189
192
|
return { delayMs: at - now, hold: true };
|
|
190
193
|
}
|
|
194
|
+
const k = deferrals + 1;
|
|
195
|
+
if (k > maxDeferrals) return { capped: true };
|
|
191
196
|
if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
|
|
192
197
|
// lease-held: "leased by X until <ISO>"
|
|
193
198
|
const until = /\buntil (\d{4}-\d\d-\d\dT[\d:.]+Z)/.exec(String(r?.detail ?? ""))?.[1];
|
|
@@ -248,7 +253,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
248
253
|
store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
249
254
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
|
|
250
255
|
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
|
|
251
|
-
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null
|
|
256
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
|
|
257
|
+
getChildren = null, autoChildren = null }) {
|
|
252
258
|
const saved = store?.load?.() ?? null;
|
|
253
259
|
const order = saved ? [...saved.order] : [];
|
|
254
260
|
const set = new Set(order);
|
|
@@ -632,7 +638,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
632
638
|
try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
|
|
633
639
|
}
|
|
634
640
|
if (!pol.capped) {
|
|
635
|
-
if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
|
|
641
|
+
if (!pol.host && !pol.hold) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
|
|
636
642
|
// A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
|
|
637
643
|
// account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
|
|
638
644
|
let delayMs = pol.delayMs;
|
|
@@ -655,7 +661,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
655
661
|
return;
|
|
656
662
|
}
|
|
657
663
|
}
|
|
658
|
-
if (row.outcome === "decomposed" && subs.length) {
|
|
664
|
+
if (row.outcome === "decomposed" && subs.length && joinsDecomposed(autoChildren?.mode ?? "decomposed")) {
|
|
659
665
|
await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
|
|
660
666
|
decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
|
|
661
667
|
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
@@ -847,11 +853,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
847
853
|
|
|
848
854
|
// #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
|
|
849
855
|
const drainAdds = async () => {
|
|
850
|
-
|
|
856
|
+
const kids = await drainChildren();
|
|
857
|
+
if (!getAdded) return kids;
|
|
851
858
|
let got = [];
|
|
852
859
|
try { got = getAdded() ?? []; } catch { got = []; }
|
|
853
860
|
const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
|
|
854
|
-
if (!fresh.length) return
|
|
861
|
+
if (!fresh.length) return kids;
|
|
855
862
|
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
856
863
|
for (const n of pending) {
|
|
857
864
|
let body;
|
|
@@ -864,6 +871,38 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
864
871
|
const list = fresh.map((n) => `#${n}`).join(", ");
|
|
865
872
|
decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
|
|
866
873
|
update({ issue: null, outcome: "added", detail: list });
|
|
874
|
+
return fresh.length + kids;
|
|
875
|
+
};
|
|
876
|
+
// #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
|
|
877
|
+
let nextChildScan = 0, childScanFailed = false;
|
|
878
|
+
const drainChildren = async () => {
|
|
879
|
+
if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
|
|
880
|
+
const t = now();
|
|
881
|
+
if (t < nextChildScan) return 0;
|
|
882
|
+
nextChildScan = t + Math.max(0, autoChildren.pollS ?? DEFAULT_POLL_S) * 1000;
|
|
883
|
+
let list, elsewhere;
|
|
884
|
+
try { ({ list, elsewhere } = await getChildren()); } catch (e) {
|
|
885
|
+
if (!childScanFailed) { childScanFailed = true; decided({ issue: null, what: `auto_add_children: child scan failed (${firstLine(e?.message ?? e)}); retried every ${autoChildren.pollS ?? DEFAULT_POLL_S}s`, why: "the open-issue list could not be read", alternative: "add the children by hand with forgedock orchestrate --add" }); }
|
|
886
|
+
return 0;
|
|
887
|
+
}
|
|
888
|
+
childScanFailed = false;
|
|
889
|
+
const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
|
|
890
|
+
.filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
|
|
891
|
+
if (!fresh.length) return 0;
|
|
892
|
+
for (const n of pending) {
|
|
893
|
+
let body;
|
|
894
|
+
try { body = await readBody(n); } catch { continue; }
|
|
895
|
+
if (drift?.holds) bodies.set(n, body);
|
|
896
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
897
|
+
}
|
|
898
|
+
await addIssues(fresh);
|
|
899
|
+
save();
|
|
900
|
+
const list2 = fresh.map((n) => `#${n}`).join(", ");
|
|
901
|
+
const created = fresh.flatMap((n) => edges(n).map((d) => ({ from: n, to: d })));
|
|
902
|
+
for (const n of pending) if (!fresh.includes(n)) for (const d of edges(n)) if (fresh.includes(d)) created.push({ from: n, to: d });
|
|
903
|
+
const detail = created.length ? `${list2}; edges ${edgeText(created)}` : list2;
|
|
904
|
+
decided({ issue: null, what: `added ${list2} to the batch (orchestration.auto_add_children: ${autoChildren.mode})${created.length ? `; edges ${edgeText(created)}` : ""}`, why: "open children of a batch issue or epic", alternative: "add them by hand with forgedock orchestrate --add" });
|
|
905
|
+
update({ issue: null, outcome: "added", detail });
|
|
867
906
|
return fresh.length;
|
|
868
907
|
};
|
|
869
908
|
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
@@ -1163,7 +1202,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1163
1202
|
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
1164
1203
|
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
1165
1204
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1166
|
-
if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1205
|
+
if ((getAdded || getRequeued || (getChildren && addsEpicChildren(autoChildren?.mode))) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1167
1206
|
if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
|
|
1168
1207
|
if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
|
|
1169
1208
|
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
package/bin/lean/postmerge.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { exportNames, isTestPath } from "./enumerating.mjs";
|
|
|
15
15
|
const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py)$/;
|
|
16
16
|
const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build|coverage)\//;
|
|
17
17
|
const MANIFESTS = ["package.json", "pyproject.toml", "setup.py", "go.mod", "Cargo.toml"];
|
|
18
|
+
// entry points a standalone script defines by convention: never a shared export
|
|
19
|
+
const ENTRY_NAMES = new Set(["main", "cli"]);
|
|
18
20
|
const MIN_NAME = 3, MAX_NAMES = 50, CONVENTION = 3;
|
|
19
21
|
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
20
22
|
const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
|
|
@@ -68,13 +70,14 @@ export async function duplicateExports({ git, cwd, base }) {
|
|
|
68
70
|
let names = 0;
|
|
69
71
|
for (const f of changed) {
|
|
70
72
|
const was = new Set(added.has(f) ? [] : declaredExports(await g(["show", `${base}:${f}`]), f));
|
|
71
|
-
const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME);
|
|
73
|
+
const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME && !ENTRY_NAMES.has(n));
|
|
72
74
|
const root = packageRoot(f, manifests);
|
|
75
|
+
if (root === "." && /^scripts\//.test(f)) continue; // standalone scripts, not a package
|
|
73
76
|
for (const name of fresh) {
|
|
74
77
|
if (++names > MAX_NAMES) return res;
|
|
75
78
|
const hits = lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-w", "-e", name, "HEAD", "--", ...(root === "." ? [] : [`${root}/`])]))
|
|
76
79
|
.map((l) => l.replace(/^HEAD:/, ""))
|
|
77
|
-
.filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root);
|
|
80
|
+
.filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root && !(root === "." && /^scripts\//.test(p)));
|
|
78
81
|
const others = [];
|
|
79
82
|
for (const p of hits) if (declaredExports(read(p), p).includes(name)) others.push(p);
|
|
80
83
|
if (others.length && others.length < CONVENTION) res.push({ symbol: name, path: f, others: others.sort() });
|
|
@@ -51,6 +51,13 @@ describe("duplicate exports (#4406)", () => {
|
|
|
51
51
|
assert.deepEqual(await dups(d), []);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it("standalone scripts each defining main are not duplicate exports; a real duplicate still is", async () => {
|
|
55
|
+
const d = repo({ "scripts/openapi_diff.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" }, { "scripts/seed_minimal.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" });
|
|
56
|
+
assert.deepEqual(await dups(d), []);
|
|
57
|
+
const e = repo({ "svc/pyproject.toml": "", "svc/a.py": "def main():\n pass\n" }, { "svc/b.py": "def main():\n pass\n\ndef resolve_x():\n pass\n" });
|
|
58
|
+
assert.deepEqual(await dups(e), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
54
61
|
it("Python modules of one package collide too; unreadable git yields []", async () => {
|
|
55
62
|
const d = repo({ "svc/pyproject.toml": "", "svc/a.py": "def resolve_tenant():\n pass\n" }, { "svc/b.py": "def resolve_tenant():\n pass\n" });
|
|
56
63
|
assert.deepEqual(await dups(d), [{ symbol: "resolve_tenant", path: "svc/b.py", others: ["svc/a.py"] }]);
|
package/bin/lean/preflight.mjs
CHANGED
|
@@ -131,6 +131,27 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
|
|
|
131
131
|
return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* #4857: validate commands that touch a path under a configured forge.yaml `verification.test_runners` prefix
|
|
136
|
+
* ({"services/worker": "poetry -C services/worker run"}) without using that runner, one problem per validate id. Pure.
|
|
137
|
+
*/
|
|
138
|
+
export function testRunnerProblems(contract, runners) {
|
|
139
|
+
const entries = Object.entries(runners && typeof runners === "object" ? runners : {}).filter(([k, v]) => k && typeof v === "string" && v.trim());
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
|
|
142
|
+
if (!v?.id || typeof v.command !== "string") continue;
|
|
143
|
+
const cmd = v.command.replace(/\s+/g, " ");
|
|
144
|
+
for (const [prefix, runner] of entries) {
|
|
145
|
+
const p = prefix.replace(/^\.?\/+|\/+$/g, "");
|
|
146
|
+
if (new RegExp(`(^|[\\s=:'"])(\\./)?${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).test(cmd) && !cmd.includes(runner.trim().replace(/\s+/g, " "))) {
|
|
147
|
+
out.push(`${v.id}: touches ${p}/ but does not run through that path's configured test runner (forge.yaml verification.test_runners: \`${runner.trim()}\`); use it, with paths relative to its cwd`);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
const FIX_HOW = "run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install";
|
|
135
156
|
/** One contract problem per miss: names the validate ids and the tool (or path, or env var), and how to fix the plan (never a global install). */
|
|
136
157
|
export function preflightProblems(misses) {
|
|
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
7
|
+
import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
8
8
|
import { runCheckCommand } from "./effects.mjs";
|
|
9
9
|
import { repoTracks } from "./conformance.mjs";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
@@ -106,3 +106,17 @@ describe("#4393 validate preflight: running the command on base", () => {
|
|
|
106
106
|
assert.deepEqual(r.misses.map((m) => [m.ids[0], m.tool]), [["V1", "absent-pkg-4393"]]);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe("testRunnerProblems (#4857)", () => {
|
|
111
|
+
const runners = { "services/worker": "poetry -C services/worker run" };
|
|
112
|
+
it("flags a validate on a mapped path that skips its runner", () => {
|
|
113
|
+
const p = testRunnerProblems(C("python3 -m pytest services/worker/tests/test_a.py"), runners);
|
|
114
|
+
assert.equal(p.length, 1);
|
|
115
|
+
assert.match(p[0], /V1: touches services\/worker\//);
|
|
116
|
+
});
|
|
117
|
+
it("accepts the runner, other paths, and no config", () => {
|
|
118
|
+
assert.deepEqual(testRunnerProblems(C("poetry -C services/worker run pytest tests/test_a.py"), runners), []);
|
|
119
|
+
assert.deepEqual(testRunnerProblems(C("node --test bin/lean/x.test.mjs"), runners), []);
|
|
120
|
+
assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export const syncChecks = (mode = "local") => (mode === "ci" ? `${LIGHT} (the en
|
|
|
43
43
|
/** CI failed on the pushed branch (#4167): one fix round in the builder's own session. */
|
|
44
44
|
export function ciFailurePrompt(failures, mode = "local") {
|
|
45
45
|
const list = failures.slice(0, 10).map((f) => `#### ${f.job ?? "check"}${f.url ? ` (${f.url})` : ""}\n${f.log ? `\`\`\`\n${f.log}\n\`\`\`` : "(no log available: open the url)"}`).join("\n\n");
|
|
46
|
-
return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
46
|
+
return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). A failing check whose files this diff does not touch (a flaky or timing-dependent test) is not an amendment: name the check and why it is unrelated in ${IMPLEMENTATION_FILE} and change no code for it; unknown amendment kinds are rejected. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** The repo's declared check commands (forge.yaml learned.test_commands) failed before the PR (#4240): fixed in-session. */
|
|
@@ -182,6 +182,15 @@ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as writt
|
|
|
182
182
|
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
183
183
|
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
184
184
|
|
|
185
|
+
/** #4959: reviewer-only (kept out of REVIEW_RUBRIC, which the builder prompt shares and its token budget caps). */
|
|
186
|
+
const VERDICT_RULE = "A criterion asking for a validation or finding verdict (VALIDATED, FALSE_POSITIVE, INCONCLUSIVE) is met by the recorded verdict here (read by the engine from the lane's investigation) together with a regression or repro test in the diff; it needs no prose in the diff. A gap you yourself call minor is not a failing row.";
|
|
187
|
+
|
|
188
|
+
/** #4959: the verdict word of an investigation note's \`**Verdict**:\` line (uppercased), or null. */
|
|
189
|
+
export function investigationVerdict(md) {
|
|
190
|
+
const m = /^\*\*Verdict\*\*:\s*([A-Za-z_-]+)/m.exec(String(md ?? ""));
|
|
191
|
+
return m ? m[1].toUpperCase() : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
/** Builder call 2, same session: implement the frozen contract. */
|
|
186
195
|
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
|
|
187
196
|
const remote = mode === "ci" || mode === "hybrid";
|
|
@@ -234,6 +243,12 @@ export function contextPackNote(pack) {
|
|
|
234
243
|
}
|
|
235
244
|
|
|
236
245
|
/** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
|
|
246
|
+
/** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
|
|
247
|
+
export function unrunnableValidateNote(testRunners = null) {
|
|
248
|
+
const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
|
|
249
|
+
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
237
252
|
export function reproPlanNote(on) {
|
|
238
253
|
if (!on) return "";
|
|
239
254
|
return `\n\nThis issue is a bug. When it is testable, ${CONTRACT_FILE} must also carry "repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"}: a test that fails on the base and passes after the fix. The engine proves it with scripts/acceptance-red-green.sh (head's changed test files are overlaid on the base) and fails the run if it is not red on base and green on head. When it is not testable (specs, docs, workflows, environment), write "repro_test":"none","repro_test_reason":"<one line why>" instead.`;
|
|
@@ -384,7 +399,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
|
|
|
384
399
|
|
|
385
400
|
You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
|
|
386
401
|
- issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
|
|
387
|
-
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
402
|
+
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. A requirement with \`amended_from\` was contracted defer or post-deploy and flipped to deliver: compare amended_from.quote and amended_from.why with amendment.text side by side, and mark the row partial when the rewording drops the original work or only trivially restates it. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
388
403
|
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
389
404
|
- post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
|
|
390
405
|
- scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
|
|
@@ -441,12 +456,12 @@ export function decisionNote(comments = []) {
|
|
|
441
456
|
return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
|
|
442
457
|
}
|
|
443
458
|
|
|
444
|
-
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
|
|
459
|
+
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null }) {
|
|
445
460
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
446
461
|
const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
|
|
447
462
|
notes.push(...decisions);
|
|
448
463
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
449
|
-
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
464
|
+
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
450
465
|
}
|
|
451
466
|
|
|
452
467
|
/**
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* {type:"ok", cancelled:false, finished:true}, nothing written
|
|
21
21
|
* {op:"hold"|"release", id, issues} → {type:"ok", changed, hosted} (#4592): merges into / removes from the batch's `hold` file; a hosted
|
|
22
22
|
* runner admits no held issue from its next pass (running lanes are not stopped)
|
|
23
|
-
* {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running}]} (#
|
|
23
|
+
* {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running, lastProgressAt, lastProgressAgeSec, pendingAdds}]} (#4775: also an unhosted batch with pending adds)
|
|
24
24
|
* Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
|
|
25
25
|
* never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
|
|
26
26
|
* report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
36
36
|
import { createConnection, createServer } from "node:net";
|
|
37
|
-
import { spawn } from "node:child_process";
|
|
38
|
-
import { tmpdir } from "node:os";
|
|
37
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
38
|
+
import { homedir, tmpdir } from "node:os";
|
|
39
39
|
import { createHash } from "node:crypto";
|
|
40
40
|
import { join, resolve } from "node:path";
|
|
41
41
|
import { fileURLToPath } from "node:url";
|
|
@@ -46,6 +46,10 @@ import { batchRoot, listBatches, openBatch, SECRET_ENV } from "./batch.mjs";
|
|
|
46
46
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
47
47
|
export const STOP_MS = 10 * 1000;
|
|
48
48
|
export const LOCK_CHECK_MS = 5 * 1000;
|
|
49
|
+
export const ADMIT_MS = 120 * 1000; // #4775: how long one add/requeue/attach/resume waits for its batch to start before answering an error
|
|
50
|
+
export const LAG_MS = 5 * 1000; // #4775: an event-loop stall longer than this is logged
|
|
51
|
+
export const REQUEST_MS = 15 * 1000; // #4775: default bound of a control/status request
|
|
52
|
+
export const UNIT = "forgedock-schedd";
|
|
49
53
|
export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
|
|
50
54
|
|
|
51
55
|
/** #4762: win32 `net` servers only accept named pipes, so the socket is a per-dir pipe there (it vanishes with its server). */
|
|
@@ -130,7 +134,7 @@ function takeLock(lock, pid, alive) {
|
|
|
130
134
|
*/
|
|
131
135
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
132
136
|
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
133
|
-
lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
|
|
137
|
+
lockCheckMs = LOCK_CHECK_MS, admitMs = Number(process.env.FORGEDOCK_SCHEDD_ADMIT_MS) || ADMIT_MS, lagMs = LAG_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
|
|
134
138
|
const p = scheddPaths(dir);
|
|
135
139
|
mkdirSync(dir, { recursive: true });
|
|
136
140
|
const lock = takeLock(p.lock, pid, alive);
|
|
@@ -176,8 +180,31 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
176
180
|
runs.set(run, ac);
|
|
177
181
|
});
|
|
178
182
|
|
|
183
|
+
/** #4775: wait for a batch start at most `admitMs`; the start itself goes on (a second host() would duplicate the batch). */
|
|
184
|
+
const bound = (p, what) => {
|
|
185
|
+
let t;
|
|
186
|
+
return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${what} did not start within ${admitMs} ms (schedd is busy; retry)`)), admitMs); t.unref?.(); })]).finally(() => clearTimeout(t));
|
|
187
|
+
};
|
|
188
|
+
const hostBounded = (args, first = null, what = "batch") => bound(host(args, first), what);
|
|
189
|
+
|
|
190
|
+
/** #4775: per batch id, last progress and pending adds: every hosted batch, plus any daemon batch holding adds it never took. */
|
|
191
|
+
const statusBatches = () => {
|
|
192
|
+
const now = Date.now(), out = [];
|
|
193
|
+
const ids = new Set(hosted.keys());
|
|
194
|
+
for (const id of starting.keys()) ids.add(id);
|
|
195
|
+
try { for (const id of listBatches(root)) if (!ids.has(id) && openBatch(root, id).readPlan()?.daemon) ids.add(id); } catch { /* unreadable root */ }
|
|
196
|
+
for (const id of [...ids].sort()) {
|
|
197
|
+
const isHosted = hosted.has(id) || starting.has(id);
|
|
198
|
+
let last = null, pending = [];
|
|
199
|
+
try { const b = openBatch(root, id); last = b.lastProgressMs(); pending = b.unrunAdds(); } catch { /* a half-written batch */ }
|
|
200
|
+
if (!isHosted && !pending.length) continue;
|
|
201
|
+
out.push({ id, running: isHosted, lastProgressAt: last === null ? null : new Date(last).toISOString(), lastProgressAgeSec: last === null ? null : Math.max(0, Math.round((now - last) / 1000)), pendingAdds: pending });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
};
|
|
205
|
+
|
|
179
206
|
const handle = async (req, send) => {
|
|
180
|
-
if (req.op === "status") return send({ type: "status", pid, lastTick, batches:
|
|
207
|
+
if (req.op === "status") return send({ type: "status", pid, lastTick, batches: statusBatches() }, true);
|
|
181
208
|
if (req.op === "set-parallel") {
|
|
182
209
|
const b = openBatch(root, req.id);
|
|
183
210
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
@@ -190,7 +217,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
190
217
|
const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
|
|
191
218
|
// #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
|
|
192
219
|
const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
|
|
193
|
-
if (reopen) await
|
|
220
|
+
if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
|
|
194
221
|
return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
195
222
|
}
|
|
196
223
|
if (req.op === "requeue") {
|
|
@@ -199,7 +226,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
199
226
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
200
227
|
const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
|
|
201
228
|
const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
|
|
202
|
-
if (reopen) await
|
|
229
|
+
if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
|
|
203
230
|
return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
204
231
|
}
|
|
205
232
|
if (req.op === "hold" || req.op === "release") {
|
|
@@ -222,7 +249,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
222
249
|
}
|
|
223
250
|
if (req.op === "attach") {
|
|
224
251
|
if (req.resume) { const rb = openBatch(root, req.id); if (rb.exists()) rb.clearCancel(); } // #4592: the operator's resume continues a cancelled batch the daemon still hosts
|
|
225
|
-
if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
|
|
252
|
+
if (!hosted.has(req.id) && starting.has(req.id)) await bound(starting.get(req.id), `batch ${req.id}`).catch((e) => { if (/did not start within/.test(e?.message)) throw e; });
|
|
226
253
|
const live = hosted.get(req.id);
|
|
227
254
|
if (live) {
|
|
228
255
|
// Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
|
|
@@ -236,7 +263,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
236
263
|
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
|
|
237
264
|
return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
|
|
238
265
|
}
|
|
239
|
-
const ent = await
|
|
266
|
+
const ent = await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub, `batch ${req.id}`);
|
|
240
267
|
return send({ type: "batch", id: ent.id, dir: ent.dir });
|
|
241
268
|
}
|
|
242
269
|
throw new Error(`unknown op ${JSON.stringify(req.op)}`);
|
|
@@ -277,6 +304,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
277
304
|
if (closing) return closing;
|
|
278
305
|
clearInterval(idleTimer);
|
|
279
306
|
clearInterval(lockTimer);
|
|
307
|
+
clearInterval(lagTimer);
|
|
280
308
|
clearInterval(beatTimer);
|
|
281
309
|
if (dueTimer) clearInterval(dueTimer);
|
|
282
310
|
for (const c of conns) c.destroy();
|
|
@@ -309,6 +337,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
309
337
|
else if (process.platform !== "win32" && !existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
|
|
310
338
|
}, lockCheckMs);
|
|
311
339
|
lockTimer.unref?.();
|
|
340
|
+
// #4775: an event loop starved by host load leaves sockets unanswered and children unreaped; leave a trace of it.
|
|
341
|
+
let lagAt = Date.now(), lagLogged = 0;
|
|
342
|
+
const lagTimer = setInterval(() => {
|
|
343
|
+
const now = Date.now(), lag = now - lagAt - 1000;
|
|
344
|
+
lagAt = now;
|
|
345
|
+
if (lag >= lagMs && now - lagLogged >= 60_000) { lagLogged = now; log(`schedd: event loop stalled ${Math.round(lag / 1000)}s (host overloaded?)`); }
|
|
346
|
+
}, 1000);
|
|
347
|
+
lagTimer.unref?.();
|
|
312
348
|
// #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
|
|
313
349
|
let dueRunning = false;
|
|
314
350
|
const dueTimer = due ? setInterval(async () => {
|
|
@@ -333,6 +369,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
333
369
|
log(`schedd: resuming batch ${id}`);
|
|
334
370
|
const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
|
|
335
371
|
starting.set(id, started);
|
|
372
|
+
const stall = setTimeout(() => log(`schedd: batch ${id} has not started after ${admitMs} ms (still resuming; other batches are served)`), admitMs);
|
|
373
|
+
stall.unref?.();
|
|
374
|
+
started.finally(() => clearTimeout(stall)).catch(() => {});
|
|
336
375
|
started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
|
|
337
376
|
}
|
|
338
377
|
}
|
|
@@ -362,8 +401,29 @@ export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s ===
|
|
|
362
401
|
on("exit", (code) => log(`schedd: exited (code ${code})`));
|
|
363
402
|
}
|
|
364
403
|
|
|
365
|
-
/**
|
|
366
|
-
|
|
404
|
+
/**
|
|
405
|
+
* #4775: state of the systemd-user unit (`active`, `activating`, `inactive`, ...), or null when no unit is installed or
|
|
406
|
+
* systemd is not usable here (then a client may spawn the daemon itself).
|
|
407
|
+
*/
|
|
408
|
+
export function unitState({ unitFile = join(homedir(), ".config", "systemd", "user", `${UNIT}.service`), exists = existsSync, run = execFileSync } = {}) {
|
|
409
|
+
if (!exists(unitFile)) return null;
|
|
410
|
+
try { return String(run("systemctl", ["--user", "is-active", UNIT], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })).trim() || null; }
|
|
411
|
+
catch (e) { return e?.code === "ENOENT" || e?.killed ? null : String(e?.stdout ?? "").trim() || null; } // is-active exits 3 and prints the state when not active
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Start the installed unit (best effort: the connect retry reports a failure). */
|
|
415
|
+
export function startUnit({ run = execFileSync } = {}) {
|
|
416
|
+
try { run("systemctl", ["--user", "start", "--no-block", UNIT], { timeout: 5000, stdio: "ignore" }); } catch { /* reported by the connect retry */ }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Connect to the daemon. When nobody listens it is started: a systemd-managed instance (`unitState()` active, activating
|
|
421
|
+
* or reloading: restarting) is waited for and never raced by a second daemon, an installed but stopped unit is started
|
|
422
|
+
* (`startUnit()`), and only without a unit does `spawnDaemon()` run (#4775). `spawnDaemon: null` never starts anything.
|
|
423
|
+
* @returns {Promise<import("node:net").Socket>}
|
|
424
|
+
*/
|
|
425
|
+
export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
426
|
+
unit = unitState, start = startUnit } = {}) {
|
|
367
427
|
const { sock } = scheddPaths(dir);
|
|
368
428
|
const tryConnect = () => new Promise((res, rej) => {
|
|
369
429
|
const c = createConnection(sock);
|
|
@@ -371,7 +431,9 @@ export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpa
|
|
|
371
431
|
c.once("error", rej);
|
|
372
432
|
});
|
|
373
433
|
try { return await tryConnect(); } catch (e) { if (!["ENOENT", "ECONNREFUSED"].includes(e?.code) || !spawnDaemon) throw e; }
|
|
374
|
-
|
|
434
|
+
const state = unit();
|
|
435
|
+
if (state === null) spawnDaemon({ dir });
|
|
436
|
+
else if (!["active", "activating", "reloading"].includes(state)) start();
|
|
375
437
|
const end = Date.now() + waitMs;
|
|
376
438
|
for (;;) {
|
|
377
439
|
await sleep(100);
|
|
@@ -396,10 +458,12 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
|
|
|
396
458
|
* Send one request and feed every reply to `onMsg`. Resolves with the last message (`done`, `ok`, `status`), rejects on
|
|
397
459
|
* `error` or when the daemon closes the stream first.
|
|
398
460
|
*/
|
|
399
|
-
export function request(sock, msg, onMsg = () => {}) {
|
|
461
|
+
export function request(sock, msg, onMsg = () => {}, { timeoutMs = 0 } = {}) {
|
|
400
462
|
return new Promise((resolve, reject) => {
|
|
401
|
-
let buf = "", last = null, settled = false;
|
|
402
|
-
const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };
|
|
463
|
+
let buf = "", last = null, settled = false, timer = null;
|
|
464
|
+
const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v); } };
|
|
465
|
+
// #4775: a bound for one-shot requests; a stalled daemon answers an error instead of hanging the caller (streams pass none)
|
|
466
|
+
if (timeoutMs > 0) timer = setTimeout(() => { sock.destroy(); finish(reject, Object.assign(new Error(`schedd did not answer within ${Math.round(timeoutMs / 1000)}s (not responding)`), { code: "schedd.timeout" })); }, timeoutMs);
|
|
403
467
|
sock.on("data", (d) => {
|
|
404
468
|
buf += d;
|
|
405
469
|
let i;
|