forgedock 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +5 -1
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +60 -12
- package/bin/lean/lean.test.mjs +80 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +19 -2
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -310,6 +310,22 @@ describe("core work-on", () => {
|
|
|
310
310
|
assert.match(t, /\| Plan \| not reached \|/);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
it("an unknown amendment kind gets one in-lane correction before the gate and does not use the conformance resume (#5089)", async () => {
|
|
314
|
+
const w = world({ amendments: [{ kind: "deliverable_change", requirement: "R1", why: "no Linux build" }], resumeAmendments: [], reviews: [ok()] });
|
|
315
|
+
const r = await run(w);
|
|
316
|
+
assert.equal(r.terminalReason, "merged");
|
|
317
|
+
assert.ok(builders(w).some((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")), "the correction prompt ran");
|
|
318
|
+
assert.match(traj(w)[0], /Amendment kinds invalid \(1\); builder asked to correct/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("an unknown amendment kind the builder does not fix parks only after the one correction (#5089)", async () => {
|
|
322
|
+
const bad = [{ kind: "deliverable_change", requirement: "R1", why: "w" }];
|
|
323
|
+
const w = world({ amendments: bad, resumeAmendments: bad, reviews: [ok()] });
|
|
324
|
+
const r = await run(w);
|
|
325
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
326
|
+
assert.equal(builders(w).filter((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")).length, 1);
|
|
327
|
+
});
|
|
328
|
+
|
|
313
329
|
it("nonconforming review -> builder resumed in its session with the failed rows -> re-review conforms -> merge; the note is re-posted", async () => {
|
|
314
330
|
const w = world({ reviews: [bad("src/foo.js:9 still wrong"), ok()] });
|
|
315
331
|
const r = await run(w);
|
|
@@ -443,6 +459,19 @@ describe("core work-on", () => {
|
|
|
443
459
|
assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
|
|
444
460
|
});
|
|
445
461
|
|
|
462
|
+
it("#5080 a credentials block that is only read-only prod access ends host-task locally, env.capability_remote remotely, never builder.blocked; real credentials stay builder.blocked", async () => {
|
|
463
|
+
const prod = { result: { status: "blocked", summary: "gated on read-only production telemetry: a host-only prod_monitor capability", block: { kind: "human", why: "credentials" } } };
|
|
464
|
+
const lw = world(prod);
|
|
465
|
+
const local = await run(lw);
|
|
466
|
+
assert.equal(local.terminalReason, "host-task"); assert.equal(local.code, "env.capability_remote");
|
|
467
|
+
assert.ok(lw.edits.some((e) => e.includes("--add-label") && e.includes("host-task")), JSON.stringify(lw.edits));
|
|
468
|
+
assert.ok(!lw.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")), JSON.stringify(lw.edits));
|
|
469
|
+
const remote = await run(world(prod), { placement: { where: "remote", reason: "mode remote" } });
|
|
470
|
+
assert.equal(remote.code, "env.capability_remote"); assert.notEqual(remote.terminalReason, "host-task");
|
|
471
|
+
const real = await run(world({ result: { status: "blocked", summary: "needs the production API key to read prod data", block: { kind: "human", why: "credentials" } } }));
|
|
472
|
+
assert.equal(real.code, "builder.blocked");
|
|
473
|
+
});
|
|
474
|
+
|
|
446
475
|
// #4124: a builder block is typed; only kind "human" with an allowed reason may reach needs-human.
|
|
447
476
|
const depWorld = (o, deps) => {
|
|
448
477
|
const w = world(o);
|
|
@@ -928,6 +957,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
|
|
|
928
957
|
assert.equal(w.followups.length, 1);
|
|
929
958
|
assert.deepEqual(w.followups[0].items.map((i) => i.key), ["R2: Existing callers of foo keep working."]);
|
|
930
959
|
assert.match(w.followups[0].items[0].why, /^post-deploy: \[bucket:post-deploy\] callers live in another module/);
|
|
960
|
+
assert.deepEqual(r.followups, [{ issue: 900, class: "post-deploy", count: 1 }]); // #5078: the batch report's follow-ups line
|
|
931
961
|
const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
|
|
932
962
|
assert.match(body, /## Remaining work tracked\n- #900\n\nCloses #7$/);
|
|
933
963
|
assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && c.includes("#900")));
|
|
@@ -1938,6 +1968,17 @@ describe("bug lanes freeze a red-green repro_test (#4271)", () => {
|
|
|
1938
1968
|
assert.equal((await run(fixed, { conformance: "shadow" })).terminalReason, "merged"); assert.equal(fixed.rgCalls.length, 2);
|
|
1939
1969
|
});
|
|
1940
1970
|
|
|
1971
|
+
it("#5080 a repro that already passes on the base ends already-fixed: the issue closes as completed citing the base, nothing merges, no resume", async () => {
|
|
1972
|
+
for (const conformance of ["enforce", "shadow"]) {
|
|
1973
|
+
const w = rworld([{ base: "RAN", head: "RAN" }], { changed: ["test/foo.test.js"] });
|
|
1974
|
+
const r = await run(w, { conformance });
|
|
1975
|
+
assert.equal(r.terminalReason, "invalid", conformance); assert.equal(w.merges.length, 0);
|
|
1976
|
+
assert.equal(builders(w).length, 2, "no resume"); assert.equal(reviewers(w).length, 0);
|
|
1977
|
+
assert.equal(w.closed.length, 1); assert.equal(w.closed[0][w.closed[0].indexOf("--reason") + 1], "completed");
|
|
1978
|
+
assert.match(w.closed[0][w.closed[0].indexOf("--comment") + 1], /Already fixed on the base/);
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1941
1982
|
it("a repro test the harness cannot run on head (or no harness result) is env.repro_unrunnable: recoverable, no resume, no reviewer (#4334)", async () => {
|
|
1942
1983
|
for (const conformance of ["enforce", "shadow"]) {
|
|
1943
1984
|
for (const rg of [{ base: "SKIPPED", head: "UNRUNNABLE" }, null]) {
|
|
@@ -3298,6 +3339,45 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
|
|
|
3298
3339
|
assert.match(r.detail, /resets at unknown/);
|
|
3299
3340
|
});
|
|
3300
3341
|
|
|
3342
|
+
// #5094: an account rotator re-sends the limited call on the next account at once, with the same args (resume too).
|
|
3343
|
+
const fakeRotator = (o = {}) => ({ kind: "tokenbaton", accounts: o.accounts ?? [{ n: 1 }, { n: 2 }], headroom: () => o.headroom ?? true, resumeAt: () => null,
|
|
3344
|
+
currentIdentity: () => o.id ?? "acct-2", accountIdentity: (n) => `acct-${n}`, isAccount: () => true });
|
|
3345
|
+
const captureBuilders = (w, err, times) => { const orig = w.io.runner; const sent = []; let n = 0;
|
|
3346
|
+
w.io.runner = async (c) => { if (c.role === "builder") { sent.push({ prompt: c.prompt, resume: c.resume ?? null, model: c.model ?? null }); if (n++ < times) throw err; } return orig(c); }; return sent; };
|
|
3347
|
+
|
|
3348
|
+
it("#5094 a rotator switches accounts mid-lane: the limited call is re-sent on the next account at once and the lane continues", async () => {
|
|
3349
|
+
const w = world(); const sleeps = []; const dir = tmpDir("core-");
|
|
3350
|
+
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
|
|
3351
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 1);
|
|
3352
|
+
w.io.rotator = fakeRotator();
|
|
3353
|
+
const r = await run(w, { dir });
|
|
3354
|
+
assert.equal(r.terminalReason, "merged", "no deferral: the next account ran the call");
|
|
3355
|
+
assert.equal(sleeps.includes(3 * 3600 * 1000), false, "no pause for the exhausted account's reset");
|
|
3356
|
+
assert.deepEqual(sent[1], sent[0], "the same step is re-sent (same prompt, same resume session): no state lost");
|
|
3357
|
+
const clean = world(); await run(clean);
|
|
3358
|
+
assert.equal(builders(w).length, builders(clean).length, "no builder phase is repeated beyond the re-sent call");
|
|
3359
|
+
assert.ok(readLog(dir, 7).some((e) => e.event === "PHASE_ACCOUNT_ROTATED" && e.phase === "builder"));
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
it("#5094 rotations stop at the account count, then the lane defers naming the rotator's current account", async () => {
|
|
3363
|
+
const w = world();
|
|
3364
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
|
|
3365
|
+
w.io.rotator = fakeRotator({ id: "acct-2" });
|
|
3366
|
+
const r = await run(w);
|
|
3367
|
+
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
|
|
3368
|
+
assert.equal(sent.length, 3, "first call plus one retry per account");
|
|
3369
|
+
assert.equal(r.backendId, "acct-2");
|
|
3370
|
+
});
|
|
3371
|
+
|
|
3372
|
+
it("#5094 a rotator without headroom does not retry: the usual pause/defer applies", async () => {
|
|
3373
|
+
const w = world();
|
|
3374
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
|
|
3375
|
+
w.io.rotator = fakeRotator({ headroom: false });
|
|
3376
|
+
const r = await run(w);
|
|
3377
|
+
assert.equal(r.terminalReason, "deferred");
|
|
3378
|
+
assert.equal(sent.length, 1);
|
|
3379
|
+
});
|
|
3380
|
+
|
|
3301
3381
|
it("#4101 a non-limit runner error is still engine-error", async () => {
|
|
3302
3382
|
const w = world(); failFirstBuilder(w, new Error("boom"));
|
|
3303
3383
|
assert.equal((await run(w)).terminalReason, "engine-error");
|
package/bin/lean/limits.mjs
CHANGED
|
@@ -13,8 +13,12 @@ import { join } from "node:path";
|
|
|
13
13
|
import { backendIdentity } from "../engine.mjs";
|
|
14
14
|
import { writeAtomic } from "./batch.mjs";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Identity of the account a core lane runs on: the lane always uses the claude CLI with the dispatcher's env. With an
|
|
18
|
+
* account rotator (#5094, rotator.mjs) the shim picks the account per call, so the rotator's current account is the identity.
|
|
19
|
+
*/
|
|
20
|
+
export function laneBackendId(env = process.env, rotator = null) {
|
|
21
|
+
if (rotator) { try { const id = rotator.currentIdentity(); if (id) return id; } catch { /* fail-open: the env identity */ } }
|
|
18
22
|
return backendIdentity({ backend: "cli", env });
|
|
19
23
|
}
|
|
20
24
|
|
package/bin/lean/limits.test.mjs
CHANGED
|
@@ -183,3 +183,89 @@ describe("account-scoped usage-limit holds (#4190)", () => {
|
|
|
183
183
|
assert.deepEqual(limits.list(), []);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("#5095: a held pool account places hybrid lanes local", () => {
|
|
188
|
+
const remoteFor = (extra = {}) => ({ max: 2, isRemote: () => true, place: () => ({ where: "remote", reason: "heavy" }), identity: () => "remote:pool", ...extra });
|
|
189
|
+
const runWith = async ({ hold, remote }) => {
|
|
190
|
+
const { c, now, sleep } = fakeClock();
|
|
191
|
+
const limits = openLimits(storeFile(), { now });
|
|
192
|
+
for (const [id, ms] of Object.entries(hold)) limits.set(id, c.t + ms, "test");
|
|
193
|
+
const ctxs = [];
|
|
194
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, now, sleep, readBody: async () => "", limits, identity: () => "local", remote,
|
|
195
|
+
runOne: async (n, _l, ctx) => { ctxs.push({ at: now() - 1_000_000, remote: ctx.remote, placement: ctx.placement }); return { terminalReason: "merged" }; } });
|
|
196
|
+
return { rows, ctxs };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it("hybrid: a hold on the remote identity and none on the local one places the next admission local", async () => {
|
|
200
|
+
const { rows, ctxs } = await runWith({ hold: { "remote:pool": 600_000 }, remote: remoteFor({ fallbackLocal: true }) });
|
|
201
|
+
assert.equal(rows[0].outcome, "merged");
|
|
202
|
+
assert.equal(ctxs.length, 1);
|
|
203
|
+
assert.equal(ctxs[0].at, 0, "admitted at once, not held");
|
|
204
|
+
assert.equal(ctxs[0].remote, false);
|
|
205
|
+
assert.equal(ctxs[0].placement.where, "local");
|
|
206
|
+
assert.match(ctxs[0].placement.reason, /fallback to local: pool usage limit until/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("remote-only: a held pool account waits for the reset; no hold places the lane remote", async () => {
|
|
210
|
+
const held = await runWith({ hold: { "remote:pool": 600_000 }, remote: remoteFor() });
|
|
211
|
+
assert.equal(held.ctxs[0].remote, true);
|
|
212
|
+
assert.ok(held.ctxs[0].at >= 600_000, "the remote lane starts after the pool reset");
|
|
213
|
+
const free = await runWith({ hold: {}, remote: remoteFor({ fallbackLocal: true }) });
|
|
214
|
+
assert.deepEqual([free.ctxs[0].at, free.ctxs[0].remote, free.ctxs[0].placement.where], [0, true, "remote"]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("account rotator and usage-limit holds (#5094)", () => {
|
|
219
|
+
it("#5094 rotator headroom: a rate_limited lane records no host hold and admissions continue", async () => {
|
|
220
|
+
const { c, now, sleep } = fakeClock();
|
|
221
|
+
const t0 = c.t;
|
|
222
|
+
const limits = openLimits(storeFile(), { now });
|
|
223
|
+
let acct = "acct-1";
|
|
224
|
+
const rotator = { kind: "tokenbaton", accounts: [{ n: 1 }, { n: 2 }], headroom: () => true, resumeAt: () => now(),
|
|
225
|
+
isAccount: (id) => id === "acct-1" || id === "acct-2", currentIdentity: () => acct };
|
|
226
|
+
const starts = {};
|
|
227
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", limits, rotator, identity: () => laneBackendId({}, rotator), batchId: "b1",
|
|
228
|
+
runOne: async (n) => {
|
|
229
|
+
(starts[n] ??= []).push(now());
|
|
230
|
+
if (n === 1 && starts[1].length === 1) { acct = "acct-2"; return limited("acct-1", t0 + 600_000); } // tokenbaton rotates
|
|
231
|
+
return { terminalReason: "merged" };
|
|
232
|
+
} });
|
|
233
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
234
|
+
assert.deepEqual(limits.list(), [], "no host-wide hold while an account has headroom");
|
|
235
|
+
assert.equal(starts[2][0], t0, "admissions continue");
|
|
236
|
+
assert.equal(starts[1][1], t0, "the limited issue reruns at once on the next account");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("#5094 a hold set for identity A does not block admissions once the rotator's active identity is B", async () => {
|
|
240
|
+
const { c, now, sleep } = fakeClock();
|
|
241
|
+
const t0 = c.t;
|
|
242
|
+
const limits = openLimits(storeFile(), { now });
|
|
243
|
+
limits.set("acct-1", t0 + 3_600_000, "batch b0 #1");
|
|
244
|
+
const rotator = { currentIdentity: () => "acct-2" };
|
|
245
|
+
const starts = [];
|
|
246
|
+
await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => laneBackendId({}, rotator),
|
|
247
|
+
runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
|
|
248
|
+
assert.deepEqual(starts, [t0]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("#5094 #5095 a limited pool account keeps its own hold under a rotator with headroom; the host account is never held", async () => {
|
|
252
|
+
const { c, now, sleep } = fakeClock();
|
|
253
|
+
const t0 = c.t;
|
|
254
|
+
const limits = openLimits(storeFile(), { now });
|
|
255
|
+
const rotator = { accounts: [{ n: 1 }], headroom: () => true, resumeAt: () => now(), isAccount: (id) => id === "acct-1", currentIdentity: () => "acct-1" };
|
|
256
|
+
const remote = { max: 2, fallbackLocal: true, isRemote: () => true, place: () => ({ where: "remote", reason: "heavy" }), identity: () => "remote:pool" };
|
|
257
|
+
const runs = [];
|
|
258
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", limits, rotator, remote, identity: () => laneBackendId({}, rotator),
|
|
259
|
+
runOne: async (n, _l, ctx) => {
|
|
260
|
+
runs.push({ n, at: now(), where: ctx.placement?.where, poolHeld: limits.holdUntil("remote:pool"), hostHeld: limits.holdUntil("acct-1") });
|
|
261
|
+
return n === 1 && runs.length === 1 ? limited("remote:pool", t0 + 600_000) : { terminalReason: "merged" };
|
|
262
|
+
} });
|
|
263
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
264
|
+
const second = runs.find((x) => x.n === 2);
|
|
265
|
+
assert.equal(runs[0].where, "remote");
|
|
266
|
+
assert.equal(second.at, t0, "the host account is not held: admissions continue");
|
|
267
|
+
assert.equal(second.poolHeld, t0 + 600_000, "the pool's own hold is recorded (#5095)");
|
|
268
|
+
assert.ok(!(second.hostHeld > now()), "no host-wide hold while the rotator has headroom");
|
|
269
|
+
assert.equal(second.where, "local", "hybrid falls back local while the pool is held");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -18,7 +18,7 @@ import { parseForgeYaml } from "../forge-utils.mjs";
|
|
|
18
18
|
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
19
19
|
import { parseLifecycle } from "./lifecycle.mjs";
|
|
20
20
|
import { parseMinAge } from "./prefilter.mjs";
|
|
21
|
-
import { DEFAULT_FEATURE_PATTERN, milestoneSlug, readBranches } from "./lane.mjs";
|
|
21
|
+
import { DEFAULT_FEATURE_PATTERN, listMilestones, milestoneSlug, readBranches } from "./lane.mjs";
|
|
22
22
|
|
|
23
23
|
export const SYNC_REASONS = ["ship", "batch", "conflict-risk", "manual"];
|
|
24
24
|
export const DEFAULT_DEBOUNCE_MS = 10 * 60 * 1000;
|
|
@@ -57,7 +57,7 @@ export async function syncMilestone({ gh, git, repo, slug, base, branch = `miles
|
|
|
57
57
|
let wt = null, baseSha;
|
|
58
58
|
try {
|
|
59
59
|
if (!SYNC_REASONS.includes(reason)) return { status: "refused", detail: `sync is on demand: reason must be one of ${SYNC_REASONS.join("|")}` };
|
|
60
|
-
const list =
|
|
60
|
+
const list = await listMilestones({ gh, repo, state: "all" });
|
|
61
61
|
const ms = (Array.isArray(list) ? list : []).find((m) => milestoneSlug(m?.title) === slug);
|
|
62
62
|
if (!ms) return { status: "refused", detail: `no milestone with slug ${slug}` };
|
|
63
63
|
if (!isSyncable(ms)) return { status: "shipped", detail: `milestone is ${parseLifecycle(ms.description)?.state ?? ms.state}: never synced` };
|
|
@@ -131,3 +131,25 @@ describe("syncMilestone (#4663)", () => {
|
|
|
131
131
|
assert.equal(remote("rev-parse", "milestone/alpha"), before);
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
/** #5084: a GitHub-like milestones endpoint: pages of 100; without --paginate only page 1; `--jq '.[] | @json'` prints one object per line. */
|
|
136
|
+
const pagedMilestones = (all) => (a) => {
|
|
137
|
+
const state = /state=(\w+)/.exec(a[1])?.[1] ?? "open";
|
|
138
|
+
const items = all.filter((m) => state === "all" || m.state === state);
|
|
139
|
+
const pages = []; for (let i = 0; i < items.length; i += 100) pages.push(items.slice(i, i + 100));
|
|
140
|
+
if (!a.includes("--paginate")) return JSON.stringify(pages[0] ?? []);
|
|
141
|
+
if (a.includes("--jq")) return items.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
|
142
|
+
return pages.map((p) => JSON.stringify(p)).join("");
|
|
143
|
+
};
|
|
144
|
+
const manyMilestones = (target, n = 120) => [...Array.from({ length: n }, (_, i) => ({ number: i + 1, title: `M ${i + 1}`, state: i % 2 ? "open" : "closed", description: "" })), { number: n + 1, ...target }];
|
|
145
|
+
|
|
146
|
+
describe("milestone pagination (#5084)", () => {
|
|
147
|
+
it("finds a milestone on page 2 of the milestones API (#5084)", async () => {
|
|
148
|
+
sh(work, "push", "-q", "origin", "milestone/alpha:milestone/search-research-api");
|
|
149
|
+
const list = pagedMilestones(manyMilestones({ title: "Search & Research API", state: "open", description: "" }));
|
|
150
|
+
const paged = async (args) => (args[0] === "api" && args[1].includes("/milestones?") ? list(args) : gh(args));
|
|
151
|
+
const r = await syncMilestone({ gh: paged, git, repo: "o/r", slug: "search-research-api", base: "staging", reason: "manual", cfg: {}, tmp: tmpDir("msync-tmp-") });
|
|
152
|
+
assert.notEqual(r.status, "refused", r.detail);
|
|
153
|
+
assert.equal(r.status, "up-to-date");
|
|
154
|
+
});
|
|
155
|
+
});
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
|
|
24
24
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
25
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
|
-
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset.
|
|
26
|
+
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset.
|
|
27
|
+
* #5095: `remote.identity()` names the pool's account (execution.mjs `remoteHoldId`); while it is held, a remote-placed lane
|
|
28
|
+
* runs local in hybrid (`fallbackLocal`, local account not held) or waits for the reset; admission is not held. An `engine-error` is
|
|
27
29
|
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
28
30
|
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
29
31
|
* `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
|
|
@@ -116,6 +118,7 @@ import { laneSize, SIZES } from "./budget.mjs";
|
|
|
116
118
|
import { requeueKey, REQUEUABLE } from "./batch.mjs";
|
|
117
119
|
import { makeCapWait } from "./capwait.mjs";
|
|
118
120
|
import { mapLimit } from "./pmap.mjs";
|
|
121
|
+
import { quotaRequeue } from "./rotator.mjs";
|
|
119
122
|
import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_POLL_S } from "./autochildren.mjs";
|
|
120
123
|
|
|
121
124
|
// A dependency that finished like this no longer blocks its dependents.
|
|
@@ -257,7 +260,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
257
260
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
|
|
258
261
|
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
|
|
259
262
|
estimate = null, starveMs = DEFAULT_STARVE_MS, setupConcurrency = DEFAULT_SETUP_CONCURRENCY, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
|
|
260
|
-
getChildren = null, autoChildren = null, baseWait = null }) {
|
|
263
|
+
getChildren = null, autoChildren = null, baseWait = null, rotator = null }) {
|
|
261
264
|
const saved = store?.load?.() ?? null;
|
|
262
265
|
const order = saved ? [...saved.order] : [];
|
|
263
266
|
const set = new Set(order);
|
|
@@ -279,7 +282,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
279
282
|
let errorTimes = [...(saved?.errorTimes ?? [])];
|
|
280
283
|
// A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
|
|
281
284
|
const holds = limits ?? memoryLimits(now);
|
|
282
|
-
|
|
285
|
+
// #5094: with an account rotator, admissions stop only when no account has headroom, and resume when the first one resets.
|
|
286
|
+
const rotatorHold = () => {
|
|
287
|
+
if (!rotator) return undefined;
|
|
288
|
+
try { if (rotator.headroom()) return null; const u = rotator.resumeAt(); return Number.isFinite(u) && u > now() ? u : undefined; } catch { return undefined; }
|
|
289
|
+
};
|
|
290
|
+
const heldUntil = () => {
|
|
291
|
+
const r = rotatorHold();
|
|
292
|
+
if (r !== undefined) return r;
|
|
293
|
+
try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; }
|
|
294
|
+
};
|
|
295
|
+
// #5094: a quota pause carries its attempt's cost and minutes into the issue's next row once; the resume time labels the report row.
|
|
296
|
+
const quota = toMap(saved?.quota); // issue -> {cost_usd, minutes, resumeAt (epoch ms), rotations}
|
|
283
297
|
let tripped = saved?.breaker ?? null;
|
|
284
298
|
let pfAll = false, pfRemote = false, pfNoted = new Set(); // #4866: lane preflight holds
|
|
285
299
|
let opHeld = new Set(); // #4592: operator-held issues this pass (the batch `hold` file is the authority)
|
|
@@ -344,7 +358,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
344
358
|
const save = () => {
|
|
345
359
|
if (!store || halted) return;
|
|
346
360
|
store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
347
|
-
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
361
|
+
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}),
|
|
348
362
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
349
363
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
350
364
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
|
|
@@ -507,6 +521,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
507
521
|
const grant = (n, wanted) => {
|
|
508
522
|
if (lanes.get(n)?.ok === false) return true;
|
|
509
523
|
let p = placeOf(n);
|
|
524
|
+
if (p?.where === "remote" && typeof remote?.identity === "function") {
|
|
525
|
+
// #5095: the pool account is held for a usage limit: hybrid runs the lane on the host, remote-only waits for the reset
|
|
526
|
+
let poolHeld = null;
|
|
527
|
+
try { const u = holds.holdUntil(remote.identity()); poolHeld = Number.isFinite(u) && u > now() ? u : null; } catch { poolHeld = null; }
|
|
528
|
+
if (poolHeld !== null) {
|
|
529
|
+
if (!remote.fallbackLocal || heldUntil() !== null) { notBefore.set(n, poolHeld); return false; }
|
|
530
|
+
p = { where: "local", reason: `fallback to local: pool usage limit until ${new Date(poolHeld).toISOString()}` };
|
|
531
|
+
}
|
|
532
|
+
}
|
|
510
533
|
if (p?.where === "remote" && reg?.holder) {
|
|
511
534
|
let other = null;
|
|
512
535
|
try { other = reg.holder(entryOf(n)) ?? null; } catch { other = null; }
|
|
@@ -587,11 +610,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
587
610
|
...(() => { const w = capWait.take(n, r?.waits); return w && typeof w === "object" ? { waits: w } : {}; })(), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
588
611
|
...(Number.isFinite(r?.wall_ms) ? { wall_ms: r.wall_ms } : {}), ...(Number.isFinite(r?.model_ms) ? { model_ms: r.model_ms } : {}), // #4451
|
|
589
612
|
...(phasesOf(r, rec) ? { phases: phasesOf(r, rec) } : {}), // #4908
|
|
613
|
+
...(Array.isArray(r?.followups) && r.followups.length ? { followups: r.followups } : {}), // #5078
|
|
590
614
|
...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
|
|
591
615
|
...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
|
|
592
616
|
...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
|
|
593
617
|
...(r?.conformance && typeof r.conformance === "object" ? { conformance: r.conformance } : {}), // #4398
|
|
594
618
|
...(rec?.placement ? { placement: rec.placement } : {}), ...(estimates.has(n) ? { est_minutes: Math.round(estimates.get(n) * 10) / 10 } : {}) }; // #4409
|
|
619
|
+
const quotaPause = row.outcome === "deferred" && r?.code === "rate_limited" && !cancelled;
|
|
620
|
+
if (!quotaPause && quota.has(n)) { // #5094: the paused attempts' cost, added once
|
|
621
|
+
const q = quota.get(n); quota.delete(n);
|
|
622
|
+
row.cost_usd = Math.round(((Number(row.cost_usd) || 0) + (Number(q.cost_usd) || 0)) * 1e6) / 1e6;
|
|
623
|
+
row.minutes = Math.round(((Number(row.minutes) || 0) + (Number(q.minutes) || 0)) * 10) / 10;
|
|
624
|
+
}
|
|
595
625
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
596
626
|
const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
597
627
|
if (subs.length) row.subissues = subs; // #4396: links a decomposed parent to its sub-lanes in the report
|
|
@@ -664,10 +694,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
664
694
|
// account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
|
|
665
695
|
let delayMs = pol.delayMs;
|
|
666
696
|
if (pol.hold) {
|
|
667
|
-
|
|
697
|
+
// #5094: an account rotator with headroom means another account runs the next call, so no host hold is recorded.
|
|
698
|
+
const rq = quotaRequeue({ rotator, r, prior: quota.get(n), delayMs: pol.delayMs });
|
|
699
|
+
if (rq) {
|
|
700
|
+
delayMs = rq.delayMs;
|
|
701
|
+
// #5095 + #5094: the pool account's own hold (not the host's) still routes hybrid lanes local until its reset.
|
|
702
|
+
try { const pool = remote?.identity?.(); if (!rq.rotated && pool && r?.backendId === pool) holds.set(pool, now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); } catch { /* fail-open */ }
|
|
703
|
+
} else try { holds.set(r?.backendId ?? identity(), now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); delayMs = 0; } catch { /* fail-open */ }
|
|
704
|
+
const q = quota.get(n) ?? { cost_usd: 0, minutes: 0, rotations: 0 };
|
|
705
|
+
let rotAt = null;
|
|
706
|
+
if (!rq && rotator) { try { rotAt = rotator.resumeAt(); } catch { rotAt = null; } }
|
|
707
|
+
const resumeAt = Math.max(now() + (rq ? rq.delayMs : pol.delayMs), Number.isFinite(rotAt) ? rotAt : 0);
|
|
708
|
+
quota.set(n, { cost_usd: Math.round(((Number(q.cost_usd) || 0) + (Number(row.cost_usd) || 0)) * 1e6) / 1e6, minutes: Math.round(((Number(q.minutes) || 0) + (Number(row.minutes) || 0)) * 10) / 10,
|
|
709
|
+
resumeAt, rotations: (q.rotations ?? 0) + (rq?.rotated ? 1 : 0) });
|
|
710
|
+
requeue(n, delayMs); done(); save();
|
|
711
|
+
const iso = new Date(resumeAt).toISOString();
|
|
712
|
+
update({ ...row, outcome: "paused", quota: { resumeAt: iso }, detail: `paused (quota) until ${iso}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
|
|
713
|
+
return;
|
|
668
714
|
}
|
|
669
|
-
requeue(n, capGated
|
|
670
|
-
update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s
|
|
715
|
+
requeue(n, capGated ? 0 : delayMs); done(); save();
|
|
716
|
+
update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
|
|
671
717
|
return;
|
|
672
718
|
}
|
|
673
719
|
row.detail = `gave up after ${deferrals.get(n) ?? 0} deferrals: ${r?.detail ?? r?.code ?? ""}`;
|
|
@@ -1288,7 +1334,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1288
1334
|
for (const [n, r] of results) if (r?.waiting) { const { waiting, ...rest } = r; results.set(n, rest); } // #4687: the batch ended: skipped for good
|
|
1289
1335
|
save();
|
|
1290
1336
|
const withRequeue = (r) => (r && requeuedInfo.has(r.issue) ? { ...r, requeued: { reason: requeuedInfo.get(r.issue).reason, by: requeuedInfo.get(r.issue).by, prior: requeuedInfo.get(r.issue).prior.map((p) => ({ outcome: p.outcome, detail: p.detail ?? null, pr: p.pr ?? null })) } } : r);
|
|
1291
|
-
|
|
1337
|
+
const quotaRow = (n) => { // #5094: still pending after a quota pause: held (resumable), labelled paused (quota), with the paused attempts' cost
|
|
1338
|
+
const q = quota.get(n);
|
|
1339
|
+
if (!q || cancelled || opHeld.has(n)) return {};
|
|
1340
|
+
const iso = new Date(Number(q.resumeAt) || now()).toISOString();
|
|
1341
|
+
return { cost_usd: Number(q.cost_usd) || 0, minutes: Number(q.minutes) || 0, quota: { resumeAt: iso }, detail: `paused (quota) until ${iso}` };
|
|
1342
|
+
};
|
|
1343
|
+
return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n), ...quotaRow(n),
|
|
1292
1344
|
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
1293
1345
|
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
1294
1346
|
}
|
|
@@ -1314,6 +1366,22 @@ export const OUTCOME_LINE_MAX = 200;
|
|
|
1314
1366
|
* mechanical needs-human row (failed recovery or a terminal code), then one escalation list holding only human codes,
|
|
1315
1367
|
* asked once at the end of the batch. Empty when there is nothing to list.
|
|
1316
1368
|
*/
|
|
1369
|
+
/**
|
|
1370
|
+
* #5078: one `follow-ups appended:` line grouped by issue across rows: `#N (<class>: k item(s) from #a,#b)`, the post-deploy
|
|
1371
|
+
* rollup named as such. Null when no row filed follow-ups.
|
|
1372
|
+
*/
|
|
1373
|
+
function formatFollowupsLine(rows) {
|
|
1374
|
+
const by = new Map();
|
|
1375
|
+
for (const r of rows ?? []) for (const f of Array.isArray(r?.followups) ? r.followups : []) {
|
|
1376
|
+
if (!Number.isInteger(f?.issue) || f.issue <= 0) continue;
|
|
1377
|
+
const g = by.get(f.issue) ?? { classes: new Set(), count: 0, from: new Set() };
|
|
1378
|
+
if (f.class) g.classes.add(String(f.class));
|
|
1379
|
+
g.count += Number(f.count) || 0; g.from.add(r.issue); by.set(f.issue, g);
|
|
1380
|
+
}
|
|
1381
|
+
if (!by.size) return null;
|
|
1382
|
+
const name = (c) => (c === "post-deploy" ? "post-deploy rollup" : c === "external" ? "external aggregate" : c);
|
|
1383
|
+
return `follow-ups appended: ${[...by].sort((a, b) => a[0] - b[0]).map(([n, g]) => `#${n} (${[...g.classes].sort().map(name).join("+") || "follow-up"}: ${g.count} item(s) from ${[...g.from].map((i) => `#${i}`).join(",")})`).join(" ")}`;
|
|
1384
|
+
}
|
|
1317
1385
|
export function formatOutcomeLists(rows) {
|
|
1318
1386
|
const out = [];
|
|
1319
1387
|
const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
|
|
@@ -1332,6 +1400,8 @@ export function formatOutcomeLists(rows) {
|
|
|
1332
1400
|
if (rq.length) out.push(`remediation queue (/work-on:remediate): ${rq.map((r) => `#${r.issue} (parked:${r.code}${r.pr ? ` PR #${r.pr}` : ""})`).join(" ")}`);
|
|
1333
1401
|
const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
|
|
1334
1402
|
if (parked) out.push(parked);
|
|
1403
|
+
const fu = formatFollowupsLine(rows); // #5078: follow-ups appended, not "issues filed"
|
|
1404
|
+
if (fu) out.push(fu);
|
|
1335
1405
|
const decided = (rows ?? []).filter((r) => r?.decided); // #4766: settled by the decision engine, listed not hidden
|
|
1336
1406
|
if (decided.length) out.push(["decided (north-star engine, veto with the forge:decision-veto label):", ...decided.map((r) => `- #${r.issue}: ${one(r.decided.choice)}${r.decided.shadow ? " (shadow)" : ""}`)].join("\n"));
|
|
1337
1407
|
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
@@ -1342,7 +1412,7 @@ export function formatTable(rows) {
|
|
|
1342
1412
|
// #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
|
|
1343
1413
|
const sbx = rows.some((r) => r?.sandbox);
|
|
1344
1414
|
const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
|
|
1345
|
-
const body = rows.map((r) => [`#${r.issue}`, (r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome) + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
|
|
1415
|
+
const body = rows.map((r) => [`#${r.issue}`, r.quota?.resumeAt && ["held", "paused"].includes(r.outcome) ? `paused (quota) until ${r.quota.resumeAt}` : (r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome) + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
|
|
1346
1416
|
...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
|
|
1347
1417
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
1348
1418
|
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
package/bin/lean/probe.mjs
CHANGED
|
@@ -282,7 +282,7 @@ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null
|
|
|
282
282
|
const rows = [];
|
|
283
283
|
for (const issue of list) {
|
|
284
284
|
try { rows.push(await probeIssue({ issue, repo, cwd, io, defaultBranch, dryRun, config, env, spawn })); } catch (e) {
|
|
285
|
-
rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0,
|
|
285
|
+
rows.push({ issue, outcome: "error", detail: `probe #${issue}: ${String(e?.message ?? e).slice(0, 140)}` });
|
|
286
286
|
}
|
|
287
287
|
try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep (#4630) */ }
|
|
288
288
|
}
|
|
@@ -292,7 +292,9 @@ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null
|
|
|
292
292
|
const GROUPS = [["pass", "pass"], ["fail", "fail (reopened)"], ["awaiting-promotion", "awaiting promotion"], ["unresolved", "unresolved fix"], ["untrusted", "untrusted (not run)"], ["error", "error"]];
|
|
293
293
|
/** One line, e.g. `probes: pass #A; fail (reopened) #B; awaiting promotion #C`; "" when nothing ran or waits. */
|
|
294
294
|
export function formatProbeSummary(rows) {
|
|
295
|
-
|
|
295
|
+
// #5080: an error row names its cause (the probe step and message) instead of a bare issue number
|
|
296
|
+
const ref = (r) => r.outcome === "error" && String(r.detail ?? "").trim() ? `#${r.issue} (${String(r.detail).replace(/\s+/g, " ").trim().slice(0, 120)})` : `#${r.issue}`;
|
|
297
|
+
const parts = GROUPS.map(([k, label]) => [label, (rows ?? []).filter((r) => r.outcome === k).map(ref)])
|
|
296
298
|
.filter(([, l]) => l.length).map(([label, l]) => `${label} ${l.join(" ")}`);
|
|
297
299
|
return parts.length ? `probes: ${parts.join("; ")}` : "";
|
|
298
300
|
}
|
package/bin/lean/probe.test.mjs
CHANGED
|
@@ -199,6 +199,10 @@ describe("probe (#4399)", () => {
|
|
|
199
199
|
"probes: pass #1; fail (reopened) #2; awaiting promotion #3");
|
|
200
200
|
assert.equal(formatProbeSummary([{ issue: 4, outcome: "already-recorded" }]), "");
|
|
201
201
|
});
|
|
202
|
+
it("#5080 an error row names the probe and the cause, not a bare issue number", () => {
|
|
203
|
+
assert.equal(formatProbeSummary([{ issue: 38074, outcome: "error", detail: "probe #38074: gh: HTTP 502" }]), "probes: error #38074 (probe #38074: gh: HTTP 502)");
|
|
204
|
+
assert.equal(formatProbeSummary([{ issue: 7, outcome: "error" }]), "probes: error #7");
|
|
205
|
+
});
|
|
202
206
|
|
|
203
207
|
it("forge.yaml config defaults and a real spawn honour exit, timeout and env", async () => {
|
|
204
208
|
assert.deepEqual(readProbeConfig("/nonexistent"), { env: [], timeoutS: 120, sweepDays: 30, enabled: true });
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -157,19 +157,21 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
|
157
157
|
|
|
158
158
|
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.
|
|
159
159
|
|
|
160
|
-
2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify
|
|
161
|
-
{"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>"}],
|
|
160
|
+
2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify from this file alone.
|
|
161
|
+
{"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|host>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
|
|
162
162
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
163
163
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
164
164
|
"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>"}],
|
|
165
165
|
"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"}]}
|
|
166
|
-
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,
|
|
166
|
+
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> (only credentials, spend, an account, a third party, a customer; defer, never post-deploy), host (read-only prod data: obs-psql, *-monitor) or new-issue (future work). A post-promotion check without a check id is post-deploy. One sentence is one row. An outcome criterion this change can ship as a test, metric or alert 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.
|
|
167
167
|
A new default-off flag or rollout key needs an activation row: defer, bucket \`owner:promote:<flag>\`, why = first cohort, guard metrics, shadow evidence (an AC naming shadow/cohort/treated fails if it ships off).
|
|
168
168
|
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.
|
|
169
169
|
A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
|
|
170
170
|
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.
|
|
171
171
|
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.
|
|
172
172
|
|
|
173
|
+
An added route needs its production ingress rule cited in ${INVESTIGATION_FILE}. No decision may swap e2e for mocked tests without a forge-probe.
|
|
174
|
+
|
|
173
175
|
${BLOCK_RULES}`;
|
|
174
176
|
}
|
|
175
177
|
|
|
@@ -177,7 +179,7 @@ ${BLOCK_RULES}`;
|
|
|
177
179
|
* #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
|
|
178
180
|
* `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
|
|
179
181
|
*/
|
|
180
|
-
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
|
|
182
|
+
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 result. A [type:e2e] requirement is delivered only by a real end-to-end validate, not a unit test with stubbed I/O. Swapping e2e for mocks without a forge-probe, or a route without ingress evidence, is not delivery.
|
|
181
183
|
2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
|
|
182
184
|
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
183
185
|
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
@@ -201,16 +203,16 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
|
201
203
|
The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
|
|
202
204
|
- {"kind":"add-deliverable","path":"<file>","why":""}: a file not listed.
|
|
203
205
|
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: a missed requirement.
|
|
204
|
-
- {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check, filed as a follow-up. Not "type"; unknown kinds are rejected. One requirement id per row.
|
|
206
|
+
- {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check, filed as a follow-up. Not "type"; unknown kinds are rejected. One requirement id per row. An impossible deliverable is a defer, not a new kind.
|
|
205
207
|
- {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded.
|
|
206
|
-
- {"kind":"supersedes","guard":"#N","why":""}: overrides a guard_broken failure
|
|
208
|
+
- {"kind":"supersedes","guard":"#N","why":""}: overrides a guard_broken failure.
|
|
207
209
|
- {"kind":"decision","decision":{"choice":"","anchors":["<base path>"],"supersedes":"<id>"},"why":""}: supersedes changes an H-row.
|
|
208
210
|
New keys: translate, never copy English; status entries in place.
|
|
209
211
|
Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
|
|
210
212
|
Never name or number migration files by hand; use the generator.
|
|
211
213
|
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.
|
|
212
214
|
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.
|
|
213
|
-
If
|
|
215
|
+
If scripts/check-devdocs-freshness.sh exists, a behaviour change under its paths must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change; run it against the base first.
|
|
214
216
|
|
|
215
217
|
Before writing ${RESULT_FILE}, after your last edit, run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes; if it cannot pass, report the problem as blocked.
|
|
216
218
|
|
|
@@ -246,7 +248,7 @@ export function contextPackNote(pack) {
|
|
|
246
248
|
/** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
|
|
247
249
|
export function unrunnableValidateNote(testRunners = null) {
|
|
248
250
|
const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
|
|
249
|
-
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>"
|
|
251
|
+
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>" only when it needs credentials, spend, an account, a third party or a customer; read-only prod data is "host". forge.yaml validate.recipes rewrite matching commands at freeze.`;
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
export function reproPlanNote(on) {
|
|
@@ -331,6 +333,11 @@ export function contractCorrectionPrompt(problems) {
|
|
|
331
333
|
return `Your ${CONTRACT_FILE} is not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nFix the contract (planning only, no code, no commit) and stop. Do not push or touch GitHub.`;
|
|
332
334
|
}
|
|
333
335
|
|
|
336
|
+
/** #5089: the builder's amendments name an unknown kind or miss a field; one correction before the conformance gate. */
|
|
337
|
+
export function amendmentCorrectionPrompt(problems) {
|
|
338
|
+
return `The amendments in ${RESULT_FILE} are not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nRewrite them with a valid kind: add-deliverable, add-requirement, defer, post-deploy, amend-requirement, supersedes, decision, substitute-validate or out-of-band. A deliverable that is impossible on this platform is {"kind":"defer","requirement":"R#","why":""}, not a changed deliverable. Edit only ${RESULT_FILE} (commit nothing else) and stop. Do not push or touch GitHub.`;
|
|
339
|
+
}
|
|
340
|
+
|
|
334
341
|
/** The one bounded correction round, in the builder's own session. */
|
|
335
342
|
export function conformanceResumePrompt(failures, mode = "local", lane = "") {
|
|
336
343
|
const baseMoved = lane && failures.some((f) => f?.kind === "validate_failed")
|
|
@@ -364,11 +371,11 @@ export const HUMAN_BLOCK_REASONS = Object.freeze(["credentials", "spend", "promo
|
|
|
364
371
|
|
|
365
372
|
const BLOCK_RULES = `"blocked" needs a typed "block" in ${RESULT_FILE}, one of:
|
|
366
373
|
- {"kind":"dependency","issues":[N]}: an open upstream issue must land first. The engine requeues this issue behind it.
|
|
367
|
-
- {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split,
|
|
374
|
+
- {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split, siblings editing the same hub (barrel or index export list, union type, shared fixture, index doc) get its slots pre-staged in the earliest sub-issue, which the others depend on, or their own per-feature files; the engine serialises sub-issues still sharing a file.
|
|
368
375
|
- {"kind":"human","why":"${HUMAN_BLOCK_REASONS.join("|")}"}: only an external action agents cannot take, or a product decision too critical for agents. Optional "remainder":[{"title","body"}]: the agent-doable rest, filed as its own issue.
|
|
369
376
|
"An upstream issue must land first", "needs a split" and "needs prod data that read-only monitoring can read" are never "human".
|
|
370
|
-
A check that times out (rc 124) is host load, never a block
|
|
371
|
-
Remote sandbox lacking a host-only capability (sops, prod_monitor, ops_credentials): name it in the summary
|
|
377
|
+
A check that times out (rc 124) is host load, never a block: run the narrowest targeted command for your changed files and say the full one could not finish.
|
|
378
|
+
Remote sandbox lacking a host-only capability (sops, prod_monitor, ops_credentials): name it in the summary.`;
|
|
372
379
|
|
|
373
380
|
export function blockCorrectionPrompt(problem) {
|
|
374
381
|
return `Your ${RESULT_FILE} says "blocked", but ${String(problem).slice(0, 1000)}. That is not a block that needs a human.\n\n${BLOCK_RULES}\n\nEither do the work (fix, commit, update the stage notes and ${RESULT_FILE}) or write a valid typed "block". Do not push or touch GitHub.`;
|