forgedock 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) 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 +17 -2
  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-decisions.mjs +175 -0
  20. package/bin/lean/issuewatch-decisions.test.mjs +353 -0
  21. package/bin/lean/issuewatch-release.test.mjs +3 -1
  22. package/bin/lean/issuewatch.mjs +67 -10
  23. package/bin/lean/issuewatch.test.mjs +18 -0
  24. package/bin/lean/lane.mjs +13 -0
  25. package/bin/lean/lane.test.mjs +28 -1
  26. package/bin/lean/lean.mjs +64 -17
  27. package/bin/lean/lean.test.mjs +95 -0
  28. package/bin/lean/limits.mjs +6 -2
  29. package/bin/lean/limits.test.mjs +86 -0
  30. package/bin/lean/milestonesync.mjs +2 -2
  31. package/bin/lean/milestonesync.test.mjs +22 -0
  32. package/bin/lean/orchestrate.mjs +79 -9
  33. package/bin/lean/probe.mjs +4 -2
  34. package/bin/lean/probe.test.mjs +4 -0
  35. package/bin/lean/prompts.mjs +18 -11
  36. package/bin/lean/rotator.mjs +96 -0
  37. package/bin/lean/rotator.test.mjs +143 -0
  38. package/bin/lean/routecheck.mjs +48 -0
  39. package/bin/lean/routecheck.test.mjs +69 -0
  40. package/bin/lean/salvage-resume.mjs +26 -0
  41. package/bin/lean/salvage-resume.test.mjs +51 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  43. package/bin/lean/sandbox-remote.mjs +4 -2
  44. package/bin/lean/verify.mjs +2 -1
  45. package/bin/review-staging.mjs +20 -3
  46. package/commands/deploy-info.md +4 -4
  47. package/commands/issue.md +6 -1
  48. package/commands/review-pr-agents/frontend.md +1 -0
  49. package/commands/review-pr-agents/infra.md +2 -0
  50. package/commands/review-pr-staging.md +2 -0
  51. package/commands/work-on/build/context.md +7 -1
  52. package/commands/work-on/investigate.md +15 -3
  53. package/docs/CONFIG.md +22 -3
  54. package/docs/DECISIONS.md +18 -7
  55. package/docs/OUTCOME-CODES.md +2 -1
  56. package/docs/RELEASE-NOTES.md +1 -0
  57. package/package.json +1 -1
  58. package/scripts/file-deferred-followups.sh +125 -28
  59. package/scripts/file-deferred-followups.test.sh +49 -0
  60. package/scripts/issue-create.sh +7 -0
  61. package/scripts/issue-create.test.sh +13 -0
  62. package/scripts/issue-dedup.sh +5 -4
  63. package/scripts/issue-dedup.test.sh +15 -0
  64. package/scripts/owner-route-parity.test.sh +32 -0
  65. package/scripts/prior-attempts.mjs +72 -0
  66. package/scripts/prior-attempts.test.mjs +52 -0
  67. package/scripts/review-finalize.sh +35 -4
  68. package/scripts/review-finalize.test.sh +10 -0
  69. package/scripts/review-prepare.sh +16 -4
  70. package/scripts/review-prepare.test.sh +8 -0
  71. package/scripts/review-roster.sh +3 -1
  72. package/scripts/review-roster.test.sh +30 -2
  73. package/scripts/review-staging-pre.sh +8 -6
  74. package/scripts/review-staging-pre.test.sh +11 -1
@@ -20,8 +20,10 @@
20
20
  *
21
21
  * `remote` (#4254, sandbox-remote.mjs) reaches a runner pool or remote session through a transport (`execution.endpoint`,
22
22
  * credential `execution.token_env` from the env or the keychain). It is never a default, and a GitHub Actions endpoint
