forgedock 1.49.0 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -0
  2. package/bin/engine-cli.mjs +123 -20
  3. package/bin/forgedock.mjs +31 -3
  4. package/bin/lean/batch-health.mjs +219 -0
  5. package/bin/lean/batch-health.test.mjs +156 -0
  6. package/bin/lean/batch.mjs +12 -3
  7. package/bin/lean/batch.test.mjs +304 -8
  8. package/bin/lean/conformance.mjs +79 -7
  9. package/bin/lean/conformance.test.mjs +63 -1
  10. package/bin/lean/context.mjs +8 -2
  11. package/bin/lean/context.test.mjs +11 -1
  12. package/bin/lean/credentials.mjs +18 -0
  13. package/bin/lean/execution.mjs +23 -9
  14. package/bin/lean/execution.test.mjs +93 -0
  15. package/bin/lean/fixclosereasons.mjs +52 -0
  16. package/bin/lean/fixclosereasons.test.mjs +64 -0
  17. package/bin/lean/heavy.mjs +14 -4
  18. package/bin/lean/heavy.test.mjs +55 -1
  19. package/bin/lean/lean.mjs +93 -36
  20. package/bin/lean/lean.test.mjs +143 -19
  21. package/bin/lean/live-execution.mjs +136 -0
  22. package/bin/lean/live-execution.test.mjs +83 -0
  23. package/bin/lean/orchestrate-blocked.test.mjs +47 -0
  24. package/bin/lean/orchestrate.mjs +135 -17
  25. package/bin/lean/prefilter.mjs +4 -3
  26. package/bin/lean/prefilter.test.mjs +5 -4
  27. package/bin/lean/prompts.mjs +4 -2
  28. package/bin/lean/recovery.test.mjs +5 -5
  29. package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
  30. package/bin/lean/sandbox-remote.mjs +6 -2
  31. package/bin/lean/schedd.mjs +9 -0
  32. package/bin/pool/agent.mjs +3 -1
  33. package/bin/pool/router.mjs +72 -7
  34. package/bin/review-staging.mjs +2 -0
  35. package/bin/runner.mjs +24 -0
  36. package/commands/orchestrate.md +2 -1
  37. package/commands/review-pr-staging.md +2 -2
  38. package/docs/CONFIG.md +39 -1
  39. package/docs/OUTCOME-CODES.md +2 -1
  40. package/package.json +1 -1
  41. package/pool/README.md +2 -0
  42. package/pool/image/cs-cred +8 -1
  43. package/scripts/review-staging-post.sh +70 -25
  44. package/scripts/review-staging-post.test.sh +40 -8
