forgedock 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +5 -1
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +60 -12
- package/bin/lean/lean.test.mjs +80 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +19 -2
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
package/bin/lean/execution.mjs
CHANGED
|
@@ -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: `
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
+
});
|
package/bin/lean/integration.mjs
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -21,6 +21,19 @@ export function milestoneSlug(title) {
|
|
|
21
21
|
return String(title ?? "").toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Every milestone of `repo` in `state` (all|open|closed), across every page (#5084: one page of 100 hid newer milestones).
|
|
26
|
+
* One `gh api --paginate` call, one object per line; a whole-JSON reply (a single array) is accepted too. gh errors propagate.
|
|
27
|
+
*/
|
|
28
|
+
export async function listMilestones({ gh, repo, state = "all" }) {
|
|
29
|
+
const text = String(await gh(["api", `repos/${repo}/milestones?state=${state}&per_page=100`, "--paginate", "--jq", ".[] | @json"]) ?? "").trim();
|
|
30
|
+
if (!text) return [];
|
|
31
|
+
let whole;
|
|
32
|
+
try { whole = JSON.parse(text); } catch { whole = undefined; }
|
|
33
|
+
if (whole !== undefined) return Array.isArray(whole) ? whole : whole && typeof whole === "object" ? [whole] : [];
|
|
34
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean).flatMap((l) => JSON.parse(l));
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/** forge.yaml `branches` from the checkout: {default, staging, featurePattern}; missing keys are null. */
|
|
25
38
|
export function readBranches(cwd = process.cwd()) {
|
|
26
39
|
let b = {};
|
package/bin/lean/lane.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
7
|
-
import { milestoneSlug, readBranches, resolveLane, makeLaneCache } from "./lane.mjs";
|
|
7
|
+
import { milestoneSlug, readBranches, resolveLane, makeLaneCache, listMilestones } from "./lane.mjs";
|
|
8
8
|
|
|
9
9
|
/** Fake gh/git: the issue's milestone, GitHub's default branch, and the branches on origin. */
|
|
10
10
|
function io({ milestone = null, ghDefault = "main", onOrigin = ["staging", "main"], ghFails = null, gitFails = false } = {}) {
|
|
@@ -133,3 +133,30 @@ describe("per-batch lane lookups (#4963)", () => {
|
|
|
133
133
|
assert.equal((await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null })).ok, true);
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
describe("(#5084) listMilestones", () => {
|
|
138
|
+
it("(#5084) listMilestones pages with --paginate, path at args[1], state in the query", async () => {
|
|
139
|
+
const calls = [];
|
|
140
|
+
const gh = async (a) => { calls.push(a); return '{"number":1,"title":"A"}\n{"number":2,"title":"B"}\n'; };
|
|
141
|
+
assert.deepEqual(await listMilestones({ gh, repo: "o/r", state: "open" }), [{ number: 1, title: "A" }, { number: 2, title: "B" }]);
|
|
142
|
+
assert.equal(calls[0][1], "repos/o/r/milestones?state=open&per_page=100");
|
|
143
|
+
assert.ok(calls[0].includes("--paginate"));
|
|
144
|
+
assert.equal(calls[0][calls[0].indexOf("--jq") + 1], ".[] | @json");
|
|
145
|
+
await listMilestones({ gh, repo: "o/r" });
|
|
146
|
+
assert.match(calls[1][1], /state=all/);
|
|
147
|
+
});
|
|
148
|
+
it("(#5084) listMilestones accepts a single array, a single object and empty output", async () => {
|
|
149
|
+
assert.deepEqual(await listMilestones({ gh: async () => '[{"number":3}]', repo: "o/r" }), [{ number: 3 }]);
|
|
150
|
+
assert.deepEqual(await listMilestones({ gh: async () => '{"number":4}\n', repo: "o/r" }), [{ number: 4 }]);
|
|
151
|
+
assert.deepEqual(await listMilestones({ gh: async () => " \n", repo: "o/r" }), []);
|
|
152
|
+
assert.deepEqual(await listMilestones({ gh: async () => "", repo: "o/r" }), []);
|
|
153
|
+
});
|
|
154
|
+
it("(#5084) listMilestones keeps a description containing '][' intact", async () => {
|
|
155
|
+
const ms = [{ number: 1, title: "A", description: "see [a][b] and ] [" }, { number: 2, title: "B", description: "]\n[" }];
|
|
156
|
+
const out = ms.map((m) => JSON.stringify(m)).join("\n");
|
|
157
|
+
assert.deepEqual(await listMilestones({ gh: async () => out, repo: "o/r" }), ms);
|
|
158
|
+
});
|
|
159
|
+
it("(#5084) listMilestones propagates a gh error", async () => {
|
|
160
|
+
await assert.rejects(listMilestones({ gh: async () => { throw new Error("HTTP 502"); }, repo: "o/r" }), /HTTP 502/);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
15
15
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
17
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
18
|
-
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
18
|
+
import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
19
19
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
20
20
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
21
21
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
@@ -38,9 +38,11 @@ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
|
|
|
38
38
|
const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
|
|
39
39
|
/** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
|
|
40
40
|
export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
|
|
41
|
-
import { CHECKS_CLI, FORGEDOCK_CLI, REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
41
|
+
import { CHECKS_CLI, FORGEDOCK_CLI, REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, amendmentCorrectionPrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
42
42
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
43
43
|
import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
|
|
44
|
+
import { routePathsFromFiles, routeReachabilityFailures } from "./routecheck.mjs";
|
|
45
|
+
import { parseCoupled, coupledFailures } from "./coupled.mjs";
|
|
44
46
|
import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
|
|
45
47
|
import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
|
|
46
48
|
import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
|
|
@@ -51,6 +53,7 @@ import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
|
51
53
|
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
|
|
52
54
|
import { e2eSpecs, e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
|
|
53
55
|
import { laneBackendId } from "./limits.mjs";
|
|
56
|
+
import { openRotator, rotatorRetry } from "./rotator.mjs";
|
|
54
57
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
55
58
|
import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
|
|
56
59
|
import { priorityFromLabels } from "./sched.mjs";
|
|
@@ -194,6 +197,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
194
197
|
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
195
198
|
* @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
|
|
196
199
|
* @param {string} [o.followupsDefaultMilestone] forge.yaml followups.default_milestone (#4664): milestone of deferred follow-ups whose parent's milestone has shipped and has no next one
|
|
200
|
+
* @param {string} [o.followupsRollupLabel] forge.yaml followups.rollup_issue_label (#5078): a repo's own open issue with this label is the post-deploy rollup when no marked one is open
|
|
197
201
|
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
198
202
|
* @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
|
|
199
203
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
@@ -226,8 +230,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
226
230
|
*/
|
|
227
231
|
export async function runCore(o) {
|
|
228
232
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
229
|
-
toolchainOverride = null, testRunners = null, validateRecipes = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
233
|
+
toolchainOverride = null, testRunners = null, coupledFiles = null, validateRecipes = null, routeReachability = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
230
234
|
const now = io.now ?? (() => Date.now());
|
|
235
|
+
const rotator = io.rotator !== undefined ? io.rotator : openRotator({ kind: o.accountRotator }); // #5094
|
|
231
236
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
232
237
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
233
238
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
@@ -348,6 +353,7 @@ export async function runCore(o) {
|
|
|
348
353
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
349
354
|
let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
|
|
350
355
|
const waits = {};
|
|
356
|
+
const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
|
|
351
357
|
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
352
358
|
const addWait = (phase, ms, queued = 0) => {
|
|
353
359
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
@@ -470,13 +476,14 @@ export async function runCore(o) {
|
|
|
470
476
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
471
477
|
} catch { /* best effort */ }
|
|
472
478
|
}
|
|
473
|
-
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
|
|
479
|
+
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed", "host-task"].includes(reason)) {
|
|
474
480
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
475
481
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
476
482
|
}
|
|
477
483
|
if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
|
|
478
484
|
if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
|
|
479
485
|
else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
486
|
+
else if (reason === "invalid" && extra.close === "already-fixed") T.close = ["issue closed (completed)", "already fixed on the base"]; // #5080
|
|
480
487
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
481
488
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
482
489
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
@@ -484,7 +491,7 @@ export async function runCore(o) {
|
|
|
484
491
|
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
485
492
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs, phases: phaseLedger.snapshot(),
|
|
486
493
|
...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
487
|
-
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
494
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
488
495
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
489
496
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
490
497
|
...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
|
|
@@ -625,7 +632,7 @@ export async function runCore(o) {
|
|
|
625
632
|
}
|
|
626
633
|
/** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
627
634
|
async function callRunner(args) {
|
|
628
|
-
let pauses = 0;
|
|
635
|
+
let pauses = 0, rotations = 0;
|
|
629
636
|
for (;;) {
|
|
630
637
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
631
638
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
@@ -637,6 +644,7 @@ export async function runCore(o) {
|
|
|
637
644
|
catch (e) {
|
|
638
645
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
639
646
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
647
|
+
if (rotatorRetry(rotator, rotations)) { rotations++; ev({ event: "PHASE_ACCOUNT_ROTATED", phase: args.role }); continue; } // #5094: same args (resume too), next account
|
|
640
648
|
const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
|
|
641
649
|
if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
|
|
642
650
|
pauses++;
|
|
@@ -929,6 +937,17 @@ export async function runCore(o) {
|
|
|
929
937
|
const caps = hostCapabilityBlock(s, o.hostCaps);
|
|
930
938
|
if (caps.length) return await needsHuman(`Remote sandbox lacks host capability ${caps.join(", ")} (Builder is blocked (credentials)): ${s}\n\nThe dispatching host has it; the lane is recovered locally.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
|
|
931
939
|
}
|
|
940
|
+
if (b.kind === "human" && b.why === "credentials" && READONLY_PROD_RE.test(s) && !EXTERNAL_NEED_RE.test(s)) {
|
|
941
|
+
// #5080: read-only production access with no credential/spend/account need is host work: recovered on the host, never a human ask
|
|
942
|
+
T.decisions.push("Builder block: read-only production access is host work (host-task), not an owner ask");
|
|
943
|
+
if (o.placement?.where !== "remote") { // on the host already: a host task (label host-task), never needs-human
|
|
944
|
+
const why = `Host-task: the lane needs read-only production access (Builder is blocked (credentials)): ${s}\n\nNot a credential or an owner ask: run it on the host with its read-only prod tools.`;
|
|
945
|
+
try { await post("FORGE:HOST_TASK", `## Host task\n${why}\n\n_${usageLine()}_`); } catch { /* best effort */ }
|
|
946
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "host-task"]); } catch { /* best effort */ }
|
|
947
|
+
return await finish("host-task", why, { code: OUTCOME_CODES.ENV_CAPABILITY_REMOTE }, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
|
|
948
|
+
}
|
|
949
|
+
return await needsHuman(`Host-task: the lane needs read-only production access, which the dispatching host has (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered on the host.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
|
|
950
|
+
}
|
|
932
951
|
if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote" && missingToolBlock(s)) {
|
|
933
952
|
// #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
|
|
934
953
|
return await needsHuman(`Remote sandbox lacks a validate tool (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered where the tool exists.`, OUTCOME_CODES.ENV_TOOL_MISSING);
|
|
@@ -1265,6 +1284,13 @@ export async function runCore(o) {
|
|
|
1265
1284
|
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
1266
1285
|
return touched.filter((f) => differs.has(f));
|
|
1267
1286
|
};
|
|
1287
|
+
/** #5079: verification.coupled_files failures for the lane diff ([] when unconfigured; added files only read when a rule needs them) */
|
|
1288
|
+
const coupledOf = async (files) => {
|
|
1289
|
+
const rules = parseCoupled(coupledFiles);
|
|
1290
|
+
if (!rules.length) return null;
|
|
1291
|
+
const addedFiles = rules.some((r) => r.addedOnly) ? names(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")) : [];
|
|
1292
|
+
return coupledFailures({ rules, changedFiles: files, addedFiles });
|
|
1293
|
+
};
|
|
1268
1294
|
/** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
|
|
1269
1295
|
const selfCheckNow = async () => {
|
|
1270
1296
|
try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
|
|
@@ -1332,7 +1358,7 @@ export async function runCore(o) {
|
|
|
1332
1358
|
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1333
1359
|
// #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
|
|
1334
1360
|
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, ...ctxAb, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
1335
|
-
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
1361
|
+
hash: p.hash, cached: r.cached, ...(p.recall ? { recall: p.recall.present ? "present" : "absent" } : {}), ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
1336
1362
|
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
1337
1363
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
1338
1364
|
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
@@ -1381,7 +1407,7 @@ export async function runCore(o) {
|
|
|
1381
1407
|
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
1382
1408
|
const items = followupItems(reqs, { now: now() });
|
|
1383
1409
|
let filed;
|
|
1384
|
-
try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1410
|
+
try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1385
1411
|
for (const [i, r] of reqs.entries()) {
|
|
1386
1412
|
const n = Number(filed?.[items[i].key]);
|
|
1387
1413
|
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
|
|
@@ -1389,6 +1415,8 @@ export async function runCore(o) {
|
|
|
1389
1415
|
try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
|
|
1390
1416
|
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
|
|
1391
1417
|
r.tracked = `#${n}`;
|
|
1418
|
+
const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
|
|
1419
|
+
if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
|
|
1392
1420
|
}
|
|
1393
1421
|
const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
|
|
1394
1422
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
|
|
@@ -1802,7 +1830,7 @@ export async function runCore(o) {
|
|
|
1802
1830
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1803
1831
|
if (isSplit) return await needsHuman(`Declared check command(s) fail on the refactor:split move (no builder edits a split); nothing merged: ${names}`, OUTCOME_CODES.CI_RED);
|
|
1804
1832
|
checkCommandsFailing = true;
|
|
1805
|
-
if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
|
|
1833
|
+
if (round >= vcfg.maxRounds || !sessionId) { if (vcfg.precheckBlock) return await needsHuman(`Declared check command(s) still failing before the PR (verify.precheck_block); nothing merged: ${names}`, OUTCOME_CODES.CI_RED); T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
|
|
1806
1834
|
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1807
1835
|
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1808
1836
|
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
@@ -1816,7 +1844,7 @@ export async function runCore(o) {
|
|
|
1816
1844
|
await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1817
1845
|
await upsertStage("implementation");
|
|
1818
1846
|
result = (await readResult()) ?? result;
|
|
1819
|
-
if (String(await git(["rev-parse", "HEAD"])).trim() === before) { T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
|
|
1847
|
+
if (String(await git(["rev-parse", "HEAD"])).trim() === before) { if (vcfg.precheckBlock) return await needsHuman(`Declared check command(s) failing and the builder made no new commit for them (verify.precheck_block); nothing merged: ${names}`, OUTCOME_CODES.CI_RED); T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
|
|
1820
1848
|
files = await changedFiles();
|
|
1821
1849
|
}
|
|
1822
1850
|
}
|
|
@@ -2301,6 +2329,7 @@ export async function runCore(o) {
|
|
|
2301
2329
|
const cfg = readRollouts(wt);
|
|
2302
2330
|
return cfg ? detectNewOffKeys(cfg, String(await git(["diff", "-U0", `${base}...HEAD`, "--", ...cfg.files]).catch(() => ""))) : [];
|
|
2303
2331
|
}
|
|
2332
|
+
let amendFixed = false;
|
|
2304
2333
|
async function gate() {
|
|
2305
2334
|
await refreshIssue("conformance");
|
|
2306
2335
|
result = (await readResult()) ?? result;
|
|
@@ -2313,6 +2342,14 @@ export async function runCore(o) {
|
|
|
2313
2342
|
ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
|
|
2314
2343
|
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
2315
2344
|
}
|
|
2345
|
+
if (!amendFixed && sessionId && amendmentKindProblems(result?.amendments).length) { // #5089: one in-lane correction, outside the conformance resume budget
|
|
2346
|
+
amendFixed = true;
|
|
2347
|
+
const bad = amendmentKindProblems(result.amendments);
|
|
2348
|
+
T.decisions.push(`Amendment kinds invalid (${bad.length}); builder asked to correct ${RESULT_FILE} once`);
|
|
2349
|
+
await builder(++attemptN, amendmentCorrectionPrompt(bad), sessionId, false, undefined, { mode: "resume", trigger: "contract_correction" });
|
|
2350
|
+
result = (await readResult()) ?? result;
|
|
2351
|
+
files = await changedFiles();
|
|
2352
|
+
}
|
|
2316
2353
|
const amendProblems = await amend();
|
|
2317
2354
|
const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
2318
2355
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
@@ -2321,6 +2358,8 @@ export async function runCore(o) {
|
|
|
2321
2358
|
// #4693: guards run beside the validate rerun; SPEC-11: the substituted-original rerun shares the one validate phase
|
|
2322
2359
|
const validatePhase = phaseLedger.during("validate", async () => ({ runs: await rerunFailedValidatesRaw(effective), subs: await rerunSubstitutedOriginals(effective) })).finally(() => { validateMs = now() - gateT0; });
|
|
2323
2360
|
const [{ runs: validateRuns, subs: substitutedRuns }, guardFailures] = await Promise.all([validatePhase, guardGate(files)]);
|
|
2361
|
+
// #5087: forge.yaml topology.route_reachability gates the route handlers this diff adds; unset = no check
|
|
2362
|
+
const routeFailures = routeReachability && io.runCommand ? await routeReachabilityFailures({ command: routeReachability, paths: routePathsFromFiles(String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)), run: async ({ command }) => (await io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env: laneEnv() }).then((x) => x?.r ?? x)) ?? { rc: 127 } }) : [];
|
|
2324
2363
|
// #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
|
|
2325
2364
|
const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
|
|
2326
2365
|
guardAddedMs += guardRunMs;
|
|
@@ -2330,7 +2369,7 @@ export async function runCore(o) {
|
|
|
2330
2369
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2331
2370
|
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2332
2371
|
const subBaseFiles = (effective.validate ?? []).some((v) => v.substituted) ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : []; // #5003
|
|
2333
|
-
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths, baseFiles: subBaseFiles });
|
|
2372
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, coupledFailures: await coupledOf(files), routeFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths, baseFiles: subBaseFiles });
|
|
2334
2373
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2335
2374
|
if (pc.superseded?.length) {
|
|
2336
2375
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
@@ -2367,6 +2406,8 @@ export async function runCore(o) {
|
|
|
2367
2406
|
const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
|
|
2368
2407
|
const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems, ...mutBlock];
|
|
2369
2408
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
2409
|
+
// #5080: the repro already passes on the base and the diff changes no source: the fix is on the base, not missing
|
|
2410
|
+
if (alreadyFixedOnBase(pre, files, IGNORE)) return { state: "already-fixed", by: "precheck", failures: pre };
|
|
2370
2411
|
if (pre.length && !recording()) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
2371
2412
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
2372
2413
|
const hard = recording() ? blockingOf(pre, SHADOW_PRE_BLOCKS).block : [];
|
|
@@ -2462,6 +2503,13 @@ export async function runCore(o) {
|
|
|
2462
2503
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
2463
2504
|
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
2464
2505
|
if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
|
|
2506
|
+
if (outcome.state === "already-fixed") { // #5080: close as delivered, citing the base commit
|
|
2507
|
+
const sha = String(await git(["rev-parse", base]).catch(() => "")).trim().slice(0, 12) || base;
|
|
2508
|
+
const note = `Already fixed on the base: the repro test passes on \`${base}\` (${sha}) without this change, and the diff changes no source file. Closing as delivered.\n\n${list}`;
|
|
2509
|
+
T.decisions.push(`Already fixed on base ${sha} (repro BASE=RAN HEAD=RAN)`);
|
|
2510
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed", "--comment", note]);
|
|
2511
|
+
return await finish("invalid", `already fixed on base ${sha}`, { close: "already-fixed" });
|
|
2512
|
+
}
|
|
2465
2513
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
2466
2514
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
2467
2515
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -2982,7 +3030,7 @@ export async function runCore(o) {
|
|
|
2982
3030
|
} catch (e) {
|
|
2983
3031
|
if (e?.deferUsageLimit) {
|
|
2984
3032
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
2985
|
-
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env) }, OUTCOME_CODES.RATE_LIMITED);
|
|
3033
|
+
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env, rotator) }, OUTCOME_CODES.RATE_LIMITED);
|
|
2986
3034
|
}
|
|
2987
3035
|
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
|
|
2988
3036
|
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|