forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/engine.mjs
CHANGED
|
@@ -506,12 +506,14 @@ export async function runIssue(opts) {
|
|
|
506
506
|
// declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
|
|
507
507
|
{
|
|
508
508
|
let dep = null;
|
|
509
|
-
try { dep = await openDependency(issue, io); } catch (e) {
|
|
509
|
+
try { dep = await openDependency(issue, io, { lane }); } catch (e) {
|
|
510
510
|
dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
|
|
511
511
|
}
|
|
512
512
|
if (dep?.unreadable) {
|
|
513
513
|
return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
|
|
514
514
|
}
|
|
515
|
+
// #4686: an open dependency whose PR is already on this lane is satisfied; name the merge commit.
|
|
516
|
+
for (const x of dep?.satisfied ?? []) emitProgress({ event: "dependency_satisfied", detail: `#${x.issue} merged into ${lane} by PR #${x.pr} (merge commit ${x.sha.slice(0, 12)})` });
|
|
515
517
|
if (dep?.open?.length) {
|
|
516
518
|
return {
|
|
517
519
|
terminalReason: "dependency-open",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4629: live A/B experiments. forge.yaml `experiment:` section (flat keys, the parser has one nesting level):
|
|
5
|
+
* id: light-vs-full arms: light,full ratio: 1,1 (weights, default equal)
|
|
6
|
+
* metrics: usd_median,wall_p50_ms,first_pass_rate,needs_human_rate (default all)
|
|
7
|
+
* targets: light.usd_median<=1.2xfull,light.first_pass_rate>=0.8 min_n: 10
|
|
8
|
+
* The arm is a hash of (id, issue), so a re-run or resume keeps its arm. Pure; the stats report is bin/stats/experiment.mjs.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
14
|
+
|
|
15
|
+
export const EXPERIMENT_METRICS = ["usd_median", "wall_p50_ms", "first_pass_rate", "needs_human_rate"];
|
|
16
|
+
export const BENCH_SCHEMA = "forge.bench.v1";
|
|
17
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
18
|
+
const TARGET = /^([A-Za-z0-9][A-Za-z0-9._-]*)\.([a-z_0-9]+)(<=|>=)(\d+(?:\.\d+)?)(?:x([A-Za-z0-9][A-Za-z0-9._-]*))?$/;
|
|
19
|
+
const list = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
20
|
+
|
|
21
|
+
/** Parsed declaration {id, arms, ratio, metrics, targets, minN}, or null when absent or invalid. */
|
|
22
|
+
export function parseExperiment(sec, { defaultMinN = 10 } = {}) {
|
|
23
|
+
if (!sec || typeof sec !== "object") return null;
|
|
24
|
+
const id = String(sec.id ?? "").trim(), arms = list(sec.arms);
|
|
25
|
+
if (!NAME.test(id) || arms.length < 2 || new Set(arms).size !== arms.length || !arms.every((a) => NAME.test(a))) return null;
|
|
26
|
+
const ratio = sec.ratio == null || sec.ratio === "" ? arms.map(() => 1) : list(sec.ratio).map((x) => (/^[1-9]\d*$/.test(x) ? Number(x) : NaN));
|
|
27
|
+
if (ratio.length !== arms.length || ratio.some(Number.isNaN)) return null;
|
|
28
|
+
const metrics = sec.metrics ? list(sec.metrics) : [...EXPERIMENT_METRICS];
|
|
29
|
+
if (!metrics.length || metrics.some((m) => !EXPERIMENT_METRICS.includes(m))) return null;
|
|
30
|
+
const targets = [];
|
|
31
|
+
for (const t of list(sec.targets)) {
|
|
32
|
+
const m = TARGET.exec(t);
|
|
33
|
+
if (!m || !arms.includes(m[1]) || !metrics.includes(m[2]) || (m[5] && !arms.includes(m[5]))) return null;
|
|
34
|
+
targets.push({ arm: m[1], metric: m[2], op: m[3], limit: Number(m[4]), vs: m[5] ?? null, text: t });
|
|
35
|
+
}
|
|
36
|
+
const mn = String(sec.min_n ?? "").trim();
|
|
37
|
+
if (mn && !/^\d+$/.test(mn)) return null;
|
|
38
|
+
return { id, arms, ratio, metrics, targets, minN: mn ? Number(mn) : defaultMinN };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** forge.yaml `experiment:` of `cwd`, or null. */
|
|
42
|
+
export function readExperiment(cwd = process.cwd()) {
|
|
43
|
+
try { return parseExperiment(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.experiment); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Deterministic arm of an issue: sha1(id:issue) mod the ratio total, walked over the arms in declared order. */
|
|
47
|
+
export function assignArm(exp, issue) {
|
|
48
|
+
const total = exp.ratio.reduce((a, b) => a + b, 0);
|
|
49
|
+
let pick = createHash("sha1").update(`${exp.id}:${issue}`).digest().readUInt32BE(0) % total;
|
|
50
|
+
for (let i = 0; i < exp.arms.length; i++) { if (pick < exp.ratio[i]) return exp.arms[i]; pick -= exp.ratio[i]; }
|
|
51
|
+
return exp.arms[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The shared result record of the bench harness, the eval gate and `stats --experiment`: targets are {pass: true|false|null, value, limit}. */
|
|
55
|
+
export const benchRecord = ({ source, arms = {}, targets = {} }) => ({ schema: BENCH_SCHEMA, source, arms, targets });
|
package/bin/forgedock.mjs
CHANGED
|
@@ -86,6 +86,8 @@ import {
|
|
|
86
86
|
CYAN,
|
|
87
87
|
} from "./tui.mjs";
|
|
88
88
|
import { buildMinimalForgeYaml } from "./init-detect.mjs";
|
|
89
|
+
import { checkContext, refreshContextFlow } from "./review-context.mjs";
|
|
90
|
+
import { botTokenStatus } from "./bot-token-status.mjs";
|
|
89
91
|
import {
|
|
90
92
|
parseNameStatusDiff,
|
|
91
93
|
classifyCommandChanges,
|
|
@@ -234,7 +236,7 @@ const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
|
234
236
|
// --minimal is init-only. Subcommands with their own flag parsing (run,
|
|
235
237
|
// run-issue, demo) receive restArgs — everything after the command token.
|
|
236
238
|
const rawArgs = process.argv.slice(2);
|
|
237
|
-
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras"]);
|
|
239
|
+
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras", "--refresh-context"]);
|
|
238
240
|
const flags = rawArgs.filter((a) => FLAGS.has(a));
|
|
239
241
|
// --global is stripped from positionals/restArgs for backward compatibility
|
|
240
242
|
// with existing invocations, but is otherwise a no-op — see the install-mode
|
|
@@ -1042,6 +1044,13 @@ function ensureToolStateIgnored(c) {
|
|
|
1042
1044
|
|
|
1043
1045
|
async function initFlow(c) {
|
|
1044
1046
|
const outputPath = join(c.cwd, "forge.yaml");
|
|
1047
|
+
if (flags.includes("--refresh-context")) {
|
|
1048
|
+
return refreshContextFlow({
|
|
1049
|
+
cwd: c.cwd,
|
|
1050
|
+
out: c.stdout,
|
|
1051
|
+
deps: { interactive: process.stdin.isTTY === true, confirm, backup: backupExisting, write: atomicWriteFile },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1045
1054
|
const hasExisting = existsSync(outputPath);
|
|
1046
1055
|
if (hasExisting && process.stdin.isTTY !== true) {
|
|
1047
1056
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
@@ -2704,6 +2713,15 @@ async function doctor(fix = false) {
|
|
|
2704
2713
|
);
|
|
2705
2714
|
}
|
|
2706
2715
|
|
|
2716
|
+
try {
|
|
2717
|
+
const ctxCheck = checkContext({ configPath: forgeYamlPath, root: process.cwd(), repo: forgeOwner && forgeRepo ? `${forgeOwner}/${forgeRepo}` : "" });
|
|
2718
|
+
if (ctxCheck.stale) {
|
|
2719
|
+
warn("review context", `REVIEW_CONTEXT_STALE: ${ctxCheck.reasons.map((r) => r.detail).join("; ")}. Run: npx forgedock init --refresh-context`);
|
|
2720
|
+
} else if (ctxCheck.advisory) {
|
|
2721
|
+
warn("review context", `REVIEW_CONTEXT_STALE: generated ${ctxCheck.generated_at}, ${ctxCheck.merged_since} PRs merged since (limit ${ctxCheck.max_merged_prs}). Run: npx forgedock init --refresh-context`);
|
|
2722
|
+
}
|
|
2723
|
+
} catch { /* advisory check: never fail doctor */ }
|
|
2724
|
+
|
|
2707
2725
|
const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
|
|
2708
2726
|
const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
|
|
2709
2727
|
if (
|
|
@@ -3243,14 +3261,20 @@ async function doctor(fix = false) {
|
|
|
3243
3261
|
}
|
|
3244
3262
|
|
|
3245
3263
|
// ── Check 12: GitHub App / bot token status ─────────────────────────────────
|
|
3246
|
-
// Informational
|
|
3264
|
+
// Informational without a refresh stamp (#4662: with one, a stale or expiring token fails). Installing the ForgeDock GitHub
|
|
3247
3265
|
// App registers it against the account/org but does not, by itself, mint a
|
|
3248
3266
|
// bot token: that requires the app's private key, which only the app owner
|
|
3249
3267
|
// holds. Pipeline gh calls always use whatever `gh auth` context is active
|
|
3250
3268
|
// locally (personal token unless the operator manually configured a bot
|
|
3251
3269
|
// token) — this check exists so that state is surfaced instead of silent.
|
|
3252
3270
|
// See docs/CONFIG.md "GitHub App Install" and forge#1890.
|
|
3253
|
-
|
|
3271
|
+
const botTok = botTokenStatus(); // #4662: lifetime from the refresh stamp; no stamp means no refresher is installed
|
|
3272
|
+
if (botTok.configured) {
|
|
3273
|
+
const min = (ms) => Math.round(ms / 60000);
|
|
3274
|
+
const detail = botTok.remainingMs != null ? `${min(botTok.remainingMs)} min left, refreshed ${min(botTok.ageMs)} min ago (${botTok.refreshedAt})` : botTok.reason;
|
|
3275
|
+
if (botTok.ok) pass("GitHub App / bot token", detail);
|
|
3276
|
+
else fail(`GitHub App / bot token: ${botTok.reason} (${detail})`, "run scripts/refresh-bot-token.sh and install the timer: docs/CONFIG.md \"Bot token refresh\"");
|
|
3277
|
+
} else {
|
|
3254
3278
|
warn(
|
|
3255
3279
|
"GitHub App / bot token",
|
|
3256
3280
|
"pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
|
|
@@ -3277,6 +3301,19 @@ async function doctor(fix = false) {
|
|
|
3277
3301
|
}
|
|
3278
3302
|
}
|
|
3279
3303
|
|
|
3304
|
+
// ── Check 14: Router/schedd restarts (#4673) ───────────────────────────────
|
|
3305
|
+
// A restart in the last hour usually means an OOM killer or SIGTERM took the coordinator and dropped in-flight lanes. Warn only.
|
|
3306
|
+
{
|
|
3307
|
+
try {
|
|
3308
|
+
const { restartCheck } = await import("./pool/health.mjs");
|
|
3309
|
+
for (const unit of ["computeshare-router", "forgedock-schedd"]) {
|
|
3310
|
+
const r = restartCheck({ unit });
|
|
3311
|
+
if (r.status === "warn") warn(`Restarts: ${unit}`, r.detail);
|
|
3312
|
+
else if (r.status === "pass") pass(`Restarts: ${unit}`, r.detail);
|
|
3313
|
+
}
|
|
3314
|
+
} catch { /* no systemd here: nothing to report */ }
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3280
3317
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
3281
3318
|
console.log("");
|
|
3282
3319
|
if (fix && fixesApplied > 0) {
|
|
@@ -3314,12 +3351,16 @@ function help() {
|
|
|
3314
3351
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3315
3352
|
["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
|
|
3316
3353
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3354
|
+
["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
|
|
3317
3355
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3318
|
-
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
|
|
3356
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
|
|
3319
3357
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3320
3358
|
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3321
3359
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
3360
|
+
["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
|
|
3361
|
+
["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
|
|
3322
3362
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3363
|
+
["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
|
|
3323
3364
|
["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
|
|
3324
3365
|
["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
|
|
3325
3366
|
["npx forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>", "Built-in remote compute pool: run the router (foreground, or install it as a user service), print the one-time install command for a host, list agents, upgrade or remove them"],
|
|
@@ -3350,6 +3391,7 @@ function help() {
|
|
|
3350
3391
|
["--manual", "Plain text prompts instead of the review screen (init)"],
|
|
3351
3392
|
["--verbose", "Show detection sources for every field (init)"],
|
|
3352
3393
|
["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
|
|
3394
|
+
["--refresh-context", "Regenerate review.context/tech_stack from manifests and show a diff for approval (init)"],
|
|
3353
3395
|
["--version, -v", "Print the installed version and check for updates"],
|
|
3354
3396
|
];
|
|
3355
3397
|
|
|
@@ -3903,7 +3945,7 @@ async function watch() {
|
|
|
3903
3945
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3904
3946
|
const KNOWN_COMMANDS = new Set([
|
|
3905
3947
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3906
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3948
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3907
3949
|
"version", "--version", "-v",
|
|
3908
3950
|
]);
|
|
3909
3951
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4060,6 +4102,12 @@ switch (command) {
|
|
|
4060
4102
|
case "run":
|
|
4061
4103
|
await run();
|
|
4062
4104
|
break;
|
|
4105
|
+
case "plan": {
|
|
4106
|
+
// #4659: bootstrap-validate's dependency rules on live GitHub state
|
|
4107
|
+
const { runLive } = await import("../scripts/plan-validate-live.mjs");
|
|
4108
|
+
exitCode = await runLive(restArgs);
|
|
4109
|
+
break;
|
|
4110
|
+
}
|
|
4063
4111
|
case "bootstrap": {
|
|
4064
4112
|
// #4177: headless /bootstrap; --plan runs scripts/bootstrap-create.mjs directly (no LLM), a re-run resumes.
|
|
4065
4113
|
const { runBootstrap } = await import("./bootstrap.mjs");
|
|
@@ -4136,6 +4184,17 @@ switch (command) {
|
|
|
4136
4184
|
}
|
|
4137
4185
|
break;
|
|
4138
4186
|
}
|
|
4187
|
+
case "decisions": {
|
|
4188
|
+
// #4695: anchored decisions of the local index
|
|
4189
|
+
const { decisionsFromCli } = await import("./engine-cli.mjs");
|
|
4190
|
+
try {
|
|
4191
|
+
await decisionsFromCli(restArgs);
|
|
4192
|
+
} catch (err) {
|
|
4193
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4194
|
+
exitCode = 1;
|
|
4195
|
+
}
|
|
4196
|
+
break;
|
|
4197
|
+
}
|
|
4139
4198
|
case "check": {
|
|
4140
4199
|
// #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
|
|
4141
4200
|
const { checkFromCli } = await import("./engine-cli.mjs");
|
|
@@ -4199,6 +4258,50 @@ switch (command) {
|
|
|
4199
4258
|
}
|
|
4200
4259
|
break;
|
|
4201
4260
|
}
|
|
4261
|
+
case "milestone-sync": {
|
|
4262
|
+
// On-demand milestone branch sync (#4663): PR + auto-merge under protected branches, one debounced sync PR.
|
|
4263
|
+
const { milestoneSyncFromCli } = await import("./engine-cli.mjs");
|
|
4264
|
+
try {
|
|
4265
|
+
exitCode = await milestoneSyncFromCli(restArgs);
|
|
4266
|
+
} catch (err) {
|
|
4267
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4268
|
+
exitCode = 1;
|
|
4269
|
+
}
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
case "milestone": {
|
|
4273
|
+
// Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
|
|
4274
|
+
const { trainFromCli } = await import("./engine-cli.mjs");
|
|
4275
|
+
try {
|
|
4276
|
+
if (restArgs[0] !== "train") throw new Error("usage: forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> | --show <id>");
|
|
4277
|
+
const result = await trainFromCli(restArgs.slice(1));
|
|
4278
|
+
if (result && !result.ok) exitCode = 1;
|
|
4279
|
+
} catch (err) {
|
|
4280
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4281
|
+
exitCode = 1;
|
|
4282
|
+
}
|
|
4283
|
+
break;
|
|
4284
|
+
}
|
|
4285
|
+
case "guards": {
|
|
4286
|
+
// Fix guards (#4692): `forgedock guards list [--path p]` | `guards backfill --since <date>`.
|
|
4287
|
+
const { guardsFromCli } = await import("./engine-cli.mjs");
|
|
4288
|
+
try { exitCode = await guardsFromCli(restArgs); } catch (err) {
|
|
4289
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4290
|
+
exitCode = 1;
|
|
4291
|
+
}
|
|
4292
|
+
break;
|
|
4293
|
+
}
|
|
4294
|
+
case "integration": {
|
|
4295
|
+
// Milestone integration runs (#4667): `forgedock integration run` for cron or a scheduled workflow.
|
|
4296
|
+
const { integrationFromCli } = await import("./engine-cli.mjs");
|
|
4297
|
+
try {
|
|
4298
|
+
exitCode = await integrationFromCli(restArgs);
|
|
4299
|
+
} catch (err) {
|
|
4300
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4301
|
+
exitCode = 1;
|
|
4302
|
+
}
|
|
4303
|
+
break;
|
|
4304
|
+
}
|
|
4202
4305
|
case "probe": {
|
|
4203
4306
|
const { probeFromCli } = await import("./engine-cli.mjs");
|
|
4204
4307
|
try {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* #4580: the same script is also a PreToolUse guard on Read: a full-file Read (no offset/limit) of a path already read
|
|
10
10
|
* REREAD_MIN-1 times whose mtime is unchanged since its last read is denied with a reason that points at notes or a line
|
|
11
11
|
* range. Ranged reads and Bash never deny, and any error allows. A changed mtime resets the path's count.
|
|
12
|
+
* #4622: the guard counts only full-file Reads ({path: count} in <session_id>.full.json), so ranged Reads and Bash samples never
|
|
13
|
+
* make a first full Read look like a re-read; the nudge still counts every read.
|
|
12
14
|
* State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json plus {path: mtimeMs} in <session_id>.mtime.json (per-run caches).
|
|
13
15
|
*/
|
|
14
16
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -44,7 +46,13 @@ export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =
|
|
|
44
46
|
const mtimeOf = (p) => { try { return statSync(p).mtimeMs; } catch { return null; } };
|
|
45
47
|
const loadJson = (file) => { try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
|
|
46
48
|
|
|
47
|
-
/**
|
|
49
|
+
/** Paths a payload reads whole: a Read with no offset/limit (what the guard counts, #4622). */
|
|
50
|
+
export function fullReads(payload) {
|
|
51
|
+
const inp = payload?.tool_input ?? {};
|
|
52
|
+
return payload?.tool_name === "Read" && inp.offset == null && inp.limit == null ? payloadReads(payload) : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure core of the guard: the deny reason for a payload, or null. `counts` are full-read counts; `mtimeNow(path)` is the file's current mtime. */
|
|
48
56
|
export function guardFor(payload, counts = {}, mtimes = {}, mtimeNow = mtimeOf) {
|
|
49
57
|
const inp = payload?.tool_input ?? {};
|
|
50
58
|
if (payload?.tool_name !== "Read" || inp.offset != null || inp.limit != null) return null;
|
|
@@ -64,20 +72,23 @@ export function main(input, { dir } = {}) {
|
|
|
64
72
|
if (!payload?.session_id) return "";
|
|
65
73
|
if (!payloadReads(payload).length) return "";
|
|
66
74
|
const file = stateFile(payload.session_id, dir), mfile = file.replace(/\.json$/, ".mtime.json");
|
|
67
|
-
const
|
|
75
|
+
const ffile = file.replace(/\.json$/, ".full.json");
|
|
76
|
+
const counts = loadJson(file), mtimes = loadJson(mfile), full = loadJson(ffile);
|
|
68
77
|
if (payload.hook_event_name === "PreToolUse") {
|
|
69
|
-
const reason = guardFor(payload,
|
|
78
|
+
const reason = guardFor(payload, full, mtimes);
|
|
70
79
|
return reason ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n` : "";
|
|
71
80
|
}
|
|
72
81
|
for (const p of payloadReads(payload)) {
|
|
73
82
|
const now = mtimeOf(p);
|
|
74
|
-
if (now != null && mtimes[p] !== now) delete counts[p]; // the file changed (or is new): earlier reads no longer count
|
|
83
|
+
if (now != null && mtimes[p] !== now) { delete counts[p]; delete full[p]; } // the file changed (or is new): earlier reads no longer count
|
|
75
84
|
if (now != null) mtimes[p] = now;
|
|
76
85
|
}
|
|
86
|
+
for (const p of fullReads(payload)) full[p] = (full[p] ?? 0) + 1;
|
|
77
87
|
const r = nudgeFor(payload, counts);
|
|
78
88
|
mkdirSync(dirname(file), { recursive: true });
|
|
79
89
|
writeFileSync(file, JSON.stringify(r.counts));
|
|
80
90
|
writeFileSync(mfile, JSON.stringify(mtimes));
|
|
91
|
+
writeFileSync(ffile, JSON.stringify(full));
|
|
81
92
|
return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
|
|
82
93
|
} catch {
|
|
83
94
|
return "";
|
package/bin/labels.json
CHANGED
|
@@ -69,11 +69,31 @@
|
|
|
69
69
|
"color": "E4E669",
|
|
70
70
|
"description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
"name": "needs-human:external",
|
|
74
|
+
"color": "E4E669",
|
|
75
|
+
"description": "Waiting on an external prerequisite (credential, account, sign-off). Comment names the owner and unblock condition. Managed by ForgeDock."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "parked",
|
|
79
|
+
"color": "FBCA04",
|
|
80
|
+
"description": "Mechanical park on a terminal code; per-code labels are parked:<code>. Managed by ForgeDock."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "remediation-queue",
|
|
84
|
+
"color": "FBCA04",
|
|
85
|
+
"description": "Parked issue awaiting /work-on:remediate. Managed by ForgeDock."
|
|
86
|
+
},
|
|
72
87
|
{
|
|
73
88
|
"name": "workflow:engine-error",
|
|
74
89
|
"color": "B60205",
|
|
75
90
|
"description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
|
|
76
91
|
},
|
|
92
|
+
{
|
|
93
|
+
"name": "awaiting-due",
|
|
94
|
+
"color": "C5DEF5",
|
|
95
|
+
"description": "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock."
|
|
96
|
+
},
|
|
77
97
|
{
|
|
78
98
|
"name": "awaiting-promotion",
|
|
79
99
|
"color": "C5DEF5",
|
|
@@ -183,5 +203,35 @@
|
|
|
183
203
|
"name": "performance",
|
|
184
204
|
"color": "F9D0C4",
|
|
185
205
|
"description": "Performance optimization or regression. Managed by ForgeDock."
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"name": "integration-red",
|
|
209
|
+
"color": "D93F0B",
|
|
210
|
+
"description": "Scheduled full-suite integration run on a milestone branch is red. Managed by ForgeDock."
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"name": "followup:engineering",
|
|
214
|
+
"color": "BFD4F2",
|
|
215
|
+
"description": "Deferred buildable engineering work. Managed by ForgeDock."
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "followup:post-deploy",
|
|
219
|
+
"color": "BFD4F2",
|
|
220
|
+
"description": "Deferred post-deploy checks. Managed by ForgeDock."
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"name": "followup:external",
|
|
224
|
+
"color": "BFD4F2",
|
|
225
|
+
"description": "Deferred items blocked on something outside the repo (credentials, third parties). Managed by ForgeDock."
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"name": "followup:ops",
|
|
229
|
+
"color": "BFD4F2",
|
|
230
|
+
"description": "Deferred ops work (drills, runbooks). Managed by ForgeDock."
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"name": "external-prerequisite",
|
|
234
|
+
"color": "E99695",
|
|
235
|
+
"description": "Follow-up that waits on an external prerequisite. Managed by ForgeDock."
|
|
186
236
|
}
|
|
187
237
|
]
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
|
|
7
7
|
* scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
|
|
8
8
|
* `add` (issues added to the batch by `--add`, #4408: a cumulative JSON set the runner re-reads every pass),
|
|
9
|
+
* `requeue` (`--requeue`, #4550: a cumulative JSON list of `{issue, reason, by, at}` the runner applies once each, moving a terminal issue back to pending),
|
|
9
10
|
* `hold` (issues held by `--hold`, #4592: a cumulative JSON set; the runner admits none of them until `--release` or a `--resume` names them),
|
|
10
11
|
* `cancel` (`{at, reason}` written by `--cancel`, #4376: the runner stops admitting and stops its local lanes; a resume clears it),
|
|
11
12
|
* `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
|
|
@@ -69,6 +70,16 @@ const readAdded = (file) => {
|
|
|
69
70
|
return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
|
|
70
71
|
};
|
|
71
72
|
|
|
73
|
+
/** `outcome`s `--requeue` may move back to pending (#4550). */
|
|
74
|
+
export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
75
|
+
/** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
|
|
76
|
+
export const requeueKey = (e) => `${e.issue}@${e.at}`;
|
|
77
|
+
|
|
78
|
+
const readRequeueFile = (file) => {
|
|
79
|
+
const v = readJson(file);
|
|
80
|
+
return Array.isArray(v) ? v.filter((e) => e && Number.isSafeInteger(e.issue) && e.issue > 0 && typeof e.at === "string") : [];
|
|
81
|
+
};
|
|
82
|
+
|
|
72
83
|
/** The positive integers among `list` (numbers, `7`, `#7`). */
|
|
73
84
|
const ints = (list) => (Array.isArray(list) ? list : []).map((x) => (typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN)).filter((n) => Number.isSafeInteger(n) && n > 0);
|
|
74
85
|
|
|
@@ -88,7 +99,7 @@ export function openBatch(root, id) {
|
|
|
88
99
|
if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
|
|
89
100
|
const dir = join(root, id);
|
|
90
101
|
const f = (name) => join(dir, name);
|
|
91
|
-
|
|
102
|
+
const self = {
|
|
92
103
|
id, dir,
|
|
93
104
|
exists: () => existsSync(f("plan.json")),
|
|
94
105
|
readPlan: () => readJson(f("plan.json")),
|
|
@@ -111,19 +122,53 @@ export function openBatch(root, id) {
|
|
|
111
122
|
* `order`, in the file or earlier in `list`: skipped `already in batch`. @returns {{added:number[], skipped:Array<{issue, reason}>}}
|
|
112
123
|
*/
|
|
113
124
|
addIssues: (list) => {
|
|
114
|
-
const
|
|
115
|
-
const
|
|
125
|
+
const st = readJson(f("state.json")) ?? {};
|
|
126
|
+
const have = new Set([...(st.order ?? []), ...readAdded(f("add"))]);
|
|
127
|
+
const added = [], skipped = [], requeued = [];
|
|
116
128
|
for (const x of Array.isArray(list) ? list : []) {
|
|
117
129
|
const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
|
|
118
130
|
if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
|
|
131
|
+
// #4687: an issue skipped in the batch (a dependency ended) is re-queued, not refused; any other row is "already in batch"
|
|
132
|
+
if (have.has(n) && st.results?.[n]?.outcome === "skipped" && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
|
|
119
133
|
if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
|
|
120
134
|
have.add(n); added.push(n);
|
|
121
135
|
}
|
|
122
136
|
if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
|
|
123
|
-
return { added, skipped };
|
|
137
|
+
return { added, skipped, requeued };
|
|
138
|
+
},
|
|
139
|
+
/**
|
|
140
|
+
* `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
|
|
141
|
+
* `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
|
|
142
|
+
* closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
|
|
143
|
+
*/
|
|
144
|
+
requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
|
|
145
|
+
const st = readJson(f("state.json")) ?? {};
|
|
146
|
+
const order = new Set(st.order ?? []), applied = new Set(st.requeueApplied ?? []);
|
|
147
|
+
const entries = readRequeueFile(f("requeue"));
|
|
148
|
+
const pend = new Set(entries.filter((e) => !applied.has(requeueKey(e))).map((e) => e.issue));
|
|
149
|
+
const requeued = [], refused = [];
|
|
150
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
151
|
+
const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
|
|
152
|
+
if (!Number.isSafeInteger(n) || n < 1) refused.push({ issue: x, reason: "not an integer" });
|
|
153
|
+
else if (!order.has(n)) refused.push({ issue: n, reason: "not in batch" });
|
|
154
|
+
else if (pend.has(n)) refused.push({ issue: n, reason: "already requeued" });
|
|
155
|
+
else if (!REQUEUABLE(st.results?.[n]?.outcome)) refused.push({ issue: n, reason: "not terminal" });
|
|
156
|
+
else { pend.add(n); requeued.push(n); }
|
|
157
|
+
}
|
|
158
|
+
if (requeued.length) {
|
|
159
|
+
const at = new Date().toISOString();
|
|
160
|
+
writeAtomic(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
|
|
161
|
+
}
|
|
162
|
+
return { requeued, refused };
|
|
124
163
|
},
|
|
164
|
+
/** Every `--requeue` entry, in order; [] when missing or invalid. */
|
|
165
|
+
readRequeued: () => readRequeueFile(f("requeue")),
|
|
166
|
+
/** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
|
|
167
|
+
unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
|
|
125
168
|
/** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
|
|
126
169
|
readHeld: () => readAdded(f("hold")),
|
|
170
|
+
/** #4656: gates waiting on issues (`gates` file: [{ name, waitsOn: [n...], since? }], written by the ship/settle pre-flight); [] when missing or invalid. */
|
|
171
|
+
readGates: () => { const v = readJson(f("gates")); return Array.isArray(v) ? v.filter((g) => g && typeof g.name === "string" && Array.isArray(g.waitsOn)) : []; },
|
|
127
172
|
/** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
|
|
128
173
|
holdIssues: (list) => {
|
|
129
174
|
const have = new Set(readAdded(f("hold")));
|
|
@@ -138,8 +183,8 @@ export function openBatch(root, id) {
|
|
|
138
183
|
if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
|
|
139
184
|
return freed;
|
|
140
185
|
},
|
|
141
|
-
/** `--cancel` (#4376): `{at, reason}` in the `cancel` file; the runner re-reads it every pass. */
|
|
142
|
-
requestCancel: ({ reason = "operator" } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason) }) + "\n"),
|
|
186
|
+
/** `--cancel` (#4376): `{at, reason, stopRemote?}` in the `cancel` file; the runner re-reads it every pass. `stopRemote` (#4612) also asks remote lanes to stop at their checkpoint. */
|
|
187
|
+
requestCancel: ({ reason = "operator", stopRemote = false } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
|
|
143
188
|
/** The cancel record, or null when missing or invalid. */
|
|
144
189
|
readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
|
|
145
190
|
clearCancel: () => rmSync(f("cancel"), { force: true }),
|
|
@@ -157,6 +202,7 @@ export function openBatch(root, id) {
|
|
|
157
202
|
resultFile: (n) => f(join("lanes", `${n}.json`)),
|
|
158
203
|
logFile: (n) => f(join("lanes", `${n}.log`)),
|
|
159
204
|
};
|
|
205
|
+
return self;
|
|
160
206
|
}
|
|
161
207
|
|
|
162
208
|
/** True when `pid` is alive and (where /proc exists) its command line names `marker`, so a reused pid is not adopted. */
|