@@ -0,0 +1,136 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Live execution control of a running batch (#5138): `forgedock orchestrate --set-max-remote <id> N` and
5
+ * `--set-placement <id> remote|hybrid|local`, like `--set-parallel` for K. The override lives in the batch's `execution`
6
+ * file (batch.mjs `readExecution`/`setExecution`, JSON `{maxRemote?, mode?, provider?}`); the runner re-reads it on every
7
+ * admission through the getters `liveRemote` puts on the `remote` object orchestrate gets (`max`, `fallbackLocal`, `place`).
8
+ * Running lanes are never stopped: a lower `max_remote` only holds new remote admissions. A batch that started without a
9
+ * remote provider (`provider` not true: mode local, or `max_remote` 0) refuses `remote`/`hybrid`, since its provider,
10
+ * ledger and execution budget are built once at start; start a new batch for that.
11
+ */
12
+
13
+ export const LIVE_MODES = Object.freeze(["remote", "hybrid", "local"]);
14
+ const LIGHT_WEIGHT = Object.freeze({ heavy: false, signals: [] });
15
+
16
+ /** Validate a patch `{maxRemote?, mode?, provider?}`; throws on a bad value. @returns the cleaned patch. */
17
+ export function validateExecutionPatch(patch = {}) {
18
+ const out = {};
19
+ if (patch.maxRemote !== undefined) {
20
+ const n = typeof patch.maxRemote === "string" && /^\d+$/.test(patch.maxRemote.trim()) ? parseInt(patch.maxRemote, 10) : patch.maxRemote;
21
+ if (!Number.isInteger(n) || n < 0) throw new Error("max_remote must be a non-negative integer");
22
+ out.maxRemote = n;
23
+ }
24
+ if (patch.mode !== undefined) {
25
+ if (!LIVE_MODES.includes(patch.mode)) throw new Error(`placement must be one of ${LIVE_MODES.join("|")}`);
26
+ out.mode = patch.mode;
27
+ }
28
+ if (patch.provider !== undefined) {
29
+ if (typeof patch.provider !== "boolean") throw new Error("provider must be a boolean");
30
+ out.provider = patch.provider;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** The fields of a stored override that are valid (anything else is dropped); null when there is none. */
36
+ export function sanitizeOverride(o) {
37
+ if (!o || typeof o !== "object" || Array.isArray(o)) return null;
38
+ const out = {};
39
+ if (Number.isInteger(o.maxRemote) && o.maxRemote >= 0) out.maxRemote = o.maxRemote;
40
+ if (LIVE_MODES.includes(o.mode)) out.mode = o.mode;
41
+ if (typeof o.provider === "boolean") out.provider = o.provider;
42
+ return out;
43
+ }
44
+
45
+ /** The pinned forge.yaml `exec` with the live override's `maxRemote` and `mode` applied. */
46
+ export function effectiveExecution(exec, override) {
47
+ const o = sanitizeOverride(override) ?? {};
48
+ return { ...(exec ?? {}), ...(o.maxRemote !== undefined ? { maxRemote: o.maxRemote } : {}), ...(o.mode !== undefined ? { mode: o.mode } : {}) };
49
+ }
50
+
51
+ /**
52
+ * `--set-max-remote <id> N` / `--set-placement <id> MODE` in argv → `{id, patch}`, or null when neither flag is there.
53
+ * Throws on a missing id or a bad value.
54
+ */
55
+ export function parseSetExecution(argv = []) {
56
+ const at = (flag) => argv.indexOf(flag);
57
+ let id = null;
58
+ const patch = {};
59
+ for (const [flag, key] of [["--set-max-remote", "maxRemote"], ["--set-placement", "mode"]]) {
60
+ const i = at(flag);
61
+ if (i < 0) continue;
62
+ const bid = argv[i + 1], val = argv[i + 2];
63
+ if (!bid || val === undefined) throw new Error(`usage: forgedock orchestrate ${flag} <id> ${key === "mode" ? LIVE_MODES.join("|") : "N"}`);
64
+ if (id !== null && id !== bid) throw new Error("--set-max-remote and --set-placement must name the same batch");
65
+ id = bid;
66
+ patch[key] = key === "maxRemote" ? (/^\d+$/.test(val) ? parseInt(val, 10) : NaN) : val;
67
+ }
68
+ return id === null ? null : { id, patch: validateExecutionPatch(patch) };
69
+ }
70
+
71
+ /**
72
+ * Apply a patch to `batch` (batch.mjs openBatch): refused for a missing batch, and `remote`/`hybrid` for a batch whose
73
+ * `execution` file does not record `provider: true`. @returns the stored override.
74
+ */
75
+ export function applySetExecution(batch, patch) {
76
+ if (!batch.exists()) throw new Error(`no batch ${batch.id}`);
77
+ const p = validateExecutionPatch(patch);
78
+ if ((p.mode === "remote" || p.mode === "hybrid") && batch.readExecution()?.provider !== true) {
79
+ throw new Error(`batch ${batch.id} has no remote provider; placement ${p.mode} needs one: start a new batch with forge.yaml execution`);
80
+ }
81
+ return batch.setExecution(p);
82
+ }
83
+
84
+ /** Text for a stored override: `max_remote 2, placement hybrid`. */
85
+ export function formatOverride(o) {
86
+ const s = sanitizeOverride(o) ?? {};
87
+ return [s.maxRemote !== undefined ? `max_remote ${s.maxRemote}` : null, s.mode ? `placement ${s.mode}` : null].filter(Boolean).join(", ") || "no change";
88
+ }
89
+
90
+ /**
91
+ * Hybrid validation weight (#4256) that is computed only when first needed: `initial` (already computed) or `compute()`
92
+ * started on the first `get()`; until it resolves (or when it fails) a light weight is used.
93
+ */
94
+ export function lazyWeight({ initial = null, compute = null } = {}) {
95
+ let w = initial, started = initial !== null;
96
+ return {
97
+ get: () => {
98
+ if (!started && typeof compute === "function") {
99
+ started = true;
100
+ Promise.resolve().then(compute).then((r) => { w = r ?? LIGHT_WEIGHT; }, () => { w = LIGHT_WEIGHT; });
101
+ }
102
+ return w ?? LIGHT_WEIGHT;
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Put live `max`, `fallbackLocal` and `place` getters on `base` (the `remote` object orchestrate gets; never spread it
109
+ * afterwards, a spread freezes the getters). `place(exec, n)` places one lane under the effective exec (execution.mjs
110
+ * `placeLane` with the remote or hybrid facts); mode `local` places every new lane on the host. `onChange(exec)` is
111
+ * called once each time the effective `{mode, maxRemote}` differs from the last one seen (the pinned one first).
112
+ */
113
+ export function liveRemote(base, { exec, readOverride = () => null, place, onChange = null }) {
114
+ const keyOf = (e) => `${e.mode}|${e.maxRemote}`;
115
+ let last = keyOf(effectiveExecution(exec, null));
116
+ const current = () => {
117
+ let o = null;
118
+ try { o = readOverride() ?? null; } catch { o = null; }
119
+ const e = effectiveExecution(exec, o);
120
+ const k = keyOf(e);
121
+ if (k !== last) { last = k; try { onChange?.(e); } catch { /* advisory */ } }
122
+ return e;
123
+ };
124
+ return Object.defineProperties(base, {
125
+ max: { get: () => current().maxRemote, enumerable: true, configurable: true },
126
+ fallbackLocal: { get: () => { const e = current(); return e.mode === "hybrid" && Number.isInteger(e.maxRemote) && e.maxRemote >= 1; }, enumerable: true, configurable: true },
127
+ place: {
128
+ value: (n) => {
129
+ const e = current();
130
+ if (e.mode === "local") return { where: "local", reason: "live placement: local" };
131
+ return place(e, n);
132
+ },
133
+ enumerable: true, configurable: true, writable: true,
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { tmpDir } from "../tests/_tmp.mjs";
6
+ import { openBatch } from "./batch.mjs";
7
+ import { validateExecutionPatch, sanitizeOverride, effectiveExecution, parseSetExecution, applySetExecution, formatOverride, lazyWeight, liveRemote, LIVE_MODES } from "./live-execution.mjs";
8
+
9
+ describe("#5138 live execution control", () => {
10
+ it("validates patches: max_remote a non-negative integer, mode remote|hybrid|local", () => {
11
+ assert.deepEqual(LIVE_MODES, ["remote", "hybrid", "local"]);
12
+ assert.deepEqual(validateExecutionPatch({ maxRemote: 0, mode: "local" }), { maxRemote: 0, mode: "local" });
13
+ assert.deepEqual(validateExecutionPatch({ maxRemote: "3" }), { maxRemote: 3 });
14
+ for (const bad of [{ maxRemote: -1 }, { maxRemote: 1.5 }, { maxRemote: "x" }]) assert.throws(() => validateExecutionPatch(bad), /non-negative integer/);
15
+ assert.throws(() => validateExecutionPatch({ mode: "cloud" }), /placement must be one of/);
16
+ assert.throws(() => validateExecutionPatch({ provider: "yes" }), /boolean/);
17
+ assert.deepEqual(sanitizeOverride({ maxRemote: -2, mode: "x", provider: true }), { provider: true });
18
+ assert.equal(sanitizeOverride(null), null);
19
+ });
20
+
21
+ it("merges the override over the pinned exec", () => {
22
+ const exec = { mode: "remote", maxRemote: 4, provider: "container" };
23
+ assert.deepEqual(effectiveExecution(exec, null), exec);
24
+ assert.deepEqual(effectiveExecution(exec, { maxRemote: 1, mode: "hybrid" }), { mode: "hybrid", maxRemote: 1, provider: "container" });
25
+ assert.equal(formatOverride({ maxRemote: 2, mode: "hybrid" }), "max_remote 2, placement hybrid");
26
+ });
27
+
28
+ it("parses --set-max-remote / --set-placement", () => {
29
+ assert.equal(parseSetExecution(["--set-parallel", "b", "2"]), null);
30
+ assert.deepEqual(parseSetExecution(["--set-max-remote", "b1", "2"]), { id: "b1", patch: { maxRemote: 2 } });
31
+ assert.deepEqual(parseSetExecution(["--set-placement", "b1", "hybrid", "--set-max-remote", "b1", "0"]), { id: "b1", patch: { maxRemote: 0, mode: "hybrid" } });
32
+ assert.throws(() => parseSetExecution(["--set-max-remote", "b1", "-1"]), /non-negative/);
33
+ assert.throws(() => parseSetExecution(["--set-placement", "b1"]), /usage/);
34
+ assert.throws(() => parseSetExecution(["--set-placement", "b1", "x"]), /placement must be/);
35
+ assert.throws(() => parseSetExecution(["--set-placement", "b1", "local", "--set-max-remote", "b2", "1"]), /same batch/);
36
+ });
37
+
38
+ it("applySetExecution: missing batch and a provider-less batch going remote/hybrid are refused", () => {
39
+ const root = tmpDir("live-exec-");
40
+ assert.throws(() => applySetExecution(openBatch(root, "nope"), { mode: "local" }), /no batch nope/);
41
+ const b = openBatch(root, "b1");
42
+ b.writePlan({ argv: ["1"] });
43
+ for (const mode of ["remote", "hybrid"]) assert.throws(() => applySetExecution(b, { mode }), /no remote provider/);
44
+ assert.deepEqual(applySetExecution(b, { mode: "local", maxRemote: 0 }), { mode: "local", maxRemote: 0 });
45
+ b.setExecution({ provider: true });
46
+ assert.deepEqual(applySetExecution(b, { mode: "hybrid" }), { mode: "hybrid", maxRemote: 0, provider: true });
47
+ assert.deepEqual(b.readExecution(), { mode: "hybrid", maxRemote: 0, provider: true });
48
+ });
49
+
50
+ it("liveRemote getters follow the override file; local places on the host; onChange once per change", () => {
51
+ let o = null;
52
+ const changes = [], placed = [];
53
+ const r = liveRemote({ onBudget: "stop" }, { exec: { mode: "remote", maxRemote: 2 }, readOverride: () => o, onChange: (e) => changes.push(`${e.mode}/${e.maxRemote}`),
54
+ place: (e, n) => { placed.push([e.mode, n]); return { where: "remote", reason: `mode ${e.mode}` }; } });
55
+ assert.equal(r.max, 2);
56
+ assert.equal(r.fallbackLocal, false);
57
+ assert.deepEqual(r.place(1), { where: "remote", reason: "mode remote" });
58
+ o = { maxRemote: 5 };
59
+ assert.equal(r.max, 5);
60
+ o = { maxRemote: 5, mode: "hybrid" };
61
+ assert.equal(r.fallbackLocal, true);
62
+ r.place(2);
63
+ o = { maxRemote: 5, mode: "local" };
64
+ assert.deepEqual(r.place(3), { where: "local", reason: "live placement: local" });
65
+ assert.deepEqual(placed, [["remote", 1], ["hybrid", 2]]);
66
+ assert.deepEqual(changes, ["remote/5", "hybrid/5", "local/5"]);
67
+ assert.equal({ ...r }.onBudget, "stop");
68
+ });
69
+
70
+ it("lazyWeight is light until the computed weight resolves, and computes once", async () => {
71
+ let calls = 0;
72
+ const w = lazyWeight({ compute: async () => { calls++; return { heavy: true, signals: ["tsc"] }; } });
73
+ assert.deepEqual(w.get(), { heavy: false, signals: [] });
74
+ await new Promise((r) => setImmediate(r));
75
+ assert.deepEqual(w.get(), { heavy: true, signals: ["tsc"] });
76
+ w.get();
77
+ assert.equal(calls, 1);
78
+ assert.deepEqual(lazyWeight({ initial: { heavy: true, signals: [] } }).get(), { heavy: true, signals: [] });
79
+ const failing = lazyWeight({ compute: () => { throw new Error("scan"); } });
80
+ failing.get(); await new Promise((r) => setImmediate(r));
81
+ assert.deepEqual(failing.get(), { heavy: false, signals: [] });
82
+ });
83
+ });
@@ -120,3 +120,50 @@ describe("#5048: the pool is unreachable only after a failed capacity probe", ()
120
120
  assert.deepEqual(notes, ["down", "clear"]);
121
121
  });
122
122
  });
123
+
124
+ describe("#5162: failed roots hold their subtree; only real cycles are cycles", () => {
125
+ const memStore = () => { let st = null; return { load: () => st, save: (s) => { st = JSON.parse(JSON.stringify(s)); }, get: () => st }; };
126
+ const chain = { 1: "", 2: "**Depends on**: #1", 3: "**Depends on**: #2", 4: "**Depends on**: #1, #3" };
127
+ const outcome = (rows, n) => rows.find((r) => r.issue === n);
128
+
129
+ it("a DAG whose engine-error root ends marks no row dependency cycle; every dependent is held and names the root", async () => {
130
+ const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 2, pollMs: 5, readBody: async (n) => chain[n], engineErrorRetries: 0, transientRetries: 0,
131
+ runOne: async () => ({ terminalReason: "engine-error", detail: "boom" }) });
132
+ assert.ok(!rows.some((r) => /dependency cycle/.test(r.detail ?? "")), JSON.stringify(rows));
133
+ for (const n of [2, 3, 4]) { assert.equal(outcome(rows, n).outcome, "held"); assert.match(outcome(rows, n).detail, /^blocked by #1 \(engine-error\)$/); }
134
+ });
135
+
136
+ it("a genuine cycle is still labelled dependency cycle; its downstream names a member", async () => {
137
+ const bodies = { 1: "**Depends on**: #2", 2: "**Depends on**: #1", 3: "**Depends on**: #2" };
138
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 2, pollMs: 5, readBody: async (n) => bodies[n], runOne: async () => merged });
139
+ assert.deepEqual(rows.map((r) => r.outcome), ["skipped", "skipped", "skipped"]);
140
+ assert.equal(outcome(rows, 1).detail, "dependency cycle");
141
+ assert.equal(outcome(rows, 2).detail, "dependency cycle");
142
+ assert.match(outcome(rows, 3).detail, /^blocked by #[12] \(dependency cycle\)$/);
143
+ });
144
+
145
+ it("dependents of a needs-human root end held; after the label is removed a resume admits root then dependents in order", async () => {
146
+ const store = memStore();
147
+ const first = await orchestrate({ issues: [1, 2, 3, 4], parallel: 2, pollMs: 5, store, readBody: async (n) => chain[n], runOne: async () => ({ terminalReason: "needs-human", code: "ci.red", detail: "red" }) });
148
+ assert.equal(outcome(first, 1).outcome, "needs-human");
149
+ for (const n of [2, 3, 4]) assert.deepEqual([outcome(first, n).outcome, outcome(first, n).detail], ["held", "blocked by #1 (needs-human)"]);
150
+ const started = [];
151
+ const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, pollMs: 5, store, readBody: async (n) => chain[n], issueLabels: async () => [],
152
+ runOne: async (n) => { started.push(n); await new Promise((r) => setTimeout(r, 5)); return merged; } });
153
+ assert.deepEqual(started, [1, 2, 3, 4]);
154
+ assert.ok(rows.every((r) => r.outcome === "merged"), JSON.stringify(rows));
155
+ });
156
+
157
+ it("a resume with the needs-human label still on the root keeps the subtree held", async () => {
158
+ const store = memStore();
159
+ await orchestrate({ issues: [1, 2], parallel: 1, pollMs: 5, store, readBody: async (n) => chain[n], runOne: async () => ({ terminalReason: "needs-human", detail: "x" }) });
160
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, pollMs: 5, store, readBody: async (n) => chain[n], issueLabels: async () => ["needs-human"], runOne: async () => merged });
161
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "held"]);
162
+ });
163
+
164
+ it("the report lists a held subtree with its root and one --requeue command", async () => {
165
+ const { formatOutcomeLists } = await import("./orchestrate.mjs");
166
+ const lines = formatOutcomeLists([{ issue: 1, outcome: "engine-error" }, { issue: 2, outcome: "held", root: 1 }, { issue: 3, outcome: "held", root: 1 }]);
167
+ assert.ok(lines.some((l) => /held subtree: #1 \(engine-error\) holds #2 #3; release: forgedock orchestrate --requeue <batch> 1/.test(l)), lines.join("\n"));
168
+ });
169
+ });
@@ -20,8 +20,10 @@
20
20
  * A seam calls `onLive()` before it waits on a live lane (#4504); an adopt that resolves first is settled (or requeued on null)
21
21
  * before scheduling, so a live lane does not hold up the others.
22
22
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
23
- * limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
24
- * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
23
+ * limit (and #5168 a claude login failure, `backend.auth`) holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
24
+ * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784; `backend.auth`, #5168) stop after `maxDeferrals`.
25
+ * #5161: an engine-error whose detail is a transport/gateway failure (`isTransientEngineError`) is requeued with backoff
26
+ * (`transientRetryDelayMs`) up to `transientRetries` times, not counted against `ENGINE_ERROR_RELAUNCHES`; then it ends engine-error.
25
27
  * #5112: with `remote.offlineFallback` (`execution.fallback_local`), an issue deferred `remote.capacityFallbackAfter` times (default
26
28
  * `CAPACITY_FALLBACK_AFTER`) for `sandbox.capacity` runs on the host when `remote.headroom()` (host-busy guard) allows it.
27
29
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
@@ -31,6 +33,8 @@
31
33
  * runs local in hybrid (`fallbackLocal`, local account not held) or waits for the reset; admission is not held. An `engine-error` is
32
34
  * relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
33
35
  * rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
36
+ * #5138: `remote.max`, `remote.fallbackLocal` and `remote.place` are read on every grant too (engine-cli puts live getters on
37
+ * them, live-execution.mjs), so `--set-max-remote` / `--set-placement` change a running batch; running lanes are not stopped.
34
38
  * `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
35
39
  * it reads one `{list, elsewhere}` and adds the open children (`**Parent**: #P`, sub-issues) of every batch issue and `epics` through the same
36
40
  * path as `getAdded` (one decision and one `added` row listing the edges); mode `off` also stops the decomposed-lane join.
@@ -126,6 +130,27 @@ import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_PO
126
130
 
127
131
  // A dependency that finished like this no longer blocks its dependents.
128
132
  const SATISFIED = new Set(["merged", "invalid"]);
133
+ // #5162: a dependent of a root that ended like this is held (resumable), not skipped.
134
+ const HOLD_ROOT = new Set(["needs-human", "engine-error", "deferred", "budget-exceeded"]);
135
+ /** #5162: members of a real dependency cycle (a nontrivial SCC or a self-loop) among `nodes`; `edgesOf(n)` lists n's dependencies. */
136
+ export function cyclicNodes(nodes, edgesOf) {
137
+ const inSet = new Set(nodes), idx = new Map(), low = new Map(), stack = [], on = new Set(), out = new Set();
138
+ let i = 0;
139
+ const visit = (v) => {
140
+ idx.set(v, i); low.set(v, i); i++; stack.push(v); on.add(v);
141
+ for (const w of edgesOf(v).filter((d) => inSet.has(d))) {
142
+ if (w === v) out.add(v);
143
+ if (!idx.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); } else if (on.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
144
+ }
145
+ if (low.get(v) === idx.get(v)) {
146
+ const comp = [];
147
+ for (let w; w !== v;) { w = stack.pop(); on.delete(w); comp.push(w); }
148
+ if (comp.length > 1) for (const c of comp) out.add(c);
149
+ }
150
+ };
151
+ for (const n of nodes) if (!idx.has(n)) visit(n);
152
+ return out;
153
+ }
129
154
  const WATCH_EVERY_MS = 60_000; // #4595
130
155
  // Times one issue may be requeued behind a dependency before its dependency-open row is final.
131
156
  export const MAX_REQUEUES = 2;
@@ -144,6 +169,15 @@ export const CAPACITY_FALLBACK_AFTER = 3;
144
169
  export const RATE_LIMIT_FALLBACK_S = 15 * 60;
145
170
  export const DEFER_BACKOFF_S = 120;
146
171
  export const DEFER_BACKOFF_MAX_S = 30 * 60;
172
+ // #4685 + #5161: an engine-error detail that names a link or gateway failure (curl's "returned error: 502", HTTP/1.1 503, ...).
173
+ export const TRANSPORT_TEXT = /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|HTTP 50[234]|returned error: 5\d\d|HTTP\/\d(\.\d)? 5\d\d|Bad Gateway|Service Unavailable|overloaded/i;
174
+ /** #5161: a transient engine failure (model gateway 5xx, connection reset): retried with backoff, not the single relaunch. */
175
+ export const isTransientEngineError = (detail) => TRANSPORT_TEXT.test(String(detail ?? ""));
176
+ // #5161: transient engine-error retries per issue (forge.yaml `orchestration.transient_retries`) and their backoff: 30s, 2m, 8m, then 15m.
177
+ export const DEFAULT_TRANSIENT_RETRIES = 4;
178
+ export const TRANSIENT_BACKOFF_S = 30;
179
+ export const TRANSIENT_BACKOFF_MAX_S = 15 * 60;
180
+ export const transientRetryDelayMs = (k) => Math.min(TRANSIENT_BACKOFF_S * 4 ** (Math.max(1, k) - 1), TRANSIENT_BACKOFF_MAX_S) * 1000;
147
181
  // A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
148
182
  export const DEP_REQUEUE_DELAY_S = 60;
149
183
 
@@ -202,6 +236,8 @@ export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_
202
236
  const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
203
237
  return { delayMs: at - now, hold: true };
204
238
  }
239
+ // #5168: a failed claude login is an account condition, not an engine fault: held like a usage limit, never capped.
240
+ if (code === "backend.auth") return { delayMs: (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000, hold: true };
205
241
  const k = deferrals + 1;
206
242
  if (k > maxDeferrals) return { capped: true };
207
243
  if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
@@ -265,7 +301,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
265
301
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
266
302
  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,
267
303
  estimate = null, starveMs = DEFAULT_STARVE_MS, setupConcurrency = DEFAULT_SETUP_CONCURRENCY, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
268
- getChildren = null, autoChildren = null, baseWait = null, rotator = null }) {
304
+ getChildren = null, autoChildren = null, baseWait = null, rotator = null, transientRetries = DEFAULT_TRANSIENT_RETRIES }) {
269
305
  const saved = store?.load?.() ?? null;
270
306
  const order = saved ? [...saved.order] : [];
271
307
  const set = new Set(order);
@@ -282,6 +318,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
282
318
  const capDefers = toMap(saved?.capDeferrals); // #5112: issue -> `sandbox.capacity` deferrals (uncapped, so not in `deferrals`)
283
319
  const capFellBack = new Set(); // #5112: issues already reported as a capacity fallback (one decision each)
284
320
  const engineErrors = toMap(saved?.engineErrors);
321
+ const transients = toMap(saved?.transientRetries); // #5161: issue -> transient engine-error retries taken
322
+ const transientMax = Number.isInteger(transientRetries) && transientRetries >= 0 ? transientRetries : DEFAULT_TRANSIENT_RETRIES;
285
323
  const checked = toMap(saved?.prefilter); // issue -> pre-filter result
286
324
  const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
287
325
  const enqueued = toMap(saved?.enqueued); // #4409: issue -> when it last entered the queue (starvation guard)
@@ -319,7 +357,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
319
357
  const integHeld = new Map(); // issue -> reason (red integration streak on its milestone lane, #4667) holding it this pass
320
358
  const reg = sched?.registry ?? null;
321
359
  const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
322
- const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
360
+ // #5138: read on every grant, so `--set-max-remote` reaches a running batch (a getter on `remote`); running lanes are never stopped.
361
+ const remoteMax = () => { const m = remote?.max; return Number.isInteger(m) && m >= 0 ? m : 0; };
362
+ const remoteCapped = new Set(); // #5138: issues waiting for a max_remote slot; polled so a raised live max_remote admits them
323
363
  let poolOfflineSince = null, poolStallNoted = false; // #4788: when sampled online first read 0, and whether the stall was reported
324
364
  const capWait = makeCapWait({ now }); // #4909: a lane refused for capacity waits for a free slot, recorded as queue time
325
365
  let capAt, capAdmits = 0; // #4540: provider capacity sample and the remote admissions made since it
@@ -365,7 +405,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
365
405
  const save = () => {
366
406
  if (!store || halted) return;
367
407
  store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
368
- pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}), ...(capDefers.size ? { capDeferrals: toObj(capDefers) } : {}),
408
+ pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(transients.size ? { transientRetries: toObj(transients) } : {}), ...(quota.size ? { quota: toObj(quota) } : {}), ...(capDefers.size ? { capDeferrals: toObj(capDefers) } : {}),
369
409
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
370
410
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
371
411
  ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
