forgedock 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +113 -18
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +1 -1
  8. package/bin/hooks/reread-nudge.mjs +15 -4
  9. package/bin/lean/batch.mjs +42 -2
  10. package/bin/lean/batch.test.mjs +101 -1
  11. package/bin/lean/conformance.mjs +14 -3
  12. package/bin/lean/conformance.test.mjs +26 -0
  13. package/bin/lean/effects.mjs +3 -0
  14. package/bin/lean/execution.mjs +2 -0
  15. package/bin/lean/i18n.mjs +65 -0
  16. package/bin/lean/i18n.test.mjs +47 -0
  17. package/bin/lean/lane.mjs +5 -2
  18. package/bin/lean/lean.mjs +37 -4
  19. package/bin/lean/lean.test.mjs +124 -5
  20. package/bin/lean/orchestrate.mjs +49 -5
  21. package/bin/lean/postdeploy-check.mjs +149 -0
  22. package/bin/lean/postdeploy-check.test.mjs +146 -0
  23. package/bin/lean/postdeploy-monitor.mjs +87 -0
  24. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  25. package/bin/lean/precheck.mjs +6 -4
  26. package/bin/lean/precheck.test.mjs +17 -2
  27. package/bin/lean/prefilter.mjs +29 -4
  28. package/bin/lean/prefilter.test.mjs +13 -5
  29. package/bin/lean/probe.mjs +2 -1
  30. package/bin/lean/prompts.mjs +10 -2
  31. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  32. package/bin/lean/sandbox-remote.mjs +7 -0
  33. package/bin/lean/schedd.mjs +24 -4
  34. package/bin/lean/schedd.test.mjs +54 -0
  35. package/bin/stats/aggregate.mjs +1 -1
  36. package/bin/stats/experiment.mjs +44 -0
  37. package/bin/stats/github-source.mjs +2 -1
  38. package/bin/stats/index.mjs +42 -2
  39. package/bin/stats/release.mjs +75 -0
  40. package/bin/stats/release.test.mjs +68 -0
  41. package/commands/orchestrate.md +8 -3
  42. package/docs/CONFIG.md +49 -0
  43. package/docs/EVENTS.md +68 -0
  44. package/docs/STATS.md +31 -0
  45. package/docs/TELEMETRY.md +3 -1
  46. package/docs/site/install.md +89 -0
  47. package/package.json +1 -1
  48. package/scripts/bench-lightlane.mjs +2 -1
  49. package/scripts/eval-gate-scorecard.mjs +7 -0
  50. package/scripts/file-deferred-followups.sh +1 -0
  51. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -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
