forgedock 1.43.1 → 1.45.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 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
package/bin/lean/lane.mjs
CHANGED
|
@@ -41,11 +41,25 @@ export async function defaultBranches({ repo, io, branches = {} }) {
|
|
|
41
41
|
return [...new Set([branches.default, gh].filter(Boolean))];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** #4963: one batch's shared lookups: the default branches once, `ls-remote` once per lane. Failures are evicted, so a retry re-reads. */
|
|
45
|
+
export function makeLaneCache() { return new Map(); }
|
|
46
|
+
const once = (cache, key, load, keep = () => true) => {
|
|
47
|
+
let p = cache.get(key);
|
|
48
|
+
if (!p) {
|
|
49
|
+
p = Promise.resolve().then(load);
|
|
50
|
+
cache.set(key, p);
|
|
51
|
+
p.then((v) => { if (!keep(v) && cache.get(key) === p) cache.delete(key); }, () => { if (cache.get(key) === p) cache.delete(key); });
|
|
52
|
+
}
|
|
53
|
+
return p;
|
|
54
|
+
};
|
|
55
|
+
|
|
44
56
|
/**
|
|
57
|
+
* `cache` (makeLaneCache) shares the default-branch and ls-remote reads across issues; `milestone` (a title, or null for none)
|
|
58
|
+
* is a pre-read value that skips the per-issue `gh issue view`; undefined reads it.
|
|
45
59
|
* @returns {Promise<{ok:true, lane:string, expected:string, milestone:?string, overridden:boolean, defaults:string[]}
|
|
46
60
|
* | {ok:false, code:string, detail:string}>}
|
|
47
61
|
*/
|
|
48
|
-
export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false }) {
|
|
62
|
+
export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false, cache = null, milestone: known }) {
|
|
49
63
|
const refuse = (code, detail) => ({ ok: false, code, detail });
|
|
50
64
|
const transient = (detail) => ({ ...refuse(OUTCOME_CODES.LANE_UNRESOLVED, detail), transient: true }); // #4791: a gh/git command failed; retry
|
|
51
65
|
const staging = branches.staging || DEFAULT_STAGING_BRANCH;
|
|
@@ -53,9 +67,12 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
53
67
|
if (!pattern.includes("{slug}")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `forge.yaml branches.feature_pattern "${pattern}" has no {slug}`);
|
|
54
68
|
let milestone = null, defaults;
|
|
55
69
|
try {
|
|
56
|
-
|
|
70
|
+
if (known !== undefined) milestone = known || null;
|
|
71
|
+
else milestone = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "milestone"])))?.milestone?.title || null;
|
|
57
72
|
} catch (e) { return transient(`cannot read the milestone of #${issue}: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
58
|
-
try {
|
|
73
|
+
try {
|
|
74
|
+
defaults = cache ? await once(cache, "default-branches", () => defaultBranches({ repo, io, branches })) : await defaultBranches({ repo, io, branches });
|
|
75
|
+
} catch (e) {
|
|
59
76
|
return transient(`cannot read the default branch of ${repo}: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
60
77
|
}
|
|
61
78
|
let expected = staging;
|
|
@@ -78,7 +95,8 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
78
95
|
}
|
|
79
96
|
if (!LANE_RE.test(lane) || lane.includes("..")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `invalid lane ${JSON.stringify(lane)}`);
|
|
80
97
|
let out;
|
|
81
|
-
|
|
98
|
+
const lsRemote = () => io.git(["ls-remote", "--heads", "origin", `refs/heads/${lane}`], { cwd });
|
|
99
|
+
try { out = String(cache ? await once(cache, `lane:${lane}`, async () => String(await lsRemote()), (v) => v.trim() !== "") : await lsRemote()); } catch (e) {
|
|
82
100
|
return transient(`cannot check origin for ${lane}: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
83
101
|
}
|
|
84
102
|
if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
|
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 } from "./lane.mjs";
|
|
7
|
+
import { milestoneSlug, readBranches, resolveLane, makeLaneCache } 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 } = {}) {
|
|
@@ -101,3 +101,35 @@ describe("lane resolution (#4125)", () => {
|
|
|
101
101
|
assert.deepEqual([slug.code, !!slug.transient], ["lane.unresolved", false]);
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("per-batch lane lookups (#4963)", () => {
|
|
106
|
+
it("one default-branch read and one ls-remote per lane across issues; a supplied milestone skips the issue view", async () => {
|
|
107
|
+
const fake = io({ onOrigin: ["staging", "main", "milestone/m1"] }), cache = makeLaneCache();
|
|
108
|
+
const rs = await Promise.all([1, 2, 3, 4].map((issue) => resolveLane({ issue, repo: "o/r", io: fake, cwd: "/r", cache, milestone: issue % 2 ? "M1" : null })));
|
|
109
|
+
assert.deepEqual(rs.map((r) => r.lane), ["milestone/m1", "staging", "milestone/m1", "staging"]);
|
|
110
|
+
const n = (k, ...m) => fake.calls.filter((c) => c[0] === k && m.every((x, i) => c[i + 1] === x)).length;
|
|
111
|
+
assert.equal(n("gh", "repo", "view"), 1);
|
|
112
|
+
assert.equal(n("gh", "issue", "view"), 0);
|
|
113
|
+
assert.equal(fake.calls.filter((c) => c[1] === "ls-remote").length, 2);
|
|
114
|
+
});
|
|
115
|
+
it("without cache or milestone every call reads (unchanged)", async () => {
|
|
116
|
+
const fake = io();
|
|
117
|
+
await resolveLane({ issue: 1, repo: "o/r", io: fake, cwd: "/r" });
|
|
118
|
+
await resolveLane({ issue: 2, repo: "o/r", io: fake, cwd: "/r" });
|
|
119
|
+
assert.equal(fake.calls.filter((c) => c[1] === "issue").length, 2);
|
|
120
|
+
assert.equal(fake.calls.filter((c) => c[1] === "repo").length, 2);
|
|
121
|
+
});
|
|
122
|
+
it("a failed lookup is not cached: the retry re-reads", async () => {
|
|
123
|
+
const cache = makeLaneCache();
|
|
124
|
+
const bad = await resolveLane({ issue: 1, repo: "o/r", io: io({ ghFails: "repo" }), cwd: "/r", cache, milestone: null });
|
|
125
|
+
assert.equal(bad.transient, true);
|
|
126
|
+
const ok = await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null });
|
|
127
|
+
assert.equal(ok.ok, true);
|
|
128
|
+
});
|
|
129
|
+
it("a missing lane branch is not cached either", async () => {
|
|
130
|
+
const cache = makeLaneCache();
|
|
131
|
+
const miss = await resolveLane({ issue: 1, repo: "o/r", io: io({ onOrigin: ["main"] }), cwd: "/r", cache, milestone: null });
|
|
132
|
+
assert.equal(miss.ok, false);
|
|
133
|
+
assert.equal((await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null })).ok, true);
|
|
134
|
+
});
|
|
135
|
+
});
|
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 { 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, precheck, 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";
|
|
@@ -64,7 +64,7 @@ import { buildResumeDigest } from "./resume.mjs";
|
|
|
64
64
|
import { parsePredicate, evalPredicate, premiseAnnotation, registerAsk, registryPkgVersion, closedEdgeNote, onlyAsk, fileRemainder } from "./needs-human-premise.mjs";
|
|
65
65
|
import { lightEligibility } from "./light.mjs";
|
|
66
66
|
import { untrackToolState, unstageToolState, isToolState, clearLaneScratch } from "./toolstate.mjs";
|
|
67
|
-
import { ensureSignedOff } from "./signoff.mjs";
|
|
67
|
+
import { ensureSignedOff, salvageBranch, dropSalvageTracking } from "./signoff.mjs";
|
|
68
68
|
import { createLedger, flattenPhases } from "./phaseledger.mjs";
|
|
69
69
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
70
70
|
|
|
@@ -82,6 +82,9 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
82
82
|
[OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
|
|
83
83
|
[ENV_VALIDATE_TIMEOUT]: "a validate command timed out (exit 124) on the engine's rerun, even after one longer retry: host load, not a failed check; the change may already be correct. Do not edit the deliverables for it: run the same command narrowed to the changed files (or through the repo's own cached toolchain) with a longer timeout, record that exit under the same validate id and name the command in your summary",
|
|
84
84
|
[OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
|
|
85
|
+
[OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED]: "an earlier run's conformance check found a requirement or validate command not done after one builder resume; the branch holds its work. For each named gap deliver the requirement against the frozen contract, adding any file it needs as an `add-deliverable` amendment, and re-run the validate commands",
|
|
86
|
+
[OUTCOME_CODES.CONFORMANCE_OMISSION]: "an earlier run's review found a requirement the change left out or narrowed after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record an `add-requirement` / `defer` amendment with a why so the engine files a tracked follow-up instead of stopping",
|
|
87
|
+
[OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]: "an earlier run deferred a requirement without a sufficient why after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record a `defer` amendment with a concrete why so the engine files a tracked follow-up instead of stopping",
|
|
85
88
|
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
86
89
|
});
|
|
87
90
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
@@ -222,7 +225,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
222
225
|
*/
|
|
223
226
|
export async function runCore(o) {
|
|
224
227
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
225
|
-
toolchainOverride = null, testRunners = 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;
|
|
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;
|
|
226
229
|
const now = io.now ?? (() => Date.now());
|
|
227
230
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
228
231
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
@@ -513,6 +516,10 @@ export async function runCore(o) {
|
|
|
513
516
|
return true;
|
|
514
517
|
} catch { return false; }
|
|
515
518
|
}
|
|
519
|
+
const labelFailed = (step, e) => { // #4905: a failed needs-human comment/label call is recorded, not swallowed (the issue showed needs-human with no label)
|
|
520
|
+
const error = String(e?.message ?? e).slice(0, 200);
|
|
521
|
+
T.anomalies.push(`needs-human ${step} failed: ${error}`); ev({ event: "NEEDS_HUMAN_LABEL_FAILED", step, error });
|
|
522
|
+
};
|
|
516
523
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, { ask: askText, note: askNote = "", ...extra } = {}) {
|
|
517
524
|
// #4725: a recorded premise (`Premise: <kind>:<arg>`) that already holds is no blocker: no label, the lane runs again.
|
|
518
525
|
const predicate = parsePredicate(why);
|
|
@@ -528,8 +535,10 @@ export async function runCore(o) {
|
|
|
528
535
|
link = reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : reg.entry ?? "";
|
|
529
536
|
}
|
|
530
537
|
const notes = [premiseAnnotation(predicate), closedEdgeNote(closedDeps), link, askNote].filter(Boolean).join("\n");
|
|
531
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
532
|
-
|
|
538
|
+
try { await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`); }
|
|
539
|
+
catch (e) { labelFailed("comment", e); }
|
|
540
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); }
|
|
541
|
+
catch (e) { labelFailed("label", e); }
|
|
533
542
|
} catch { /* best effort */ }
|
|
534
543
|
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
535
544
|
}
|
|
@@ -642,11 +651,17 @@ export async function runCore(o) {
|
|
|
642
651
|
const signOffUnpushed = async () => {
|
|
643
652
|
try {
|
|
644
653
|
const r = await ensureSignedOff({ git, lane, branch });
|
|
645
|
-
if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten }); }
|
|
654
|
+
if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten, ...(r.dirty ? { dirty: r.dirty } : {}) }); }
|
|
646
655
|
} catch (e) { throw Object.assign(e, e?.noIdentity === true ? { laneCode: OUTCOME_CODES.CI_DCO } : { engineCode: OUTCOME_CODES.ENGINE_DCO_REWRITE }); } // #4997: a failed rewrite is mechanical (engine-error, recoverable), only a missing identity is a human block
|
|
647
656
|
};
|
|
648
657
|
const gitPush = async (args) => {
|
|
649
|
-
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
|
|
658
|
+
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
|
|
659
|
+
try { await signOffUnpushed(); }
|
|
660
|
+
catch (e) { // #4905: a failed rewrite must not cost the lane's finished commits: push them as-is before reporting; #5047: to a salvage ref, never the lane branch (the sign-off range excludes origin/<branch>)
|
|
661
|
+
if (!e?.noIdentity) { try { const sha = String(await git(["rev-parse", "HEAD"])).trim(), ref = salvageBranch(issue, sha); await git(["push", "origin", `${sha}:refs/heads/${ref}`]); laneHead = { branch, sha, pushedTo: ref }; ev({ event: "DCO_RESCUE_PUSH", branch: ref }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
} // #4590: never push .forge-lean/ or tool caches this branch added
|
|
650
665
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
651
666
|
catch (e) {
|
|
652
667
|
if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
@@ -664,14 +679,14 @@ export async function runCore(o) {
|
|
|
664
679
|
if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
|
|
665
680
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
666
681
|
return true;
|
|
667
|
-
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected) : false; }
|
|
682
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected || e?.engineCode === OUTCOME_CODES.ENGINE_DCO_REWRITE) : false; }
|
|
668
683
|
}
|
|
669
684
|
/** #4958: the normal push failed (sign-off rewrite, rejection): push HEAD as is to the lane branch, else to a salvage branch, so a torn-down remote sandbox never takes the commits with it. */
|
|
670
685
|
async function rescuePush(reason, rejected) {
|
|
671
686
|
// #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
|
|
672
687
|
try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
|
|
673
688
|
// a rejected lane-branch push (protected branch, permissions) would be rejected again: go straight to the salvage branch
|
|
674
|
-
for (const ref of [...(rejected ? [] : [branch]),
|
|
689
|
+
for (const ref of [...(rejected ? [] : [branch]), salvageBranch(issue, laneHead.sha)]) {
|
|
675
690
|
try {
|
|
676
691
|
await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
|
|
677
692
|
laneHead.pushedTo = ref;
|
|
@@ -1074,14 +1089,14 @@ export async function runCore(o) {
|
|
|
1074
1089
|
|
|
1075
1090
|
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
1076
1091
|
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
1077
|
-
let resumed = null;
|
|
1092
|
+
let resumed = null, fromSalvage = false;
|
|
1078
1093
|
try {
|
|
1079
1094
|
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
|
|
1080
1095
|
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
1081
1096
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
1082
1097
|
if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
|
|
1083
1098
|
const salv = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd })).split("\n").map((l) => l.trim().split(/\s+/)[1]).filter(Boolean).sort().at(-1);
|
|
1084
|
-
if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
|
|
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 }); }
|
|
1085
1100
|
}
|
|
1086
1101
|
} catch { /* no resume info: start fresh */ }
|
|
1087
1102
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
@@ -1168,6 +1183,7 @@ export async function runCore(o) {
|
|
|
1168
1183
|
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
|
|
1169
1184
|
}
|
|
1170
1185
|
wtCreated = true;
|
|
1186
|
+
if (fromSalvage) await dropSalvageTracking({ git, branch }); // #5047: the salvaged commits are unpublished: the sign-off range must include them
|
|
1171
1187
|
if (wtInfo?.sparse) {
|
|
1172
1188
|
sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
|
|
1173
1189
|
ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
|
|
@@ -1271,7 +1287,13 @@ export async function runCore(o) {
|
|
|
1271
1287
|
return null;
|
|
1272
1288
|
}
|
|
1273
1289
|
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
|
|
1274
|
-
|
|
1290
|
+
let words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
1291
|
+
if (words.head === "FAILED" && io.runCommand) { // #5020: a head that failed because a tool or env is missing is env.repro_unrunnable, not a red-green verdict
|
|
1292
|
+
const hr = await io.runCommand({ command: String(t.command), cwd: wt, timeoutMs: 120000, env: laneEnv() }).then((x) => x?.r ?? x).catch(() => null);
|
|
1293
|
+
// release review BUG-1: a module the repo tracks is the change's own failure, never an env miss (same repoHas as the validate rerun)
|
|
1294
|
+
const repoHas = repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1295
|
+
if (reproEnvMiss(hr, { command: String(t.command), repoHas })) words = { ...words, head: "UNRUNNABLE" };
|
|
1296
|
+
}
|
|
1275
1297
|
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
1276
1298
|
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
1277
1299
|
return words;
|
|
@@ -1376,6 +1398,14 @@ export async function runCore(o) {
|
|
|
1376
1398
|
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
1377
1399
|
* env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
|
|
1378
1400
|
*/
|
|
1401
|
+
/** #5020: forge.yaml validate.recipes rewrite frozen commands; a check-less post-deploy requirement becomes a defer to the orchestrator. */
|
|
1402
|
+
function freezeRewrites(c) {
|
|
1403
|
+
const r = applyValidateRecipes(c, validateRecipes);
|
|
1404
|
+
for (const x of r.applied) T.decisions.push(`validate recipe rewrote ${x.id}: \`${x.from}\` -> \`${x.to}\``);
|
|
1405
|
+
const d = deferChecklessPostDeploy(r.contract);
|
|
1406
|
+
if (d.converted.length) T.decisions.push(`Post-deploy with no registered check deferred to the orchestrator: ${d.converted.join(", ")}`);
|
|
1407
|
+
return d.contract;
|
|
1408
|
+
}
|
|
1379
1409
|
async function preflightValidates(contract) {
|
|
1380
1410
|
if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
|
|
1381
1411
|
const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
@@ -1652,6 +1682,7 @@ export async function runCore(o) {
|
|
|
1652
1682
|
return o.contract;
|
|
1653
1683
|
};
|
|
1654
1684
|
let contract = dropScope(await readContract());
|
|
1685
|
+
contract = freezeRewrites(contract);
|
|
1655
1686
|
await refreshIssue("contract validation");
|
|
1656
1687
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1657
1688
|
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
@@ -1660,7 +1691,7 @@ export async function runCore(o) {
|
|
|
1660
1691
|
const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
|
|
1661
1692
|
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1662
1693
|
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1663
|
-
contract = dropScope(await readContract());
|
|
1694
|
+
contract = freezeRewrites(dropScope(await readContract()));
|
|
1664
1695
|
await refreshIssue("contract validation");
|
|
1665
1696
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1666
1697
|
if (missed.length) misses = await preflightValidates(contract);
|
|
@@ -2116,7 +2147,7 @@ export async function runCore(o) {
|
|
|
2116
2147
|
async function amend() {
|
|
2117
2148
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
2118
2149
|
const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
|
|
2119
|
-
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\
|
|
2150
|
+
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : null;
|
|
2120
2151
|
const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
|
|
2121
2152
|
const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
|
|
2122
2153
|
const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
|
|
@@ -2125,7 +2156,8 @@ export async function runCore(o) {
|
|
|
2125
2156
|
const sig = JSON.stringify(a.applied);
|
|
2126
2157
|
if (a.applied.length && sig !== lastAmend) {
|
|
2127
2158
|
lastAmend = sig;
|
|
2128
|
-
await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all }));
|
|
2159
|
+
try { await post("FORGE:AMENDMENT", renderAmendments(a.applied, {}, { contract: hash, amendments: all })); } // #5020: retried by ghw; a still-failing post is an anomaly, never an engine-error
|
|
2160
|
+
catch (e) { T.anomalies.push(`FORGE:AMENDMENT comment not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
|
|
2129
2161
|
T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
|
|
2130
2162
|
}
|
|
2131
2163
|
pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
|
|
@@ -2298,7 +2330,8 @@ export async function runCore(o) {
|
|
|
2298
2330
|
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2299
2331
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2300
2332
|
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2301
|
-
const
|
|
2333
|
+
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 });
|
|
2302
2335
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2303
2336
|
if (pc.superseded?.length) {
|
|
2304
2337
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -288,6 +288,15 @@ describe("core work-on", () => {
|
|
|
288
288
|
assert.equal((await run(w)).terminalReason, "invalid");
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
it("a failed needs-human comment is recorded as an anomaly and the lane still ends needs-human (#4905)", async () => {
|
|
292
|
+
const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
|
|
293
|
+
const gh = w.io.gh;
|
|
294
|
+
w.io.gh = async (a, opts) => { if (a[0] === "issue" && a[1] === "edit" && a.includes("--add-label")) throw new Error("label boom"); return gh(a, opts); };
|
|
295
|
+
const r = await run(w);
|
|
296
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
297
|
+
assert.match(traj(w)[0], /needs-human label failed: label boom/);
|
|
298
|
+
});
|
|
299
|
+
|
|
291
300
|
it("invalid without evidence goes to needs-human and does not close", async () => {
|
|
292
301
|
const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
|
|
293
302
|
const r = await run(w);
|
|
@@ -2705,6 +2714,28 @@ describe("orchestrate", () => {
|
|
|
2705
2714
|
assert.match(t, /skipped \(dependency #1/);
|
|
2706
2715
|
});
|
|
2707
2716
|
|
|
2717
|
+
it("orchestrateFromCli reads the milestone with the body: no second issue view, one default-branch read, one ls-remote per lane (#4963)", async () => {
|
|
2718
|
+
const calls = [];
|
|
2719
|
+
const leanIo = {
|
|
2720
|
+
gh: async (a) => {
|
|
2721
|
+
calls.push(["gh", ...a]);
|
|
2722
|
+
if (a[0] === "repo" && a[1] === "view") return "main\n";
|
|
2723
|
+
if (a[0] === "api") return "OWNER\n";
|
|
2724
|
+
return JSON.stringify({ body: "", authorAssociation: "OWNER", milestone: a[2] === "2" ? null : { title: "M One" } });
|
|
2725
|
+
},
|
|
2726
|
+
git: async (a) => { calls.push(["git", ...a]); return a[0] === "ls-remote" && ["refs/heads/staging", "refs/heads/milestone/m-one"].includes(a.at(-1)) ? "abc\tref\n" : ""; },
|
|
2727
|
+
};
|
|
2728
|
+
const deps = { dir: tmpDir("core-orch-"), batchRoot: tmpDir("core-orch-batch-"), allowTmpfs: true, emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo,
|
|
2729
|
+
leanModule: { runCore: async (o) => ({ terminalReason: "merged", pr: 50 + o.issue, cost_usd: 1, wall_ms: 1 }) } };
|
|
2730
|
+
const l = console.log; console.log = () => {};
|
|
2731
|
+
let res;
|
|
2732
|
+
try { res = await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], deps); } finally { console.log = l; }
|
|
2733
|
+
assert.equal(res.ok, true);
|
|
2734
|
+
assert.equal(calls.filter((c) => c.includes("milestone") && !c.some((x) => /body/.test(x))).length, 0, "no milestone-only view");
|
|
2735
|
+
assert.equal(calls.filter((c) => c[1] === "repo" && c[2] === "view").length, 2, "the up-front --lane check plus one batch-wide lane lookup, not one per issue");
|
|
2736
|
+
assert.equal(calls.filter((c) => c[0] === "git" && c[1] === "ls-remote").length, 2);
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2708
2739
|
it("orchestrateFromCli routes each issue to its own lane (no --lane), reports cross-lane edges, refuses --lane main", async () => {
|
|
2709
2740
|
const seen = [];
|
|
2710
2741
|
const logs = [];
|
|
@@ -3867,6 +3898,34 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3867
3898
|
assert.ok(traj(w).some((c) => /Lane commits pushed: branch `forge-salvage\/7-/.test(c)));
|
|
3868
3899
|
});
|
|
3869
3900
|
|
|
3901
|
+
it("#5047 a failed DCO rewrite rescue-pushes to a salvage ref, never the lane branch", async () => {
|
|
3902
|
+
const w = world();
|
|
3903
|
+
const g = w.io.git;
|
|
3904
|
+
const SEP = "\x1f", END = "\x1e";
|
|
3905
|
+
w.io.git = async (a, o2) => {
|
|
3906
|
+
if (a[0] === "log" && a.some((x) => String(x).startsWith("--format=%H"))) return `deadbeef${SEP}x@localhost${SEP}x@localhost${SEP}${SEP}parent${END}`;
|
|
3907
|
+
if (a[0] === "config") return "Lane Bot";
|
|
3908
|
+
if (a[0] === "filter-branch") throw new Error("boom");
|
|
3909
|
+
return g(a, o2);
|
|
3910
|
+
};
|
|
3911
|
+
const r = await run(w);
|
|
3912
|
+
const pushes = w.calls.filter((c) => c[0] === "git" && c[1] === "push").map((c) => c.join(" "));
|
|
3913
|
+
assert.ok(pushes.length > 0);
|
|
3914
|
+
assert.ok(pushes.some((p) => /origin [0-9a-z]+:refs\/heads\/forge-salvage\/7-/.test(p)), "the commits are published under a salvage ref");
|
|
3915
|
+
assert.ok(!pushes.some((p) => /origin feat\/issue-7-lean$|:refs\/heads\/feat\/issue-7-lean$/.test(p)), `never the lane branch: ${pushes.join(" | ")}`);
|
|
3916
|
+
assert.match(String(r.laneHead?.pushedTo), /^forge-salvage\/7-/);
|
|
3917
|
+
});
|
|
3918
|
+
|
|
3919
|
+
it("#5047 a lane resumed from a salvage branch drops the origin/<branch> tracking ref so the sign-off range covers its commits", async () => {
|
|
3920
|
+
const w = world({ prList: [] });
|
|
3921
|
+
const g = w.io.git;
|
|
3922
|
+
w.io.git = async (a, o2) => (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*" ? "abc\trefs/heads/forge-salvage/7-1234567890\n" : g(a, o2));
|
|
3923
|
+
await run(w);
|
|
3924
|
+
const wtAdd = w.calls.findIndex((c) => c[1] === "worktree" && c[2] === "add");
|
|
3925
|
+
const drop = w.calls.findIndex((c) => c[1] === "update-ref" && c[2] === "-d" && c[3] === "refs/remotes/origin/feat/issue-7-lean");
|
|
3926
|
+
assert.ok(wtAdd >= 0 && drop > wtAdd, "dropped after the worktree is built from it");
|
|
3927
|
+
});
|
|
3928
|
+
|
|
3870
3929
|
it("#4958 when every rescue push is rejected the head is reported as NOT pushed", async () => {
|
|
3871
3930
|
const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
|
|
3872
3931
|
const w = world({ pushErrors: [msg, msg, msg, msg] });
|
|
@@ -4017,6 +4076,13 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
4017
4076
|
assert.equal(n, 1);
|
|
4018
4077
|
});
|
|
4019
4078
|
|
|
4079
|
+
it("#5020 forge.yaml validate.recipes rewrite the frozen validate command and the freeze records it", async () => {
|
|
4080
|
+
const w = world({});
|
|
4081
|
+
const r = await run(w, { validateRecipes: [{ match: "^node --test ", replace: "node --test --test-reporter=dot " }] });
|
|
4082
|
+
assert.equal(r.terminalReason, "merged");
|
|
4083
|
+
assert.ok(w.comments.some((c) => c.includes("FORGE:CONTRACT") && c.includes("node --test --test-reporter=dot test/foo.test.js")));
|
|
4084
|
+
});
|
|
4085
|
+
|
|
4020
4086
|
// ---- #4112 ----
|
|
4021
4087
|
const fakeCli = async (name, body) => {
|
|
4022
4088
|
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
@@ -85,3 +85,38 @@ describe("#4917: a deferred issue still waiting when the last running lane ends"
|
|
|
85
85
|
assert.deepEqual(saved.running, {});
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
describe("remote lanes with a registry lacking the #4964 methods", () => {
|
|
90
|
+
it("a remote-placed lane runs and settles when the registry stub has no noteRemote/dropRemote/notePool", async () => {
|
|
91
|
+
const calls = [];
|
|
92
|
+
const registry = { tryAcquire: () => { calls.push("acquire"); return { ok: true }; }, offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null };
|
|
93
|
+
const rows = await orchestrate({ issues: [4], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
|
|
94
|
+
remote: { max: 1, isRemote: () => true, capacity: () => null },
|
|
95
|
+
runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: "s-4" }, remote: true }); return merged; } });
|
|
96
|
+
assert.equal(rows[0].outcome, "merged");
|
|
97
|
+
assert.deepEqual(calls, [], "a remote lane never takes a host slot");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("#5048: the pool is unreachable only after a failed capacity probe", () => {
|
|
102
|
+
const run = async (capacity) => {
|
|
103
|
+
const notes = [];
|
|
104
|
+
const registry = { tryAcquire: () => ({ ok: true }), offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null, notePool: (b, st) => notes.push(st === null ? "clear" : "down") };
|
|
105
|
+
const rows = await orchestrate({ issues: [4, 5, 6], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
|
|
106
|
+
remote: { max: 1, isRemote: () => true, ...(capacity ? { capacity } : {}) },
|
|
107
|
+
runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: `s-${n}` }, remote: true }); return merged; } });
|
|
108
|
+
assert.ok(rows.every((r) => r.outcome === "merged"));
|
|
109
|
+
return notes;
|
|
110
|
+
};
|
|
111
|
+
it("unknown capacity (null) does not mark the pool unreachable", async () => {
|
|
112
|
+
assert.deepEqual(await run(() => null), []);
|
|
113
|
+
});
|
|
114
|
+
it("a provider without capacity() never marks the pool unreachable", async () => {
|
|
115
|
+
assert.deepEqual(await run(null), []);
|
|
116
|
+
});
|
|
117
|
+
it("a throwing probe marks it down and a later successful sample clears it", async () => {
|
|
118
|
+
let calls = 0;
|
|
119
|
+
const notes = await run(() => { if (++calls === 1) throw new Error("boom"); return { free: 1, online: 1, at: calls }; });
|
|
120
|
+
assert.deepEqual(notes, ["down", "clear"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -18,7 +18,7 @@ async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodi
|
|
|
18
18
|
runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
|
|
19
19
|
return { started, log, decisions, calls: () => calls, open, done };
|
|
20
20
|
}
|
|
21
|
-
const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
|
|
21
|
+
const L = (...kids) => [{ number: 1, body: "", labels: [], authorAssociation: "MEMBER" }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }))];
|
|
22
22
|
|
|
23
23
|
describe("#4965 auto_add_children", () => {
|
|
24
24
|
it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
|
|
@@ -40,8 +40,20 @@ describe("#4965 auto_add_children", () => {
|
|
|
40
40
|
assert.deepEqual(r.started, [1]);
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
|
+
it("#4982 an untrusted author's child is not added and logs one skipped row", async () => {
|
|
44
|
+
const list = [...L(2), { number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "NONE" }];
|
|
45
|
+
const r = await run({ mode: "epic", list });
|
|
46
|
+
await until(() => r.started.includes(2) && r.log.some((x) => x.outcome === "skipped"));
|
|
47
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
48
|
+
r.open(); await r.done;
|
|
49
|
+
assert.ok(!r.started.includes(3));
|
|
50
|
+
const sk = r.log.filter((x) => x.outcome === "skipped");
|
|
51
|
+
assert.equal(sk.length, 1);
|
|
52
|
+
assert.equal(sk[0].issue, 3);
|
|
53
|
+
assert.match(sk[0].detail, /#3 .*untrusted author/);
|
|
54
|
+
});
|
|
43
55
|
it("needs-human children and children live in another batch are not added", async () => {
|
|
44
|
-
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
|
|
56
|
+
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" }];
|
|
45
57
|
const r = await run({ mode: "all", list, elsewhere: [3] });
|
|
46
58
|
await until(() => r.started.includes(2));
|
|
47
59
|
await new Promise((x) => setTimeout(x, 40));
|
|
@@ -49,10 +61,10 @@ describe("#4965 auto_add_children", () => {
|
|
|
49
61
|
assert.deepEqual([...r.started].sort(), [1, 2]);
|
|
50
62
|
});
|
|
51
63
|
it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
|
|
52
|
-
let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
|
|
64
|
+
let list = [{ number: 50, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
|
|
53
65
|
const r = await run({ mode: "epic", epics: [50], list: () => list });
|
|
54
66
|
await until(() => r.started.includes(51));
|
|
55
|
-
list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
|
|
67
|
+
list = [...list, { number: 52, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
|
|
56
68
|
await until(() => r.started.includes(52));
|
|
57
69
|
r.open(); await r.done;
|
|
58
70
|
assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);
|