@@ -601,11 +641,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
601
641
  } else if (cap && Number.isInteger(cap.free)) {
602
642
  if (cap.at !== capAt) { capAt = cap.at; capAdmits = 0; }
603
643
  if (capWait.blocked(n, cap)) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
604
- if (capAdmits >= cap.free && remoteRun.size < remoteMax) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
644
+ if (capAdmits >= cap.free && remoteRun.size < remoteMax()) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
605
645
  }
606
- if (p.where === "remote" && remoteRun.size < remoteMax) { capWait.admitted(n); remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
607
- if (p.where === "remote" && !remote.fallbackLocal) return false;
608
- if (p.where === "remote") p = { where: "local", reason: `fallback to local: max_remote ${remoteMax} reached (${p.reason})` }; // #4256: hybrid runs it on the host
646
+ if (p.where === "remote" && remoteRun.size < remoteMax()) { remoteCapped.delete(n); capWait.admitted(n); remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
647
+ if (p.where === "remote" && !remote.fallbackLocal) { remoteCapped.add(n); return false; }
648
+ if (p.where === "remote") p = { where: "local", reason: `fallback to local: max_remote ${remoteMax()} reached (${p.reason})` }; // #4256: hybrid runs it on the host
609
649
  } else {
610
650
  noteBudget(adm.reason);
611
651
  if (remote.onBudget !== "local" && !remote.fallbackLocal) { budgetStop = adm.reason; return false; }
@@ -629,15 +669,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
629
669
  results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail, ...(trigger !== null && dispatchCheck ? { waiting: true } : {}) });
