forgedock 1.36.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/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -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/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- 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 +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -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/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- 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 +8 -1
- 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 +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -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/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- 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
|
@@ -12,13 +12,15 @@ const CHECKS = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }
|
|
|
12
12
|
const BODY = "## Acceptance Criteria\n\n- [ ] `R1: health row exists` — post-deploy: [check:soak] soak\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n";
|
|
13
13
|
const rec = (o) => ({ authorAssociation: "OWNER", author: { login: "bot" }, body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "fix1234567890", default_sha: "rel1", result: "pass", at: new Date(T0).toISOString(), window_until: new Date(T0 + 7 * 24 * H).toISOString(), every_ms: 6 * H, ...o })}` });
|
|
14
14
|
|
|
15
|
-
function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN" } = {}) {
|
|
15
|
+
function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN", regressions = [], failClose = false } = {}) {
|
|
16
16
|
const w = { gh: [], git: [], spawned: [] };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
19
19
|
w.gh.push(a);
|
|
20
20
|
const k = a.slice(0, 2).join(" ");
|
|
21
21
|
if (k === "repo view") return "main\n";
|
|
22
|
+
if (k === "issue list" && a.includes("--search")) return JSON.stringify(regressions);
|
|
23
|
+
if (failClose && k === "issue close") throw new Error("rate limit");
|
|
22
24
|
if (k === "issue list") return JSON.stringify([{ number: 41 }]);
|
|
23
25
|
if (k === "issue view") return JSON.stringify({ number: 41, state, title: "", body: BODY, labels: [{ name: "post-deploy-monitoring" }], comments });
|
|
24
26
|
if (k === "issue create") return "https://github.com/o/r/issues/77\n";
|
|
@@ -80,6 +82,30 @@ describe("post-deploy windowed monitoring (#4627)", () => {
|
|
|
80
82
|
assert.match(cm[cm.indexOf("--body") + 1], /"result":"fail".*"regression":77/s);
|
|
81
83
|
});
|
|
82
84
|
|
|
85
|
+
const reg = (n, o = {}) => ({ number: n, body: `<!-- FORGE:REGRESSION -->\n- Fix commit: \`${o.fix ?? "fix1234567890"}\`\n- Monitored follow-up: #${o.issue ?? 41}\n` });
|
|
86
|
+
|
|
87
|
+
it("#4653: a regression already filed for this follow-up and fix is reused, not duplicated", async () => {
|
|
88
|
+
const w = world({ head: "rel2", code: 1, regressions: [reg(90)] });
|
|
89
|
+
const rows = await sweep(w);
|
|
90
|
+
assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "90");
|
|
91
|
+
assert.equal(calls(w, "issue create").length, 0); assert.equal(calls(w, "issue close").length, 1);
|
|
92
|
+
const cm = calls(w, "issue comment")[0];
|
|
93
|
+
assert.match(cm[cm.indexOf("--body") + 1], /"regression":90/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("#4653: finish() failing after create does not file a second regression on the next sweep", async () => {
|
|
97
|
+
const w = world({ head: "rel2", code: 1, failClose: true });
|
|
98
|
+
assert.equal((await sweep(w))[0].outcome, "error"); assert.equal(calls(w, "issue create").length, 1);
|
|
99
|
+
const w2 = world({ head: "rel2", code: 1, regressions: [reg(77)] });
|
|
100
|
+
assert.equal((await sweep(w2))[0].regression, "77"); assert.equal(calls(w2, "issue create").length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("#4653: a regression for another follow-up, fix commit or issue number prefix is not reused", async () => {
|
|
104
|
+
const w = world({ head: "rel2", code: 1, regressions: [reg(5, { issue: 4 }), reg(6, { issue: 410 }), reg(7, { fix: "other" })] });
|
|
105
|
+
const rows = await sweep(w);
|
|
106
|
+
assert.equal(rows[0].regression, "77"); assert.equal(calls(w, "issue create").length, 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
83
109
|
it("a follow-up whose latest record is a fail is never re-run or re-filed", async () => {
|
|
84
110
|
const w = world({ head: "rel3", code: 1, comments: [rec({}), rec({ result: "fail", default_sha: "rel2", regression: 77 })] });
|
|
85
111
|
const rows = await sweep(w);
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prom
|
|
|
25
25
|
import { isTrustedComment } from "../engine/phases.mjs";
|
|
26
26
|
import { APP_SLUG } from "../init-repo.mjs";
|
|
27
27
|
import { probeResultOf } from "./probe.mjs";
|
|
28
|
-
import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
|
|
28
|
+
import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, OWNER_ASK_MARKER, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
|
|
29
29
|
|
|
30
30
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
31
31
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -157,17 +157,38 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
157
157
|
// post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
|
|
158
158
|
export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
|
|
159
159
|
export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
|
|
160
|
+
export const AWAITING_DUE_LABEL = "awaiting-due"; // #4689
|
|
160
161
|
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
161
162
|
/** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
|
|
162
163
|
export function deferredFrom(body) {
|
|
163
164
|
const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
|
|
164
165
|
return m ? Number(m[1]) : null;
|
|
165
166
|
}
|
|
167
|
+
const DEFERRED_FROM_ALL_RE = new RegExp(DEFERRED_FROM_RE.source, "g");
|
|
168
|
+
/** #4724: every parent #P of a post-deploy rollup (one `<!-- FORGE:DEFERRED_FROM: #P -->` per `### #P` section), in order, unique; [] when none. */
|
|
169
|
+
export const deferredFromAll = (body) => [...new Set([...String(body ?? "").matchAll(DEFERRED_FROM_ALL_RE)].map((m) => Number(m[1])))];
|
|
166
170
|
const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
167
|
-
|
|
171
|
+
const checkItems = (body) => noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
|
|
172
|
+
const DECISION_ITEM_RE = /^`[^`]*`\s+[—-]+\s*decision:/; // #4689: an item only its owner can decide
|
|
173
|
+
/** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems), or a `decision:` item (#4689). */
|
|
168
174
|
export function postDeployOnly(body) {
|
|
169
|
-
const items =
|
|
170
|
-
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
|
|
175
|
+
const items = checkItems(body);
|
|
176
|
+
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t) || DECISION_ITEM_RE.test(t));
|
|
177
|
+
}
|
|
178
|
+
/** #4689: true when a checklist item is a `decision:` item (owner ask, never a lane). */
|
|
179
|
+
export const hasDecisionItem = (body) => checkItems(body).some((t) => DECISION_ITEM_RE.test(t));
|
|
180
|
+
const DUE_RE = /\bdue:(\d{4})-(\d{2})-(\d{2})\b/;
|
|
181
|
+
/** #4689: the latest `due:YYYY-MM-DD` of the post-deploy items (ms, UTC midnight), or null when none states one. */
|
|
182
|
+
export function itemDueMs(body) {
|
|
183
|
+
const ds = checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => DUE_RE.exec(t)).filter(Boolean)
|
|
184
|
+
.map((m) => Date.UTC(+m[1], +m[2] - 1, +m[3])).filter(Number.isFinite);
|
|
185
|
+
return ds.length ? Math.max(...ds) : null;
|
|
186
|
+
}
|
|
187
|
+
const ANCHOR_RE = /\bfrom:(\d{4})-(\d{2})-(\d{2})\b/;
|
|
188
|
+
/** #4724: the items' `due:` dates with their contract-time `from:` anchor (ms, UTC midnight; null when absent). */
|
|
189
|
+
export function itemDueAnchors(body) {
|
|
190
|
+
return checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => [DUE_RE.exec(t), ANCHOR_RE.exec(t)]).filter(([d]) => d)
|
|
191
|
+
.map(([d, a]) => ({ dueMs: Date.UTC(+d[1], +d[2] - 1, +d[3]), fromMs: a ? Date.UTC(+a[1], +a[2] - 1, +a[3]) : null })).filter((x) => Number.isFinite(x.dueMs));
|
|
171
192
|
}
|
|
172
193
|
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
173
194
|
/**
|
|
@@ -243,18 +264,23 @@ const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms %
|
|
|
243
264
|
/**
|
|
244
265
|
* #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
|
|
245
266
|
* #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
|
|
246
|
-
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
267
|
+
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-due"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
247
268
|
* Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
|
|
248
269
|
*/
|
|
249
270
|
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
|
|
250
|
-
const
|
|
271
|
+
const parents = deferredFromAll(iss?.body), parent = parents[0] ?? null; // #4724: a rollup has one parent per section
|
|
272
|
+
const ps = parents.map((p) => `#${p}`).join(", ");
|
|
251
273
|
const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
|
|
252
274
|
if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
|
|
253
275
|
try {
|
|
254
276
|
const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
|
|
255
277
|
if (!def) return res("unresolved", "default branch unknown");
|
|
256
|
-
const shas =
|
|
257
|
-
|
|
278
|
+
const shas = [];
|
|
279
|
+
for (const p of parents) {
|
|
280
|
+
const found = await parentMergeShas({ parent: p, repo, io });
|
|
281
|
+
if (!found.length) return res("unresolved", `no merge SHA found for #${p}`);
|
|
282
|
+
shas.push(...found);
|
|
283
|
+
}
|
|
258
284
|
const off = [];
|
|
259
285
|
for (const sha of shas) {
|
|
260
286
|
let on;
|
|
@@ -262,7 +288,22 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
|
|
|
262
288
|
if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
|
|
263
289
|
if (!on) off.push(sha);
|
|
264
290
|
}
|
|
265
|
-
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of
|
|
291
|
+
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of ${ps} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
|
|
292
|
+
let dueMs = itemDueMs(iss.body); // #4689: an explicit due date (UTC midnight)
|
|
293
|
+
if (dueMs != null) {
|
|
294
|
+
// #4724: re-anchor a date item to when its fix actually landed on <def>: due + (landed - the contract-time anchor)
|
|
295
|
+
let landedMs = null;
|
|
296
|
+
for (const a of itemDueAnchors(iss.body)) {
|
|
297
|
+
if (a.fromMs == null) continue;
|
|
298
|
+
if (landedMs == null) { landedMs = 0; for (const sha of shas) landedMs = Math.max(landedMs, ((await landedAt({ sha, def, io, cwd })) ?? 0) * 1000); }
|
|
299
|
+
if (landedMs > a.fromMs) dueMs = Math.max(dueMs, a.dueMs + (landedMs - a.fromMs));
|
|
300
|
+
}
|
|
301
|
+
const at = now ?? io.now?.() ?? Date.now();
|
|
302
|
+
if (at < dueMs) {
|
|
303
|
+
const eligibleAt = new Date(dueMs).toISOString();
|
|
304
|
+
return res("awaiting-due", `fix of ${ps} is on ${def}; the checks are due ${eligibleAt.slice(0, 10)}`, { shas, default: def, eligibleAt });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
266
307
|
if (minAge != null) {
|
|
267
308
|
const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
|
|
268
309
|
let landed = 0;
|
|
@@ -274,16 +315,16 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
|
|
|
274
315
|
const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
|
|
275
316
|
if (at < until) {
|
|
276
317
|
const eligibleAt = new Date(until).toISOString();
|
|
277
|
-
return res("awaiting-min-age", `fix of
|
|
318
|
+
return res("awaiting-min-age", `fix of ${ps} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
|
|
278
319
|
}
|
|
279
320
|
}
|
|
280
321
|
if (preconditionCmd) {
|
|
281
322
|
const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
|
|
282
323
|
{ cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
|
|
283
|
-
if (code === 1) return res("awaiting-precondition", `fix of
|
|
324
|
+
if (code === 1) return res("awaiting-precondition", `fix of ${ps} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
|
|
284
325
|
if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
|
|
285
326
|
}
|
|
286
|
-
return res("ready", `fix of
|
|
327
|
+
return res("ready", `fix of ${ps} is on ${def}`, { shas, default: def });
|
|
287
328
|
} catch (e) {
|
|
288
329
|
return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
|
|
289
330
|
}
|
|
@@ -301,21 +342,29 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
|
|
|
301
342
|
return name;
|
|
302
343
|
})());
|
|
303
344
|
}
|
|
345
|
+
/** #4689: one typed `FORGE:OWNER_ASK` comment for the `decision:` items of a follow-up, posted once; best effort. */
|
|
346
|
+
async function ownerAsk({ iss, repo, io, dryRun }) {
|
|
347
|
+
if (dryRun || (iss.comments ?? []).some((c) => String(c?.body ?? "").includes(OWNER_ASK_MARKER) && isTrustedIssueComment(c))) return;
|
|
348
|
+
const asks = checkItems(iss.body).filter((t) => DECISION_ITEM_RE.test(t)).map((t) => `- ${sanitizeMd(t).slice(0, 300)}`);
|
|
349
|
+
try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", capComment(`<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started: these items need a decision from the owner.\n\n${asks.join("\n")}`)]); } catch { /* the report still shows it */ }
|
|
350
|
+
}
|
|
304
351
|
/** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
|
|
305
352
|
async function markParked({ issue, repo, io, had, gate }) {
|
|
306
353
|
const measure = gate.state === "awaiting-measurement"; // #4626
|
|
307
|
-
const
|
|
308
|
-
const
|
|
354
|
+
const due = gate.state === "awaiting-due"; // #4689
|
|
355
|
+
const label = measure ? AWAITING_MEASUREMENT_LABEL : due ? AWAITING_DUE_LABEL : AWAITING_PROMOTION_LABEL;
|
|
356
|
+
const labels = due && gate.eligibleAt ? [label, `due:${gate.eligibleAt.slice(0, 10)}`] : [label];
|
|
357
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", labels.join(",")]);
|
|
309
358
|
try { await edit(); } catch {
|
|
310
359
|
try {
|
|
311
|
-
await io.gh(["label", "create",
|
|
360
|
+
for (const l of labels) await io.gh(["label", "create", l, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : due ? "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
|
|
312
361
|
await edit();
|
|
313
362
|
} catch { /* the comment and the report still show it */ }
|
|
314
363
|
}
|
|
315
364
|
if (had) return;
|
|
316
|
-
const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
365
|
+
const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : due ? "awaiting its due date" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
317
366
|
const tail = measure ? "Every item here is a post-deploy check, and at least one names no runnable check, so no lane was started and none will be. Name each one with `[check:<id>]` and add `<id>` to the checks file on the default branch, or measure it by hand."
|
|
318
|
-
: "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and
|
|
367
|
+
: "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run, or the daemon's parked sweep, checks again and releases this issue once the precondition holds.";
|
|
319
368
|
const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
|
|
320
369
|
try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
|
|
321
370
|
}
|
|
@@ -335,6 +384,10 @@ export function formatParkedLine(rows) {
|
|
|
335
384
|
const defs = uniq(soak.map((r) => r.prefilter.default));
|
|
336
385
|
out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
|
|
337
386
|
}
|
|
387
|
+
const due = parked.filter((r) => r.prefilter.kind === "awaiting-due"); // #4689
|
|
388
|
+
if (due.length) out.push(`due date: ${due.map((r) => `#${r.issue}${r.prefilter.eligibleAt ? ` (${r.prefilter.eligibleAt.slice(0, 10)})` : ""}`).join(" ")} (re-queued on or after the date)`);
|
|
389
|
+
const ask = parked.filter((r) => r.prefilter.kind === "owner-ask");
|
|
390
|
+
if (ask.length) out.push(`owner ask: ${ask.map((r) => `#${r.issue}`).join(" ")} (a decision or measurement only the owner can give; no lane)`);
|
|
338
391
|
const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
|
|
339
392
|
if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
|
|
340
393
|
const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
|
|
@@ -350,7 +403,7 @@ export function formatParkedLine(rows) {
|
|
|
350
403
|
* Pre-filter one issue. Never throws.
|
|
351
404
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
352
405
|
*/
|
|
353
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null }) {
|
|
406
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null, gateOnly = false }) {
|
|
354
407
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
355
408
|
try {
|
|
356
409
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
@@ -367,15 +420,20 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
367
420
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
368
421
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
369
422
|
}
|
|
423
|
+
if (gated && hasDecisionItem(iss.body)) { // #4689: a decision item is the owner's: parked, one typed ask, no lane
|
|
424
|
+
await ownerAsk({ iss, repo, io, dryRun });
|
|
425
|
+
return { ...out, parked: true, kind: "owner-ask", parent: deferredFrom(iss.body), lane, detail: "a decision item needs the owner" };
|
|
426
|
+
}
|
|
370
427
|
if (gated) {
|
|
371
428
|
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
|
|
372
429
|
const had = names.includes(AWAITING_PROMOTION_LABEL);
|
|
373
|
-
if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
374
|
-
if (!dryRun) await markParked({ issue, repo, io, had, gate });
|
|
375
|
-
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
|
|
430
|
+
if (["awaiting-promotion", "awaiting-due", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
431
|
+
if (!dryRun) await markParked({ issue, repo, io, had: had || (gate.state === "awaiting-due" && names.includes(AWAITING_DUE_LABEL)), gate });
|
|
432
|
+
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, ...(gate.eligibleAt ? { eligibleAt: gate.eligibleAt } : {}), detail: gate.detail };
|
|
376
433
|
}
|
|
377
|
-
|
|
378
|
-
|
|
434
|
+
const stale = names.filter((l) => l === AWAITING_PROMOTION_LABEL || l === AWAITING_DUE_LABEL || /^due:/.test(l)); // #4689: released: every wait label goes
|
|
435
|
+
if (gate.state === "ready" && stale.length && !dryRun) {
|
|
436
|
+
try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", stale.join(",")]); } catch { /* best effort */ }
|
|
379
437
|
}
|
|
380
438
|
if (gate.state === "ready") {
|
|
381
439
|
const m = await measureFollowup({ iss, repo, parent: gate.parent, def: gate.default, shas: gate.shas, cwd, io, dryRun, isTrusted: isTrustedIssueComment, ...(io.spawnCheck ? { spawn: io.spawnCheck } : {}) });
|
|
@@ -387,8 +445,10 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
387
445
|
if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
388
446
|
if (m.state === "fail" || m.state === "recorded-fail") return { ...out, parked: true, kind: "check-failed", parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
389
447
|
}
|
|
448
|
+
if (gateOnly) return { ...out, detail: `released (post-deploy gate: ${gate.state})` }; // #4689: the sweep never runs the already-fixed path
|
|
390
449
|
if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
|
|
391
450
|
}
|
|
451
|
+
if (gateOnly) return { ...out, detail: "not a gated follow-up" };
|
|
392
452
|
// #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
|
|
393
453
|
if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
|
|
394
454
|
&& flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt, itemDueMs, hasDecisionItem, deferredFromAll, itemDueAnchors } from "./prefilter.mjs";
|
|
6
6
|
import { writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
@@ -516,6 +516,36 @@ describe("post_deploy.min_age soak window (#4425)", () => {
|
|
|
516
516
|
assert.deepEqual(itemWindowsMs(followup([item("R1", "after 24h of traffic, then 7 days of retention"), item("R2", "only in prod")])), [7 * 24 * H * 1000, null]);
|
|
517
517
|
});
|
|
518
518
|
|
|
519
|
+
describe("post-deploy rollup with several parents (#4724)", () => {
|
|
520
|
+
const M2 = "f".repeat(40);
|
|
521
|
+
const prs = [...parentPr, { number: 91, title: "fix(y): other (#50)", body: "", mergeCommit: { oid: M2 } }];
|
|
522
|
+
const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items[0]}\n### #50\n<!-- FORGE:DEFERRED_FROM: #50 -->\n${items[1] ?? items[0]}\n`;
|
|
523
|
+
it("deferredFromAll lists every section parent once, in order", () => {
|
|
524
|
+
assert.deepEqual(deferredFromAll(rollup([item("R1", "x")])), [40, 50]);
|
|
525
|
+
assert.deepEqual(deferredFromAll("no marker"), []);
|
|
526
|
+
assert.equal(deferredFrom(rollup([item("R1", "x")])), 40);
|
|
527
|
+
});
|
|
528
|
+
it("the rollup waits while ANY parent is off the default branch, and names them all", async () => {
|
|
529
|
+
const w = soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 1 } });
|
|
530
|
+
const g = await gate(w, rollup([item("R1", "x")]));
|
|
531
|
+
assert.equal(g.state, "awaiting-promotion"); assert.match(g.detail, /#40, #50/); assert.deepEqual(g.shas, [MERGE, M2]);
|
|
532
|
+
});
|
|
533
|
+
it("is ready once every parent is on the default branch; a parent without a merge SHA is unresolved", async () => {
|
|
534
|
+
assert.equal((await gate(soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 0 } }), rollup([item("R1", "x")]))).state, "ready");
|
|
535
|
+
const g = await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), rollup([item("R1", "x")]));
|
|
536
|
+
assert.equal(g.state, "unresolved"); assert.match(g.detail, /#50/);
|
|
537
|
+
});
|
|
538
|
+
it("a date item is re-anchored to when its fix landed: due + (landed - from)", async () => {
|
|
539
|
+
const it1 = item("R1", "[bucket:date] due:2027-02-01 from:2027-01-01 later");
|
|
540
|
+
assert.deepEqual(itemDueAnchors(followup([it1])), [{ dueMs: Date.UTC(2027, 1, 1), fromMs: Date.UTC(2027, 0, 1) }]);
|
|
541
|
+
const w = soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
542
|
+
const g = await gate(w, followup([it1]), { now: Date.UTC(2027, 1, 10) }); // past the contract-time date, before the shifted one
|
|
543
|
+
assert.equal(g.state, "awaiting-due");
|
|
544
|
+
assert.equal(g.eligibleAt, new Date(Date.UTC(2027, 1, 1) + LANDED * 1000 - Date.UTC(2027, 0, 1)).toISOString());
|
|
545
|
+
assert.equal((await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), followup([it1]), { now: Date.UTC(2027, 2, 1) })).state, "ready");
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
519
549
|
it("landedAt: the promotion merge time, or the SHA's own time when it is on the mainline", async () => {
|
|
520
550
|
const w = soakWorld();
|
|
521
551
|
assert.equal(await landedAt({ sha: MERGE, def: "main", io: w.io, cwd: "/repo" }), LANDED);
|
|
@@ -602,3 +632,45 @@ describe("#4467: isTrustedIssueComment", () => {
|
|
|
602
632
|
assert.equal(isTrustedIssueComment(null), false);
|
|
603
633
|
});
|
|
604
634
|
});
|
|
635
|
+
|
|
636
|
+
describe("due-date and decision follow-ups (#4689)", () => {
|
|
637
|
+
const MERGE = "a".repeat(40);
|
|
638
|
+
const dueItem = (date) => `- [ ] \`R2: the 7-day window shows a health row\` — post-deploy: due:${date} only checkable after the window`;
|
|
639
|
+
const decision = "- [ ] `R3: pick the retention` — decision: owner chooses 30 or 90 days";
|
|
640
|
+
const body = (items) => `## Problem\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
|
|
641
|
+
const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
|
|
642
|
+
const at = (iso) => Date.parse(iso);
|
|
643
|
+
|
|
644
|
+
it("parses the due token (latest wins) and decision items; a decision-only body counts as verification-only", () => {
|
|
645
|
+
assert.equal(itemDueMs(body([dueItem("2026-11-03"), dueItem("2026-11-10")])), Date.UTC(2026, 10, 10));
|
|
646
|
+
assert.equal(itemDueMs(body(["- [ ] `R2: x` — post-deploy: no date"])), null);
|
|
647
|
+
assert.ok(postDeployOnly(body([decision]))); assert.ok(hasDecisionItem(body([decision])));
|
|
648
|
+
assert.ok(!hasDecisionItem(body([dueItem("2026-11-03")])));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("before the due date the follow-up parks awaiting-due with a due:<date> label, no lane; after it, the labels are removed", async () => {
|
|
652
|
+
const w = world({ issues: { 41: { body: body([dueItem("2026-11-03")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
653
|
+
w.io.now = () => at("2026-11-01T00:00:00Z");
|
|
654
|
+
const r = await pf(w, 41, { defaultBranch: "main" });
|
|
655
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-due"); assert.equal(r.eligibleAt, "2026-11-03T00:00:00.000Z");
|
|
656
|
+
assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-due,due:2026-11-03"]]);
|
|
657
|
+
assert.equal(w.runner.length, 0);
|
|
658
|
+
assert.match(formatParkedLine([{ issue: 41, prefilter: r }]), /^due date: #41 \(2026-11-03\)/);
|
|
659
|
+
const w2 = world({ issues: { 41: { body: body([dueItem("2026-11-03")]), labels: [{ name: "awaiting-due" }, { name: "due:2026-11-03" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
660
|
+
w2.io.now = () => at("2026-11-03T00:00:01Z");
|
|
661
|
+
const r2 = await pf(w2, 41, { defaultBranch: "main", gateOnly: true });
|
|
662
|
+
assert.equal(r2.kind, "awaiting-measurement"); // released from the date; with no check on main it waits on its owner, never a lane
|
|
663
|
+
assert.deepEqual(w2.edits[0], [41, "--remove-label", "awaiting-due,due:2026-11-03"]);
|
|
664
|
+
assert.ok(w2.comments.some(([, b]) => /FORGE:OWNER_ASK/.test(b)));
|
|
665
|
+
assert.equal(w2.runner.length, 0);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("a decision item is parked as one typed owner ask (posted once), never a lane, never needs-human", async () => {
|
|
669
|
+
const w = world({ issues: { 42: { body: body([decision]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
670
|
+
const r = await pf(w, 42, { defaultBranch: "main" });
|
|
671
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "owner-ask"); assert.equal(w.runner.length, 0);
|
|
672
|
+
assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:OWNER_ASK/);
|
|
673
|
+
assert.match(formatParkedLine([{ issue: 42, prefilter: r }]), /^owner ask: #42/);
|
|
674
|
+
assert.deepEqual(w.edits, []);
|
|
675
|
+
});
|
|
676
|
+
});
|
package/bin/lean/probe.mjs
CHANGED
|
@@ -244,14 +244,14 @@ export async function probeIssue({ issue, repo, cwd = process.cwd(), io, default
|
|
|
244
244
|
try { defaultSha = String(await io.git(["rev-parse", `origin/${defaultBranch}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
|
|
245
245
|
const r = await runProbe(d.probe, { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
|
|
246
246
|
const detail = `exit ${r.exit ?? "timeout"} in ${r.ms}ms`;
|
|
247
|
-
if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, output: r.output });
|
|
247
|
+
if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, defaultBranch, output: r.output });
|
|
248
248
|
const body = renderProbeComment({ probe: d.probe, fix, defaultBranch, defaultSha, r });
|
|
249
249
|
let note = "";
|
|
250
250
|
if (r.result === "fail") {
|
|
251
251
|
try { await io.gh(["issue", "reopen", String(issue), "-R", repo]); } catch (e) { note = `; reopen failed: ${String(e?.message ?? e).slice(0, 120)}`; }
|
|
252
252
|
}
|
|
253
253
|
await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", body]);
|
|
254
|
-
return row(r.result, detail + note, { fix });
|
|
254
|
+
return row(r.result, detail + note, { fix, defaultBranch });
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** The default branch: forge.yaml `branches.default`, else GitHub's. */
|
package/bin/lean/probe.test.mjs
CHANGED
|
@@ -125,7 +125,7 @@ describe("probe (#4399)", () => {
|
|
|
125
125
|
const w = world({ comments: [], prs: [{ number: 3, title: "x", body: "mentions #7 only", mergeCommit: { oid: "1111111" }, mergedAt: "2026-10-02" },
|
|
126
126
|
{ number: 4, title: "fix", body: "Closes #7", mergeCommit: { oid: "2222222" }, mergedAt: "2026-10-01" }] });
|
|
127
127
|
const r = await run(w);
|
|
128
|
-
assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222");
|
|
128
|
+
assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222"); assert.ok(r.defaultBranch);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
it("pass: records FORGE:PROBE with the result JSON, no reopen", async () => {
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -72,7 +72,10 @@ export function i18nCopiesPrompt(findings, mode = "local") {
|
|
|
72
72
|
/** #4591: lane-added migrations the repo's generator did not produce. */
|
|
73
73
|
export function migrationGeneratorPrompt(problems, mode = "local") {
|
|
74
74
|
const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
|
|
75
|
-
|
|
75
|
+
const stale = problems.some((p) => /not above the base max when|is not after/.test(p))
|
|
76
|
+
? ` Entries reported with a stale \`when\` are stamped older than the base's newest migration, so a newest-only migrator (drizzle \`migrate()\`) skips them on a persistent database: re-stamp each one (a \`when\` and name above the base journal's max, same SQL, snapshot regenerated by the generator) and keep \`when\` increasing in the journal.`
|
|
77
|
+
: "";
|
|
78
|
+
return `These migration problems were found (hand-named files the repo's migration generator did not produce, or journal timestamps older than the base's):\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand.${stale} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
|
|
@@ -154,12 +157,13 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
|
154
157
|
If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
|
|
155
158
|
|
|
156
159
|
2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
|
|
157
|
-
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
|
|
160
|
+
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","bucket":"<defer, post-deploy: post-deploy|verify-now|date:YYYY-MM-DD|owner:<action>|new-issue>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
|
|
158
161
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
159
162
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
160
163
|
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
|
|
161
164
|
"decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
|
|
162
|
-
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
|
|
165
|
+
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. Every defer or post-deploy row has one "bucket": post-deploy (needs a release), verify-now (parent already on the default branch), date:YYYY-MM-DD (absolute, never a relative wait), owner:<action> (credentials, spend, settings; disposition defer, never post-deploy) or new-issue (future work). One sentence is one row. An outcome criterion this change can ship as a test, metric or alert is "deliver". A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
|
|
166
|
+
A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
|
|
163
167
|
A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
|
|
164
168
|
Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
|
|
165
169
|
A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
|
|
@@ -171,7 +175,7 @@ ${BLOCK_RULES}`;
|
|
|
171
175
|
* #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
|
|
172
176
|
* `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
|
|
173
177
|
*/
|
|
174
|
-
export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
|
|
178
|
+
export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result. A [type:e2e] requirement is delivered only when its validate command is a real end-to-end check, not a unit test with stubbed I/O.
|
|
175
179
|
2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
|
|
176
180
|
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
177
181
|
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
@@ -184,13 +188,15 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
|
|
|
184
188
|
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
185
189
|
|
|
186
190
|
The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
|
|
187
|
-
- {"kind":"add-deliverable","path":"<file>","why":""}: a file
|
|
188
|
-
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}:
|
|
189
|
-
- {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}:
|
|
190
|
-
- {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded
|
|
191
|
+
- {"kind":"add-deliverable","path":"<file>","why":""}: a file not listed.
|
|
192
|
+
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: a missed issue requirement.
|
|
193
|
+
- {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; unknown kinds are rejected. One requirement id per row (R2, R3 as two rows).
|
|
194
|
+
- {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded.
|
|
195
|
+
- {"kind":"decision","decision":{"choice":"","anchors":["<base path>"],"supersedes":"<id>"},"why":""}: supersedes changes an H-row.
|
|
191
196
|
New keys: translate, never copy English; status entries in place.
|
|
192
197
|
Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
|
|
193
198
|
Never name or number migration files by hand; use the generator.
|
|
199
|
+
A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
|
|
194
200
|
A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
|
|
195
201
|
If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
|
|
196
202
|
|
|
@@ -371,7 +377,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
|
|
|
371
377
|
You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
|
|
372
378
|
- issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
|
|
373
379
|
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
374
|
-
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
380
|
+
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
375
381
|
- post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
|
|
376
382
|
- scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
|
|
377
383
|
- preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
|
package/bin/lean/resume.test.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n$
|
|
|
11
11
|
const CONTRACT = {
|
|
12
12
|
requirements: [
|
|
13
13
|
{ id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
|
|
14
|
-
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
|
|
14
|
+
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod", bucket: "post-deploy" },
|
|
15
15
|
{ id: "R3", quote: "Log the retry count.", disposition: "deliver" },
|
|
16
16
|
],
|
|
17
17
|
validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
|
package/bin/lean/retry.mjs
CHANGED
|
@@ -43,3 +43,31 @@ export async function ghCacheRetry(call, cache) {
|
|
|
43
43
|
return await call(cache?.env?.());
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** #4662: an expired or revoked token (HTTP 401). 403 is a missing scope or a rate limit, which a refresh does not fix. */
|
|
48
|
+
export const GH_AUTH_RE = /HTTP 401|\b401\b[^\n]{0,40}(?:unauthorized|bad credentials)|bad credentials|requires authentication|authentication required|gh auth login|try authenticating/i;
|
|
49
|
+
export const GH_AUTH_RETRY_SECONDS = 120;
|
|
50
|
+
|
|
51
|
+
/** Runs scripts/refresh-bot-token.sh (or $FORGE_REFRESH_TOKEN_SCRIPT); resolves on exit 0, rejects otherwise. */
|
|
52
|
+
export async function runRefreshScript(env = process.env) {
|
|
53
|
+
const { execFile } = await import("node:child_process");
|
|
54
|
+
const script = env.FORGE_REFRESH_TOKEN_SCRIPT || new URL("../../scripts/refresh-bot-token.sh", import.meta.url).pathname;
|
|
55
|
+
return new Promise((res, rej) => execFile("bash", [script], { env, timeout: 60000 }, (err, _out, stderr) => (err ? rej(Object.assign(err, { stderr })) : res())));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* #4662: one refresh-and-retry for an auth failure. `refresh` runs once per `state` (shared across calls, so a dead token
|
|
60
|
+
* does not spawn a refresh per gh call); when the call still fails with an auth error the error carries
|
|
61
|
+
* `deferCode: "gh.auth"` so the lane ends `deferred` (requeued in `retryInSeconds`), never `engine-error`.
|
|
62
|
+
*/
|
|
63
|
+
export async function refreshAuthOnce(call, { refresh, state = { refreshed: false } }) {
|
|
64
|
+
try { return await call(); } catch (e) {
|
|
65
|
+
if (!GH_AUTH_RE.test(errText(e))) throw e;
|
|
66
|
+
if (!state.refreshed) {
|
|
67
|
+
state.refreshed = true;
|
|
68
|
+
try { await refresh(); state.ok = true; } catch { state.ok = false; }
|
|
69
|
+
if (state.ok) { try { const r = await call(); state.refreshed = false; return r; } catch (e2) { if (!GH_AUTH_RE.test(errText(e2))) throw e2; e = e2; } }
|
|
70
|
+
}
|
|
71
|
+
throw Object.assign(e, { deferCode: "gh.auth", deferDetail: `gh auth failed (HTTP 401) after a token refresh: ${errText(e).trim().split("\n")[0].slice(0, 200)}`, retryInSeconds: GH_AUTH_RETRY_SECONDS });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -186,9 +186,7 @@ describe("remote adapter (#4254)", () => {
|
|
|
186
186
|
const flaky = { ...fakeTransport(), status: async () => { throw new Error("ECONNRESET"); } };
|
|
187
187
|
const q = remoteProvider({ batch, repo: "o/r", transport: flaky, env: {} });
|
|
188
188
|
const h = await q.start({ issue: 6, args: [] });
|
|
189
|
-
|
|
190
|
-
for (let i = 0; i < 5; i++) seen.push(await q.status(h));
|
|
191
|
-
assert.deepEqual(seen, ["running", "running", "running", "running", "gone"]);
|
|
189
|
+
await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
|
|
192
190
|
});
|
|
193
191
|
|
|
194
192
|
it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
|
|
@@ -303,6 +301,13 @@ describe("pool provider (#4546)", () => {
|
|
|
303
301
|
assert.equal(Buffer.from(spec.env.CS_FORGE_YAML_B64, "base64").toString(), "verify:\n mode: ci\n");
|
|
304
302
|
});
|
|
305
303
|
|
|
304
|
+
it("#4685 a configured token_env that resolves empty falls back to the router token", async () => {
|
|
305
|
+
const { cfg } = setupPool();
|
|
306
|
+
const batch = openBatch(tmpDir("pool-b3-"), "bt");
|
|
307
|
+
const c = await launchOnce(() => providerByName("pool", { batch, repo: "o/r", cwd: tmpDir("pool-cwd3-"), exec: parseExecution({ mode: "remote", provider: "pool", token_env: "NO_SUCH_TOKEN_4685" }), env: {}, poolConfigPath: cfg }));
|
|
308
|
+
assert.equal(c.init.headers.authorization, "Bearer pool-token-789");
|
|
309
|
+
});
|
|
310
|
+
|
|
306
311
|
it("an explicit endpoint wins; no forge.yaml adds no env; a missing router config is misconfigured", async () => {
|
|
307
312
|
const { dir } = setupPool();
|
|
308
313
|
const batch = openBatch(tmpDir("pool-b2-"), "bt");
|
|
@@ -23,7 +23,6 @@ import { scopedEnv } from "./execution.mjs";
|
|
|
23
23
|
import { resolveCredential } from "./credentials.mjs";
|
|
24
24
|
|
|
25
25
|
const REDACTED = "[redacted]";
|
|
26
|
-
const STATUS_ERRORS = 5;
|
|
27
26
|
/** Replace every value of `secrets` (4+ chars, longest first) in `text`. */
|
|
28
27
|
export function redact(text, secrets = []) {
|
|
29
28
|
let s = String(text ?? "");
|
|
@@ -58,6 +57,8 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
58
57
|
};
|
|
59
58
|
const fail = (what, r) => {
|
|
60
59
|
const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
|
|
60
|
+
e.status = r.status;
|
|
61
|
+
if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
|
|
61
62
|
if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
|
|
62
63
|
return e;
|
|
63
64
|
};
|
|
@@ -113,7 +114,6 @@ export function gitIdentityEnv(identity, laneEnv = {}) {
|
|
|
113
114
|
export function remoteProvider({ batch, repo, settings = {}, name = "remote", transport, env = process.env, credentials = (name) => resolveCredential(name, { env }), now = () => Date.now(), gitIdentity = hostGitIdentity }) {
|
|
114
115
|
if (!transport) throw new Error("remote provider needs a transport");
|
|
115
116
|
const idFor = (issue) => `fdrsbx-${safeSegment(batch.id)}-${issue}`;
|
|
116
|
-
const errs = new Map(); // sandbox id -> consecutive status errors
|
|
117
117
|
const known = new Set(); // secret values seen this process, for redaction
|
|
118
118
|
const remember = (v) => { if (typeof v === "string" && v.length >= 4) known.add(v); };
|
|
119
119
|
for (const k of settings.secrets ?? []) remember(credentials(k));
|
|
@@ -156,8 +156,8 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
156
156
|
},
|
|
157
157
|
async status(h) {
|
|
158
158
|
let s;
|
|
159
|
-
//
|
|
160
|
-
|
|
159
|
+
// #4672: a transport error is not an exit; it propagates and providerLanes retries it for execution.unreachable_window_min.
|
|
160
|
+
s = await transport.status(h.id);
|
|
161
161
|
await pullLogs(h);
|
|
162
162
|
return s === "running" ? "running" : s === "gone" ? "gone" : "exited";
|
|
163
163
|
},
|