forgedock 1.46.0 → 1.48.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/issuewatch.mjs +134 -37
- package/bin/lean/issuewatch.test.mjs +123 -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 +21 -2
- package/docs/ISSUE-WATCH.md +22 -1
- 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/plan-validate-live.mjs +18 -6
- 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/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.`;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Account rotators (#5094). With `execution.account_rotator: tokenbaton`, the `claude` on PATH is a shim that runs every
|
|
5
|
+
* call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`), so an exhausted account is not an
|
|
6
|
+
* exhausted host. This adapter reads tokenbaton's per-account quota state: `~/.tokenbaton/current` (the account number)
|
|
7
|
+
* and `~/.tokenbaton/live.<n>` (`"<5h used %> <5h reset epoch s> <weekly used %> <weekly reset epoch s> <updated s>"`).
|
|
8
|
+
* Every method re-reads the files (no cache), never writes, and fails open: unreadable state behaves as no rotator.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { backendIdentity } from "../engine.mjs";
|
|
14
|
+
|
|
15
|
+
export const ROTATOR_KINDS = ["tokenbaton"];
|
|
16
|
+
/** An account has headroom below this 5-hour use (%), like the computeshare quota-aware picker. */
|
|
17
|
+
export const HEADROOM_PCT = 95;
|
|
18
|
+
|
|
19
|
+
/** One `live.N` line as numbers, or null when malformed. */
|
|
20
|
+
export function parseLive(text) {
|
|
21
|
+
const f = String(text ?? "").trim().split(/\s+/).map(Number);
|
|
22
|
+
if (f.length < 4 || !f.slice(0, 4).every(Number.isFinite)) return null;
|
|
23
|
+
return { fiveHourPct: f[0], fiveHourResetMs: f[1] * 1000, weeklyPct: f[2], weeklyResetMs: f[3] * 1000 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Handle on an account rotator, or null (unknown kind, or no readable state).
|
|
28
|
+
* @returns {null | {kind, accounts, current(), headroom(), resumeAt(), accountIdentity(n), currentIdentity(), isAccount(id)}}
|
|
29
|
+
*/
|
|
30
|
+
export function openRotator({ kind, home = homedir(), env = process.env, now = () => Date.now(), readFile = (p) => readFileSync(p, "utf8"), listDir = (d) => readdirSync(d) } = {}) {
|
|
31
|
+
if (!ROTATOR_KINDS.includes(kind)) return null;
|
|
32
|
+
const dir = join(home, ".tokenbaton");
|
|
33
|
+
const current = () => { try { const n = Number(String(readFile(join(dir, "current"))).trim()); return Number.isInteger(n) && n > 0 ? n : null; } catch { return null; } };
|
|
34
|
+
const accounts = () => {
|
|
35
|
+
let names = [];
|
|
36
|
+
try { names = listDir(dir); } catch { return []; }
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const m = /^live\.(\d+)$/.exec(name);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
let live = null;
|
|
42
|
+
try { live = parseLive(readFile(join(dir, name))); } catch { live = null; }
|
|
43
|
+
if (live) out.push({ n: Number(m[1]), ...live });
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => a.n - b.n);
|
|
46
|
+
};
|
|
47
|
+
if (current() === null || !accounts().length) return null;
|
|
48
|
+
const used = (pct, resetMs) => (resetMs <= now() ? 0 : pct);
|
|
49
|
+
const hasRoom = (a) => used(a.fiveHourPct, a.fiveHourResetMs) < HEADROOM_PCT && used(a.weeklyPct, a.weeklyResetMs) < 100;
|
|
50
|
+
const accountIdentity = (n) => backendIdentity({ backend: "cli", env: { ...env, CLAUDE_CONFIG_DIR: join(home, `.claude-${n}`) } });
|
|
51
|
+
return {
|
|
52
|
+
kind,
|
|
53
|
+
/** The accounts with readable `live.N` state, re-read on every access. */
|
|
54
|
+
get accounts() { return accounts(); },
|
|
55
|
+
current,
|
|
56
|
+
/** True when any account has headroom. */
|
|
57
|
+
headroom: () => accounts().some(hasRoom),
|
|
58
|
+
/** Epoch ms at which the first account regains headroom (now when one has it), or null when unknown. */
|
|
59
|
+
resumeAt: () => {
|
|
60
|
+
const t = now(), at = accounts().map((a) => {
|
|
61
|
+
if (hasRoom(a)) return t;
|
|
62
|
+
const r = [];
|
|
63
|
+
if (used(a.fiveHourPct, a.fiveHourResetMs) >= HEADROOM_PCT) r.push(a.fiveHourResetMs);
|
|
64
|
+
if (used(a.weeklyPct, a.weeklyResetMs) >= 100) r.push(a.weeklyResetMs);
|
|
65
|
+
return Math.max(...r);
|
|
66
|
+
}).filter((x) => Number.isFinite(x) && x > 0);
|
|
67
|
+
return at.length ? Math.min(...at) : null;
|
|
68
|
+
},
|
|
69
|
+
accountIdentity,
|
|
70
|
+
/** Identity of the current account; null when `current` is unreadable. */
|
|
71
|
+
currentIdentity: () => { const n = current(); return n === null ? null : accountIdentity(n); },
|
|
72
|
+
/** True when `id` is the identity of one of the rotator's accounts. */
|
|
73
|
+
isAccount: (id) => typeof id === "string" && accounts().some((a) => accountIdentity(a.n) === id),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True while a usage-limited call should be retried at once on the next account: headroom, and fewer tries than accounts. */
|
|
78
|
+
export function rotatorRetry(rotator, tries) {
|
|
79
|
+
try { return Boolean(rotator) && tries < rotator.accounts.length && rotator.headroom(); } catch { return false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How orchestrate requeues a `rate_limited` lane under a rotator (#5094), or null when the host hold applies (no rotator,
|
|
84
|
+
* no account with headroom, unreadable state). A lane that ran on one of the rotator's accounts (or reported none) is rerun
|
|
85
|
+
* at once, at most once per account; any other credential (a remote sandbox's) waits for its own reset. Neither sets a host hold.
|
|
86
|
+
* @returns {null | {delayMs:number, rotated:boolean}}
|
|
87
|
+
*/
|
|
88
|
+
export function quotaRequeue({ rotator, r, prior = null, delayMs }) {
|
|
89
|
+
if (!rotator) return null;
|
|
90
|
+
try {
|
|
91
|
+
if (!rotator.headroom()) return null;
|
|
92
|
+
const id = r?.backendId ?? null, rotations = Number(prior?.rotations) || 0;
|
|
93
|
+
if ((!id || rotator.isAccount(id)) && rotations < rotator.accounts.length) return { delayMs: 0, rotated: true };
|
|
94
|
+
return { delayMs, rotated: false };
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|