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.
Files changed (69) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/issuewatch.mjs +134 -37
  20. package/bin/lean/issuewatch.test.mjs +123 -1
  21. package/bin/lean/lane.mjs +13 -0
  22. package/bin/lean/lane.test.mjs +28 -1
  23. package/bin/lean/lean.mjs +60 -12
  24. package/bin/lean/lean.test.mjs +80 -0
  25. package/bin/lean/limits.mjs +6 -2
  26. package/bin/lean/limits.test.mjs +86 -0
  27. package/bin/lean/milestonesync.mjs +2 -2
  28. package/bin/lean/milestonesync.test.mjs +22 -0
  29. package/bin/lean/orchestrate.mjs +79 -9
  30. package/bin/lean/probe.mjs +4 -2
  31. package/bin/lean/probe.test.mjs +4 -0
  32. package/bin/lean/prompts.mjs +18 -11
  33. package/bin/lean/rotator.mjs +96 -0
  34. package/bin/lean/rotator.test.mjs +143 -0
  35. package/bin/lean/routecheck.mjs +48 -0
  36. package/bin/lean/routecheck.test.mjs +69 -0
  37. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  38. package/bin/lean/sandbox-remote.mjs +4 -2
  39. package/bin/lean/verify.mjs +2 -1
  40. package/bin/review-staging.mjs +20 -3
  41. package/commands/deploy-info.md +4 -4
  42. package/commands/issue.md +6 -1
  43. package/commands/review-pr-agents/frontend.md +1 -0
  44. package/commands/review-pr-agents/infra.md +2 -0
  45. package/commands/review-pr-staging.md +2 -0
  46. package/commands/work-on/build/context.md +7 -1
  47. package/commands/work-on/investigate.md +15 -3
  48. package/docs/CONFIG.md +21 -2
  49. package/docs/ISSUE-WATCH.md +22 -1
  50. package/docs/OUTCOME-CODES.md +2 -1
  51. package/package.json +1 -1
  52. package/scripts/file-deferred-followups.sh +125 -28
  53. package/scripts/file-deferred-followups.test.sh +49 -0
  54. package/scripts/issue-create.sh +7 -0
  55. package/scripts/issue-create.test.sh +13 -0
  56. package/scripts/issue-dedup.sh +5 -4
  57. package/scripts/issue-dedup.test.sh +15 -0
  58. package/scripts/owner-route-parity.test.sh +32 -0
  59. package/scripts/plan-validate-live.mjs +18 -6
  60. package/scripts/prior-attempts.mjs +72 -0
  61. package/scripts/prior-attempts.test.mjs +52 -0
  62. package/scripts/review-finalize.sh +35 -4
  63. package/scripts/review-finalize.test.sh +10 -0
  64. package/scripts/review-prepare.sh +16 -4
  65. package/scripts/review-prepare.test.sh +8 -0
  66. package/scripts/review-roster.sh +3 -1
  67. package/scripts/review-roster.test.sh +30 -2
  68. package/scripts/review-staging-pre.sh +8 -6
  69. package/scripts/review-staging-pre.test.sh +11 -1
@@ -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 = JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=all&per_page=100`])) || "[]");
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
+ });
@@ -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. An `engine-error` is
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
- const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
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
- try { holds.set(r?.backendId ?? identity(), now() + pol.delayMs, `batch ${batchId ?? "?"} #${n}`); delayMs = 0; } catch { /* fail-open */ }
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 && !pol.hold ? 0 : delayMs); done(); save();
670
- update({ ...row, detail: `requeued in ${Math.round(pol.delayMs / 1000)}s${pol.hold ? " (admissions held)" : ""}: ${r?.code ?? "deferred"}${r?.detail ? ` ${r.detail}` : ""}` });
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
- return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
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();
@@ -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, 160) });
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
- const parts = GROUPS.map(([k, label]) => [label, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)])
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
  }
@@ -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 });
@@ -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 it 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>","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, settings, promotion of a default-off flag; disposition defer, never post-deploy) or new-issue (future work). One sentence is one row. An outcome criterion this change can ship as a test, metric or alert is "deliver". A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
166
+ 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 measured result. A [type:e2e] requirement is delivered only when its validate command is a real end-to-end check, not a unit test with stubbed I/O.
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 (e.g. new enum code).
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 the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
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>" (the engine also converts a check-less post-deploy to a defer to the orchestrator). forge.yaml validate.recipes rewrite matching commands at freeze.`;
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, when siblings would edit the same hub (a barrel or index export list, a union type, a shared test fixture, an index doc), pre-stage its slots in the earliest sub-issue and make the others depend on it, or give each sibling its own per-feature file; the engine serialises sub-issues that still share a file.
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 or "human" product-decision: run the narrowest targeted command for your changed files and say the full one could not finish in time.
371
- Remote sandbox lacking a host-only capability (sops, prod_monitor, ops_credentials): name it in the summary; the host reruns the lane.`;
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
+ }