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.
- 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 +17 -2
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/issuewatch-decisions.mjs +175 -0
- package/bin/lean/issuewatch-decisions.test.mjs +353 -0
- package/bin/lean/issuewatch-release.test.mjs +3 -1
- package/bin/lean/issuewatch.mjs +67 -10
- package/bin/lean/issuewatch.test.mjs +18 -0
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +64 -17
- package/bin/lean/lean.test.mjs +95 -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/salvage-resume.mjs +26 -0
- package/bin/lean/salvage-resume.test.mjs +51 -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 +22 -3
- package/docs/DECISIONS.md +18 -7
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/RELEASE-NOTES.md +1 -0
- 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/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";
|
|
@@ -64,6 +67,7 @@ import { buildResumeDigest } from "./resume.mjs";
|
|
|
64
67
|
import { parsePredicate, evalPredicate, premiseAnnotation, registerAsk, registryPkgVersion, closedEdgeNote, onlyAsk, fileRemainder } from "./needs-human-premise.mjs";
|
|
65
68
|
import { lightEligibility } from "./light.mjs";
|
|
66
69
|
import { untrackToolState, unstageToolState, isToolState, clearLaneScratch } from "./toolstate.mjs";
|
|
70
|
+
import { pickSalvage } from "./salvage-resume.mjs";
|
|
67
71
|
import { ensureSignedOff, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
|
|
68
72
|
import { createLedger, flattenPhases } from "./phaseledger.mjs";
|
|
69
73
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
@@ -193,6 +197,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
193
197
|
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
194
198
|
* @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
|
|
195
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
|
|
196
201
|
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
197
202
|
* @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
|
|
198
203
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
@@ -225,8 +230,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
225
230
|
*/
|
|
226
231
|
export async function runCore(o) {
|
|
227
232
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
228
|
-
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;
|
|
229
234
|
const now = io.now ?? (() => Date.now());
|
|
235
|
+
const rotator = io.rotator !== undefined ? io.rotator : openRotator({ kind: o.accountRotator }); // #5094
|
|
230
236
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
231
237
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
232
238
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
@@ -347,6 +353,7 @@ export async function runCore(o) {
|
|
|
347
353
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
348
354
|
let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
|
|
349
355
|
const waits = {};
|
|
356
|
+
const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
|
|
350
357
|
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
351
358
|
const addWait = (phase, ms, queued = 0) => {
|
|
352
359
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
@@ -469,13 +476,14 @@ export async function runCore(o) {
|
|
|
469
476
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
470
477
|
} catch { /* best effort */ }
|
|
471
478
|
}
|
|
472
|
-
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)) {
|
|
473
480
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
474
481
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
475
482
|
}
|
|
476
483
|
if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
|
|
477
484
|
if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
|
|
478
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
|
|
479
487
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
480
488
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
481
489
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
@@ -483,7 +491,7 @@ export async function runCore(o) {
|
|
|
483
491
|
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
484
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(),
|
|
485
493
|
...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
486
|
-
...(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 } : {}),
|
|
487
495
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
488
496
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
489
497
|
...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
|
|
@@ -624,7 +632,7 @@ export async function runCore(o) {
|
|
|
624
632
|
}
|
|
625
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). */
|
|
626
634
|
async function callRunner(args) {
|
|
627
|
-
let pauses = 0;
|
|
635
|
+
let pauses = 0, rotations = 0;
|
|
628
636
|
for (;;) {
|
|
629
637
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
630
638
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
@@ -636,6 +644,7 @@ export async function runCore(o) {
|
|
|
636
644
|
catch (e) {
|
|
637
645
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
638
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
|
|
639
648
|
const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
|
|
640
649
|
if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
|
|
641
650
|
pauses++;
|
|
@@ -928,6 +937,17 @@ export async function runCore(o) {
|
|
|
928
937
|
const caps = hostCapabilityBlock(s, o.hostCaps);
|
|
929
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);
|
|
930
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
|
+
}
|
|
931
951
|
if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote" && missingToolBlock(s)) {
|
|
932
952
|
// #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
|
|
933
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);
|
|
@@ -1094,10 +1114,8 @@ export async function runCore(o) {
|
|
|
1094
1114
|
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
|
|
1095
1115
|
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
1096
1116
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; fromSalvage = true; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
|
|
1100
|
-
}
|
|
1117
|
+
const salv = (await pickSalvage({ io, cwd, issue, branch, hasBranch: Boolean(remote) }).catch((e) => { ev({ event: "SALVAGE_PICK_FAILED", error: String(e?.message ?? e).slice(0, 200) }); return null; }))?.ref; // release #5068 delta SPEC-2: never silent // #4958, #5063: the newest salvage ref that is not behind the lane branch
|
|
1118
|
+
if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null }; fromSalvage = true; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
|
|
1101
1119
|
} catch { /* no resume info: start fresh */ }
|
|
1102
1120
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
1103
1121
|
// #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
|
|
@@ -1148,7 +1166,7 @@ export async function runCore(o) {
|
|
|
1148
1166
|
}
|
|
1149
1167
|
}
|
|
1150
1168
|
};
|
|
1151
|
-
if (resumed) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
|
|
1169
|
+
if (resumed && !fromSalvage) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
|
|
1152
1170
|
// #4786: an open-PR-less remote branch whose only changes vs the base are tool state (.forge-lean/ scratch) holds no work: plan fresh, never resume its stale contract.
|
|
1153
1171
|
const toolOnly = async (ref) => { const n = String(await io.git(["diff", "--name-only", `${base}...${ref}`], { cwd })).split("\n").map((x) => x.trim()).filter(Boolean); return n.every((f) => isToolState(f)); };
|
|
1154
1172
|
let scratchOnly = false;
|
|
@@ -1266,6 +1284,13 @@ export async function runCore(o) {
|
|
|
1266
1284
|
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
1267
1285
|
return touched.filter((f) => differs.has(f));
|
|
1268
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
|
+
};
|
|
1269
1294
|
/** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
|
|
1270
1295
|
const selfCheckNow = async () => {
|
|
1271
1296
|
try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
|
|
@@ -1333,7 +1358,7 @@ export async function runCore(o) {
|
|
|
1333
1358
|
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1334
1359
|
// #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
|
|
1335
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,
|
|
1336
|
-
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 } : {}) };
|
|
1337
1362
|
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
1338
1363
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
1339
1364
|
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
@@ -1382,7 +1407,7 @@ export async function runCore(o) {
|
|
|
1382
1407
|
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
1383
1408
|
const items = followupItems(reqs, { now: now() });
|
|
1384
1409
|
let filed;
|
|
1385
|
-
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)}`); }
|
|
1386
1411
|
for (const [i, r] of reqs.entries()) {
|
|
1387
1412
|
const n = Number(filed?.[items[i].key]);
|
|
1388
1413
|
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
|
|
@@ -1390,6 +1415,8 @@ export async function runCore(o) {
|
|
|
1390
1415
|
try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
|
|
1391
1416
|
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
|
|
1392
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 });
|
|
1393
1420
|
}
|
|
1394
1421
|
const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
|
|
1395
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})`);
|
|
@@ -1803,7 +1830,7 @@ export async function runCore(o) {
|
|
|
1803
1830
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1804
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);
|
|
1805
1832
|
checkCommandsFailing = true;
|
|
1806
|
-
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; }
|
|
1807
1834
|
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1808
1835
|
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1809
1836
|
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
@@ -1817,7 +1844,7 @@ export async function runCore(o) {
|
|
|
1817
1844
|
await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1818
1845
|
await upsertStage("implementation");
|
|
1819
1846
|
result = (await readResult()) ?? result;
|
|
1820
|
-
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; }
|
|
1821
1848
|
files = await changedFiles();
|
|
1822
1849
|
}
|
|
1823
1850
|
}
|
|
@@ -2302,6 +2329,7 @@ export async function runCore(o) {
|
|
|
2302
2329
|
const cfg = readRollouts(wt);
|
|
2303
2330
|
return cfg ? detectNewOffKeys(cfg, String(await git(["diff", "-U0", `${base}...HEAD`, "--", ...cfg.files]).catch(() => ""))) : [];
|
|
2304
2331
|
}
|
|
2332
|
+
let amendFixed = false;
|
|
2305
2333
|
async function gate() {
|
|
2306
2334
|
await refreshIssue("conformance");
|
|
2307
2335
|
result = (await readResult()) ?? result;
|
|
@@ -2314,6 +2342,14 @@ export async function runCore(o) {
|
|
|
2314
2342
|
ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
|
|
2315
2343
|
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
2316
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
|
+
}
|
|
2317
2353
|
const amendProblems = await amend();
|
|
2318
2354
|
const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
2319
2355
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
@@ -2322,6 +2358,8 @@ export async function runCore(o) {
|
|
|
2322
2358
|
// #4693: guards run beside the validate rerun; SPEC-11: the substituted-original rerun shares the one validate phase
|
|
2323
2359
|
const validatePhase = phaseLedger.during("validate", async () => ({ runs: await rerunFailedValidatesRaw(effective), subs: await rerunSubstitutedOriginals(effective) })).finally(() => { validateMs = now() - gateT0; });
|
|
2324
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 } }) : [];
|
|
2325
2363
|
// #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
|
|
2326
2364
|
const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
|
|
2327
2365
|
guardAddedMs += guardRunMs;
|
|
@@ -2331,7 +2369,7 @@ export async function runCore(o) {
|
|
|
2331
2369
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2332
2370
|
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2333
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
|
|
2334
|
-
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 });
|
|
2335
2373
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2336
2374
|
if (pc.superseded?.length) {
|
|
2337
2375
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
@@ -2368,6 +2406,8 @@ export async function runCore(o) {
|
|
|
2368
2406
|
const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
|
|
2369
2407
|
const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems, ...mutBlock];
|
|
2370
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 };
|
|
2371
2411
|
if (pre.length && !recording()) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
2372
2412
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
2373
2413
|
const hard = recording() ? blockingOf(pre, SHADOW_PRE_BLOCKS).block : [];
|
|
@@ -2463,6 +2503,13 @@ export async function runCore(o) {
|
|
|
2463
2503
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
2464
2504
|
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
2465
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
|
+
}
|
|
2466
2513
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
2467
2514
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
2468
2515
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -2983,7 +3030,7 @@ export async function runCore(o) {
|
|
|
2983
3030
|
} catch (e) {
|
|
2984
3031
|
if (e?.deferUsageLimit) {
|
|
2985
3032
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
2986
|
-
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);
|
|
2987
3034
|
}
|
|
2988
3035
|
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
|
|
2989
3036
|
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -310,6 +310,22 @@ describe("core work-on", () => {
|
|
|
310
310
|
assert.match(t, /\| Plan \| not reached \|/);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
it("an unknown amendment kind gets one in-lane correction before the gate and does not use the conformance resume (#5089)", async () => {
|
|
314
|
+
const w = world({ amendments: [{ kind: "deliverable_change", requirement: "R1", why: "no Linux build" }], resumeAmendments: [], reviews: [ok()] });
|
|
315
|
+
const r = await run(w);
|
|
316
|
+
assert.equal(r.terminalReason, "merged");
|
|
317
|
+
assert.ok(builders(w).some((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")), "the correction prompt ran");
|
|
318
|
+
assert.match(traj(w)[0], /Amendment kinds invalid \(1\); builder asked to correct/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("an unknown amendment kind the builder does not fix parks only after the one correction (#5089)", async () => {
|
|
322
|
+
const bad = [{ kind: "deliverable_change", requirement: "R1", why: "w" }];
|
|
323
|
+
const w = world({ amendments: bad, resumeAmendments: bad, reviews: [ok()] });
|
|
324
|
+
const r = await run(w);
|
|
325
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
326
|
+
assert.equal(builders(w).filter((c) => /amendments in .*result\.json are not valid/.test(c[4] ?? "")).length, 1);
|
|
327
|
+
});
|
|
328
|
+
|
|
313
329
|
it("nonconforming review -> builder resumed in its session with the failed rows -> re-review conforms -> merge; the note is re-posted", async () => {
|
|
314
330
|
const w = world({ reviews: [bad("src/foo.js:9 still wrong"), ok()] });
|
|
315
331
|
const r = await run(w);
|
|
@@ -443,6 +459,19 @@ describe("core work-on", () => {
|
|
|
443
459
|
assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
|
|
444
460
|
});
|
|
445
461
|
|
|
462
|
+
it("#5080 a credentials block that is only read-only prod access ends host-task locally, env.capability_remote remotely, never builder.blocked; real credentials stay builder.blocked", async () => {
|
|
463
|
+
const prod = { result: { status: "blocked", summary: "gated on read-only production telemetry: a host-only prod_monitor capability", block: { kind: "human", why: "credentials" } } };
|
|
464
|
+
const lw = world(prod);
|
|
465
|
+
const local = await run(lw);
|
|
466
|
+
assert.equal(local.terminalReason, "host-task"); assert.equal(local.code, "env.capability_remote");
|
|
467
|
+
assert.ok(lw.edits.some((e) => e.includes("--add-label") && e.includes("host-task")), JSON.stringify(lw.edits));
|
|
468
|
+
assert.ok(!lw.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")), JSON.stringify(lw.edits));
|
|
469
|
+
const remote = await run(world(prod), { placement: { where: "remote", reason: "mode remote" } });
|
|
470
|
+
assert.equal(remote.code, "env.capability_remote"); assert.notEqual(remote.terminalReason, "host-task");
|
|
471
|
+
const real = await run(world({ result: { status: "blocked", summary: "needs the production API key to read prod data", block: { kind: "human", why: "credentials" } } }));
|
|
472
|
+
assert.equal(real.code, "builder.blocked");
|
|
473
|
+
});
|
|
474
|
+
|
|
446
475
|
// #4124: a builder block is typed; only kind "human" with an allowed reason may reach needs-human.
|
|
447
476
|
const depWorld = (o, deps) => {
|
|
448
477
|
const w = world(o);
|
|
@@ -928,6 +957,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
|
|
|
928
957
|
assert.equal(w.followups.length, 1);
|
|
929
958
|
assert.deepEqual(w.followups[0].items.map((i) => i.key), ["R2: Existing callers of foo keep working."]);
|
|
930
959
|
assert.match(w.followups[0].items[0].why, /^post-deploy: \[bucket:post-deploy\] callers live in another module/);
|
|
960
|
+
assert.deepEqual(r.followups, [{ issue: 900, class: "post-deploy", count: 1 }]); // #5078: the batch report's follow-ups line
|
|
931
961
|
const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
|
|
932
962
|
assert.match(body, /## Remaining work tracked\n- #900\n\nCloses #7$/);
|
|
933
963
|
assert.ok(w.comments.some((c) => c.includes("<!-- FORGE:TRACKED -->") && c.includes("#900")));
|
|
@@ -1938,6 +1968,17 @@ describe("bug lanes freeze a red-green repro_test (#4271)", () => {
|
|
|
1938
1968
|
assert.equal((await run(fixed, { conformance: "shadow" })).terminalReason, "merged"); assert.equal(fixed.rgCalls.length, 2);
|
|
1939
1969
|
});
|
|
1940
1970
|
|
|
1971
|
+
it("#5080 a repro that already passes on the base ends already-fixed: the issue closes as completed citing the base, nothing merges, no resume", async () => {
|
|
1972
|
+
for (const conformance of ["enforce", "shadow"]) {
|
|
1973
|
+
const w = rworld([{ base: "RAN", head: "RAN" }], { changed: ["test/foo.test.js"] });
|
|
1974
|
+
const r = await run(w, { conformance });
|
|
1975
|
+
assert.equal(r.terminalReason, "invalid", conformance); assert.equal(w.merges.length, 0);
|
|
1976
|
+
assert.equal(builders(w).length, 2, "no resume"); assert.equal(reviewers(w).length, 0);
|
|
1977
|
+
assert.equal(w.closed.length, 1); assert.equal(w.closed[0][w.closed[0].indexOf("--reason") + 1], "completed");
|
|
1978
|
+
assert.match(w.closed[0][w.closed[0].indexOf("--comment") + 1], /Already fixed on the base/);
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1941
1982
|
it("a repro test the harness cannot run on head (or no harness result) is env.repro_unrunnable: recoverable, no resume, no reviewer (#4334)", async () => {
|
|
1942
1983
|
for (const conformance of ["enforce", "shadow"]) {
|
|
1943
1984
|
for (const rg of [{ base: "SKIPPED", head: "UNRUNNABLE" }, null]) {
|
|
@@ -3298,6 +3339,45 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
|
|
|
3298
3339
|
assert.match(r.detail, /resets at unknown/);
|
|
3299
3340
|
});
|
|
3300
3341
|
|
|
3342
|
+
// #5094: an account rotator re-sends the limited call on the next account at once, with the same args (resume too).
|
|
3343
|
+
const fakeRotator = (o = {}) => ({ kind: "tokenbaton", accounts: o.accounts ?? [{ n: 1 }, { n: 2 }], headroom: () => o.headroom ?? true, resumeAt: () => null,
|
|
3344
|
+
currentIdentity: () => o.id ?? "acct-2", accountIdentity: (n) => `acct-${n}`, isAccount: () => true });
|
|
3345
|
+
const captureBuilders = (w, err, times) => { const orig = w.io.runner; const sent = []; let n = 0;
|
|
3346
|
+
w.io.runner = async (c) => { if (c.role === "builder") { sent.push({ prompt: c.prompt, resume: c.resume ?? null, model: c.model ?? null }); if (n++ < times) throw err; } return orig(c); }; return sent; };
|
|
3347
|
+
|
|
3348
|
+
it("#5094 a rotator switches accounts mid-lane: the limited call is re-sent on the next account at once and the lane continues", async () => {
|
|
3349
|
+
const w = world(); const sleeps = []; const dir = tmpDir("core-");
|
|
3350
|
+
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
|
|
3351
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 1);
|
|
3352
|
+
w.io.rotator = fakeRotator();
|
|
3353
|
+
const r = await run(w, { dir });
|
|
3354
|
+
assert.equal(r.terminalReason, "merged", "no deferral: the next account ran the call");
|
|
3355
|
+
assert.equal(sleeps.includes(3 * 3600 * 1000), false, "no pause for the exhausted account's reset");
|
|
3356
|
+
assert.deepEqual(sent[1], sent[0], "the same step is re-sent (same prompt, same resume session): no state lost");
|
|
3357
|
+
const clean = world(); await run(clean);
|
|
3358
|
+
assert.equal(builders(w).length, builders(clean).length, "no builder phase is repeated beyond the re-sent call");
|
|
3359
|
+
assert.ok(readLog(dir, 7).some((e) => e.event === "PHASE_ACCOUNT_ROTATED" && e.phase === "builder"));
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
it("#5094 rotations stop at the account count, then the lane defers naming the rotator's current account", async () => {
|
|
3363
|
+
const w = world();
|
|
3364
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
|
|
3365
|
+
w.io.rotator = fakeRotator({ id: "acct-2" });
|
|
3366
|
+
const r = await run(w);
|
|
3367
|
+
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
|
|
3368
|
+
assert.equal(sent.length, 3, "first call plus one retry per account");
|
|
3369
|
+
assert.equal(r.backendId, "acct-2");
|
|
3370
|
+
});
|
|
3371
|
+
|
|
3372
|
+
it("#5094 a rotator without headroom does not retry: the usual pause/defer applies", async () => {
|
|
3373
|
+
const w = world();
|
|
3374
|
+
const sent = captureBuilders(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm" }), 99);
|
|
3375
|
+
w.io.rotator = fakeRotator({ headroom: false });
|
|
3376
|
+
const r = await run(w);
|
|
3377
|
+
assert.equal(r.terminalReason, "deferred");
|
|
3378
|
+
assert.equal(sent.length, 1);
|
|
3379
|
+
});
|
|
3380
|
+
|
|
3301
3381
|
it("#4101 a non-limit runner error is still engine-error", async () => {
|
|
3302
3382
|
const w = world(); failFirstBuilder(w, new Error("boom"));
|
|
3303
3383
|
assert.equal((await run(w)).terminalReason, "engine-error");
|
|
@@ -3943,6 +4023,21 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3943
4023
|
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")), "the worktree is built from the salvaged head");
|
|
3944
4024
|
});
|
|
3945
4025
|
|
|
4026
|
+
it("#5063 a rerun with an existing lane branch resumes from a newer salvage ref", async () => {
|
|
4027
|
+
const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean\n" });
|
|
4028
|
+
const g = w.io.git;
|
|
4029
|
+
w.io.git = async (a, o2) => {
|
|
4030
|
+
if (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*") return "abc\trefs/heads/forge-salvage/7-1234567890\n";
|
|
4031
|
+
if (a[0] === "merge-base" && a[2] === "origin/feat/issue-7-lean") { w.calls.push(["git", ...a]); return ""; } // the lane branch is an ancestor of the salvage head
|
|
4032
|
+
return g(a, o2);
|
|
4033
|
+
};
|
|
4034
|
+
await run(w);
|
|
4035
|
+
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "fetch" && c.includes("refs/heads/forge-salvage/7-1234567890:refs/remotes/origin/feat/issue-7-lean")), "origin/<branch> is moved to the salvage head");
|
|
4036
|
+
const at = (f) => w.calls.findIndex(f);
|
|
4037
|
+
const add = at((c) => c[1] === "worktree" && c[2] === "add");
|
|
4038
|
+
assert.ok(add >= 0 && !w.calls.slice(0, add).some((c) => c[1] === "fetch" && c.length === 4 && c[3] === "feat/issue-7-lean"), "no refetch of the lane branch moves origin/<branch> off the salvage head before the worktree is built");
|
|
4039
|
+
});
|
|
4040
|
+
|
|
3946
4041
|
it("#4118 GitHub's transient '[remote rejected] ... (failed)' and pack write errors are retried, then merge", async () => {
|
|
3947
4042
|
for (const msg of ["To github.com:o/r.git\n ! [remote rejected] feat/issue-7-lean -> feat/issue-7-lean (failed)",
|
|
3948
4043
|
"remote: error: unable to write file ./objects/pack/pack-1.pack: No such file or directory\n ! [remote rejected] x -> x (failed)"]) {
|
package/bin/lean/limits.mjs
CHANGED
|
@@ -13,8 +13,12 @@ import { join } from "node:path";
|
|
|
13
13
|
import { backendIdentity } from "../engine.mjs";
|
|
14
14
|
import { writeAtomic } from "./batch.mjs";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Identity of the account a core lane runs on: the lane always uses the claude CLI with the dispatcher's env. With an
|
|
18
|
+
* account rotator (#5094, rotator.mjs) the shim picks the account per call, so the rotator's current account is the identity.
|
|
19
|
+
*/
|
|
20
|
+
export function laneBackendId(env = process.env, rotator = null) {
|
|
21
|
+
if (rotator) { try { const id = rotator.currentIdentity(); if (id) return id; } catch { /* fail-open: the env identity */ } }
|
|
18
22
|
return backendIdentity({ backend: "cli", env });
|
|
19
23
|
}
|
|
20
24
|
|
package/bin/lean/limits.test.mjs
CHANGED
|
@@ -183,3 +183,89 @@ describe("account-scoped usage-limit holds (#4190)", () => {
|
|
|
183
183
|
assert.deepEqual(limits.list(), []);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("#5095: a held pool account places hybrid lanes local", () => {
|
|
188
|
+
const remoteFor = (extra = {}) => ({ max: 2, isRemote: () => true, place: () => ({ where: "remote", reason: "heavy" }), identity: () => "remote:pool", ...extra });
|
|
189
|
+
const runWith = async ({ hold, remote }) => {
|
|
190
|
+
const { c, now, sleep } = fakeClock();
|
|
191
|
+
const limits = openLimits(storeFile(), { now });
|
|
192
|
+
for (const [id, ms] of Object.entries(hold)) limits.set(id, c.t + ms, "test");
|
|
193
|
+
const ctxs = [];
|
|
194
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, now, sleep, readBody: async () => "", limits, identity: () => "local", remote,
|
|
195
|
+
runOne: async (n, _l, ctx) => { ctxs.push({ at: now() - 1_000_000, remote: ctx.remote, placement: ctx.placement }); return { terminalReason: "merged" }; } });
|
|
196
|
+
return { rows, ctxs };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it("hybrid: a hold on the remote identity and none on the local one places the next admission local", async () => {
|
|
200
|
+
const { rows, ctxs } = await runWith({ hold: { "remote:pool": 600_000 }, remote: remoteFor({ fallbackLocal: true }) });
|
|
201
|
+
assert.equal(rows[0].outcome, "merged");
|
|
202
|
+
assert.equal(ctxs.length, 1);
|
|
203
|
+
assert.equal(ctxs[0].at, 0, "admitted at once, not held");
|
|
204
|
+
assert.equal(ctxs[0].remote, false);
|
|
205
|
+
assert.equal(ctxs[0].placement.where, "local");
|
|
206
|
+
assert.match(ctxs[0].placement.reason, /fallback to local: pool usage limit until/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("remote-only: a held pool account waits for the reset; no hold places the lane remote", async () => {
|
|
210
|
+
const held = await runWith({ hold: { "remote:pool": 600_000 }, remote: remoteFor() });
|
|
211
|
+
assert.equal(held.ctxs[0].remote, true);
|
|
212
|
+
assert.ok(held.ctxs[0].at >= 600_000, "the remote lane starts after the pool reset");
|
|
213
|
+
const free = await runWith({ hold: {}, remote: remoteFor({ fallbackLocal: true }) });
|
|
214
|
+
assert.deepEqual([free.ctxs[0].at, free.ctxs[0].remote, free.ctxs[0].placement.where], [0, true, "remote"]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("account rotator and usage-limit holds (#5094)", () => {
|
|
219
|
+
it("#5094 rotator headroom: a rate_limited lane records no host hold and admissions continue", async () => {
|
|
220
|
+
const { c, now, sleep } = fakeClock();
|
|
221
|
+
const t0 = c.t;
|
|
222
|
+
const limits = openLimits(storeFile(), { now });
|
|
223
|
+
let acct = "acct-1";
|
|
224
|
+
const rotator = { kind: "tokenbaton", accounts: [{ n: 1 }, { n: 2 }], headroom: () => true, resumeAt: () => now(),
|
|
225
|
+
isAccount: (id) => id === "acct-1" || id === "acct-2", currentIdentity: () => acct };
|
|
226
|
+
const starts = {};
|
|
227
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", limits, rotator, identity: () => laneBackendId({}, rotator), batchId: "b1",
|
|
228
|
+
runOne: async (n) => {
|
|
229
|
+
(starts[n] ??= []).push(now());
|
|
230
|
+
if (n === 1 && starts[1].length === 1) { acct = "acct-2"; return limited("acct-1", t0 + 600_000); } // tokenbaton rotates
|
|
231
|
+
return { terminalReason: "merged" };
|
|
232
|
+
} });
|
|
233
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
234
|
+
assert.deepEqual(limits.list(), [], "no host-wide hold while an account has headroom");
|
|
235
|
+
assert.equal(starts[2][0], t0, "admissions continue");
|
|
236
|
+
assert.equal(starts[1][1], t0, "the limited issue reruns at once on the next account");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("#5094 a hold set for identity A does not block admissions once the rotator's active identity is B", async () => {
|
|
240
|
+
const { c, now, sleep } = fakeClock();
|
|
241
|
+
const t0 = c.t;
|
|
242
|
+
const limits = openLimits(storeFile(), { now });
|
|
243
|
+
limits.set("acct-1", t0 + 3_600_000, "batch b0 #1");
|
|
244
|
+
const rotator = { currentIdentity: () => "acct-2" };
|
|
245
|
+
const starts = [];
|
|
246
|
+
await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => laneBackendId({}, rotator),
|
|
247
|
+
runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
|
|
248
|
+
assert.deepEqual(starts, [t0]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("#5094 #5095 a limited pool account keeps its own hold under a rotator with headroom; the host account is never held", async () => {
|
|
252
|
+
const { c, now, sleep } = fakeClock();
|
|
253
|
+
const t0 = c.t;
|
|
254
|
+
const limits = openLimits(storeFile(), { now });
|
|
255
|
+
const rotator = { accounts: [{ n: 1 }], headroom: () => true, resumeAt: () => now(), isAccount: (id) => id === "acct-1", currentIdentity: () => "acct-1" };
|
|
256
|
+
const remote = { max: 2, fallbackLocal: true, isRemote: () => true, place: () => ({ where: "remote", reason: "heavy" }), identity: () => "remote:pool" };
|
|
257
|
+
const runs = [];
|
|
258
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", limits, rotator, remote, identity: () => laneBackendId({}, rotator),
|
|
259
|
+
runOne: async (n, _l, ctx) => {
|
|
260
|
+
runs.push({ n, at: now(), where: ctx.placement?.where, poolHeld: limits.holdUntil("remote:pool"), hostHeld: limits.holdUntil("acct-1") });
|
|
261
|
+
return n === 1 && runs.length === 1 ? limited("remote:pool", t0 + 600_000) : { terminalReason: "merged" };
|
|
262
|
+
} });
|
|
263
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
264
|
+
const second = runs.find((x) => x.n === 2);
|
|
265
|
+
assert.equal(runs[0].where, "remote");
|
|
266
|
+
assert.equal(second.at, t0, "the host account is not held: admissions continue");
|
|
267
|
+
assert.equal(second.poolHeld, t0 + 600_000, "the pool's own hold is recorded (#5095)");
|
|
268
|
+
assert.ok(!(second.hostHeld > now()), "no host-wide hold while the rotator has headroom");
|
|
269
|
+
assert.equal(second.where, "local", "hybrid falls back local while the pool is held");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -18,7 +18,7 @@ import { parseForgeYaml } from "../forge-utils.mjs";
|
|
|
18
18
|
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
19
19
|
import { parseLifecycle } from "./lifecycle.mjs";
|
|
20
20
|
import { parseMinAge } from "./prefilter.mjs";
|
|
21
|
-
import { DEFAULT_FEATURE_PATTERN, milestoneSlug, readBranches } from "./lane.mjs";
|
|
21
|
+
import { DEFAULT_FEATURE_PATTERN, listMilestones, milestoneSlug, readBranches } from "./lane.mjs";
|
|
22
22
|
|
|
23
23
|
export const SYNC_REASONS = ["ship", "batch", "conflict-risk", "manual"];
|
|
24
24
|
export const DEFAULT_DEBOUNCE_MS = 10 * 60 * 1000;
|
|
@@ -57,7 +57,7 @@ export async function syncMilestone({ gh, git, repo, slug, base, branch = `miles
|
|
|
57
57
|
let wt = null, baseSha;
|
|
58
58
|
try {
|
|
59
59
|
if (!SYNC_REASONS.includes(reason)) return { status: "refused", detail: `sync is on demand: reason must be one of ${SYNC_REASONS.join("|")}` };
|
|
60
|
-
const list =
|
|
60
|
+
const list = await listMilestones({ gh, repo, state: "all" });
|
|
61
61
|
const ms = (Array.isArray(list) ? list : []).find((m) => milestoneSlug(m?.title) === slug);
|
|
62
62
|
if (!ms) return { status: "refused", detail: `no milestone with slug ${slug}` };
|
|
63
63
|
if (!isSyncable(ms)) return { status: "shipped", detail: `milestone is ${parseLifecycle(ms.description)?.state ?? ms.state}: never synced` };
|