forgedock 1.50.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 +90 -9
- package/bin/forgedock.mjs +30 -2
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +3 -3
- package/bin/lean/batch.test.mjs +236 -8
- package/bin/lean/conformance.mjs +50 -5
- package/bin/lean/conformance.test.mjs +39 -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 +11 -6
- package/bin/lean/execution.test.mjs +28 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/lean.mjs +28 -17
- package/bin/lean/lean.test.mjs +74 -13
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +124 -12
- 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 +15 -1
- 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/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,156 @@
|
|
|
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { openBatch } from "./batch.mjs";
|
|
9
|
+
import { DEFAULT_HEALTH, parseHealthConfig, rootsOf, evaluateHealth, formatAlert, runCheck, scheduleChecks, scheduleLine, runNotify, deliverAlert,
|
|
10
|
+
recordRefusal, readRefusals, statusLines, REFUSAL_KEEP } from "./batch-health.mjs";
|
|
11
|
+
|
|
12
|
+
// #1 and #3 are roots; #2 depends on #1, #4 on #3.
|
|
13
|
+
const base = (results, extra = {}) => ({ order: [1, 2, 3, 4], deps: { 2: [1], 4: [3] }, results, running: {}, pending: [], ...extra });
|
|
14
|
+
|
|
15
|
+
describe("#5167 evaluateHealth", () => {
|
|
16
|
+
it("roots are the issues with no in-batch dependency", () => {
|
|
17
|
+
assert.deepEqual(rootsOf(base({})), [1, 3]);
|
|
18
|
+
assert.deepEqual(rootsOf({ order: [5, 6], deps: { 6: [99] } }), [5, 6], "an out-of-batch dependency keeps a root");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("all roots engine-error, the rest skipped: root-failed and terminal-share, with recovery commands", () => {
|
|
22
|
+
const st = base({ 1: { outcome: "engine-error" }, 2: { outcome: "skipped" }, 3: { outcome: "engine-error" }, 4: { outcome: "skipped" } });
|
|
23
|
+
const h = evaluateHealth(st, { batchId: "b1", repo: "o/r" });
|
|
24
|
+
assert.equal(h.healthy, false);
|
|
25
|
+
assert.deepEqual(h.causes.map((c) => c.code), ["root-failed", "terminal-share"]);
|
|
26
|
+
assert.deepEqual(h.causes[0].issues, [1, 3]);
|
|
27
|
+
assert.match(h.causes[1].detail, /4\/4 rows ended non-merged \(2 engine-error, 2 skipped\), 0 merged/);
|
|
28
|
+
assert.ok(h.recovery.includes(`forgedock orchestrate --requeue b1 1 3 2 4 --reason "batch unhealthy"`), h.recovery.join("\n"));
|
|
29
|
+
assert.ok(h.recovery.includes("forgedock orchestrate --cancel b1") && h.recovery.includes("forgedock status --batch b1"));
|
|
30
|
+
const text = formatAlert({ batchId: "b1", checkpoint: "T+15", health: h });
|
|
31
|
+
assert.match(text, /^BATCH UNHEALTHY b1 \(T\+15\)\n {2}cause: root-failed: root #1 engine-error, #3 engine-error \[#1, #3\]/);
|
|
32
|
+
assert.match(text, /\n {2}recovery:\n {4}forgedock orchestrate --requeue b1 1 3 2 4/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("a healthy batch (running, merged, or a requeued row waiting again) has no cause", () => {
|
|
36
|
+
assert.deepEqual(evaluateHealth(base({ 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [2, 4] })), { healthy: true, causes: [], recovery: [] });
|
|
37
|
+
assert.equal(evaluateHealth(base({ 1: { outcome: "merged" }, 2: { outcome: "merged" }, 3: { outcome: "invalid" }, 4: { outcome: "decomposed" } })).healthy, true);
|
|
38
|
+
assert.equal(evaluateHealth(base({ 1: { outcome: "engine-error" } }, { running: { 1: {} } })).healthy, true, "a relaunched root is running");
|
|
39
|
+
assert.equal(evaluateHealth(base({}, { phase: "setup", pending: [1, 2, 3, 4] })).healthy, true, "setup is not a stall");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("terminal-share needs 0 merged and the configured share; root_failed: false turns that cause off", () => {
|
|
43
|
+
const st = base({ 2: { outcome: "needs-human" }, 4: { outcome: "held" }, 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [4] });
|
|
44
|
+
assert.equal(evaluateHealth(st).healthy, true, "one merge keeps the share cause off");
|
|
45
|
+
const st2 = base({ 2: { outcome: "needs-human" }, 4: { outcome: "held" } }, { running: { 1: {}, 3: {} }, pending: [4] });
|
|
46
|
+
assert.deepEqual(evaluateHealth(st2).causes.map((c) => c.code), ["terminal-share"], "held counts even though it stays pending");
|
|
47
|
+
assert.equal(evaluateHealth(st2, { config: { ...DEFAULT_HEALTH, terminalShare: 0.75 } }).healthy, true);
|
|
48
|
+
const st3 = base({ 1: { outcome: "engine-error" } }, { running: { 3: {} }, pending: [2, 4] });
|
|
49
|
+
assert.equal(evaluateHealth(st3, { config: { ...DEFAULT_HEALTH, rootFailed: false } }).healthy, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stalled: 0 running with pending, operator-held issues excepted", () => {
|
|
53
|
+
const st = base({ 1: { outcome: "merged" } }, { pending: [2, 3, 4] });
|
|
54
|
+
const h = evaluateHealth(st, { batchId: "b2" });
|
|
55
|
+
assert.deepEqual(h.causes.map((c) => [c.code, c.issues]), [["stalled", [2, 3, 4]]]);
|
|
56
|
+
assert.ok(h.recovery.includes("forgedock orchestrate --resume b2"));
|
|
57
|
+
assert.equal(evaluateHealth(st, { held: [2, 3, 4] }).healthy, true);
|
|
58
|
+
assert.equal(evaluateHealth({ ...st, cancelled: true }).healthy, true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("a refused launch is unhealthy with the merge-or-force relaunch", () => {
|
|
62
|
+
const h = evaluateHealth(null, { refused: { issues: [7], message: "parallel milestone start refused: #7 depends on #9" }, repo: "o/r" });
|
|
63
|
+
assert.deepEqual(h.causes.map((c) => c.code), ["refused"]);
|
|
64
|
+
assert.deepEqual(h.recovery, ["merge the unmet dependencies, then: forgedock orchestrate 7 --repo o/r", `forgedock orchestrate 7 --repo o/r --force "<why the unmet dependencies are acceptable>"`]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("#5167 config", () => {
|
|
69
|
+
it("defaults, and orchestrate.health.* (flattened) plus notify.command", () => {
|
|
70
|
+
assert.deepEqual(parseHealthConfig(null), { ...DEFAULT_HEALTH, checksMin: [15, 60] });
|
|
71
|
+
const c = parseHealthConfig({ orchestrate: { root_failed: "false", terminal_share: "0.4", stalled: "false", issue: "#12", checks_min: "[10, 30]" }, notify: { command: "curl -d @- x" } });
|
|
72
|
+
assert.deepEqual(c, { rootFailed: false, terminalShare: 0.4, stalled: false, checksMin: [10, 30], issue: 12, notifyCommand: "curl -d @- x" });
|
|
73
|
+
assert.equal(parseHealthConfig({ orchestrate: { terminal_share: "2" } }).terminalShare, 0.5, "out of range keeps the default");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("#5167 checkpoints, alert-once and persistence", () => {
|
|
78
|
+
const mkBatch = (state) => {
|
|
79
|
+
const root = tmpDir("bh-"), b = openBatch(root, "b20260101-000000-1");
|
|
80
|
+
mkdirSync(b.dir, { recursive: true });
|
|
81
|
+
b.writePlan({ v: 1, id: b.id, argv: ["1"], created: "2026-01-01T00:00:00.000Z" });
|
|
82
|
+
b.store.save(state);
|
|
83
|
+
return { root, b };
|
|
84
|
+
};
|
|
85
|
+
const bad = base({ 1: { outcome: "engine-error" }, 2: { outcome: "skipped" }, 3: { outcome: "engine-error" }, 4: { outcome: "skipped" } });
|
|
86
|
+
|
|
87
|
+
it("the first unhealthy check alerts; later checks and a restart do not alert again", async () => {
|
|
88
|
+
const { root, b } = mkBatch(bad), alerts = [];
|
|
89
|
+
const alert = async (t) => { alerts.push(t); };
|
|
90
|
+
const r1 = await runCheck({ batch: b, checkpoint: "T+15", alert, now: () => Date.parse("2026-01-01T00:15:00Z") });
|
|
91
|
+
const r2 = await runCheck({ batch: openBatch(root, b.id), checkpoint: "T+60", alert });
|
|
92
|
+
await runCheck({ batch: b, checkpoint: "end", alert });
|
|
93
|
+
assert.deepEqual([r1.alerted, r2.alerted, alerts.length], [true, false, 1]);
|
|
94
|
+
const h = JSON.parse(readFileSync(join(b.dir, "health.json"), "utf8"));
|
|
95
|
+
assert.equal(h.alertedAt, "2026-01-01T00:15:00.000Z");
|
|
96
|
+
assert.deepEqual(Object.keys(h.checks), ["T+15", "T+60", "end"]);
|
|
97
|
+
assert.match(statusLines(root, { now: Date.parse("2026-01-01T01:00:00Z") }).join("\n"), /BATCH UNHEALTHY b20260101-000000-1 \(since 2026-01-01T00:15:00.000Z\): root #1 engine-error/);
|
|
98
|
+
const one = statusLines(root, { batch: b.id }).join("\n");
|
|
99
|
+
assert.match(one, /UNHEALTHY since/);
|
|
100
|
+
assert.match(one, /forgedock orchestrate --cancel b20260101-000000-1/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a healthy check records no alert, and a throwing channel never escapes", async () => {
|
|
104
|
+
const { b, root } = mkBatch(base({ 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [2, 4] }));
|
|
105
|
+
const r = await runCheck({ batch: b, checkpoint: "T+15", alert: async () => { throw new Error("x"); } });
|
|
106
|
+
assert.equal(r.healthy, true);
|
|
107
|
+
assert.equal(statusLines(root).length, 0);
|
|
108
|
+
assert.match(statusLines(root, { batch: b.id })[0], /healthy at T\+15/);
|
|
109
|
+
b.store.save(bad);
|
|
110
|
+
const r2 = await runCheck({ batch: b, checkpoint: "T+60", alert: async () => { throw new Error("gh down"); } });
|
|
111
|
+
assert.equal(r2.alerted, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("scheduleChecks arms only the checkpoints health.json has not recorded, relative to created", () => {
|
|
115
|
+
const { b } = mkBatch(bad);
|
|
116
|
+
writeFileSync(join(b.dir, "health.json"), JSON.stringify({ checks: { "T+15": "x" } }));
|
|
117
|
+
const armed = [];
|
|
118
|
+
const created = Date.parse("2026-01-01T00:00:00Z");
|
|
119
|
+
const s = scheduleChecks({ batch: b, created, now: () => created + 20 * 60000, setTimer: (fn, ms) => { armed.push(ms); return 1; }, clearTimer: () => armed.push("cleared"), check: () => {} });
|
|
120
|
+
s.cancel();
|
|
121
|
+
assert.deepEqual(armed, [40 * 60000, "cleared"]);
|
|
122
|
+
assert.equal(scheduleLine("b9", created), "health checks: T+15 00:15Z, T+60 01:00Z, and at batch end; forgedock status --batch b9");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("#5167 channels and refusals", () => {
|
|
127
|
+
it("runNotify pipes the alert JSON and env to the shell command; failures are returned", async () => {
|
|
128
|
+
const out = join(tmpDir("bh-n-"), "got");
|
|
129
|
+
const ok = await runNotify(`cat > ${out}; echo "$FORGEDOCK_ALERT $FORGEDOCK_ALERT_BATCH" >> ${out}`, { batch: "b1", text: "t" });
|
|
130
|
+
assert.deepEqual(ok, { ok: true });
|
|
131
|
+
const got = readFileSync(out, "utf8").split("\n");
|
|
132
|
+
assert.equal(JSON.parse(got[0]).batch, "b1");
|
|
133
|
+
assert.equal(got[1], "BATCH_UNHEALTHY b1");
|
|
134
|
+
assert.deepEqual(await runNotify("exit 3", { batch: "b1" }), { ok: false, error: "exit 3" });
|
|
135
|
+
assert.equal((await runNotify("sleep 5", {}, { timeoutMs: 50 })).ok, false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("deliverAlert comments on the configured issue, else the epics, and reports failed channels", async () => {
|
|
139
|
+
const calls = [], lines = [];
|
|
140
|
+
const sink = { line: (l) => lines.push(l) };
|
|
141
|
+
await deliverAlert({ text: "BATCH UNHEALTHY b1", batchId: "b1", sink, gh: async (a) => { calls.push(a[2]); }, repo: "o/r", epics: [5, 6, 5] });
|
|
142
|
+
await deliverAlert({ text: "BATCH UNHEALTHY b1", batchId: "b1", sink, gh: async (a) => { calls.push(a[2]); throw new Error("403"); }, repo: "o/r", config: { ...DEFAULT_HEALTH, issue: 9, notifyCommand: "x" }, epics: [5], notify: async () => ({ ok: false, error: "exit 1" }) });
|
|
143
|
+
assert.deepEqual(calls, ["5", "6", "9"]);
|
|
144
|
+
assert.deepEqual(lines.filter((l) => /^health:/.test(l)), ["health: comment on #9 failed (403)", "health: notify.command failed (exit 1)"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("refusals are kept under the batch root, pruned, and shown by status for a day", () => {
|
|
148
|
+
const root = tmpDir("bh-r-");
|
|
149
|
+
for (let i = 0; i < REFUSAL_KEEP + 3; i++) recordRefusal(root, { at: new Date(Date.parse("2026-01-01T00:00:00Z") + i * 1000).toISOString(), issues: [7], message: `refused ${i}` });
|
|
150
|
+
assert.equal(readRefusals(root, { maxAgeMs: Infinity }).length, REFUSAL_KEEP);
|
|
151
|
+
const now = Date.parse("2026-01-01T01:00:00Z");
|
|
152
|
+
assert.match(statusLines(root, { now }).at(-1), /^ {2}LAUNCH REFUSED 2026-01-01T00:00:22.000Z: refused 22$/);
|
|
153
|
+
assert.equal(statusLines(root, { now: now + 2 * 86400000 }).length, 0);
|
|
154
|
+
assert.ok(existsSync(join(root, "refusals.json")));
|
|
155
|
+
});
|
|
156
|
+
});
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -86,8 +86,8 @@ const readAdded = (file) => {
|
|
|
86
86
|
return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
-
/** `outcome`s `--requeue` may move back to pending (#4550). */
|
|
90
|
-
export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
89
|
+
/** `outcome`s `--requeue` may move back to pending (#4550); #5168: a final `engine-error` too (the runner clears its engine-error count); #5162: `held` rows too. */
|
|
90
|
+
export const REQUEUABLE = (outcome) => ["needs-human", "engine-error", "held", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
91
91
|
/** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
|
|
92
92
|
export const requeueKey = (e) => `${e.issue}@${e.at}`;
|
|
93
93
|
|
|
@@ -177,7 +177,7 @@ export function openBatch(root, id) {
|
|
|
177
177
|
},
|
|
178
178
|
/**
|
|
179
179
|
* `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
|
|
180
|
-
* `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
|
|
180
|
+
* `order`, no terminal result yet (`not terminal`; a final `engine-error` is terminal, #5168), or already requeued and not yet applied. The needs-human label and
|
|
181
181
|
* closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
|
|
182
182
|
*/
|
|
183
183
|
requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { resolveLane, makeLaneCache } from "./lane.mjs";
|
|
10
|
-
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER } from "./orchestrate.mjs";
|
|
10
|
+
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER, isTransientEngineError, transientRetryDelayMs, DEFAULT_TRANSIENT_RETRIES } from "./orchestrate.mjs";
|
|
11
11
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
|
|
12
12
|
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
13
13
|
|
|
@@ -265,9 +265,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
265
265
|
assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), true);
|
|
266
266
|
writeFileSync(join(pd, "stat"), "42 (no) de) Z 1 42 42 0 -1\n");
|
|
267
267
|
assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), false);
|
|
268
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, blockedLabel: "blocked", globalParallel: null, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
269
|
-
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
|
|
270
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, blockedLabel: "blocked", globalParallel: 4, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
268
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, transientRetries: 4, blockedLabel: "blocked", globalParallel: null, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
269
|
+
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n transient_retries: 2\n");
|
|
270
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, transientRetries: 2, blockedLabel: "blocked", globalParallel: 4, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
271
271
|
});
|
|
272
272
|
|
|
273
273
|
it("run-issue --result-file writes the lane result, also when the run throws", async () => {
|
|
@@ -794,7 +794,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
794
794
|
const mk = (issue, prs, events = "", { body = "{}", open = "[]" } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
795
795
|
if (a[0] === "issue" && a[a.length - 1] === "body") return body; // #4920: the lease lookup
|
|
796
796
|
if (a[0] === "pr" && a.includes("open")) return open; // #4920: the open lane PR lookup
|
|
797
|
-
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
|
|
797
|
+
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason,labels"]); if (issue instanceof Error) throw issue; return issue; }
|
|
798
798
|
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
799
799
|
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
800
800
|
if (prs instanceof Error) throw prs;
|
|
@@ -813,6 +813,42 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
813
813
|
assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
|
|
814
814
|
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
815
815
|
});
|
|
816
|
+
it("#5152 makeDispatchCheck: a merged investigation closed as partial (NOT_PLANNED + workflow:merged) satisfies its dependents", async () => {
|
|
817
|
+
// the blocker's lane PR is not on feat/issue-9-lean (legacy or renamed branch): only its labels say it was delivered
|
|
818
|
+
let closed = false;
|
|
819
|
+
const gh = async (a) => {
|
|
820
|
+
if (a[0] === "issue" && a[2] === "9" && a.includes("state,stateReason,labels")) {
|
|
821
|
+
return JSON.stringify(closed ? { state: "CLOSED", stateReason: "NOT_PLANNED", labels: [{ name: "workflow:merged" }] } : { state: "OPEN", labels: [] });
|
|
822
|
+
}
|
|
823
|
+
if (a[0] === "issue" && a[a.length - 1] === "body") return "{}";
|
|
824
|
+
if (a[0] === "pr") return "[]";
|
|
825
|
+
return "";
|
|
826
|
+
};
|
|
827
|
+
const dispatchCheck = makeDispatchCheck({ repo: "o/r", gh });
|
|
828
|
+
closed = true;
|
|
829
|
+
assert.deepEqual(await dispatchCheck(9), { outcome: "merged", detail: "issue closed, workflow:merged" }, "never final: delivered scope");
|
|
830
|
+
closed = false;
|
|
831
|
+
const ran = [];
|
|
832
|
+
const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), dispatchCheck,
|
|
833
|
+
runOne: async (n) => { ran.push(n); if (n === 9) { closed = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 101 }; } });
|
|
834
|
+
assert.ok(ran.includes(1), "the dependent runs instead of being skipped");
|
|
835
|
+
assert.deepEqual(rows.find((r) => r.issue === 1)?.outcome, "merged");
|
|
836
|
+
});
|
|
837
|
+
it("#5152 makeDispatchCheck: decomposed parents are satisfied once every sub-issue closed; only an unlabelled NOT_PLANNED is final", async () => {
|
|
838
|
+
const dec = '<!-- FORGE:DECOMPOSED -->\n## Split into sub-issues\n- #21 a\n- #22 b';
|
|
839
|
+
const mk = (labels, subs, { decomposed = dec } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
840
|
+
if (a[0] === "issue" && a[2] === "9") return JSON.stringify({ state: "CLOSED", stateReason: "NOT_PLANNED", labels });
|
|
841
|
+
if (a[0] === "issue" && ["21", "22"].includes(a[2])) return JSON.stringify({ state: subs[a[2]] });
|
|
842
|
+
if (a[0] === "api") { assert.equal(a.find((x) => String(x).includes("/issues/9/comments")), "repos/o/r/issues/9/comments", "pinned to the batch repo"); return JSON.stringify({ body: decomposed, author_association: "NONE", user: { type: "Bot", login: "forgedock[bot]" } }); }
|
|
843
|
+
if (a[0] === "pr") return "[]";
|
|
844
|
+
return "";
|
|
845
|
+
} });
|
|
846
|
+
const d = [{ name: "workflow:decomposed" }];
|
|
847
|
+
assert.deepEqual(await mk(d, { 21: "CLOSED", 22: "CLOSED" })(9), { outcome: "merged", detail: "issue decomposed; all sub-issues closed" });
|
|
848
|
+
assert.deepEqual(await mk(d, { 21: "CLOSED", 22: "OPEN" })(9), { outcome: "invalid", detail: "issue closed: decomposed, sub-issues still open" }, "moving: not final");
|
|
849
|
+
assert.deepEqual(await mk(d, {}, { decomposed: "no trail" })(9), { outcome: "invalid", detail: "issue closed: decomposed, sub-issues still open" }, "no trail: not final");
|
|
850
|
+
assert.deepEqual(await mk([{ name: "workflow:invalid" }], {})(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687 kept: dropped scope is final");
|
|
851
|
+
});
|
|
816
852
|
});
|
|
817
853
|
|
|
818
854
|
describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
|
|
@@ -933,7 +969,7 @@ describe("#4550: forgedock orchestrate --requeue", () => {
|
|
|
933
969
|
if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
|
|
934
970
|
return { terminalReason: "merged" };
|
|
935
971
|
} });
|
|
936
|
-
await until(() => log.some((r) => r.issue === 2 && r.outcome
|
|
972
|
+
await until(() => log.some((r) => r.issue === 2 && ["skipped", "held"].includes(r.outcome)));
|
|
937
973
|
entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
|
|
938
974
|
await until(() => started.filter((x) => x === 2).length === 1, 3000);
|
|
939
975
|
g3.open();
|
|
@@ -1105,7 +1141,7 @@ describe("#5112: late dependents of a decomposed issue, edge re-read on --requeu
|
|
|
1105
1141
|
if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
|
|
1106
1142
|
return n === 1 ? { terminalReason: "needs-human", detail: "builder.blocked: product-decision" } : { terminalReason: "merged" };
|
|
1107
1143
|
} });
|
|
1108
|
-
await until(() => log.some((r) => r.issue === 2 && r.outcome
|
|
1144
|
+
await until(() => log.some((r) => r.issue === 2 && ["skipped", "held"].includes(r.outcome)));
|
|
1109
1145
|
bodies[2] = ""; // the operator dropped the wrong edge
|
|
1110
1146
|
entries = [{ issue: 2, reason: "edge dropped", by: "operator", at: "2026-11-05T00:00:00Z" }];
|
|
1111
1147
|
await until(() => started.includes(2), 3000);
|
|
@@ -1413,3 +1449,195 @@ describe("#5138: live max_remote and placement of a running batch", () => {
|
|
|
1413
1449
|
assert.equal(b.readExecution().mode, "hybrid");
|
|
1414
1450
|
});
|
|
1415
1451
|
});
|
|
1452
|
+
|
|
1453
|
+
describe("#5161 transient engine-error retries", () => {
|
|
1454
|
+
const CURL_502 = 'claude CLI exited with status 1. Output: curl: (22) The requested URL returned error: 502 {"duration_api_ms":0}';
|
|
1455
|
+
// Launch times per issue under the fake clock.
|
|
1456
|
+
const launches = () => { const at = new Map(); return { at, note: (n, t) => at.set(n, [...(at.get(n) ?? []), t]) }; };
|
|
1457
|
+
const gaps = (ts) => ts.slice(1).map((t, i) => t - ts[i]);
|
|
1458
|
+
|
|
1459
|
+
it("#5161 the classifier: curl 502, HTTP/1.1 5xx, Bad Gateway, Service Unavailable, overloaded and resets are transient; code failures are not", () => {
|
|
1460
|
+
for (const d of [CURL_502, "HTTP/1.1 503 Service Unavailable", "HTTP/2 504", "502 Bad Gateway", "Service Unavailable", "API overloaded", "read ECONNRESET", "sandbox launch: fetch failed"])
|
|
1461
|
+
assert.equal(isTransientEngineError(d), true, d);
|
|
1462
|
+
for (const d of ["boom", "TypeError: x is undefined", "", null, undefined]) assert.equal(isTransientEngineError(d), false, String(d));
|
|
1463
|
+
assert.deepEqual([1, 2, 3, 4, 5].map(transientRetryDelayMs), [30_000, 120_000, 480_000, 900_000, 900_000]);
|
|
1464
|
+
assert.equal(DEFAULT_TRANSIENT_RETRIES, 4);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it("#5161 a curl 502 engine-error is retried with growing backoff and merges, not ended after one immediate relaunch", async () => {
|
|
1468
|
+
const { c, now, sleep } = fakeClock();
|
|
1469
|
+
const l = launches(), updates = [];
|
|
1470
|
+
let tries = 0;
|
|
1471
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, onUpdate: (r) => updates.push(r),
|
|
1472
|
+
runOne: async (n) => { l.note(n, c.t); if (++tries <= 3) throw new Error(CURL_502); return { terminalReason: "merged", pr: 7 }; } });
|
|
1473
|
+
assert.equal(rows[0].outcome, "merged");
|
|
1474
|
+
const g = gaps(l.at.get(1));
|
|
1475
|
+
assert.equal(g.length, 3);
|
|
1476
|
+
assert.ok(g[0] >= 30_000 && g[1] >= 120_000 && g[2] >= 480_000, `gaps ${g}`);
|
|
1477
|
+
assert.ok(!updates.some((u) => /relaunching once/.test(u.detail ?? "")));
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it("#5161 an always-502 issue is retried up to transientRetries with growing delays, then ends engine-error; one decided row per retry", async () => {
|
|
1481
|
+
const { c, now, sleep } = fakeClock();
|
|
1482
|
+
const l = launches(), decisions = [];
|
|
1483
|
+
const [row] = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, transientRetries: 2, decide: (d) => decisions.push(d),
|
|
1484
|
+
runOne: async (n) => { l.note(n, c.t); throw new Error(CURL_502); } });
|
|
1485
|
+
assert.equal(row.outcome, "engine-error");
|
|
1486
|
+
assert.match(row.detail, /gave up after 2 transient retries/);
|
|
1487
|
+
const g = gaps(l.at.get(1));
|
|
1488
|
+
assert.equal(g.length, 2);
|
|
1489
|
+
assert.ok(g[0] > 0 && g[1] > g[0], `gaps ${g}`);
|
|
1490
|
+
assert.equal(decisions.length, 2);
|
|
1491
|
+
assert.ok(decisions.every((d) => d.issue === 1 && /^transient: /.test(d.why) && /retry after transient engine-error \(\d\/2\)/.test(d.what)));
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it("#5161 a dependent stays pending while its root's transient retries are pending and runs after it merges", async () => {
|
|
1495
|
+
const { now, sleep } = fakeClock();
|
|
1496
|
+
const started = [], updates = [];
|
|
1497
|
+
let tries = 0;
|
|
1498
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), now, sleep, onUpdate: (r) => updates.push(r),
|
|
1499
|
+
runOne: async (n) => { started.push(n); if (n === 1 && ++tries <= 2) throw new Error(CURL_502); return { terminalReason: "merged", pr: 10 + n }; } });
|
|
1500
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
1501
|
+
assert.ok(!updates.some((u) => u.issue === 2 && u.outcome === "skipped"));
|
|
1502
|
+
assert.deepEqual(started, [1, 1, 1, 2]);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it("#5161 a non-transient engine-error keeps the single immediate relaunch", async () => {
|
|
1506
|
+
const { c, now, sleep } = fakeClock();
|
|
1507
|
+
const l = launches();
|
|
1508
|
+
const [row] = await orchestrate({ issues: [9], readBody: async () => "", now, sleep, runOne: async (n) => { l.note(n, c.t); throw new Error("boom"); } });
|
|
1509
|
+
assert.equal(row.outcome, "engine-error");
|
|
1510
|
+
assert.deepEqual(gaps(l.at.get(9)), [0]);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it("#5161 the breaker still counts transient engine-errors as transport errors (#4685)", async () => {
|
|
1514
|
+
const { now, sleep } = fakeClock();
|
|
1515
|
+
const saves = [];
|
|
1516
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 2, window_min: 30 },
|
|
1517
|
+
store: { load: () => null, save: (st) => saves.push(st) }, runOne: async () => { throw new Error(CURL_502); } });
|
|
1518
|
+
assert.equal(saves.map((st) => st.breaker).find(Boolean)?.transport, true);
|
|
1519
|
+
assert.ok(rows.some((r) => r.outcome === "held"));
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
describe("#5167 early batch health in runBatch", () => {
|
|
1524
|
+
// #1 and #3 are roots, #2 depends on #1. #3 runs until `release`; the captured T+15 timer fires while it runs.
|
|
1525
|
+
const mk = ({ fail }) => {
|
|
1526
|
+
const timers = [], comments = [], notes = [], gate = { open: null };
|
|
1527
|
+
const released = new Promise((r) => { gate.open = r; });
|
|
1528
|
+
const bodies = { 1: "", 2: "Depends on #1", 3: "" };
|
|
1529
|
+
const leanIo = { gh: async (a) => {
|
|
1530
|
+
if (a[0] === "issue" && a[1] === "comment") { if (/FORGE:BATCH-HEALTH/.test(a.at(-1))) comments.push(a); return ""; }
|
|
1531
|
+
if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ body: bodies[a[2]] ?? "", milestone: { title: "M Two" }, comments: [], author: { login: "x" } });
|
|
1532
|
+
return a[0] === "repo" ? "main\n" : "[]";
|
|
1533
|
+
}, git: async (a) => (a[0] === "ls-remote" ? "abc\tref\n" : "") };
|
|
1534
|
+
const deps = { dir: tmpDir("bh-run-"), batchRoot: tmpDir("bh-batch-"), allowTmpfs: true, emitTelemetry: async () => {}, leanIo, pollMs: 5, preflight: null,
|
|
1535
|
+
io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ body: bodies[a[2]] ?? "", state: "OPEN" }) : "o/r\n"), git: async () => "" },
|
|
1536
|
+
healthConfig: { rootFailed: true, terminalShare: 0.5, stalled: true, checksMin: [15, 60], issue: 77, notifyCommand: "notify-me" },
|
|
1537
|
+
healthTimer: { set: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clear: () => {} },
|
|
1538
|
+
notify: async (cmd, payload) => { notes.push([cmd, payload]); return { ok: true }; },
|
|
1539
|
+
leanModule: { runCore: async (o) => {
|
|
1540
|
+
if (o.issue === 3) { await released; return { terminalReason: "merged", pr: 3, cost_usd: 0, wall_ms: 1 }; }
|
|
1541
|
+
return fail ? { terminalReason: "engine-error", detail: "lane crashed: boom", cost_usd: 0, wall_ms: 1 } : { terminalReason: "merged", pr: o.issue, cost_usd: 0, wall_ms: 1 };
|
|
1542
|
+
} } };
|
|
1543
|
+
return { deps, timers, comments, notes, gate };
|
|
1544
|
+
};
|
|
1545
|
+
const capture = async (fn) => { const lines = [], l = console.log; console.log = (...a) => lines.push(a.join(" ")); try { await fn(); } finally { console.log = l; } return lines; };
|
|
1546
|
+
|
|
1547
|
+
it("#5167 roots ending engine-error raise one BATCH UNHEALTHY at T+15 with recovery commands, on every channel", async () => {
|
|
1548
|
+
const { deps, timers, comments, notes, gate } = mk({ fail: true });
|
|
1549
|
+
let id = null;
|
|
1550
|
+
const lines = await capture(async () => {
|
|
1551
|
+
const run = orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter", "--parallel", "3"], deps);
|
|
1552
|
+
try {
|
|
1553
|
+
await until(() => { id = readdirSyncSafe(deps.batchRoot)[0]; return id && JSON.parse(readFileSync(join(deps.batchRoot, id, "state.json"), "utf8")).results?.[1]; });
|
|
1554
|
+
assert.deepEqual(timers.map((t) => t.ms > 14 * 60000 && t.ms <= 15 * 60000 ? 15 : t.ms > 59 * 60000 ? 60 : t.ms), [15, 60], "T+15 and T+60 armed");
|
|
1555
|
+
await timers[0].fn();
|
|
1556
|
+
await until(() => existsSync(join(deps.batchRoot, id, "health.json")) && JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8")).alertedAt, 5000);
|
|
1557
|
+
await until(() => notes.length === 1, 5000);
|
|
1558
|
+
} finally { gate.open(); await run; }
|
|
1559
|
+
});
|
|
1560
|
+
assert.ok(lines.some((l) => /^health checks: T\+15 \d\d:\d\dZ, T\+60 \d\d:\d\dZ, and at batch end; forgedock status --batch /.test(l)), lines.join("\n"));
|
|
1561
|
+
const alerts = lines.filter((l) => /^BATCH UNHEALTHY /.test(l));
|
|
1562
|
+
assert.equal(alerts.length, 1, "alerted once (T+15), not again at batch end");
|
|
1563
|
+
assert.match(alerts[0], /\(T\+15\)/);
|
|
1564
|
+
assert.match(alerts[0], /cause: root-failed: root #1 engine-error/);
|
|
1565
|
+
assert.match(alerts[0], /recovery:\n {4}forgedock orchestrate --requeue \S+ 1 2 --reason/);
|
|
1566
|
+
assert.match(alerts[0], new RegExp(`forgedock orchestrate --cancel ${id}`));
|
|
1567
|
+
assert.equal(comments.length, 1);
|
|
1568
|
+
assert.deepEqual(comments[0].slice(0, 5), ["issue", "comment", "77", "-R", "o/r"]);
|
|
1569
|
+
assert.equal(notes[0][0], "notify-me");
|
|
1570
|
+
assert.equal(notes[0][1].batch, id);
|
|
1571
|
+
const h = JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8"));
|
|
1572
|
+
assert.deepEqual(Object.keys(h.checks).sort(), ["T+15", "end"]);
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
it("#5167 a healthy batch emits no alert", async () => {
|
|
1576
|
+
const { deps, timers, comments, notes, gate } = mk({ fail: false });
|
|
1577
|
+
gate.open();
|
|
1578
|
+
const lines = await capture(() => orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter"], deps));
|
|
1579
|
+
for (const t of timers) await t.fn(); // a late timer after the end still finds the batch healthy
|
|
1580
|
+
assert.ok(!lines.some((l) => /BATCH UNHEALTHY/.test(l)), lines.join("\n"));
|
|
1581
|
+
assert.equal(comments.length + notes.length, 0);
|
|
1582
|
+
const [id] = readdirSyncSafe(deps.batchRoot);
|
|
1583
|
+
const h = JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8"));
|
|
1584
|
+
assert.equal(h.last.healthy, true);
|
|
1585
|
+
assert.equal(h.alertedAt, undefined);
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
describe("#5168: --requeue accepts engine-error; a claude login failure is held, not an engine-error", () => {
|
|
1591
|
+
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
1592
|
+
it("#5168 --requeue accepts a final engine-error outcome and resets its engine-error count", async () => {
|
|
1593
|
+
const b = openBatch(tmpDir("batch-requeue-5168-"), "b1");
|
|
1594
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
1595
|
+
b.store.save({ order: [1, 2], results: { 1: { outcome: "engine-error" }, 2: { outcome: "merged" } } });
|
|
1596
|
+
const r = b.requeue([1, 2], { reason: "login fixed", by: "operator" });
|
|
1597
|
+
assert.deepEqual(r.requeued, [1]);
|
|
1598
|
+
assert.deepEqual(r.refused, [{ issue: 2, reason: "not terminal" }]);
|
|
1599
|
+
|
|
1600
|
+
// The runner applies it: the issue returns to pending with a fresh relaunch (its engine-error count reset).
|
|
1601
|
+
let entries = [];
|
|
1602
|
+
const started = [], g2 = gate(), log = [];
|
|
1603
|
+
const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", getRequeued: () => entries, onUpdate: (u) => log.push(u),
|
|
1604
|
+
runOne: async (n) => {
|
|
1605
|
+
started.push(n);
|
|
1606
|
+
if (n === 2) { await g2.p; return { terminalReason: "merged" }; }
|
|
1607
|
+
return started.filter((x) => x === 1).length <= 3 ? { terminalReason: "engine-error", detail: "Failed to authenticate" } : { terminalReason: "merged" };
|
|
1608
|
+
} });
|
|
1609
|
+
await until(() => log.some((u) => u.issue === 1 && u.outcome === "engine-error" && !/relaunching/.test(u.detail ?? "")));
|
|
1610
|
+
entries = [{ issue: 1, reason: "login fixed", by: "operator", at: "2026-11-07T00:00:00Z" }];
|
|
1611
|
+
try { await until(() => started.filter((x) => x === 1).length >= 4, 3000); } catch { /* asserted below */ }
|
|
1612
|
+
g2.open();
|
|
1613
|
+
const rows = await done;
|
|
1614
|
+
assert.equal(started.filter((x) => x === 1).length, 4, "two runs before the requeue, then a fresh run and its relaunch");
|
|
1615
|
+
assert.equal(rows.find((x) => x.issue === 1).outcome, "merged");
|
|
1616
|
+
assert.equal(rows.find((x) => x.issue === 1).requeued.prior[0].outcome, "engine-error");
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it("#5168 deferPolicy holds backend.auth like a usage limit: never capped, never counted", () => {
|
|
1620
|
+
assert.deepEqual(deferPolicy({ code: "backend.auth", retryInSeconds: 900 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 900_000, hold: true });
|
|
1621
|
+
assert.equal(deferPolicy({ code: "backend.auth" }, { now: 0 }).hold, true);
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("#5168 a backend.auth lane stays pending under the account hold: no relaunch used, no breaker, no engine-error", async () => {
|
|
1625
|
+
const { now, sleep, c } = fakeClock();
|
|
1626
|
+
const t0 = c.t, updates = [], starts = {};
|
|
1627
|
+
let calls = 0;
|
|
1628
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", breaker: { errors: 1, window_min: 30 }, maxDeferrals: 1,
|
|
1629
|
+
onUpdate: (u) => updates.push(u),
|
|
1630
|
+
runOne: async (n) => {
|
|
1631
|
+
(starts[n] ??= []).push(now());
|
|
1632
|
+
if (n === 1 && ++calls <= 3) return { terminalReason: "deferred", code: "backend.auth", retryInSeconds: 900, backendId: "default", detail: "claude CLI authentication failed" };
|
|
1633
|
+
return { terminalReason: "merged", pr: 5 };
|
|
1634
|
+
} });
|
|
1635
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
1636
|
+
assert.equal(starts[1].length, 4, "deferred past maxDeferrals: the hold is uncapped");
|
|
1637
|
+
assert.ok(starts[2][0] >= t0 + 900_000, "the held account admits nothing until the hold ends");
|
|
1638
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
1639
|
+
assert.ok(updates.some((u) => u.outcome === "paused" && /paused \(backend\.auth\)/.test(u.detail ?? "")));
|
|
1640
|
+
});
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
function readdirSyncSafe(d) { try { return readdirSync(d).filter((n) => !n.endsWith(".json")); } catch { return []; } }
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -73,6 +73,32 @@ export function inIssue(quote, issueText) {
|
|
|
73
73
|
if (q.length < 3) return false;
|
|
74
74
|
return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
|
|
75
75
|
}
|
|
76
|
+
/** #5164: the issue's list or checklist item lines (item marker stripped), or every non-empty line when it has none. */
|
|
77
|
+
export function criterionLines(issueText) {
|
|
78
|
+
const lines = String(noComments(String(issueText ?? ""))).split("\n");
|
|
79
|
+
const items = lines.filter((l) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(l)).map((l) => l.replace(ITEM_PREFIX_RE, "").trim()).filter((l) => norm(l));
|
|
80
|
+
return items.length ? items : lines.map((l) => l.trim()).filter((l) => norm(l));
|
|
81
|
+
}
|
|
82
|
+
/** #5164: norm() plus what a reviewer retyping a criterion slips on: `#N` reference digits, trailing punctuation, a plural `s`. */
|
|
83
|
+
const loose = (s) => norm(String(s ?? "").replace(/#\d+/g, "#")).replace(/[\s.,;:!?)\]]+$/, "").replace(/\b([a-z]{3,})s\b/g, "$1");
|
|
84
|
+
const MATCH_MIN = 0.8;
|
|
85
|
+
/**
|
|
86
|
+
* #5164: the issue's exact criterion line a near-verbatim quote stands for, or null. After loose normalisation the quote is a
|
|
87
|
+
* prefix covering at least 80% of exactly one line, or else at least 80% of its words are in exactly one line (no ties).
|
|
88
|
+
*/
|
|
89
|
+
export function matchCriterion(quote, issueText) {
|
|
90
|
+
const q = loose(quote);
|
|
91
|
+
if (q.length < MIN_QUOTE_CHARS) return null;
|
|
92
|
+
const lines = criterionLines(issueText).map((raw) => ({ raw, l: loose(raw) }));
|
|
93
|
+
const prefix = lines.filter(({ l }) => l.startsWith(q) && q.length >= MATCH_MIN * l.length);
|
|
94
|
+
if (prefix.length === 1) return prefix[0].raw;
|
|
95
|
+
if (prefix.length > 1) return null;
|
|
96
|
+
const qw = [...new Set(q.match(WORD_RE) ?? [])];
|
|
97
|
+
if (qw.length < 3) return null;
|
|
98
|
+
const hits = lines.filter(({ l }) => { const lw = new Set(l.match(WORD_RE) ?? []); return qw.filter((w) => lw.has(w)).length / qw.length >= MATCH_MIN; });
|
|
99
|
+
return hits.length === 1 ? hits[0].raw : null;
|
|
100
|
+
}
|
|
101
|
+
const MISQUOTE_PREFIX = "issue_coverage quote is not verbatim from the issue";
|
|
76
102
|
// Scan-based (not a one-pass regex), so nested or unclosed markers cannot survive into the scope hash.
|
|
77
103
|
const stripHtmlComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
|
|
78
104
|
/**
|
|
@@ -277,9 +303,10 @@ const NEG_DROP_RE = /\b(?:do(?:es)? not|don't|never|not)\s+(?:be\s+|been\s+|bein
|
|
|
277
303
|
const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
|
|
278
304
|
// A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
|
|
279
305
|
const GAP = String.raw`(?:[\s,]+\w+){0,5}?[\s,]+`;
|
|
280
|
-
// #5145:
|
|
281
|
-
|
|
282
|
-
const
|
|
306
|
+
// #5145/#5157: only the determiner "no" ("no new fixtures needed for e2e") stops at a preposition, since it negates a noun. not/without/never
|
|
307
|
+
// negate the verb that governs e2e ("not need to run e2e", "not with e2e") and "no need/reason/point/requirement" negates the action; all keep the wide GAP.
|
|
308
|
+
const NEG_GAP = String.raw`(?:[\s,]+(?!(?:for|of|in|on|at|by|with|from|via)\b)\w+){0,5}?[\s,]+`;
|
|
309
|
+
const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no${NEG_GAP}|(?:not|without|never|no\s+(?:need|reason|point|requirement)|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP})${E2E_TERM}\b`, "i");
|
|
283
310
|
const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded|unnecessary|(?:not|no longer)\s+(?:needed|required|necessary|run\w*|used|executed))\b`, "i");
|
|
284
311
|
const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
|
|
285
312
|
const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
|
|
@@ -1002,6 +1029,13 @@ const MISSING_PATH_RE = /^[^:]+: evidence path .+ does not exist$/;
|
|
|
1002
1029
|
export function evidencePathOnly(problems = []) {
|
|
1003
1030
|
return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)));
|
|
1004
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* #5164: true when every problem of an incomplete review is a reviewer slip a targeted re-ask can fix: an evidence path that
|
|
1034
|
+
* did not resolve (#4327) or an issue_coverage misquote. A real coverage gap is never one, so it keeps the two-run limit.
|
|
1035
|
+
*/
|
|
1036
|
+
export function reaskable(problems = []) {
|
|
1037
|
+
return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)) || String(p).startsWith(MISQUOTE_PREFIX));
|
|
1038
|
+
}
|
|
1005
1039
|
const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
|
|
1006
1040
|
|
|
1007
1041
|
/**
|
|
@@ -1009,7 +1043,7 @@ const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !igno
|
|
|
1009
1043
|
* contract's own authorization ("none" when unauthorized, still scope creep), a preserve row per id as "unverified"
|
|
1010
1044
|
* (still preserve_violated), and implemented evidence rewritten to `path:line` with the repo path. Returns a new object.
|
|
1011
1045
|
*/
|
|
1012
|
-
export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [] }) {
|
|
1046
|
+
export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [], issueText = null }) {
|
|
1013
1047
|
if (!rv || typeof rv !== "object") return rv;
|
|
1014
1048
|
const out = { ...rv };
|
|
1015
1049
|
const scope = Array.isArray(rv.scope) ? [...rv.scope] : [];
|
|
@@ -1018,6 +1052,14 @@ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = []
|
|
|
1018
1052
|
const pres = Array.isArray(rv.preserve) ? [...rv.preserve] : [];
|
|
1019
1053
|
for (const c of contract.preserve ?? []) if (!pres.some((x) => x?.id === c.id)) pres.push({ id: c.id, status: "unverified", note: "no row from the reviewer", filled: true });
|
|
1020
1054
|
out.preserve = pres;
|
|
1055
|
+
// #5164: a near-verbatim coverage quote is replaced by the issue's exact criterion line; the original stays as `requoted`
|
|
1056
|
+
if (Array.isArray(rv.issue_coverage) && issueText != null) {
|
|
1057
|
+
out.issue_coverage = rv.issue_coverage.map((c) => {
|
|
1058
|
+
if (!c || typeof c !== "object" || typeof c.quote !== "string" || inIssue(c.quote, issueText)) return c;
|
|
1059
|
+
const line = matchCriterion(c.quote, issueText);
|
|
1060
|
+
return line ? { ...c, quote: line, requoted: c.quote } : c;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1021
1063
|
if (Array.isArray(rv.requirements)) {
|
|
1022
1064
|
out.requirements = rv.requirements.map((r) => {
|
|
1023
1065
|
if (r?.status !== "implemented" && r?.instrumented !== true) return r;
|
|
@@ -1038,7 +1080,10 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
1038
1080
|
if (!cov?.length) miss("issue_coverage", "issue_coverage is missing or empty");
|
|
1039
1081
|
for (const c of cov ?? []) {
|
|
1040
1082
|
if (!COVER_STATUS.includes(c?.status)) miss("issue_coverage", `issue_coverage status invalid: ${c?.status}`);
|
|
1041
|
-
if (!inIssue(c?.quote, issueText))
|
|
1083
|
+
if (!inIssue(c?.quote, issueText)) {
|
|
1084
|
+
const near = nearestSpan(c?.quote, criterionLines(issueText).join("\n"));
|
|
1085
|
+
miss("issue_coverage", `${MISQUOTE_PREFIX}: "${String(c?.quote ?? "").slice(0, 60)}"${near ? `; nearest criterion line: "${near}"` : ""}`);
|
|
1086
|
+
}
|
|
1042
1087
|
}
|
|
1043
1088
|
const reqs = arr("requirements");
|
|
1044
1089
|
if (!reqs) p.push("requirements rows are missing");
|