23
- * needs `allow_actions: true`. A sandbox that fails to start is a typed outcome, not an engine-error: `needs-human`
24
- * `sandbox.start_failed`, or `deferred` `sandbox.capacity` when the provider marks the error retryable. A provider that
23
+ * needs `allow_actions: true`. A sandbox that fails to start is a typed outcome, not an engine-error: `deferred` `sandbox.capacity`
24
+ * when the provider marks the error retryable (429/503), `deferred` `sandbox.transient` for any other 5xx (#5095), `deferred`
25
+ * `sandbox.auth` for 401/403, else `needs-human` `sandbox.start_failed` (a 4xx config error). #5095: a sandbox that exits without
26
+ * a result while its log tail shows a usage limit ends `deferred` `rate_limited` held under `remoteHoldId` (never the breaker). A provider that
25
27
  * sets `handle.minutes` (billable sandbox minutes) has them used as the lane's sandbox minutes (#4255 below).
26
28
  *
27
29
  * Sandbox cost and budget (#4255): a provider may implement `usage(handle, {startedAt, endedAt}) -> {minutes, cost_usd}`;
@@ -60,6 +62,7 @@ import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
60
62
  import { detectPins } from "./toolchain.mjs";
61
63
  import { parseContractComment, noComments } from "./render.mjs";
62
64
  import { parseRelayCommands } from "./relay.mjs";
65
+ import { detectUsageLimit, parseSessionLimitResetEpochMs } from "../runner.mjs";
63
66
 
64
67
  export const EXECUTION_MODES = Object.freeze(["local", "remote", "hybrid"]);
65
68
  export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote", "pool"]);
@@ -128,8 +131,9 @@ export function parseExecution(sec = {}) {
128
131
  const warmSandboxes = str(s.warm_sandboxes) !== "" && Number.isInteger(wsN) && wsN > 0 ? wsN : 0; // #4912: warm sandboxes the pool agent keeps ready; 0 = off
129
132
  const relay = Object.fromEntries(Object.entries(parseRelayCommands(s.relay_commands)).filter(([c]) => CAPABILITY_RE.test(c))); // #4778
130
133
  const uw = Number(str(s.unreachable_window_min));
134
+ const accountRotator = str(s.account_rotator) === "tokenbaton" ? "tokenbaton" : null; // #5094: rotator.mjs reads its per-account quota state
131
135
  const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
132
- return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}) };
136
+ return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
133
137
  }
134
138
 
135
139
  const round1 = (x) => Math.round(x * 10) / 10;