630
670
  update({ ...results.get(n), ...(trigger !== null ? { trigger } : {}) });
631
671
  };
672
+ // #5162: a dependent of a failed root ends held (trigger = the root, not the parent) and is released with the root.
673
+ const holdBehind = (n, root, rootOutcome) => {
674
+ results.set(n, { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: `blocked by #${root} (${rootOutcome})`, root, ...(dispatchCheck ? { waiting: true } : {}) });
675
+ update({ ...results.get(n), trigger: root });
676
+ };
632
677
  const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
633
- const TRANSPORT_TEXT = /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|HTTP 50[234]/i;
634
678
  const TRANSPORT_COOLDOWN_MS = 5 * 60000;
635
679
  let transportErrors = 0; // #4685: engine-errors in the current window that were link failures
636
680
  const noteEngineError = (detail = "") => {
637
681
  if (!breaker) return;
638
682
  const t = now(), windowMs = breaker.window_min * 60000;
639
683
  errorTimes = [...errorTimes.filter((x) => t - x < windowMs), t];
640
- transportErrors = TRANSPORT_TEXT.test(String(detail)) ? Math.min(transportErrors + 1, errorTimes.length) : 0;
684
+ transportErrors = isTransientEngineError(detail) ? Math.min(transportErrors + 1, errorTimes.length) : 0;
641
685
  if (!tripped && errorTimes.length >= breaker.errors) {
642
686
  tripped = { at: t, ...(transportErrors >= errorTimes.length ? { transport: true } : {}), detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
643
687
  update({ issue: null, outcome: "breaker", detail: tripped.detail });
@@ -753,7 +797,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
753
797
  resumeAt, rotations: (q.rotations ?? 0) + (rq?.rotated ? 1 : 0) });
754
798
  requeue(n, delayMs); done(); save();
755
799
  const iso = new Date(resumeAt).toISOString();
756
- update({ ...row, outcome: "paused", quota: { resumeAt: iso }, detail: `paused (quota) until ${iso}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
800
+ update({ ...row, outcome: "paused", quota: { resumeAt: iso }, detail: `paused (${r?.code === "backend.auth" ? "backend.auth" : "quota"}) until ${iso}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
757
801
  return;
758
802
  }
759
803
  requeue(n, capGated ? 0 : delayMs); done(); save();
@@ -764,7 +808,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
764
808
  }
765
809
  if (row.outcome === "engine-error") {
766
810
  noteEngineError(r?.detail);
767
- if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
811
+ // #5161: a transient failure (gateway 5xx, reset) waits out the outage with backoff instead of the single immediate relaunch.
812
+ if (transientMax > 0 && isTransientEngineError(row.detail)) {
813
+ const k = (transients.get(n) ?? 0) + 1;
814
+ if (k <= transientMax) {
815
+ const delayMs = transientRetryDelayMs(k), secs = Math.round(delayMs / 1000);
816
+ transients.set(n, k);
817
+ requeue(n, delayMs); done(); save();
818
+ decided({ issue: n, what: `retry after transient engine-error (${k}/${transientMax}) in ${secs}s`, why: `transient: ${firstLine(row.detail) || "engine-error"}`,
819
+ alternative: "end the issue engine-error and skip its dependents" });
820
+ update({ ...row, detail: `transient retry ${k}/${transientMax} in ${secs}s: ${row.detail ?? ""}` });
821
+ return;
822
+ }
823
+ row.detail = `gave up after ${transientMax} transient retries: ${row.detail ?? ""}`;
824
+ } else if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
768
825
  engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
769
826
  requeue(n, 0); done(); save();
770
827
  decided({ issue: n, what: "relaunch once after engine-error", why: firstLine(row.detail) || "engine-error", alternative: "end the issue engine-error and skip its dependents" });
@@ -1035,12 +1092,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1035
1092
  results.delete(e.issue);
1036
1093
  for (let i = 0; i < moved.length; i++) { // dependents skipped behind a requeued issue (fixpoint)
1037
1094
  for (const [m, r] of [...results]) {
1038
- if (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i]) continue;
1095
+ const heldBehind = r?.outcome === "held" && r.root === moved[i]; // #5162
1096
+ if (!heldBehind && (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i])) continue;
1039
1097
  priors.set(m, r); results.delete(m); moved.push(m);
1040
1098
  }
1041
1099
  }
