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.
@@ -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 busy resource (`host.busy`,
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 busy resource (UNCAPPED_DEFERRALS) one issue may take before its deferred row is final.
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
- const k = deferrals + 1;
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
- if (!getAdded) return 0;
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 0;
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
@@ -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"] }]);
@@ -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
+ });
@@ -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
  /**
@@ -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}]} (#4780: `lastTick` = epoch ms of the last heartbeat; see schedd-health.mjs)
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: [...hosted.keys()].map((id) => ({ id, running: true })) }, true);
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 host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
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 host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
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 host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
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
- /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
366
- export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
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
- spawnDaemon({ dir });
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;