@@ -206,7 +210,7 @@ export function placement(exec) {
206
210
  /** Host-only capabilities and the issue-text signals that mean a lane needs them (#4542). */
207
211
  const CAPABILITY_SIGNALS = Object.freeze({
208
212
  sops: /\.secrets\/[^\s`'")]*\.enc\.ya?ml|\bsops\b|\bage key\b|\.age\//i,
209
- prod_monitor: /\bssh\s+[\w.-]*-monitor\b|\b(?:ovh|netcup)-monitor\b|\bobs-psql\b|\bprod(?:uction)? monitor/i,
213
+ prod_monitor: /\bssh\s+[\w.-]*-monitor\b|\b(?:ovh|netcup)-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor|\bread[- ]only (?:prod(?:uction)? )?(?:prod(?:uction)? )?telemetry\b/i,
210
214
  ops_credentials: /\bcredentials\.yaml\b|\bops[ _-]credentials\b/i,
211
215
  });
212
216
 
@@ -225,7 +229,8 @@ export function detectNeeds(text, exec = null) {
225
229
 
226
230
  /** Declared host capabilities a remote lane's block summary names (#4542); empty when it names none the host has. */
227
231
  export function hostCapabilityBlock(summary, caps = null) {
228
- const have = new Set(caps?.hostCapabilities ?? []);
232
+ // #5080: with no declared capabilities, the read-only prod monitor (the dispatching host's, never a secret) still counts
233
+ const have = new Set(caps?.hostCapabilities?.length ? caps.hostCapabilities : ["prod_monitor"]);
229
234
  const named = detectNeeds(summary, caps);
230
235
  return named.length && named.every((n) => have.has(n)) ? named : [];
231
236
  }
@@ -531,17 +536,33 @@ export function providerByName(name, opts) {
531
536
  throw e;
532
537
  }
533
538
 
539
+ /** #5095: the usage-limit hold key of a remote provider's account (the pool's), known to the dispatcher before any lane runs. */
540
+ export const remoteHoldId = (provider) => `remote:${String(provider?.name ?? "remote")}`;
541
+ const TRANSIENT_RETRY_S = 60;
542
+ const LIMIT_RE = /usage limit hit; resets at ([^\n;]+)/i;
543
+ const RESETS_RE = /\bresets?\s+(?:at\s+)?(\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)|\d{1,2}:\d{2}\s*\([^)]+\))/i;
544
+ /** #5095: the usage-limit reset text in a lane log tail (the lean finish line, else the CLI's own limit message), or null. */
545
+ export function usageLimitInTail(tail) {
546
+ const t = String(tail ?? "");
547
+ const lean = LIMIT_RE.exec(t);
548
+ if (lean) return { resetAt: lean[1].trim() };
549
+ if (!detectUsageLimit("", t)) return null;
550
+ return { resetAt: RESETS_RE.exec(t)?.[1]?.trim() ?? null };
551
+ }
552
+
534
553
  /**
535
554
  * Lane seams for `orchestrate()` over a provider, the same contract as batch.mjs `detachedLanes`. `ledgerFor(issue)`
536
- * returns that sandbox's ledger (ledger.mjs `makeLedger`).
555
+ * returns that sandbox's ledger (ledger.mjs `makeLedger`). #5095: with `holdId` every `rate_limited` result is held under it
556
+ * (`backendId`; the sandbox's own id kept as `laneBackendId`), so orchestrate can place lanes away from a limited pool.
537
557
  */
538
558
  export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
539
- costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000, logTail = null }) {
559
+ costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000, logTail = null, holdId = null }) {
540
560
  const cpm = nonNeg(Number(costPerMinute)) ?? 0;
541
561
  // #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
542
562
  // clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
543
563
  const withUsage = async (r, h) => {
544
564
  if (!r || typeof r !== "object") return r;
565
+ if (holdId && r.code === OUTCOME_CODES.RATE_LIMITED) r = { ...r, backendId: holdId, ...(r.backendId && r.backendId !== holdId ? { laneBackendId: r.backendId } : {}) };
545
566
  const endedAt = now(), startedAt = Number.isFinite(h?.startedAt) ? h.startedAt : endedAt;
546
567
  let u = null;
547
568
  try { u = typeof provider.usage === "function" ? await provider.usage(h, { startedAt, endedAt }) : null; } catch { u = null; }
@@ -585,7 +606,15 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
585
606
  const last = await g(() => provider.fetchResult(h)); // it may have written the result just before exiting
586
607
  if (last) return last;
587
608
  const why = `sandbox ${h.id} ${st} without a result`;
588
- return isCloneFailure(await tailOf(h)) ? cloneDeferred(h.id, why) : { terminalReason: "engine-error", detail: why };
609
+ const tail = await tailOf(h);
610
+ // #5095: the pool account hit its usage limit: a known-time wait (held, never the breaker), not an engine-error
611
+ const lim = usageLimitInTail(tail);
612
+ if (lim) {
613
+ const at = lim.resetAt ? parseSessionLimitResetEpochMs(lim.resetAt, now()) : undefined;
614
+ return { terminalReason: "deferred", code: OUTCOME_CODES.RATE_LIMITED, resetAt: lim.resetAt ?? "unknown", resetAtEpochMs: Number.isFinite(at) ? at : null,
615
+ detail: `${why}: usage limit hit; resets at ${lim.resetAt ?? "unknown"}` };
616
+ }
617
+ return isCloneFailure(tail) ? cloneDeferred(h.id, why) : { terminalReason: "engine-error", detail: why };
589
618
  }
590
619
  await sleep(pollMs);
591
620
  }
@@ -596,6 +625,10 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
596
625
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; ${e?.hop === "agent" ? "the pool agent refused the router (re-enroll or fix the agent token)" : "check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)"}` }
597
626
  : e?.retryable === true
598
627
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
628
+ // #5095: a 5xx from the pool router or agent is infrastructure: deferred with backoff (capped), never needs-human
629
+ : (Number.isInteger(e?.status) && e.status >= 500 && e.status <= 599) || e?.transient === true
630
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_TRANSIENT, retryInSeconds: Number(e.retryInSeconds) > 0 ? Number(e.retryInSeconds) : TRANSIENT_RETRY_S,
631
+ detail: `sandbox ${id} could not start (transient${Number.isInteger(e?.status) ? ` HTTP ${e.status}` : ""}): ${errText(e)}` }
599
632
  : isCloneFailure(errText(e)) ? cloneDeferred(id, errText(e))
600
633
  : { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
601
634
  return {
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { renderContract } from "./render.mjs";
10
- import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
+ import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
11
11
  import { makeLedger, countLiveLanes } from "./ledger.mjs";
12
12
  import { openBatch } from "./batch.mjs";
13
13
  import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
@@ -25,7 +25,7 @@ function fakeProvider(opts = {}) {
25
25
  async start({ issue, env, secrets }) {
26
26
  p.calls.push(["start", issue]);
27
27
  p.ledgerAtStart = opts.ledger?.entries("sandbox").map((e) => e.value) ?? null;
28
- if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), opts.startThrows === 401 ? { status: 401 } : {});
28
+ if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), typeof opts.startThrows === "number" ? { status: opts.startThrows } : {});
29
29
  p.live.add(issue);
30
30
  p.env = env; p.secrets = secrets;
31
31
  if (opts.finish) { const r = opts.finish(issue); if (r !== undefined) { p.results[issue] = r; p.live.delete(issue); } }
@@ -757,6 +757,14 @@ describe("host capabilities in placement (#4542)", () => {
757
757
  assert.deepEqual(hostCapabilityBlock("owner must create an account", caps), []);
758
758
  });
759
759
 
760
+ it("#5080 the read-only prod monitor is a host capability by its own name or as read-only production telemetry, declared or not", () => {
761
+ const text = "D1 is gated by contract decision K1 on read-only production telemetry: a host-only prod_monitor capability this sandbox lacks";
762
+ assert.deepEqual(detectNeeds(text), ["prod_monitor"]);
763
+ assert.deepEqual(hostCapabilityBlock(text, null), ["prod_monitor"]);
764
+ assert.deepEqual(hostCapabilityBlock(text, { hostCapabilities: ["sops"] }), []);
765
+ assert.deepEqual(hostCapabilityBlock("no sops key", null), []);
766
+ });
767
+
760
768
  it("#4785 placementText: body, FORGE:DECISION comments and the last frozen contract's validate commands", () => {
761
769
  const exec = parseExecution({ mode: "remote", max_remote: 2, host_capabilities: "promtool" });
762
770
  const k = (cmd) => "<!-- FORGE:CONTRACT -->\n<!-- FORGE:PLAN -->\n" + renderContract({ requirements: [], deliverables: [], validate: [{ id: "V1", command: cmd }] }, "abcdef123456");
@@ -831,3 +839,65 @@ describe("host capabilities in placement (#4542)", () => {
831
839
  assert.equal(rows.find((r) => r.issue === 5).outcome, "merged");
832
840
  });
833
841
  });
842
+
843
+ describe("#5095: launch 5xx and a pool usage limit are deferred, never needs-human or engine-error", () => {
844
+ const lanesFor = (prov, extra = {}) => providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(tmpDir("exec-5095-")), pollMs: 1, now: () => 1000, sleep: async () => {}, ...extra });
845
+
846
+ it("#5095 start HTTP 5xx (500/502/504): deferred with a retry delay, never needs-human", async () => {
847
+ for (const status of [500, 502, 504]) {
848
+ const r = await lanesFor(fakeProvider({ startThrows: status })).runOne(7, null);
849
+ assert.equal(r.terminalReason, "deferred", `HTTP ${status}`);
850
+ assert.equal(r.code, "sandbox.transient");
851
+ assert.equal(r.retryInSeconds, 60);
852
+ assert.match(r.detail, new RegExp(`HTTP ${status}`));
853
+ }
854
+ // a Retry-After the pool sent wins over the default delay
855
+ const prov = fakeProvider();
856
+ prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 500"), { status: 500, transient: true, retryInSeconds: 12 }); };
857
+ assert.equal((await lanesFor(prov).runOne(7, null)).retryInSeconds, 12);
858
+ });
859
+
860
+ it("a non-auth 4xx config error stays needs-human sandbox.start_failed; 429/503 stay sandbox.capacity", async () => {
861
+ const r = await lanesFor(fakeProvider({ startThrows: 422 })).runOne(7, null);
862
+ assert.deepEqual([r.terminalReason, r.code], ["needs-human", "sandbox.start_failed"]);
863
+ const prov = fakeProvider();
864
+ prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 503"), { status: 503, transient: true, retryable: true }); };
865
+ assert.equal((await lanesFor(prov).runOne(7, null)).code, "sandbox.capacity");
866
+ });
867
+
868
+ it("usageLimitInTail reads the lean finish line and the CLI's own limit message", () => {
869
+ assert.deepEqual(usageLimitInTail("x\n[lane] deferred: usage limit hit; resets at 3:20pm (UTC)\n"), { resetAt: "3:20pm (UTC)" });
870
+ assert.equal(usageLimitInTail("sandbox exited 1\nclone ok"), null);
871
+ assert.equal(usageLimitInTail(""), null);
872
+ });
873
+
874
+ it("a remote lane with no result and a usage limit in its log tail ends deferred rate_limited, held under the pool id, and never trips the breaker", async () => {
875
+ const prov = fakeProvider({ finish: () => null });
876
+ const holdId = remoteHoldId(prov);
877
+ assert.equal(holdId, "remote:fake");
878
+ const lanes = lanesFor(prov, { holdId, logTail: async () => "building...\nusage limit hit; resets at 3:20pm (UTC)\n" });
879
+ const r = await lanes.runOne(7, null);
880
+ assert.equal(r.terminalReason, "deferred");
881
+ assert.equal(r.code, "rate_limited");
882
+ assert.equal(r.resetAt, "3:20pm (UTC)");
883
+ assert.ok(Number.isFinite(r.resetAtEpochMs));
884
+ assert.equal(r.backendId, "remote:fake");
885
+ assert.ok(prov.calls.some((c) => c[0] === "teardown"));
886
+ // the same lane ends through orchestrate: a breaker of one engine-error never trips, the issue is requeued behind the hold
887
+ let t = 0, calls = 0;
888
+ const rows = await orchestrate({ issues: [7], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms ?? 1; }, pollMs: 1,
889
+ breaker: { errors: 1, window_min: 30 }, identity: () => "local",
890
+ runOne: async () => (++calls === 1 ? { ...r, resetAtEpochMs: t + 60_000 } : { terminalReason: "merged", pr: 1 }) });
891
+ assert.ok(!rows.some((x) => x.outcome === "breaker"), "a usage limit is not an engine-error");
892
+ assert.equal(rows.find((x) => x.issue === 7).outcome, "merged");
893
+ });
894
+
895
+ it("a sandbox's own rate_limited result is held under the pool id, its own id kept as laneBackendId", async () => {
896
+ const prov = fakeProvider({ finish: () => ({ terminalReason: "deferred", code: "rate_limited", backendId: "cli:abc", resetAtEpochMs: 5000 }) });
897
+ const r = await lanesFor(prov, { holdId: remoteHoldId(prov) }).runOne(7, null);
898
+ assert.deepEqual([r.backendId, r.laneBackendId], ["remote:fake", "cli:abc"]);
899
+ // without a usage-limit signature the no-result path stays engine-error
900
+ const plain = await lanesFor(fakeProvider({ finish: () => null }), { logTail: async () => "exit 1" }).runOne(7, null);
901
+ assert.equal(plain.terminalReason, "engine-error");
902
+ });
903
+ });
@@ -14,7 +14,7 @@
14
14
  * or over N, until a green run.