@@ -3315,7 +3315,7 @@ function help() {
3315
3315
  ["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
3316
  ["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)"],
3317
3317
  ["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)"],
3318
+ ["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...", "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)"],
3319
3319
  ["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
3320
  ["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
3321
  ["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)"],
@@ -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
- /** Pure core of the guard: the deny reason for a payload, or null. `mtimeNow(path)` is the file's current mtime. */
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 counts = loadJson(file), mtimes = loadJson(mfile);
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, counts, mtimes);
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 "";
@@ -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
 
@@ -122,6 +133,35 @@ export function openBatch(root, id) {
122
133
  if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
123
134
  return { added, skipped };
124
135
  },
136
+ /**
137
+ * `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
138
+ * `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
139
+ * closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
140
+ */
141
+ requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
142
+ const st = readJson(f("state.json")) ?? {};
143
+ const order = new Set(st.order ?? []), applied = new Set(st.requeueApplied ?? []);
144
+ const entries = readRequeueFile(f("requeue"));
145
+ const pend = new Set(entries.filter((e) => !applied.has(requeueKey(e))).map((e) => e.issue));
146
+ const requeued = [], refused = [];
147
+ for (const x of Array.isArray(list) ? list : []) {
148
+ const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
149
+ if (!Number.isSafeInteger(n) || n < 1) refused.push({ issue: x, reason: "not an integer" });
150
+ else if (!order.has(n)) refused.push({ issue: n, reason: "not in batch" });
151
+ else if (pend.has(n)) refused.push({ issue: n, reason: "already requeued" });
152
+ else if (!REQUEUABLE(st.results?.[n]?.outcome)) refused.push({ issue: n, reason: "not terminal" });
153
+ else { pend.add(n); requeued.push(n); }
154
+ }
155
+ if (requeued.length) {
156
+ const at = new Date().toISOString();
157
+ writeAtomic(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
158
+ }
159
+ return { requeued, refused };
160
+ },
161
+ /** Every `--requeue` entry, in order; [] when missing or invalid. */
162
+ readRequeued: () => readRequeueFile(f("requeue")),
163
+ /** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
164
+ unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
125
165
  /** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
126
166
  readHeld: () => readAdded(f("hold")),
127
167
  /** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
@@ -138,8 +178,8 @@ export function openBatch(root, id) {
138
178
  if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
139
179
  return freed;
140
180
  },
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"),
181
+ /** `--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. */
182
+ requestCancel: ({ reason = "operator", stopRemote = false } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
143
183
  /** The cancel record, or null when missing or invalid. */
144
184
  readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
145
185
  clearCancel: () => rmSync(f("cancel"), { force: true }),
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
9
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
@@ -528,6 +528,37 @@ describe("#4376: forgedock orchestrate --cancel", () => {
528
528
  assert.deepEqual(saved.deferrals, { 1: 1 });
529
529
  });
530
530
 
531
+ for (const stopRemote of [false, true]) {
532
+ it(`a cancel ${stopRemote ? "with" : "without"} stopRemote ${stopRemote ? "stops" : "leaves"} a remote lane (#4612)`, async () => {
533
+ let cancel = null, saved = null;
534
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
535
+ const started = [], stops = [];
536
+ let release;
537
+ const gate = new Promise((r) => { release = r; });
538
+ const done = orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
539
+ stopLane: (n, rec) => { stops.push([n, rec.remote]); release(); },
540
+ runOne: async (n, _lane, ctx) => {
541
+ started.push(n);
542
+ ctx.onStart?.({ remote: true, handle: { id: "sbx-1" } });
543
+ if (!stopRemote) setTimeout(release, 100);
544
+ await gate;
545
+ return { terminalReason: "deferred", code: "run.interrupted" };
546
+ } });
547
+ await until(() => started.includes(1));
548
+ cancel = { at: new Date().toISOString(), reason: "operator", ...(stopRemote ? { stopRemote: true } : {}) };
549
+ await done;
550
+ assert.deepEqual(stops, stopRemote ? [[1, true]] : []);
551
+ });
552
+ }
553
+
554
+ it("requestCancel records stopRemote only when asked (#4612)", () => {
555
+ const b = openBatch(tmpDir("batch-cancel-stop-"), "b1");
556
+ b.requestCancel({});
557
+ assert.equal(b.readCancel().stopRemote, undefined);
558
+ b.requestCancel({ stopRemote: true });
559
+ assert.equal(b.readCancel().stopRemote, true);
560
+ });
561
+
531
562
  it("through the CLI: --cancel during a lane holds the rest, a finished batch is left alone, unknown ids reject, --resume clears it", async () => {
532
563
  const root = tmpDir("batch-cancel-cli-");
533
564
  const leanIo = {
@@ -711,6 +742,75 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
711
742
  });
712
743
  });
713
744
 
745
+ describe("#4550: forgedock orchestrate --requeue", () => {
746
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
747
+ it("a needs-human issue is requeued and relaunched; its skipped dependent returns to pending and runs after it merges; one row per issue", async () => {
748
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
749
+ let entries = [];
750
+ const started = [], g3 = gate(), log = [], decisions = [];
751
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
752
+ decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
753
+ runOne: async (n) => {
754
+ started.push(n);
755
+ if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
756
+ if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
757
+ return { terminalReason: "merged" };
758
+ } });
759
+ await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
760
+ entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
761
+ await until(() => started.filter((x) => x === 2).length === 1, 3000);
762
+ g3.open();
763
+ const rows = await done;
764
+ assert.deepEqual(started.filter((x) => x === 1).length, 2, "relaunched once");
765
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
766
+ assert.equal(rows[0].requeued.reason, "decided in scope");
767
+ assert.equal(rows[0].requeued.prior[0].outcome, "needs-human");
768
+ assert.deepEqual(formatOutcomeLists(rows), ["requeued: #1 (decided in scope)"]);
769
+ assert.ok(decisions.some((d) => d.issue === 1 && /requeued/.test(d.what) && d.why === "decided in scope"));
770
+ assert.ok(started.indexOf(2) > started.lastIndexOf(1) - 1 && started.filter((x) => x === 2).length === 1, "#2 ran once, after #1 was relaunched");
771
+ });
772
+
773
+ it("an entry is applied once; one for an issue that is not terminal changes nothing", async () => {
774
+ const entries = [{ issue: 1, reason: "r", by: "operator", at: "t1" }];
775
+ let runs = 0;
776
+ const rows = await orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", getRequeued: () => entries,
777
+ runOne: async () => { runs++; return { terminalReason: "merged" }; } });
778
+ assert.equal(runs, 1);
779
+ assert.equal(rows[0].requeued, undefined);
780
+ });
781
+
782
+ it("batch.requeue: terminal issues are recorded; unknown, merged and double requests are refused; --add still says already in batch", () => {
783
+ const b = openBatch(tmpDir("batch-requeue-"), "b1");
784
+ b.writePlan({ argv: ["1"], repo: "o/r" });
785
+ b.store.save({ order: [1, 2, 3, 4], results: { 1: { outcome: "needs-human" }, 2: { outcome: "merged" }, 3: { outcome: "dependency-blocked" } } });
786
+ const r = b.requeue([1, 2, 3, 4, 9, "x", 1], { reason: "why", by: "me" });
787
+ assert.deepEqual(r.requeued, [1, 3]);
788
+ assert.deepEqual(r.refused.map((x) => [x.issue, x.reason]), [[2, "not terminal"], [4, "not terminal"], [9, "not in batch"], ["x", "not an integer"], [1, "already requeued"]]);
789
+ assert.deepEqual(b.readRequeued().map((e) => [e.issue, e.reason, e.by]), [[1, "why", "me"], [3, "why", "me"]]);
790
+ assert.equal(b.unrunRequeues().length, 2);
791
+ assert.deepEqual(b.addIssues([1]).skipped, [{ issue: 1, reason: "already in batch" }]);
792
+ });
793
+
794
+ it("the CLI refuses a needs-human-labelled or closed issue and writes nothing; an open, cleared one is requeued (a finished batch's report is cleared)", async () => {
795
+ const root = tmpDir("batch-requeue-cli-");
796
+ const b = openBatch(root, "b1");
797
+ b.writePlan({ argv: ["1"], repo: "o/r" });
798
+ b.store.save({ order: [5, 6, 7], results: { 5: { outcome: "needs-human" }, 6: { outcome: "needs-human" }, 7: { outcome: "needs-human" } } });
799
+ b.writeReport({ rows: [], report: "x" });
800
+ const io = { gh: async (a) => JSON.stringify({ state: a[2] === "6" ? "CLOSED" : "OPEN", labels: a[2] === "7" ? [{ name: "needs-human" }] : [] }) };
801
+ const bad = await quiet(() => orchestrateFromCli(["--requeue", "b1", "6", "7", "--reason", "r"], { batchRoot: root, io }));
802
+ assert.deepEqual(bad.requeued, []);
803
+ assert.deepEqual(bad.refused.map((x) => x.issue), [6, 7]);
804
+ assert.match(bad.refused[1].reason, /needs-human/);
805
+ assert.deepEqual(b.readRequeued(), []);
806
+ assert.ok(b.readReport(), "nothing changed");
807
+ const ok = await quiet(() => orchestrateFromCli(["--requeue", "b1", "5", "--reason", "decided"], { batchRoot: root, io }));
808
+ assert.deepEqual(ok.requeued, [5]);
809
+ assert.equal(b.readRequeued()[0].reason, "decided");
810
+ assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
811
+ });
812
+ });
813
+
714
814
  describe("#4540: remote admissions are capped to the provider's free capacity", () => {
715
815
  const run = async (capacity) => {
716
816
  const c = { t: 1_000_000 };
@@ -27,6 +27,7 @@ const INCIDENTAL_RE = /(^|\/)(CHANGELOG|CHANGES|HISTORY)(\.[a-z]+)?$|(^|\/)(pack
27
27
  export const MIN_QUOTE_CHARS = 12;
28
28
  /** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
29
29
  export const MAX_CONTRACT_CHARS = 40000;
30
+ import { CHECK_ID_RE } from "./postdeploy-check.mjs";
30
31
  const C = OUTCOME_CODES;
31
32
 
32
33
  export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
@@ -231,6 +232,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
231
232
  if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
232
233
  if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
233
234
  if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
235
+ if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
234
236
  if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
235
237
  if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
236
238
  if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
@@ -331,7 +333,11 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
331
333
  } else if (TRACKED.includes(a.kind) && a.requirement) {
332
334
  const r = c.requirements.find((x) => x.id === a.requirement);
333
335
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
334
- if (!TRACKED.includes(r.disposition)) { r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r); }
336
+ if (!TRACKED.includes(r.disposition)) {
337
+ if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
338
+ r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r);
339
+ if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
340
+ }
335
341
  } else { problems.push(amendmentProblem(a, tag)); continue; }
336
342
  applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
337
343
  }
@@ -387,8 +393,12 @@ export function repoTracks(paths) {
387
393
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
388
394
  * tool), for the trajectory; always an array.
389
395
  */
390
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
396
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null }) {
391
397
  const failures = [], superseded = [], ciCovered = [];
398
+ // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
399
+ if (checkIds) for (const r of contract.requirements ?? []) {
400
+ if (r.disposition === "post-deploy" && !(r.check && checkIds.has(r.check))) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "check_unknown", detail: `${r.id} post-deploy check ${r.check ? `'${r.check}'` : "id"} is neither on the default branch nor added by this PR (.forge/post-deploy-checks.json)` });
401
+ }
392
402
  const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
393
403
  const creep = outside(contract);
394
404
  const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
@@ -745,7 +755,8 @@ export function formatShadowSummary(rows) {
745
755
  /** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
746
756
  export function followupItems(reqs) {
747
757
  const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
748
- return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400).trim() }));
758
+ const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
759
+ return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${check(r)}${r.why ?? ""}`).slice(0, 400).trim() }));
749
760
  }
750
761
 
751
762
  /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
@@ -670,3 +670,29 @@ describe("#4594 amend-requirement", () => {
670
670
  assert.match(explainFailures([{ code: "x", detail: "R1 partial" }], c)[0].detail, /amended to "regenerate and reveal/);
671
671
  });
672
672
  });
673
+
674
+ describe("post-deploy check ids (#4626)", () => {
675
+ const pd = (extra = {}) => ({ ...C, requirements: [{ id: "R1", quote: Q1, disposition: "post-deploy", why: "needs prod", ...extra }, C.requirements[1]] });
676
+ it("validateContract: a post-deploy requirement needs a well-formed check id", () => {
677
+ assert.ok(validateContract(pd(), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
678
+ assert.ok(validateContract(pd({ check: "Bad Id" }), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
679
+ assert.ok(!validateContract(pd({ check: "login-smoke" }), ISSUE).problems.some((p) => /check id/.test(p)));
680
+ });
681
+ it("applyAmendments: a post-deploy amendment needs a check id", () => {
682
+ const bad = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w" }], ISSUE);
683
+ assert.ok(bad.problems.some((p) => /needs a check id/.test(p)));
684
+ const ok = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "login-smoke" }], ISSUE);
685
+ assert.equal(ok.contract.requirements[0].check, "login-smoke");
686
+ });
687
+ it("precheck: a check id on neither branch is unimplemented; null checkIds skips the rule", () => {
688
+ const res = { requirements: [{ id: "R1", status: "post-deploy" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
689
+ const c = pd({ check: "login-smoke" });
690
+ assert.ok(precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set() }).failures.some((f) => f.kind === "check_unknown"));
691
+ assert.ok(!precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set(["login-smoke"]) }).failures.some((f) => f.kind === "check_unknown"));
692
+ assert.ok(!precheck({ contract: c, result: res, changedFiles: files }).failures.some((f) => f.kind === "check_unknown"));
693
+ });
694
+ it("followupItems names the check in a post-deploy item", () => {
695
+ const [i] = followupItems([{ id: "R1", quote: Q1, disposition: "post-deploy", why: "w", check: "login-smoke" }]);
696
+ assert.match(i.why, /^post-deploy: \[check:login-smoke\] w$/);
697
+ });
698
+ });
@@ -20,6 +20,7 @@ import { enumeratingTests, missingEntries } from "./enumerating.mjs";
20
20
  import { resolveBaseConflicts } from "./conflictres.mjs";
21
21
  import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
22
22
  import { duplicateExports } from "./postmerge.mjs";
23
+ import { englishCopies } from "./i18n.mjs";
23
24
  import { scopedChecks } from "./checks.mjs";
24
25
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
25
26
  import { followupMap } from "./conformance.mjs";
@@ -384,6 +385,8 @@ export function makeLeanIo({ repo }) {
384
385
  /** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
385
386
  postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
386
387
  duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
388
+ /** #4596: keys added to a non-en locale catalog as English copies (bin/lean/i18n.mjs). */
389
+ englishCopies: ({ cwd, base }) => englishCopies({ git: (a) => run("git")(a, { cwd }), base }),
387
390
  /**
388
391
  * #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure.
389
392
  * `env` (#4325): the lane env (run TMPDIR, toolchain), merged over process.env, so the command sees what the builder saw.
@@ -480,6 +480,8 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
480
480
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
481
481
  : { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
482
482
  return {
483
+ // #4612: a graceful stop of a live sandbox (the provider's checkpoint stop); false when it has none.
484
+ stopLane: async (n, rec) => (typeof provider.stop === "function" && rec?.handle ? (await provider.stop(rec.handle)) === true : false),
483
485
  runOne: async (n, lane, ctx = {}) => {
484
486
  const led = ledgerFor(n);
485
487
  const base = { provider: provider.name, id: provider.idFor(n), issue: n, startedAt: now() };
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * English copies in locale catalogs (#4596). A lane adds a key to the source-locale (en) JSON catalog and copies the
5
+ * English text into the other locales; the repo's own i18n check rejects it, but only on CI. Deterministic (git, no LLM):
6
+ * for each changed non-en JSON catalog whose en counterpart exists, the keys the lane added whose string value equals the
7
+ * en value. Counterpart: the path with its locale segment (a directory or the file stem) replaced by `en`.
8
+ */
9
+ const LOCALE_RE = /^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/;
10
+ const MAX_FINDINGS = 200;
11
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
12
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
13
+
14
+ /** The en counterpart path of a non-en catalog path, or null when no segment looks like a locale code. */
15
+ export function sourcePath(path) {
16
+ const segs = String(path).split("/");
17
+ for (let i = segs.length - 1; i >= 0; i--) {
18
+ const last = i === segs.length - 1;
19
+ const m = last ? /^(.+)\.json$/.exec(segs[i]) : null;
20
+ const name = last ? (m ? m[1] : "") : segs[i];
21
+ if (!LOCALE_RE.test(name) || name === "en") continue;
22
+ const copy = [...segs];
23
+ copy[i] = last ? "en.json" : "en";
24
+ return copy.join("/");
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Flattened string leaves of a JSON catalog as Map(dotted key -> value); an empty map on invalid JSON. */
30
+ export function flatten(text) {
31
+ const res = new Map();
32
+ let doc;
33
+ try { doc = JSON.parse(String(text ?? "")); } catch { return res; }
34
+ const walk = (v, key) => {
35
+ if (typeof v === "string") res.set(key, v);
36
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, key ? `${key}.${k}` : k);
37
+ };
38
+ walk(doc, "");
39
+ return res;
40
+ }
41
+
42
+ /** A value worth translating: three letters in a row once placeholders are removed (not "OK", "{{n}}", "12:00"). */
43
+ const translatable = (v) => /\p{L}{3}/u.test(v.replace(/\{\{?[^}]*\}\}?/g, ""));
44
+
45
+ /** @returns {Promise<Array<{path, key, value, source}>>}; never throws ([] on any failure). */
46
+ export async function englishCopies({ git, base }) {
47
+ try {
48
+ const g = out(git);
49
+ const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=AM", `${base}...HEAD`])).filter((f) => f.endsWith(".json"));
50
+ const res = [];
51
+ for (const f of changed) {
52
+ const src = sourcePath(f);
53
+ if (!src) continue;
54
+ const en = flatten(await g(["show", `HEAD:${src}`]));
55
+ if (!en.size) continue;
56
+ const was = flatten(await g(["show", `${base}:${f}`]));
57
+ for (const [key, value] of flatten(await g(["show", `HEAD:${f}`]))) {
58
+ if (was.has(key) || en.get(key) !== value || !translatable(value)) continue;
59
+ res.push({ path: f, key, value, source: src });
60
+ if (res.length >= MAX_FINDINGS) return res;
61
+ }
62
+ }
63
+ return res;
64
+ } catch { return []; }
65
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { englishCopies, sourcePath, flatten } from "./i18n.mjs";
10
+
11
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
12
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), JSON.stringify(t)); } };
13
+ function repo(base, lane) {
14
+ const d = tmpDir("i18n-");
15
+ sh(d, "init", "-q", "-b", "main");
16
+ put(d, base); sh(d, "add", "-A"); sh(d, "commit", "-qm", "base");
17
+ sh(d, "checkout", "-q", "-b", "lane");
18
+ put(d, lane); sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
19
+ return d;
20
+ }
21
+ const copies = (d) => englishCopies({ git: async (a) => sh(d, ...a), base: "main" });
22
+
23
+ describe("i18n english copies (#4596)", () => {
24
+ it("sourcePath maps a locale file or directory to en", () => {
25
+ assert.equal(sourcePath("messages/ta.json"), "messages/en.json");
26
+ assert.equal(sourcePath("locales/kn/common.json"), "locales/en/common.json");
27
+ assert.equal(sourcePath("messages/en.json"), null);
28
+ assert.equal(sourcePath("package.json"), null);
29
+ });
30
+ it("flatten reads nested string leaves", () => {
31
+ assert.deepEqual([...flatten('{"a":{"b":"x"},"n":1}')], [["a.b", "x"]]);
32
+ assert.equal(flatten("not json").size, 0);
33
+ });
34
+ it("flags added keys copied from en; ignores translated, pre-existing, short and placeholder values", async () => {
35
+ const d = repo(
36
+ { "messages/en.json": { old: "Old text", v: { a: "Calendar view" } }, "messages/ta.json": { old: "Old text" } },
37
+ { "messages/en.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "Week view" } },
38
+ "messages/ta.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "வாரக் காட்சி" } } });
39
+ const r = await copies(d);
40
+ assert.deepEqual(r.map((x) => `${x.path}:${x.key}`), ["messages/ta.json:v.a"]);
41
+ });
42
+ it("is empty without a source catalog and never throws", async () => {
43
+ const d = repo({ "messages/ta.json": { a: "Hello there" } }, { "messages/ta.json": { a: "Hello there", b: "Hello there" } });
44
+ assert.deepEqual(await copies(d), []);
45
+ assert.deepEqual(await englishCopies({ git: async () => { throw new Error("x"); }, base: "main" }), []);
46
+ });
47
+ });
package/bin/lean/lane.mjs CHANGED
@@ -85,7 +85,7 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
85
85
  }
86
86
 
87
87
  /**
88
- * #4593: true when `lane` is a non-staging branch fully merged into `staging` (a shipped milestone branch).
88
+ * #4593: true when `lane` is a non-staging branch fully merged into `staging` by a merge commit (a shipped milestone branch).
89
89
  * Fail closed: any git error (no such ref, offline) is "not shipped".
90
90
  */
91
91
  export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io, cwd }) {
@@ -93,6 +93,9 @@ export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io
93
93
  try {
94
94
  try { await io.git(["fetch", "origin", lane, staging], { cwd }); } catch { /* offline: use the local refs */ }
95
95
  await io.git(["merge-base", "--is-ancestor", `origin/${lane}`, `origin/${staging}`], { cwd });
96
- return true;
96
+ // #4623: an unused lane cut from staging is trivially an ancestor; shipped also needs a merge commit that took the lane tip.
97
+ const tip = String(await io.git(["rev-parse", `origin/${lane}`], { cwd })).trim();
98
+ const merges = String(await io.git(["rev-list", "--merges", "--parents", `origin/${staging}`, `^origin/${lane}`], { cwd }));
99
+ return !!tip && merges.split("\n").some((l) => l.trim().split(/\s+/).slice(2).includes(tip));
97
100
  } catch { return false; }
98
101
  }