1042
1100
  for (const m of moved) {
1043
- for (const x of [requeues, deferrals, capDefers, engineErrors, recoveries, recovering]) x.delete(m);
1101
+ for (const x of [requeues, deferrals, capDefers, engineErrors, transients, recoveries, recovering]) x.delete(m);
1044
1102
  relabelled.delete(m);
1045
1103
  if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
1046
1104
  if (!pending.includes(m)) requeue(m, 0);
@@ -1178,7 +1236,45 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1178
1236
  const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
1179
1237
  const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
1180
1238
  const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
1239
+ // #5162: held rows behind `root` return to pending (and the root too when `withRoot`: requeued, counters reset), parents first via the edges.
1240
+ const releaseHeld = (root, withRoot, why) => {
1241
+ const moved = withRoot && results.has(root) ? [root] : [];
1242
+ if (moved.length) results.delete(root);
1243
+ for (const [m, r] of [...results]) if (r?.outcome === "held" && r.root === root) { results.delete(m); moved.push(m); }
1244
+ for (const m of moved) {
1245
+ for (const x of [requeues, deferrals, capDefers, engineErrors, transients, recoveries, recovering]) x.delete(m);
1246
+ relabelled.delete(m);
1247
+ if (!pending.includes(m)) requeue(m, 0);
1248
+ update({ issue: m, outcome: "readmitted", detail: m === root ? why : `dependency #${root} ${why}`, trigger: root });
1249
+ }
1250
+ if (moved.length) { save(); decided({ issue: root, what: `released held subtree of #${root}: ${moved.filter((m) => m !== root).map((m) => `#${m}`).join(", ") || "none"}`, why, alternative: "leave it held until a --requeue" }); }
1251
+ };
1252
+ const restoredHeld = new Set([...results.values()].filter((r) => r?.outcome === "held" && Number.isInteger(r.root)).map((r) => r.root)); // roots held by an earlier run of this batch
1253
+ const watchHeldRoots = async () => {
1254
+ for (const root of new Set([...results.values()].filter((r) => r?.outcome === "held" && Number.isInteger(r.root)).map((r) => r.root))) {
1255
+ const ro = results.get(root)?.outcome;
1256
+ if (ro === undefined || running.has(root) || settled.has(root)) continue;
1257
+ if (restoredHeld.has(root) && ["needs-human", "budget-exceeded"].includes(ro) && typeof issueLabels === "function") { // label removed: requeue the root, its subtree follows
1258
+ let names = null;
1259
+ try { names = await issueLabels(root); } catch { names = null; }
1260
+ if (Array.isArray(names) && !names.includes("needs-human")) { releaseHeld(root, true, "needs-human label removed"); continue; }
1261
+ }
1262
+ if (!dispatchCheck) continue;
1263
+ let g = null;
1264
+ try { g = (await dispatchCheck(root)) ?? null; } catch { g = null; }
1265
+ if (g?.final) { // closed as not planned: terminal for its held dependents
1266
+ settled.add(root);
1267
+ for (const [m, r] of [...results]) if (r?.outcome === "held" && r.root === root && r.waiting) { const { waiting, ...rest } = r; results.set(m, rest); update({ ...rest, trigger: root }); }
1268
+ continue;
1269
+ }
1270
+ if (g && SATISFIED.has(g.outcome)) { // merged or closed outside the batch
1271
+ results.set(root, { ...results.get(root), outcome: g.outcome, pr: Number.isInteger(g.pr) ? g.pr : results.get(root).pr, detail: g.detail ?? results.get(root).detail });
1272
+ update(results.get(root)); releaseHeld(root, false, "merged or closed");
1273
+ }
1274
+ }
1275
+ };
1181
1276
  const watchBlockers = async (final) => {
1277
+ if (!cancelled && !tripped && (final || lastWatch === null || now() - lastWatch >= WATCH_EVERY_MS) && [...results.values()].some((r) => r?.outcome === "held" && Number.isInteger(r.root))) await watchHeldRoots();
1182
1278
  if (!watchable() || cancelled || tripped) return;
1183
1279
  const t0 = now();
1184
1280
  if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
@@ -1236,7 +1332,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1236
1332
  const n = pending[i];
1237
1333
  if (results.has(n)) { pending.splice(i, 1); continue; }
1238
1334
  const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
1239
- if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`, bad); again = true; }
1335
+ if (bad !== undefined) {
1336
+ pending.splice(i, 1); again = true;
1337
+ const br = results.get(bad), root = br.root ?? bad, ro = results.get(root)?.outcome ?? br.outcome;
1338
+ if (HOLD_ROOT.has(ro)) holdBehind(n, root, ro); // #5162
1339
+ else skip(n, `dependency #${bad} ended ${br.outcome}`, bad);
1340
+ }
1240
1341
  }
1241
1342
  }
1242
1343
  const t = now();
@@ -1345,6 +1446,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1345
1446
  if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
1346
1447
  if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
1347
1448
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1449
+ if (pending.some((n) => remoteCapped.has(n))) wake = Math.min(wake ?? pollMs, pollMs); // #5138: a live --set-max-remote raise
1348
1450
  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
1349
1451
  if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
1350
1452
  if (baseWait && pending.length && baseHeld.size) wake = Math.min(wake ?? pollMs, pollMs); // #5031: a landed commit releases on the next tick
@@ -1366,8 +1468,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1366
1468
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
1367
1469
  else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
1368
1470
  else if (pending.length && (driftHeld.size || integHeld.size)) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch) or a red integration streak (#4667): pending for a later batch
1471
+ else if (pending.some((n) => remoteCapped.has(n))) await nap(wake ?? pollMs); // #5138: live max_remote 0: wait for a raise or another placement
1369
1472
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
1370
- for (const n of pending.splice(0)) skip(n, "dependency cycle");
1473
+ // #5162: only real cycles are labelled so; the rest name the cycle member (or failed root) that blocks them
1474
+ const cyc = cyclicNodes(pending, edges), inP = new Set(pending);
1475
+ const blocker = (n, seen = new Set()) => {
1476
+ for (const d of edges(n)) {
1477
+ if (cyc.has(d)) return d;
1478
+ if (inP.has(d) && !seen.has(d)) { seen.add(d); const b = blocker(d, seen); if (b !== null) return b; }
1479
+ }
1480
+ return null;
1481
+ };
1482
+ for (const n of pending.splice(0)) {
1483
+ const b = cyc.has(n) ? null : blocker(n);
1484
+ skip(n, cyc.has(n) ? "dependency cycle" : b !== null ? `blocked by #${b} (dependency cycle)` : "blocked: no dependency path is runnable");
1485
+ }
1371
1486
  save();
1372
1487
  }
1373
1488
  }
