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.
- package/bin/engine/outcome-codes.mjs +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/orchestrate.md +2 -1
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- 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
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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 ?? []) {
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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
|
}
|