forgedock 1.37.0 → 1.38.1
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 +12 -3
- package/bin/engine/telemetry-seam.mjs +5 -0
- package/bin/engine-cli.mjs +97 -18
- package/bin/forgedock.mjs +13 -2
- package/bin/lean/activation.mjs +76 -0
- package/bin/lean/activation.test.mjs +75 -0
- package/bin/lean/admission.mjs +17 -1
- package/bin/lean/batch.mjs +51 -21
- package/bin/lean/batch.test.mjs +95 -5
- package/bin/lean/conformance.mjs +53 -4
- package/bin/lean/conformance.test.mjs +24 -1
- package/bin/lean/decision-engine.mjs +217 -0
- package/bin/lean/decision-engine.test.mjs +159 -0
- package/bin/lean/effects.mjs +39 -13
- package/bin/lean/execution.mjs +74 -5
- package/bin/lean/execution.test.mjs +64 -1
- package/bin/lean/explore.test.mjs +1 -1
- package/bin/lean/guards.mjs +3 -2
- package/bin/lean/guards.test.mjs +24 -3
- package/bin/lean/heavy.mjs +15 -2
- package/bin/lean/heavy.test.mjs +31 -2
- package/bin/lean/issuewatch-release.mjs +245 -0
- package/bin/lean/issuewatch-release.test.mjs +244 -0
- package/bin/lean/issuewatch.mjs +386 -0
- package/bin/lean/issuewatch.test.mjs +280 -0
- package/bin/lean/lane.mjs +4 -3
- package/bin/lean/lane.test.mjs +12 -0
- package/bin/lean/lean.mjs +103 -16
- package/bin/lean/lean.test.mjs +127 -5
- package/bin/lean/needs-human-premise.mjs +177 -0
- package/bin/lean/needs-human-premise.test.mjs +136 -0
- package/bin/lean/orchestrate-blocked.test.mjs +71 -0
- package/bin/lean/orchestrate.mjs +93 -21
- package/bin/lean/postdeploy-check.mjs +88 -2
- package/bin/lean/postdeploy-check.test.mjs +78 -0
- package/bin/lean/prodwatch-sources.mjs +165 -0
- package/bin/lean/prodwatch-sources.test.mjs +108 -0
- package/bin/lean/prompts.mjs +9 -3
- package/bin/lean/recovery.test.mjs +14 -0
- package/bin/lean/relay.mjs +182 -0
- package/bin/lean/relay.test.mjs +214 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
- package/bin/lean/sandbox-remote.mjs +53 -9
- package/bin/lean/sched.test.mjs +30 -0
- package/bin/lean/schedd-health.mjs +69 -0
- package/bin/lean/schedd-health.test.mjs +51 -0
- package/bin/lean/schedd.mjs +29 -12
- package/bin/lean/schedd.test.mjs +40 -4
- package/bin/lean/sequence.mjs +57 -24
- package/bin/lean/sequence.test.mjs +71 -0
- package/bin/pool/agent.mjs +15 -1
- package/bin/pool/router.mjs +43 -5
- package/bin/runner.mjs +18 -1
- package/commands/deploy-pr.md +12 -0
- package/commands/forgedock-init.md +1 -0
- package/commands/orchestrate/phase-4-execution.md +1 -0
- package/commands/orchestrate.md +1 -0
- package/commands/review-pr.md +17 -13
- package/docs/CONFIG.md +73 -2
- package/docs/DECISIONS.md +32 -0
- package/docs/ISSUE-WATCH.md +95 -0
- package/docs/NORTHSTAR-TEMPLATE.md +21 -0
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/TELEMETRY.md +7 -0
- package/package.json +1 -2
- package/pool/README.md +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/cs-relay +31 -0
- package/pool/image/entrypoint.sh +10 -0
- package/pool/install/install-schedd.sh +22 -0
- package/pool/install/unit-hardening.sh +6 -0
- package/scripts/postdeploy-rollup-link.sh +40 -0
- package/scripts/postdeploy-rollup-link.test.sh +46 -0
- package/scripts/refresh-bot-token.sh +122 -0
- package/scripts/review-delta.sh +14 -4
- package/scripts/review-delta.test.sh +21 -0
- package/scripts/review-finalize.sh +69 -25
- package/scripts/review-finalize.test.sh +32 -0
- package/scripts/review-pr-arguments.test.sh +10 -0
- package/scripts/review-pr-changed-verification.test.sh +54 -0
- package/scripts/review-pr-delta-wiring.test.sh +1 -1
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* before scheduling, so a live lane does not hold up the others.
|
|
21
21
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
22
22
|
* limit holds admissions for that account until its reset, and deferrals other than a busy resource (`host.busy`,
|
|
23
|
-
* `sandbox.capacity`, #4528) stop after `maxDeferrals`.
|
|
23
|
+
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788) stop after `maxDeferrals`.
|
|
24
24
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
25
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
26
|
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
|
|
32
32
|
* `getHeld()` (#4592, `--hold`/`--release`) is read on every pass: the issues it returns are never admitted (a running lane is not stopped);
|
|
33
33
|
* when only held issues (and their dependents) remain and nothing runs, the batch ends with them `held` ("held by operator"), and a `--resume <id> <issue...>` naming them (or `--release`) re-admits them.
|
|
34
|
+
* `getBlocked()` (#4773, the `blocked` label) is read on every pass: the issues it returns are in the batch but not dispatched; removing the label
|
|
35
|
+
* releases them on the next pass (the batch polls while only blocked issues wait). `trusted(n)` gates `Blocked by`/`Depends on` edges to a trusted body author.
|
|
34
36
|
*
|
|
35
37
|
* Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
|
|
36
38
|
* `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
|
|
@@ -48,7 +50,8 @@
|
|
|
48
50
|
* starts only when the batch is under its K and the registry grants it a host slot; `priorityOf(issue)` (persisted as
|
|
49
51
|
* `priorities`) ranks it host-wide, and the batch's ready issues are tried in priority order. Ready issues it cannot
|
|
50
52
|
* start yet stay offered in the registry (polled every `pollMs`) and are withdrawn once no longer ready; a settled lane
|
|
51
|
-
* releases its slot, an adopted one re-records it.
|
|
53
|
+
* releases its slot, an adopted one re-records it. `sched.cap` may be a function, evaluated at each admission attempt
|
|
54
|
+
* (#4777: the live local lane cap from cores and memory; running lanes are never revoked, over-cap issues stay queued).
|
|
52
55
|
* Per-issue lock (#4481), on with `sched.exclusive` (registry entries ask `exclusive: true`): when another batch holds a live slot for the same repo and issue (`tryAcquire` `conflict`, or
|
|
53
56
|
* `registry.holder` for a lane placed remote), the issue is not started: it is retried after `pollMs` with one `deferred`
|
|
54
57
|
* row naming that batch (no deferral count, no breaker), and admission goes on with the next ready issue.
|
|
@@ -78,7 +81,7 @@
|
|
|
78
81
|
* recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
79
82
|
* edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts, the non-human `budget-exceeded` turn overrun of #4505,
|
|
80
83
|
* recovered at the next size budget) is not final while its persisted `recoveries` stay
|
|
81
|
-
* under `maxAttempts
|
|
84
|
+
* under its class budget (#4725: `maxAttempts`; mechanical/env codes `mechanicalMaxAttempts`, default 2, never below `maxAttempts`): it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
|
|
82
85
|
* `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
|
|
83
86
|
* itself; a non-final attempt that ends needs-human/ci-pending/budget-exceeded without a successor is relabelled here, once (#4302),
|
|
84
87
|
* as is a first-run budget-exceeded the batch does not recover (#4505: the lane never labels it). A recovery
|
|
@@ -101,7 +104,7 @@
|
|
|
101
104
|
* the queue order except that decomposed sub-issues go first. A settled row carries `est_minutes` (estimated vs. actual).
|
|
102
105
|
*/
|
|
103
106
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
104
|
-
import { classifyNeedsHuman, recoveryCodeOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
107
|
+
import { classifyNeedsHuman, recoveryCodeOf, recoveryClassOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
105
108
|
import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
|
|
106
109
|
import { recoveryEvidence } from "./render.mjs";
|
|
107
110
|
import { renderAlerts } from "../stats/render.mjs";
|
|
@@ -120,7 +123,10 @@ export const DEFAULT_MAX_DEFERRALS = 3;
|
|
|
120
123
|
export const ENGINE_ERROR_RELAUNCHES = 1;
|
|
121
124
|
export const HOST_BUSY_RETRY_S = 60;
|
|
122
125
|
// #4528: a busy host or a full sandbox pool is a wait, not a failure: requeued after its retry-in, never capped.
|
|
123
|
-
|
|
126
|
+
// A lane that could not be resolved because gh/git failed (network blip) is the same kind of wait (#4791).
|
|
127
|
+
// #4788: an unreachable pool is a wait too (the lane already retried for unreachable_window_min); a stall is reported by poolStall.
|
|
128
|
+
const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity", "sandbox.unreachable", "lane.unresolved"]);
|
|
129
|
+
export const POOL_OFFLINE_ALERT_MIN = 10;
|
|
124
130
|
export const RATE_LIMIT_FALLBACK_S = 15 * 60;
|
|
125
131
|
export const DEFER_BACKOFF_S = 120;
|
|
126
132
|
export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
@@ -240,7 +246,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
240
246
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
241
247
|
store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
242
248
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
243
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, getGates = null, stopLane = null,
|
|
249
|
+
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,
|
|
244
250
|
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
|
|
245
251
|
const saved = store?.load?.() ?? null;
|
|
246
252
|
const order = saved ? [...saved.order] : [];
|
|
@@ -282,6 +288,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
282
288
|
const reg = sched?.registry ?? null;
|
|
283
289
|
const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
|
|
284
290
|
const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
|
|
291
|
+
let poolOfflineSince = null, poolStallNoted = false; // #4788: when sampled online first read 0, and whether the stall was reported
|
|
285
292
|
let capAt, capAdmits = 0; // #4540: provider capacity sample and the remote admissions made since it
|
|
286
293
|
const remoteRun = new Set(); // remote lanes running now (#4253): admitted against remoteMax, never the host cap
|
|
287
294
|
let budgetLocal = false; // the execution budget refused a remote lane under on_budget local: the rest run on the host
|
|
@@ -289,7 +296,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
289
296
|
const placeOf = (n) => {
|
|
290
297
|
if (!remote) return null;
|
|
291
298
|
// #4542: a lane recovering from a remote capability block runs on the host, which has the capability.
|
|
292
|
-
|
|
299
|
+
// #4785: a tool-missing recovery too: the sandbox that lacked the tool is not chosen again.
|
|
300
|
+
if ([OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.ENV_TOOL_MISSING].includes(recovering.get(n)?.code)) return { where: "local", reason: `host capability: recovering ${recovering.get(n).code}` };
|
|
293
301
|
if (budgetLocal) return { where: "local", reason: `fallback to local: execution budget: ${budgetHit ?? "cap reached"}` };
|
|
294
302
|
try {
|
|
295
303
|
if (typeof remote.place === "function") {
|
|
@@ -305,6 +313,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
305
313
|
const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
|
|
306
314
|
let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
|
|
307
315
|
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
316
|
+
// #4725: mechanical/env codes recover on their own budget (>= the conformance one); 0 maxAttempts still turns recovery off.
|
|
317
|
+
const mechMax = recoveryMax && Number.isInteger(recovery?.mechanicalMaxAttempts) && recovery.mechanicalMaxAttempts > 0 ? Math.max(recovery.mechanicalMaxAttempts, recoveryMax) : recoveryMax;
|
|
318
|
+
const maxFor = (cls) => (cls === "mechanical" ? mechMax : recoveryMax);
|
|
319
|
+
const classUsed = toMap(saved?.recoveryClasses); // `${issue}:${class}` -> recovery lanes launched in that class
|
|
308
320
|
const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
|
|
309
321
|
const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
|
|
310
322
|
const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
|
|
@@ -322,7 +334,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
322
334
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
323
335
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
324
336
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
325
|
-
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
337
|
+
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
|
|
326
338
|
...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
|
|
327
339
|
...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
|
|
328
340
|
...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
|
|
@@ -347,15 +359,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
347
359
|
try { v = Number(await estimate(n, { body })); } catch { v = null; }
|
|
348
360
|
estimates.set(n, Number.isFinite(v) && v > 0 ? v : EST_DEFAULT_MIN.M);
|
|
349
361
|
};
|
|
362
|
+
// #4773: a declaration line is an edge only when the body's author is trusted (the engine's admission applies the same rule).
|
|
363
|
+
const declared = async (n, body) => {
|
|
364
|
+
if (typeof trusted === "function") { let ok = false; try { ok = (await trusted(n)) === true; } catch { ok = false; } if (!ok) return []; }
|
|
365
|
+
return parseDeps(body, n);
|
|
366
|
+
};
|
|
350
367
|
const readDeps = async (n) => {
|
|
351
368
|
let body = "";
|
|
352
369
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
353
370
|
if (drift?.holds) bodies.set(n, body);
|
|
354
|
-
deps.set(n,
|
|
371
|
+
deps.set(n, await declared(n, body));
|
|
355
372
|
await readEstimate(n, body);
|
|
356
373
|
await classifyOne(n);
|
|
357
374
|
await readPriority(n);
|
|
358
|
-
if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; } lanes.set(n, l); }
|
|
375
|
+
if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; } lanes.set(n, l); }
|
|
359
376
|
};
|
|
360
377
|
const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
|
|
361
378
|
// A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
|
|
@@ -401,6 +418,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
401
418
|
// Host registry seams (#4191): `offered` issues are queued for a slot, `slotted` ones hold one.
|
|
402
419
|
const offered = new Set(), slotted = new Set();
|
|
403
420
|
let fellBack = false;
|
|
421
|
+
// #4777: `sched.cap` may be a function (the host-derived local lane cap), evaluated at every attempt so it stays live.
|
|
422
|
+
const liveCap = () => { try { return typeof sched.cap === "function" ? sched.cap() : sched.cap; } catch { return null; } };
|
|
404
423
|
const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
|
|
405
424
|
const prioOf = (n) => priorities.get(n) ?? 2;
|
|
406
425
|
// #4409: priority, then the head band (decomposed sub-issues; with an estimator also starved issues, oldest first), then
|
|
@@ -472,6 +491,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
472
491
|
// #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
|
|
473
492
|
let cap = null;
|
|
474
493
|
try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
|
|
494
|
+
// #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
|
|
495
|
+
if (cap && cap.online === 0) {
|
|
496
|
+
poolOfflineSince ??= now();
|
|
497
|
+
const limitMs = (posNum(remote.poolOfflineMin) ?? POOL_OFFLINE_ALERT_MIN) * 60000;
|
|
498
|
+
if (!poolStallNoted && now() - poolOfflineSince > limitMs) {
|
|
499
|
+
poolStallNoted = true;
|
|
500
|
+
update({ issue: null, outcome: "pool-offline", detail: `STALLED: no compute pool agent online for over ${Math.round(limitMs / 60000)} min; ${remote.offlineFallback ? "lanes fall back to the host" : "remote lanes wait (set execution.fallback_local or use mode local to run on the host)"}` });
|
|
501
|
+
}
|
|
502
|
+
} else if (cap && cap.online > 0) { poolOfflineSince = null; poolStallNoted = false; }
|
|
475
503
|
// #4547: opt-in (`execution.fallback_local`): every pool agent offline or asleep (sampled online 0) runs the lane on the host.
|
|
476
504
|
if (remote.offlineFallback && cap && cap.online === 0) {
|
|
477
505
|
p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
|
|
@@ -490,7 +518,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
490
518
|
}
|
|
491
519
|
}
|
|
492
520
|
if (!reg) { if (p) placed.set(n, p); return true; }
|
|
493
|
-
const r = reg.tryAcquire(keyOf(n), entryOf(n),
|
|
521
|
+
const r = reg.tryAcquire(keyOf(n), entryOf(n), liveCap());
|
|
494
522
|
if (!r?.ok && r?.conflict != null) return busy(n, r.conflict, wanted);
|
|
495
523
|
if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
|
|
496
524
|
offered.delete(n); slotted.add(n);
|
|
@@ -553,6 +581,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
553
581
|
for (const d of depList) {
|
|
554
582
|
if (["needs-human", "budget-exceeded"].includes(results.get(d)?.outcome)) { parked = d; break; } // #4505: an unrecovered overrun parks too
|
|
555
583
|
if (set.has(d) || typeof issueLabels !== "function") continue;
|
|
584
|
+
if (typeof issueState === "function") { // #4725: a closed (completed) dependency is satisfied: its stale needs-human label is not a blocker
|
|
585
|
+
let st = "";
|
|
586
|
+
try { st = String((await issueState(d)) ?? "").toUpperCase(); } catch { st = ""; }
|
|
587
|
+
if (st === "CLOSED") continue;
|
|
588
|
+
}
|
|
556
589
|
let names = [];
|
|
557
590
|
try { names = (await issueLabels(d)) ?? []; } catch { names = []; }
|
|
558
591
|
if (Array.isArray(names) && names.includes("needs-human")) { parked = d; break; }
|
|
@@ -641,22 +674,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
641
674
|
if (row.outcome === "ci-pending" && typeof conflicting === "function" && Number.isInteger(row.pr)) {
|
|
642
675
|
try { isConflicting = (await conflicting(n, row.pr)) === true; } catch { isConflicting = false; }
|
|
643
676
|
}
|
|
644
|
-
const facts = { ...row, issueEdited: r?.issueEdited === true, conflicting: isConflicting };
|
|
645
|
-
const
|
|
646
|
-
|
|
677
|
+
const facts = { ...row, issueEdited: r?.issueEdited === true, quoteMismatch: r?.quoteMismatch === true, conflicting: isConflicting };
|
|
678
|
+
if (prior) { const pk = `${n}:${recoveryClassOf({ code: prior.code })}`; classUsed.set(pk, Math.max(classUsed.get(pk) ?? 0, prior.attempt)); } // state saved before #4725 has no class counts
|
|
679
|
+
const cls = recoveryClassOf(facts), max = maxFor(cls), used = classUsed.get(`${n}:${cls}`) ?? 0;
|
|
680
|
+
if (classifyNeedsHuman(facts) === "recoverable" && used < max) {
|
|
647
681
|
const code = recoveryCodeOf(facts), attempt = used + 1;
|
|
682
|
+
classUsed.set(`${n}:${cls}`, attempt);
|
|
648
683
|
// #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
|
|
649
|
-
recoveries.set(n,
|
|
684
|
+
recoveries.set(n, (recoveries.get(n) ?? 0) + 1); recovering.set(n, { code, attempt, max, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
|
|
650
685
|
detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
|
|
651
686
|
requeue(n, 0); done(); save();
|
|
652
|
-
decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${
|
|
687
|
+
decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${max}); dependents stay queued`, why: firstLine(row.detail) || code,
|
|
653
688
|
alternative: "leave needs-human for the operator and skip the dependents" });
|
|
654
|
-
update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${
|
|
689
|
+
update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${max}: ${firstLine(row.detail)}` });
|
|
655
690
|
return;
|
|
656
691
|
}
|
|
657
692
|
}
|
|
658
693
|
// #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
|
|
659
|
-
if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending", "budget-exceeded"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
694
|
+
if (prior && prior.attempt < (prior.max ?? recoveryMax) && ["needs-human", "ci-pending", "budget-exceeded"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
660
695
|
try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
|
|
661
696
|
}
|
|
662
697
|
// #4505: a first-run turn overrun is unlabelled by its lane; no recovery follows (off or cap used), so the runner labels it once.
|
|
@@ -684,8 +719,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
684
719
|
};
|
|
685
720
|
|
|
686
721
|
const track = (n, p) => { running.set(n, p.catch((e) => settle(n, { terminalReason: "engine-error", detail: e?.message ?? String(e) }, runRecs.get(n), null))); };
|
|
722
|
+
const laneRetries = new Map(); // #4791: transient lane-resolution failures per issue; never counted in `deferrals`
|
|
723
|
+
// A transient refusal (gh/git failed) re-resolves; the settled `deferred` frees the slot and requeues with backoff.
|
|
724
|
+
const retryLane = (n, rec) => track(n, (async () => {
|
|
725
|
+
let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; }
|
|
726
|
+
lanes.set(n, l);
|
|
727
|
+
if (l.ok || !l.transient) { laneRetries.delete(n); return { terminalReason: "deferred", code: "lane.unresolved", retryInSeconds: 1, cost_usd: 0, detail: l.ok ? "lane resolved" : `${l.code}: ${l.detail}` }; }
|
|
728
|
+
const k = laneRetries.get(n) ?? 0; laneRetries.set(n, k + 1);
|
|
729
|
+
return { terminalReason: "deferred", code: "lane.unresolved", retryInSeconds: Math.min(DEFER_BACKOFF_S * 2 ** k, DEFER_BACKOFF_MAX_S), cost_usd: 0, detail: l.detail };
|
|
730
|
+
})().then((x) => settle(n, x, rec, null)));
|
|
687
731
|
const start = (n) => {
|
|
688
732
|
const ln = lanes.get(n);
|
|
733
|
+
if (ln && !ln.ok && ln.transient && resolveLane) { retryLane(n, runRecs.get(n) ?? { startedAt: now() }); return; }
|
|
689
734
|
if (ln && !ln.ok) { // settled synchronously: nothing runs, no lease, no comment
|
|
690
735
|
const row = { issue: n, outcome: "lane-refused", pr: null, cost_usd: 0, minutes: 0, detail: `${ln.code}: ${ln.detail}`, code: ln.code };
|
|
691
736
|
results.set(n, row); save(); update(row);
|
|
@@ -717,7 +762,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
717
762
|
else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
718
763
|
else {
|
|
719
764
|
try {
|
|
720
|
-
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
|
|
765
|
+
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= (recovering.get(n).max ?? recoveryMax) } } : {}), onStart: (info) => {
|
|
721
766
|
Object.assign(rec, info ?? {}); save();
|
|
722
767
|
if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
|
|
723
768
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
@@ -763,7 +808,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
763
808
|
if (saved && resolveLane) {
|
|
764
809
|
for (const n of order) {
|
|
765
810
|
if (results.has(n) || running.has(n)) continue;
|
|
766
|
-
let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; }
|
|
811
|
+
let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; }
|
|
767
812
|
lanes.set(n, l);
|
|
768
813
|
}
|
|
769
814
|
noteCrossLane();
|
|
@@ -801,7 +846,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
801
846
|
let body;
|
|
802
847
|
try { body = await readBody(n); } catch { continue; }
|
|
803
848
|
if (drift?.holds) bodies.set(n, body);
|
|
804
|
-
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...
|
|
849
|
+
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
805
850
|
}
|
|
806
851
|
await addIssues(fresh);
|
|
807
852
|
save();
|
|
@@ -872,6 +917,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
872
917
|
update({ issue: null, outcome: "release", detail: fmt(freed) });
|
|
873
918
|
}
|
|
874
919
|
};
|
|
920
|
+
// #4773: re-read the issues carrying the blocked label; one decision and row per change. A failed read keeps the last set.
|
|
921
|
+
let blocked = new Set();
|
|
922
|
+
const blockedHit = new Set(); // blocked issues that were pending this pass
|
|
923
|
+
const refreshBlocked = async () => {
|
|
924
|
+
if (!getBlocked) return;
|
|
925
|
+
let got;
|
|
926
|
+
try { got = await getBlocked(); } catch { return; }
|
|
927
|
+
const next = new Set((Array.isArray(got) ? got : [...(got ?? [])]).filter((n) => Number.isSafeInteger(n) && n > 0));
|
|
928
|
+
const fmt = (ns) => ns.map((n) => `#${n}`).join(", ");
|
|
929
|
+
const on = [...next].filter((n) => !blocked.has(n) && set.has(n) && !results.has(n)), off = [...blocked].filter((n) => !next.has(n));
|
|
930
|
+
blocked = next;
|
|
931
|
+
if (on.length) {
|
|
932
|
+
decided({ issue: null, what: `blocked ${fmt(on)} (blocked label)`, why: "admitted to the batch, not dispatched while the label is set", alternative: "remove the label" });
|
|
933
|
+
update({ issue: null, outcome: "blocked", detail: fmt(on) });
|
|
934
|
+
}
|
|
935
|
+
if (off.length) {
|
|
936
|
+
decided({ issue: null, what: `unblocked ${fmt(off)} (blocked label removed)`, why: "the label is gone", alternative: "keep waiting" });
|
|
937
|
+
update({ issue: null, outcome: "unblocked", detail: fmt(off) });
|
|
938
|
+
}
|
|
939
|
+
};
|
|
875
940
|
// #4656: a gate (hold list, settle, ship pre-flight) waiting on an issue the scheduler will not admit: one decision and row per (gate, issue).
|
|
876
941
|
const gateSeen = new Map(), gateReported = new Set();
|
|
877
942
|
const checkGates = () => {
|
|
@@ -990,10 +1055,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
990
1055
|
driftHeld.clear();
|
|
991
1056
|
integHeld.clear();
|
|
992
1057
|
opHeldHit.clear();
|
|
1058
|
+
blockedHit.clear();
|
|
993
1059
|
refreshHeld();
|
|
1060
|
+
await refreshBlocked();
|
|
994
1061
|
checkGates();
|
|
995
1062
|
const isReady = (n) => {
|
|
996
1063
|
if (opHeld.has(n)) { opHeldHit.add(n); return false; }
|
|
1064
|
+
if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
|
|
997
1065
|
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
998
1066
|
if (integration?.holds) {
|
|
999
1067
|
let why = null;
|
|
@@ -1072,6 +1140,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1072
1140
|
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
1073
1141
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1074
1142
|
if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1143
|
+
if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
|
|
1075
1144
|
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
|
1076
1145
|
if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
|
|
1077
1146
|
if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
|
|
@@ -1082,6 +1151,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1082
1151
|
else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
1083
1152
|
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
1084
1153
|
else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
|
|
1154
|
+
else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
|
|
1085
1155
|
else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
|
|
1086
1156
|
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
1087
1157
|
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
|
|
@@ -1149,6 +1219,8 @@ export function formatOutcomeLists(rows) {
|
|
|
1149
1219
|
if (rq.length) out.push(`remediation queue (/work-on:remediate): ${rq.map((r) => `#${r.issue} (parked:${r.code}${r.pr ? ` PR #${r.pr}` : ""})`).join(" ")}`);
|
|
1150
1220
|
const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
|
|
1151
1221
|
if (parked) out.push(parked);
|
|
1222
|
+
const decided = (rows ?? []).filter((r) => r?.decided); // #4766: settled by the decision engine, listed not hidden
|
|
1223
|
+
if (decided.length) out.push(["decided (north-star engine, veto with the forge:decision-veto label):", ...decided.map((r) => `- #${r.issue}: ${one(r.decided.choice)}${r.decided.shadow ? " (shadow)" : ""}`)].join("\n"));
|
|
1152
1224
|
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
1153
1225
|
return out;
|
|
1154
1226
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* probe runner's minimal env): all pass -> a FORGE:PROBE record and the follow-up closes; a fail -> the record, and the
|
|
10
10
|
* parent reopens with the output. An item without a runnable check is `awaiting-measurement`: no lane, ever.
|
|
11
11
|
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { noComments } from "./render.mjs";
|
|
13
14
|
import { MAX_TIMEOUT_S, OUTPUT_TAIL, PROBE_MARKER, probeEnv, probeResultOf, readProbeConfig, runProbe } from "./probe.mjs";
|
|
14
15
|
import { parseMinAge } from "./prefilter.mjs";
|
|
@@ -18,6 +19,7 @@ export const CHECK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
|
18
19
|
export const AWAITING_MEASUREMENT_LABEL = "awaiting-measurement";
|
|
19
20
|
export const MONITORING_LABEL = "post-deploy-monitoring";
|
|
20
21
|
export const OWNER_ASK_MARKER = "FORGE:OWNER_ASK"; // #4689
|
|
22
|
+
export const ROLLOUT_ASK_MARKER = "FORGE:ROLLOUT_ASK"; // #4769: distinct, so it never silences the no-runnable-check ask
|
|
21
23
|
export const DEFAULT_EVERY_MS = 6 * 3600e3;
|
|
22
24
|
const ITEM_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
23
25
|
const CHECK_TOKEN_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:\s*\[check:\s*([^\]\s]*)\s*\]/;
|
|
@@ -98,8 +100,30 @@ export async function addMonitoringLabel({ io, repo, issue }) {
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
+
/**
|
|
104
|
+
* #4689: tick every unticked post-deploy checklist item of a follow-up body (the evidence is in the FORGE:PROBE comment).
|
|
105
|
+
* #4755: with `ids` (a Set of check ids) only the items naming one of them are ticked.
|
|
106
|
+
*/
|
|
107
|
+
export function tickItems(body, ids = null) {
|
|
108
|
+
const text = String(body ?? "");
|
|
109
|
+
const re = /^(\s*[-*]\s+)\[ \](\s+`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:)/;
|
|
110
|
+
if (!ids) return text.replace(new RegExp(re.source, "gm"), "$1[x]$2");
|
|
111
|
+
const per = itemCheckIds(text);
|
|
112
|
+
let i = 0;
|
|
113
|
+
return text.split("\n").map((l) => (ITEM_RE.test(l) ? (ids.has(per[i++]) ? l.replace(re, "$1[x]$2") : l) : l)).join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** #4755: per checklist item `{id, done}`: its check id (null when none) and whether it is already ticked. */
|
|
117
|
+
const itemStates = (body) => {
|
|
118
|
+
const ids = itemCheckIds(body);
|
|
119
|
+
return noComments(String(body ?? ""), " ").split("\n").map((l) => ITEM_RE.exec(l)).filter(Boolean).map((m, i) => ({ id: ids[i], done: /^\s*[-*]\s+\[[xX]\]/.test(m[0]) }));
|
|
120
|
+
};
|
|
121
|
+
const ROLLUP_RE = /<!--\s*FORGE:POSTDEPLOY_ROLLUP:/;
|
|
122
|
+
/** #4755: the check ids a FORGE:PROBE record of `fix` reported failing. */
|
|
123
|
+
const failedIdsOf = (body, fix) => {
|
|
124
|
+
const line = String(body ?? "").split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
|
|
125
|
+
try { const j = JSON.parse(line.trim().replace(/^`|`$/g, "")); return j.fix === fix && j.result === "fail" ? (j.checks ?? []).filter((c) => c.result === "fail").map((c) => c.id) : []; } catch { return []; }
|
|
126
|
+
};
|
|
103
127
|
|
|
104
128
|
/** #4689: one typed `FORGE:OWNER_ASK` comment for the items with no runnable check, posted once; best effort, nothing in a dry run. */
|
|
105
129
|
async function postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def }) {
|
|
@@ -108,6 +132,27 @@ async function postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, de
|
|
|
108
132
|
try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", `<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started and none will be. Needed from the owner: ${what}, or measure the item by hand.`]); } catch { /* the label still shows it */ }
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
/** #4769: a check output `BLOCKED: ... <flag> = off ...` means the rollout is off, not that the change is broken: {flag, evidence} or null. */
|
|
136
|
+
export function blockedRollout(output) {
|
|
137
|
+
const m = /^BLOCKED:\s*(?:.*?\s)?([A-Za-z0-9_.:*-]+)\s*=\s*off\b.*$/m.exec(String(output ?? ""));
|
|
138
|
+
return m ? { flag: m[1], evidence: String(output).trim().slice(-OUTPUT_TAIL) } : null;
|
|
139
|
+
}
|
|
140
|
+
const evidenceHash = (text) => createHash("sha1").update(String(text).replace(/\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?/g, "").trim()).digest("hex").slice(0, 12);
|
|
141
|
+
const ASK_RE = /FORGE:ROLLOUT_ASK rollout:(\S+) evidence:([0-9a-f]+)/g;
|
|
142
|
+
/** #4769: one typed owner ask per blocked flag, re-posted only when its evidence hash changes. Returns the flags newly raised. */
|
|
143
|
+
async function postRolloutAsks({ iss, repo, io, isTrusted, blocked, def }) {
|
|
144
|
+
const prior = new Map();
|
|
145
|
+
for (const c of iss.comments ?? []) if (isTrusted(c)) for (const m of String(c?.body ?? "").matchAll(ASK_RE)) prior.set(m[1], m[2]);
|
|
146
|
+
const raised = [];
|
|
147
|
+
for (const b of blocked) {
|
|
148
|
+
const hash = evidenceHash(b.evidence);
|
|
149
|
+
if (prior.get(b.flag) === hash) continue;
|
|
150
|
+
const body = `<!-- ${ROLLOUT_ASK_MARKER} rollout:${b.flag} evidence:${hash} -->\n## Owner ask: promote \`${b.flag}\`\nThe post-deploy check on \`${def}\` is blocked because \`${b.flag}\` is off. No lane was started and none will be. Decide: promote it (first cohort, guard metrics, shadow evidence below) or record a keep-off decision.\n\n${fenced(b.evidence)}`;
|
|
151
|
+
try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]); raised.push(b.flag); } catch { /* retried next pass */ }
|
|
152
|
+
}
|
|
153
|
+
return raised;
|
|
154
|
+
}
|
|
155
|
+
|
|
111
156
|
/** #4689: one engineering issue per failing check (never a lane): the output, the fix and the follow-up. Best effort. */
|
|
112
157
|
async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
|
|
113
158
|
for (const r of bad) {
|
|
@@ -118,6 +163,46 @@ async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
|
|
|
118
163
|
}
|
|
119
164
|
}
|
|
120
165
|
|
|
166
|
+
/**
|
|
167
|
+
* #4755: a post-deploy rollup is worked per item, so one failing or check-less item never blocks the rest. Runs the unticked
|
|
168
|
+
* items that have a default-branch check (minus those a FORGE:PROBE record of this fix already failed), ticks the passing ones
|
|
169
|
+
* (the evidence is the PROBE record), files one issue per failing check, and closes the rollup only when every item is ticked.
|
|
170
|
+
*/
|
|
171
|
+
async function measureItems({ iss, repo, def, shas, checks, cwd, io, dryRun, isTrusted, config, env, spawn, now }) {
|
|
172
|
+
const fix = [...shas].sort().join(",");
|
|
173
|
+
const todo = itemStates(iss.body).filter((t) => !t.done);
|
|
174
|
+
if (!todo.length) { if (!dryRun) await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]); return { state: "pass", detail: "every item is ticked" }; }
|
|
175
|
+
const failed = new Set((iss.comments ?? []).filter((c) => isTrusted(c)).flatMap((c) => failedIdsOf(c?.body, fix)));
|
|
176
|
+
const missing = todo.filter((t) => !t.id || !checks[t.id]).map((t) => t.id);
|
|
177
|
+
const ids = todo.filter((t) => t.id && checks[t.id] && !failed.has(t.id)).map((t) => t.id);
|
|
178
|
+
if (dryRun) return { state: "dry-run", detail: `checks ${[...new Set(ids)].join(", ") || "none"} not run (dry run)` };
|
|
179
|
+
if (missing.length) await postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids: todo.map((t) => t.id), missing, def });
|
|
180
|
+
const rows = ids.length ? await runNamedChecks({ ids, checks, cwd, config, env, spawn }) : [];
|
|
181
|
+
const blockedRows = rows.filter((r) => r.result === "fail" && blockedRollout(r.output)); // #4769
|
|
182
|
+
const good = rows.filter((r) => r.result === "pass"), bad = rows.filter((r) => r.result === "fail" && !blockedRows.includes(r));
|
|
183
|
+
const blocked = [...new Map(blockedRows.map((r) => [blockedRollout(r.output).flag, blockedRollout(r.output)])).values()];
|
|
184
|
+
if (blocked.length) await postRolloutAsks({ iss, repo, io, isTrusted, blocked, def });
|
|
185
|
+
if (good.length || bad.length) {
|
|
186
|
+
let defaultSha = null;
|
|
187
|
+
try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
|
|
188
|
+
const body = [
|
|
189
|
+
`<!-- ${PROBE_MARKER} -->`, `## Post-deploy check: ${bad.length ? "fail" : "pass"}`,
|
|
190
|
+
JSON.stringify({ fix, default_sha: defaultSha, result: bad.length ? "fail" : "pass", at: new Date(now()).toISOString(), checks: rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms })) }), "",
|
|
191
|
+
`Ran on \`${def}\` per rollup item; commands come from \`${CHECKS_FILE}\` on \`${def}\`. Passing items are ticked; only failing checks get an issue.`,
|
|
192
|
+
...bad.flatMap((r) => ["", `**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output)]),
|
|
193
|
+
].join("\n").slice(0, OUTPUT_TAIL * 4);
|
|
194
|
+
await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
|
|
195
|
+
const ticked = tickItems(iss.body, new Set(good.map((r) => r.id)));
|
|
196
|
+
if (ticked !== iss.body) { try { await io.gh(["issue", "edit", String(iss.number), "-R", repo, "--body", ticked]); } catch { /* the PROBE record is the evidence */ } }
|
|
197
|
+
if (bad.length) await fileFailureIssues({ bad, iss, repo, io, parent: null, def, fix });
|
|
198
|
+
}
|
|
199
|
+
const left = todo.filter((t) => !(t.id && good.some((r) => r.id === t.id)));
|
|
200
|
+
if (!left.length) { await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]); return { state: "pass", detail: `checks ${[...new Set(good.map((r) => r.id))].join(", ")} passed` }; }
|
|
201
|
+
if (blocked.length && !bad.length && !failed.size && !missing.length) return { state: "awaiting-measurement", blocked: blocked.map((b) => b.flag), detail: `${good.length} item(s) ticked; blocked on rollout ${blocked.map((b) => b.flag).join(", ")} (owner ask)` };
|
|
202
|
+
if (missing.length) return { state: "awaiting-measurement", detail: `${good.length} item(s) ticked; no runnable check on ${def} for ${missing.map((m) => m ?? "an item without a check id").join(", ")} (${CHECKS_FILE})` };
|
|
203
|
+
return { state: bad.length ? "fail" : "recorded-fail", detail: `${good.length} item(s) ticked; failing: ${[...new Set([...bad.map((r) => r.id), ...failed])].join(", ")}` };
|
|
204
|
+
}
|
|
205
|
+
|
|
121
206
|
/**
|
|
122
207
|
* Measure a ready post-deploy follow-up. Never runs a command that is not in the default branch's checks file.
|
|
123
208
|
* @returns {Promise<{state:"awaiting-measurement"|"pass"|"fail"|"recorded-fail"|"dry-run"|"monitoring", detail:string}>}
|
|
@@ -127,6 +212,7 @@ async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
|
|
|
127
212
|
export async function measureFollowup({ iss, repo, parent, def, shas = [], cwd, io, dryRun = false, isTrusted = () => false, config = readProbeConfig(cwd), env = process.env, spawn, now = () => Date.now() }) {
|
|
128
213
|
const ids = itemCheckIds(iss.body);
|
|
129
214
|
const checks = await checksAt(io.git, [`origin/${def}`], cwd);
|
|
215
|
+
if (ROLLUP_RE.test(String(iss.body ?? "")) && !ids.some((id) => id && checks[id]?.forMs)) return measureItems({ iss, repo, def, shas, checks, cwd, io, dryRun, isTrusted, config, env, spawn, now }); // #4755
|
|
130
216
|
const missing = ids.filter((id) => !id || !checks[id]);
|
|
131
217
|
if (!ids.length || missing.length) {
|
|
132
218
|
await postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def });
|
|
@@ -204,3 +204,81 @@ describe("verification outcome (#4689)", () => {
|
|
|
204
204
|
assert.equal(w2.gh.filter((a) => a[1] === "comment").length, 0);
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("post-deploy rollup is verified per item (#4755)", () => {
|
|
209
|
+
const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items.join("\n")}\n\n<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\n`;
|
|
210
|
+
const files = JSON.stringify({ bad: "echo nope", smoke: "echo ok", other: "echo ok" });
|
|
211
|
+
it("ticks the passing item, files one issue for the failure, one owner ask for the check-less item, stays open", async () => {
|
|
212
|
+
const w = world({ file: files });
|
|
213
|
+
const r = await run(w, rollup([item("R1", "[check:smoke] x"), item("R2", "[check:bad] x"), item("R3", "[check:gone] x")]));
|
|
214
|
+
assert.equal(r.state, "awaiting-measurement");
|
|
215
|
+
const edit = w.gh.find((a) => a[1] === "edit" && a.includes("--body"));
|
|
216
|
+
const text = edit[edit.indexOf("--body") + 1];
|
|
217
|
+
assert.match(text, /- \[x\] `R1:/);
|
|
218
|
+
assert.match(text, /- \[ \] `R2:/);
|
|
219
|
+
assert.match(text, /- \[ \] `R3:/);
|
|
220
|
+
assert.equal(w.gh.filter((a) => a[1] === "create").length, 1);
|
|
221
|
+
assert.equal(w.gh.filter((a) => a[1] === "comment" && /FORGE:OWNER_ASK/.test(a[a.indexOf("--body") + 1])).length, 1);
|
|
222
|
+
assert.ok(!w.gh.some((a) => a[1] === "close" || a[1] === "reopen"));
|
|
223
|
+
const probe = w.gh.find((a) => a[1] === "comment" && /FORGE:PROBE/.test(a[a.indexOf("--body") + 1]));
|
|
224
|
+
assert.match(probe[probe.indexOf("--body") + 1], /"id":"smoke","result":"pass"/);
|
|
225
|
+
});
|
|
226
|
+
it("a failing check does not block the passing items, and is not re-run for the same fix", async () => {
|
|
227
|
+
const w = world({ file: files });
|
|
228
|
+
const b = rollup([item("R1", "[check:smoke] x"), item("R2", "[check:bad] x")]);
|
|
229
|
+
assert.equal((await run(w, b)).state, "fail");
|
|
230
|
+
const probe = w.gh.find((a) => a[1] === "comment" && /FORGE:PROBE/.test(a[a.indexOf("--body") + 1]));
|
|
231
|
+
const ticked = w.gh.find((a) => a[1] === "edit")[w.gh.find((a) => a[1] === "edit").indexOf("--body") + 1];
|
|
232
|
+
const w2 = world({ file: files });
|
|
233
|
+
const r = await run(w2, ticked, { iss: { number: 41, body: ticked, comments: [{ body: probe[probe.indexOf("--body") + 1] }] }, isTrusted: () => true });
|
|
234
|
+
assert.equal(r.state, "recorded-fail");
|
|
235
|
+
assert.deepEqual(w2.spawned, []);
|
|
236
|
+
assert.equal(w2.gh.filter((a) => a[1] === "create").length, 0);
|
|
237
|
+
});
|
|
238
|
+
it("closes only when every item is ticked", async () => {
|
|
239
|
+
const w = world({ file: files });
|
|
240
|
+
assert.equal((await run(w, rollup([item("R1", "[check:smoke] x"), item("R2", "[check:other] x")]))).state, "pass");
|
|
241
|
+
assert.ok(w.gh.some((a) => a[1] === "close"));
|
|
242
|
+
assert.ok(!w.gh.some((a) => a[1] === "create"));
|
|
243
|
+
});
|
|
244
|
+
it("tickItems with an id set ticks only those items", () => {
|
|
245
|
+
const b = [item("R1", "[check:a] x"), item("R2", "[check:b] x")].join("\n");
|
|
246
|
+
assert.equal(tickItems(b, new Set(["b"])), `${item("R1", "[check:a] x")}\n${item("R2", "[check:b] x")}`.replace("- [ ] `R2", "- [x] `R2"));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("rollout-blocked rows raise one owner ask per flag (#4769)", () => {
|
|
251
|
+
const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items.join("\n")}\n\n<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\n`;
|
|
252
|
+
const checks = JSON.stringify({ a: "echo a", b: "echo b", ok: "echo ok" });
|
|
253
|
+
const blockedOut = (evidence) => ({ code: 1, stdout: `BLOCKED: clearance_exit_routing = off in the rollout controller; owner must promote\n${evidence}`, stderr: "" });
|
|
254
|
+
const b = rollup([item("R1", "[check:a] x"), item("R2", "[check:b] y"), item("R3", "[check:ok] z")]);
|
|
255
|
+
const spawn = (evidence) => (o) => (o.run === "echo ok" ? { code: 0, stdout: "ok", stderr: "" } : blockedOut(evidence));
|
|
256
|
+
const asks = (w) => w.gh.filter((a) => a[1] === "comment").map((a) => a[a.indexOf("--body") + 1]).filter((t) => /FORGE:ROLLOUT_ASK/.test(t));
|
|
257
|
+
|
|
258
|
+
it("two blocked items on one flag give one ask, no failure issue and no PROBE fail", async () => {
|
|
259
|
+
const w = world({ file: checks, spawn: spawn("hits=0") });
|
|
260
|
+
const r = await run(w, b);
|
|
261
|
+
assert.equal(r.state, "awaiting-measurement"); assert.deepEqual(r.blocked, ["clearance_exit_routing"]);
|
|
262
|
+
assert.equal(asks(w).length, 1);
|
|
263
|
+
assert.ok(!w.gh.some((a) => a[1] === "create"), "no failing-check issue");
|
|
264
|
+
assert.ok(!w.gh.some((a) => a[1] === "comment" && /Post-deploy check: fail/.test(a[a.indexOf("--body") + 1])));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("the next release with the same evidence raises nothing; changed evidence raises it again", async () => {
|
|
268
|
+
const w1 = world({ file: checks, spawn: spawn("hits=0") });
|
|
269
|
+
await run(w1, b);
|
|
270
|
+
const posted = { body: asks(w1)[0] };
|
|
271
|
+
const w2 = world({ file: checks, spawn: spawn("hits=0") });
|
|
272
|
+
await run(w2, b, { iss: { number: 41, body: b, comments: [posted] }, isTrusted: () => true });
|
|
273
|
+
assert.equal(asks(w2).length, 0, "same evidence: no new ask");
|
|
274
|
+
const w3 = world({ file: checks, spawn: spawn("hits=7") });
|
|
275
|
+
await run(w3, b, { iss: { number: 41, body: b, comments: [posted] }, isTrusted: () => true });
|
|
276
|
+
assert.equal(asks(w3).length, 1, "changed evidence: re-raised");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("a plain failing check is still a failure", async () => {
|
|
280
|
+
const w = world({ file: checks, spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
|
|
281
|
+
const r = await run(w, rollup([item("R1", "[check:a] x")]));
|
|
282
|
+
assert.equal(r.state, "fail"); assert.equal(asks(w).length, 0);
|
|
283
|
+
});
|
|
284
|
+
});
|