@@ -1438,6 +1553,9 @@ export function formatOutcomeLists(rows) {
1438
1553
  const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
1439
1554
  const requeued = (rows ?? []).filter((r) => r?.requeued); // #4550: operator --requeue after a decision; the row is the latest outcome
1440
1555
  if (requeued.length) out.push(`requeued: ${requeued.map((r) => `#${r.issue} (${one(r.requeued.reason)})`).join(" ")}`);
1556
+ const heldBy = new Map(); // #5162: held subtrees by root, one --requeue command each
1557
+ for (const r of rows ?? []) if (r?.outcome === "held" && Number.isInteger(r.root)) heldBy.set(r.root, [...(heldBy.get(r.root) ?? []), r.issue]);
1558
+ for (const [root, xs] of heldBy) { const ro = (rows ?? []).find((r) => r?.issue === root)?.outcome ?? "failed"; out.push(`held subtree: #${root} (${ro}) holds ${xs.map((x) => `#${x}`).join(" ")}; release: forgedock orchestrate --requeue <batch> ${root}`); }
1441
1559
  const rec = (rows ?? []).filter((r) => r?.recovered);
1442
1560
  if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
1443
1561
  for (const r of rows ?? []) {
@@ -455,11 +455,12 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
455
455
  return { ...out, detail: "acceptance probe failed after promotion (FORGE:PROBE): no already-fixed close; lane" };
456
456
  }
457
457
  const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
458
- const close = async (kind, body, detail) => {
458
+ // #5152: `completed` for a delivered outcome (CI green again, ALREADY-FIXED); only an INVALID verdict is dropped scope
459
+ const close = async (kind, body, detail, reason = "completed") => {
459
460
  let note = "";
460
461
  if (!dryRun) {
461
462
  // #4315: close first: the marker is posted only on an issue that was closed, so it always means "reopened" later
462
- await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
463
+ await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", reason]);
463
464
  // #4314: the issue IS closed from here on: the result is closed:true whatever the comment does, so no lane starts on it.
464
465
  // The comment is retried once, then a marker-only comment (a second, tiny payload) keeps the #4305 reopen guard working.
465
466
  const comment = (text) => io.gh(["issue", "comment", String(issue), "-R", repo, "--body", text]);
@@ -498,7 +499,7 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
498
499
  if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return keep(`candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane`);
499
500
  const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
500
501
  return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
501
- `${v.verdict.toLowerCase()} (${why})`);
502
+ `${v.verdict.toLowerCase()} (${why})`, v.verdict === "INVALID" ? "not planned" : "completed");
502
503
  } catch (e) {
503
504
  return { ...out, detail: `pre-filter error (${String(e?.message ?? e).slice(0, 120)}): lane` };
504
505
  }