15
15
  * The workflow is dispatched on the milestone branch and awaited through runCiVerify (verify.mjs).
16
16
  */
17
- import { milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
17
+ import { listMilestones, milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
18
18
  import { readMilestone } from "./drift.mjs";
19
19
  import { runCiVerify } from "./verify.mjs";
20
20
  import { parseForgeYaml } from "../forge-utils.mjs";
@@ -174,7 +174,7 @@ export async function runMilestone({ io, repo, ms, cfg, pattern, nowFn = () => D
174
174
 
175
175
  /** Every open milestone → {number, title, description}. */
176
176
  export async function listOpenMilestones({ gh, repo }) {
177
- return arr(JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=open&per_page=100`])) || "[]")).map((m) => ({ number: m.number, title: m.title, description: m.description ?? "" }));
177
+ return arr(await listMilestones({ gh, repo, state: "open" })).map((m) => ({ number: m.number, title: m.title, description: m.description ?? "" }));
178
178
  }
179
179
 
180
180
  /** Run every due active milestone (optionally only `only`, a title). Sequential: one suite at a time. Resolves the per-milestone results. */
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState } from "./integration.mjs";
5
+ import { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState, listOpenMilestones } from "./integration.mjs";
6
6
  import { orchestrate } from "./orchestrate.mjs";
7
7
 
8
8
  const SHA = "a".repeat(40), SHA2 = "b".repeat(40);
@@ -187,3 +187,24 @@ describe("integration status", () => {
187
187
  assert.equal(off.length, 1, "without ci.workflow only milestones with a state block");
188
188
  });
189
189
  });
190
+
191
+ /** #5084: a GitHub-like milestones endpoint: pages of 100; without --paginate only page 1; `--jq '.[] | @json'` prints one object per line. */
192
+ const pagedMilestones = (all) => (a) => {
193
+ const state = /state=(\w+)/.exec(a[1])?.[1] ?? "open";
194
+ const items = all.filter((m) => state === "all" || m.state === state);
195
+ const pages = []; for (let i = 0; i < items.length; i += 100) pages.push(items.slice(i, i + 100));
196
+ if (!a.includes("--paginate")) return JSON.stringify(pages[0] ?? []);
197
+ if (a.includes("--jq")) return items.map((m) => JSON.stringify(m)).join("\n") + "\n";
198
+ return pages.map((p) => JSON.stringify(p)).join("");
199
+ };
200
+ 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 }];
201
+
202
+ describe("listOpenMilestones pagination (#5084)", () => {
203
+ it("(#5084) listOpenMilestones returns open milestones past page 1", async () => {
204
+ const all = manyMilestones({ title: "Target", state: "open" }, 220).map((m) => ({ ...m, state: "open" }));
205
+ const gh = async (a) => pagedMilestones(all)(a);
206
+ const got = await listOpenMilestones({ gh, repo: "o/r" });
207
+ assert.equal(got.length, 221);
208
+ assert.deepEqual(got.at(-1), { number: 221, title: "Target", description: "" });
209
+ });
210
+ });
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4793: the north-star decision engine (#4766) as an issue-watch stage. Each round the watcher lists the open `needs-human`
5
+ * issues that match its selectors, reads the newest trusted `FORGE:NEEDS_HUMAN` comment of each (its comment id is the escalation
6
+ * id the engine is idempotent on) and the typed `terminalCode` from a trusted `FORGE:STATE`, and hands it to `settle`. Active when
7
+ * `decisions.mode: live`, or when the watch sets `sweep_needs_human: true`; escalations that predate the watch are a backlog and
8
+ * only a `sweep_needs_human` watch touches them. `--dry-run` classifies and writes nothing. Handled issues count against the
9
+ * watch's `max_issues`, issues sent back to a lane against `max_launches` (`claimsUsed`, shared with the watcher's own caps).
10
+ * Only live mode acts: a post-deploy-only escalation is folded into `postdeploy_rollup` and closed, an evidence escalation on a repo
11
+ * that declares `prod_watch` sources goes back to a lane with the prod monitor capability; in shadow mode each only posts.
12
+ * Gh, the clock, the judge, the lane launch and the rollup `fold` are injected. The judge is the only LLM call.
13
+ */
14
+ import { settle, classify, tally, isVetoed, revertVeto, effectiveMode, NEEDS_HUMAN_LABEL } from "./decision-engine.mjs";
15
+ import { codeOf } from "./context.mjs";
16
+ import { noComments } from "./render.mjs";
17
+ import { listAssociations } from "./gh-assoc.mjs";
18
+ import { parseProdWatch } from "./prodwatch.mjs";
19
+ import { resolveOwnLogins } from "./prefilter.mjs";
20
+
21
+ const ROWS_MAX = 200;
22
+ const LIST_LIMIT = "200";
23
+ const MAX_ATTEMPTS = 3; // a judge/gh failure is retried this many rounds, then the escalation is left to a human
24
+ const JUDGE_ROUND_MAX = 20; // judge calls per round, whether or not the watch sets max_issues
25
+ const MS_PER_HOUR = 3600_000;
26
+ const commentId = (c) => /#issuecomment-(\d+)/.exec(String(c?.url ?? ""))?.[1] ?? c?.id ?? null;
27
+ const marked = (body, m) => new RegExp(`^\\s*<!--\\s*FORGE:${m}\\b`).test(String(body ?? ""));
28
+
29
+ /** True when this watch runs the stage: `decisions.mode: live` (policy from loadPolicy) or `sweep_needs_human: true`. */
30
+ export const decisionsActive = (watch, policy) => policy?.mode === "live" || watch.sweep_needs_human === true;
31
+
32
+ /** What the watch has used up: `issues` (claimed or handled by the stage, for max_issues) and `launches` (claimed or sent back to a lane, for max_launches). */
33
+ export function claimsUsed(state) {
34
+ const d = state.decisions;
35
+ return { issues: new Set([...(state.claimed ?? []), ...(d?.issues ?? [])]).size, launches: new Set([...(state.claimed ?? []), ...(d?.launched ?? [])]).size };
36
+ }
37
+
38
+ /** `{policy, hasSource}`: the repo's decisions policy and whether forge.yaml declares read-only data access (`prod_watch` sources). */
39
+ export function decisionContext({ loadPolicy, readFile, cwd = "." }) {
40
+ const policy = loadPolicy({ io: { readFile }, cwd });
41
+ let hasSource = false;
42
+ try { hasSource = Object.keys(parseProdWatch(readFile(`${cwd}/forge.yaml`)).sources ?? {}).length > 0; } catch { /* no forge.yaml or no prod_watch block */ }
43
+ return { policy, hasSource };
44
+ }
45
+
46
+ /** The typed terminal code of the newest FORGE:STATE record (trusted comments, then the issue body, whose author the stage already vetted). */
47
+ export function terminalCodeOf(body, comments, trusted) {
48
+ for (const t of [...(comments ?? []).filter(trusted).map((c) => c.body).reverse(), body]) {
49
+ const m = /FORGE:STATE\s*(\{[^\n]*\})/.exec(String(t ?? ""));
50
+ if (!m) continue;
51
+ try { const st = JSON.parse(m[1]); return codeOf(st.terminalCode) ?? codeOf(st.terminalReason); } catch { /* next record */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** An issue's open escalation: `{id, text, at}` from its newest trusted FORGE:NEEDS_HUMAN comment, or null. */
57
+ export function escalationOf(comments, trusted) {
58
+ const c = [...(comments ?? [])].reverse().find((x) => marked(x.body, "NEEDS_HUMAN") && trusted(x));
59
+ return c && commentId(c) ? { id: String(commentId(c)), text: noComments(c.body).replace(/^\s*## Needs human\s*/i, "").trim(), at: Date.parse(c.createdAt) || 0 } : null;
60
+ }
61
+
62
+ const dec = (state) => (state.decisions ??= { rows: [], handled: {}, attempts: {}, issues: [], launched: [], live: [], stats: { decided: 0, reversed: 0 } });
63
+
64
+ /** Counts for the watcher status: decided, evidence-gathered, folded, external-kept (decision-engine `tally`). */
65
+ export const decisionCounts = (state) => tally(state.decisions?.rows ?? []);
66
+
67
+ const ghJson = async (io, args) => JSON.parse(String(await io.gh(args)));
68
+ const poster = (io, repo) => async (_marker, body, issue) => { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:DECISION -->\n${body}`]); };
69
+ const labeller = (io, repo) => async ({ issue, add = [], remove = [] }) => {
70
+ if (!add.length && !remove.length) return;
71
+ await io.gh(["issue", "edit", String(issue), "-R", repo, ...add.flatMap((l) => ["--add-label", l]), ...remove.flatMap((l) => ["--remove-label", l])]);
72
+ };
73
+
74
+ /**
75
+ * One round. `ctx`: `{policy, hasSource}`; `shared`: issuewatch's `{matches(issue, selectors), fold({issue, watch, repo, io, state}), labelNames, isBot, TRUSTED}`;
76
+ * `launch(issue)→bool` sends the issue back to a lane (the watcher's per-issue launch; false leaves it to normal selection).
77
+ * `io.judge(q)→answer` is the judgment call (none: every decision part escalates). Returns `{rows}`; with `dryRun` the rows are
78
+ * classifications and nothing is written to GitHub or `state`. Never throws on one issue: a failure is retried, then left to a human.
79
+ */
80
+ export async function decisionStep({ watch, repo, io, state, ctx, shared, launch = () => false, dryRun = false }) {
81
+ const d = dec(state);
82
+ const startedAt = state.started ?? io.now();
83
+ if (!dryRun) state.started ??= startedAt;
84
+ let own = [];
85
+ try { own = (await resolveOwnLogins(io.gh)).logins; } catch { /* only the associations count */ }
86
+ const bare = (l) => String(l ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
87
+ const trusted = (c) => shared.TRUSTED.has(c?.authorAssociation) || (bare(c?.author?.login) !== "" && own.includes(bare(c.author.login))); // never another CONTRIBUTOR
88
+ const round = { judged: 0 };
89
+ const rio = { ...io, judge: io.judge && ((q) => { if (round.judged >= JUDGE_ROUND_MAX) throw new Error("judge cap for this round reached"); round.judged++; return io.judge(q); }) };
90
+ const rows = [];
91
+ const issues = await ghJson(io, ["issue", "list", "-R", repo, "--state", "open", "--label", NEEDS_HUMAN_LABEL, "--limit", LIST_LIMIT, "--json", "number,title,body,labels,milestone,author,createdAt"]);
92
+ const assoc = await listAssociations(io.gh, repo);
93
+ const sel = { ...watch, labels: (watch.labels ?? []).filter((l) => l !== NEEDS_HUMAN_LABEL), exclude_labels: (watch.exclude_labels ?? []).filter((l) => l !== NEEDS_HUMAN_LABEL) };
94
+ for (const raw of issues) {
95
+ const i = { ...raw, authorAssociation: assoc.get(raw.number) };
96
+ if (!shared.TRUSTED.has(i.authorAssociation) || !shared.matches(i, sel)) continue;
97
+ let view; // release #5068 MAT-2: one unreadable issue is retried next round, never ends the round for the rest
98
+ try { view = await ghJson(io, ["issue", "view", String(i.number), "-R", repo, "--json", "comments,body,labels"]); } catch (e) { rows.push({ issue: i.number, action: "error", reason: String(e?.message ?? e).slice(0, 200) }); continue; }
99
+ const esc = escalationOf(view.comments, trusted);
100
+ if (!esc || d.handled[esc.id]) continue;
101
+ const backlog = esc.at < startedAt;
102
+ if (backlog && !watch.sweep_needs_human && !dryRun) continue;
103
+ const item = { issue: i.number, repo, escalationId: esc.id, text: esc.text, title: i.title, terminalCode: terminalCodeOf(view.body, view.comments, trusted), comments: (view.comments ?? []).filter(trusted) }; // release #5068 SEC-1: a forged FORGE:DECISION can neither finish nor suppress a decision
104
+ if (dryRun) { rows.push({ ...classify(item, ctx.policy), backlog }); continue; }
105
+ if (round.judged >= JUDGE_ROUND_MAX) break;
106
+ const used = claimsUsed(state);
107
+ if (watch.max_issues && used.issues >= watch.max_issues) break;
108
+ if (watch.max_launches && used.launches >= watch.max_launches) break;
109
+ const row = await handle({ item, issue: i, watch, repo, io: rio, state, ctx, shared, launch, d });
110
+ if (row.action === "error") { // not handled: retried next round, bounded
111
+ d.attempts[esc.id] = (d.attempts[esc.id] ?? 0) + 1;
112
+ if (d.attempts[esc.id] >= MAX_ATTEMPTS) d.handled[esc.id] = "error";
113
+ rows.push(row);
114
+ continue;
115
+ }
116
+ d.handled[esc.id] = row.action;
117
+ if (row.action === "skipped") continue;
118
+ d.rows = [...d.rows, { issue: row.issue, escalationId: esc.id, action: row.action, kept: row.kept ?? false }].slice(-ROWS_MAX);
119
+ if (!d.issues.includes(i.number)) d.issues.push(i.number);
120
+ rows.push(row);
121
+ }
122
+ if (!dryRun) await vetoes({ repo, io, ctx, d, shared });
123
+ return { rows };
124
+ }
125
+
126
+ async function handle({ item, issue, watch, repo, io, state, ctx, shared, launch, d }) {
127
+ const n = item.issue;
128
+ try {
129
+ const post = poster(io, repo), setLabels = labeller(io, repo);
130
+ const back = async () => { if ((await launch(issue)) && !d.launched.includes(n)) d.launched.push(n); }; // label cleared: a lane takes it again; release #5068 MAT-4: counted only when it does
131
+ const deps = { judge: (q) => (io.judge ? io.judge(q) : null), post, setLabels, requeue: back, records: (await io.records?.()) ?? [], stats: d.stats, emit: io.emit };
132
+ const row = await settle(item, ctx.policy, deps);
133
+ if (row.action === "error") return row;
134
+ if (row.action === "decided" && !row.resumed) { d.stats.decided++; if (!row.kept) d.live.push({ issue: n, escalationId: item.escalationId, at: io.now() }); }
135
+ const key = `Escalation: ${item.escalationId}`;
136
+ if (row.action !== "post-deploy-only" && row.action !== "evidence-needed") return row;
137
+ if (effectiveMode(ctx.policy, d.stats).mode !== "live") { // shadow: say what would happen, change nothing
138
+ await post(null, `## Decision (shadow)\n${key}\nWould ${row.action === "post-deploy-only" ? "fold into the release checklist and close" : "send back to a prod-monitor lane"}; nothing changed.`, n);
139
+ return { ...row, action: "shadow" };
140
+ }
141
+ if (row.action === "post-deploy-only") {
142
+ if (!watch.postdeploy_rollup) return { ...row, action: "post-deploy-kept", reason: "no postdeploy_rollup to fold into" };
143
+ await shared.fold({ issue: { number: n, title: item.title ?? `#${n}` }, watch, repo, io, state });
144
+ await post(null, `## Post-deploy only\n${key}\nEvery remaining item is post-deploy: folded into #${watch.postdeploy_rollup} (the release checklist) and closed.`, n);
145
+ await setLabels({ issue: n, remove: [NEEDS_HUMAN_LABEL] });
146
+ await io.gh(["issue", "close", String(n), "-R", repo, "--reason", "completed"]);
147
+ return { ...row, folded: true };
148
+ }
149
+ if (!ctx.hasSource) return { ...row, action: "evidence-kept", reason: "no read-only data access declared in forge.yaml (prod_watch.sources)" };
150
+ await post(null, `## Evidence needed\n${key}\nThe block is missing read-only production data and forge.yaml declares a read-only source (prod_watch.sources): back to a lane that has the prod monitor capability (placement prod_monitor), needs-human cleared.`, n);
151
+ await setLabels({ issue: n, remove: [NEEDS_HUMAN_LABEL] });
152
+ await back();
153
+ return row;
154
+ } catch (e) {
155
+ return { issue: n, escalationId: item.escalationId, action: "error", reason: String(e?.message ?? e).slice(0, 200) };
156
+ }
157
+ }
158
+
159
+ /** A live decision vetoed inside its window (label or reply) is reverted once; a window that ended retires the record. */
160
+ async function vetoes({ repo, io, ctx, d, shared }) {
161
+ const keep = [];
162
+ for (const r of d.live) {
163
+ try {
164
+ const v = await ghJson(io, ["issue", "view", String(r.issue), "-R", repo, "--json", "comments,labels"]);
165
+ if (isVetoed({ labels: shared.labelNames(v), comments: v.comments, decidedAt: r.at, vetoHours: ctx.policy.vetoHours, now: io.now() })) {
166
+ await revertVeto({ issue: r.issue, escalationId: r.escalationId }, { setLabels: labeller(io, repo), post: poster(io, repo), emit: io.emit });
167
+ d.stats.reversed++;
168
+ } else if (io.now() < r.at + ctx.policy.vetoHours * MS_PER_HOUR) keep.push(r);
169
+ } catch { keep.push(r); }
170
+ }
171
+ d.live = keep;
172
+ }
173
+
174
+ /** Dry-run text: one line per escalation with its classification. */
175
+ export const formatDecisionDryRun = (rows) => rows.length ? rows.map((r) => `#${r.issue} ${r.kind}${r.backlog ? " (backlog)" : ""}: ${r.decisions} decision part(s)${r.external.length ? `, external ${r.external.join(",")}` : ""}`).join("\n") : "no needs-human escalations to classify";