forgedock 1.43.0 → 1.44.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 +3 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +59 -10
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.mjs +3 -1
- package/bin/lean/batch.test.mjs +83 -0
- package/bin/lean/conformance.mjs +72 -13
- package/bin/lean/conformance.test.mjs +100 -9
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +65 -17
- package/bin/lean/lean.test.mjs +84 -2
- package/bin/lean/orchestrate-blocked.test.mjs +28 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +58 -11
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/probe.mjs +1 -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/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/schedd.mjs +38 -5
- package/bin/lean/schedd.test.mjs +109 -0
- package/bin/lean/selftest-live.mjs +57 -32
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +8 -4
- 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 +14 -2
- package/bin/lean/signoff.test.mjs +45 -1
- package/bin/pool/agent.mjs +1 -1
- package/bin/pool/cli.mjs +29 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +9 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +3 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
|
|
6
6
|
* severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
|
|
7
7
|
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
8
9
|
import { join, dirname, basename, delimiter } from "node:path";
|
|
9
10
|
import { hostname as osHostname } from "node:os";
|
|
10
11
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
@@ -14,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
14
15
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
15
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
17
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
17
|
-
import { 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 { 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
19
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
19
20
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
20
21
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
@@ -37,7 +38,7 @@ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
|
|
|
37
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;
|
|
38
39
|
/** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
|
|
39
40
|
export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
|
|
40
|
-
import { 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, 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
42
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
42
43
|
import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
|
|
43
44
|
import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
|
|
@@ -81,6 +82,9 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
81
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",
|
|
82
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",
|
|
83
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",
|
|
84
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",
|
|
85
89
|
});
|
|
86
90
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
@@ -356,7 +360,7 @@ export async function runCore(o) {
|
|
|
356
360
|
}
|
|
357
361
|
async function heavyStep(phase, fn) { const g = await heavyAcquire(phase); try { return await fn(); } finally { try { g?.release?.(); } catch { /* fail-open */ } } }
|
|
358
362
|
// ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
|
|
359
|
-
let ledger = null, deleteLocalBranch = false;
|
|
363
|
+
let ledger = null, deleteLocalBranch = false, laneHead = null; // #4958: laneHead = {branch, sha, pushedTo}
|
|
360
364
|
const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
|
|
361
365
|
const protectedBranch = (b) => isProtectedLaneBranch(b, { issue, lane, branches, defaults });
|
|
362
366
|
/** Teardown from the ledger (#4179), on every exit path: builder pids, checkpoint of unpushed work, run tmp, worktree, branch, docker. */
|
|
@@ -366,6 +370,7 @@ export async function runCore(o) {
|
|
|
366
370
|
}
|
|
367
371
|
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
368
372
|
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
373
|
+
await reportLaneHead(reason);
|
|
369
374
|
try { ghCache?.dispose?.(); } catch { /* best effort */ }
|
|
370
375
|
if (runTmp) {
|
|
371
376
|
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
@@ -449,7 +454,7 @@ export async function runCore(o) {
|
|
|
449
454
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
450
455
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
451
456
|
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
452
|
-
context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, ...(guardAddedMs ? { guard_added_ms: guardAddedMs } : {}), model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
457
|
+
...(laneHead && reason !== "merged" ? { lane_branch: laneHead.pushedTo ?? "unpushed", lane_sha: laneHead.sha } : {}), context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, ...(guardAddedMs ? { guard_added_ms: guardAddedMs } : {}), model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
453
458
|
...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
|
|
454
459
|
const phases = phaseLedger.snapshot(); // #4908: run log gets the nested ledger, FORGE:TELEMETRY the flattened scalars
|
|
455
460
|
ev({ ...term, phases });
|
|
@@ -477,7 +482,7 @@ export async function runCore(o) {
|
|
|
477
482
|
}
|
|
478
483
|
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
479
484
|
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(),
|
|
480
|
-
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
485
|
+
...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
481
486
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
482
487
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
483
488
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
@@ -511,6 +516,10 @@ export async function runCore(o) {
|
|
|
511
516
|
return true;
|
|
512
517
|
} catch { return false; }
|
|
513
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
|
+
};
|
|
514
523
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, { ask: askText, note: askNote = "", ...extra } = {}) {
|
|
515
524
|
// #4725: a recorded premise (`Premise: <kind>:<arg>`) that already holds is no blocker: no label, the lane runs again.
|
|
516
525
|
const predicate = parsePredicate(why);
|
|
@@ -526,8 +535,10 @@ export async function runCore(o) {
|
|
|
526
535
|
link = reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : reg.entry ?? "";
|
|
527
536
|
}
|
|
528
537
|
const notes = [premiseAnnotation(predicate), closedEdgeNote(closedDeps), link, askNote].filter(Boolean).join("\n");
|
|
529
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
530
|
-
|
|
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); }
|
|
531
542
|
} catch { /* best effort */ }
|
|
532
543
|
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
533
544
|
}
|
|
@@ -640,11 +651,17 @@ export async function runCore(o) {
|
|
|
640
651
|
const signOffUnpushed = async () => {
|
|
641
652
|
try {
|
|
642
653
|
const r = await ensureSignedOff({ git, lane, branch });
|
|
643
|
-
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 }); }
|
|
644
|
-
} catch (e) { throw Object.assign(e, { laneCode: OUTCOME_CODES.CI_DCO }); }
|
|
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 } : {}) }); }
|
|
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
|
|
645
656
|
};
|
|
646
657
|
const gitPush = async (args) => {
|
|
647
|
-
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 the branch as-is before reporting
|
|
661
|
+
if (!e?.noIdentity) { try { await git(["push", "-u", "origin", branch]); ev({ event: "DCO_RESCUE_PUSH", branch }); } 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
|
|
648
665
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
649
666
|
catch (e) {
|
|
650
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)}`; }
|
|
@@ -659,10 +676,33 @@ export async function runCore(o) {
|
|
|
659
676
|
const dirty = String(await git(["status", "--porcelain"])).trim();
|
|
660
677
|
if (dirty) { await git(["add", "-A"]); if (await unstageToolState(git)) await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); } // #4590: tool state is never staged
|
|
661
678
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
662
|
-
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
679
|
+
if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
|
|
663
680
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
664
681
|
return true;
|
|
665
|
-
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
682
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected) : false; }
|
|
683
|
+
}
|
|
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. */
|
|
685
|
+
async function rescuePush(reason, rejected) {
|
|
686
|
+
// #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
|
|
687
|
+
try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
|
|
688
|
+
// a rejected lane-branch push (protected branch, permissions) would be rejected again: go straight to the salvage branch
|
|
689
|
+
for (const ref of [...(rejected ? [] : [branch]), `forge-salvage/${issue}-${laneHead.sha.slice(0, 10)}`]) {
|
|
690
|
+
try {
|
|
691
|
+
await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
|
|
692
|
+
laneHead.pushedTo = ref;
|
|
693
|
+
ev({ event: "CHECKPOINT_RESCUED", reason, ref, sha: laneHead.sha });
|
|
694
|
+
return !String(await git(["status", "--porcelain"])).trim(); // an uncommitted remainder keeps the worktree
|
|
695
|
+
} catch (e) { ev({ event: "CHECKPOINT_RESCUE_FAILED", reason, ref, error: String(e?.message ?? e).slice(0, 300) }); }
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
/** #4958: where the lane's commits ended up, for the terminal comment, RUN_TERMINAL and the returned result. */
|
|
700
|
+
async function reportLaneHead(reason) {
|
|
701
|
+
if (!laneHead || reason === "merged") return;
|
|
702
|
+
const { branch: b, sha, pushedTo } = laneHead;
|
|
703
|
+
const line = pushedTo ? `Lane commits pushed: branch \`${pushedTo}\` at ${sha.slice(0, 10)}; a rerun or --requeue resumes from it` : `Lane commits NOT pushed: ${sha.slice(0, 10)} on \`${b}\` exists only in this lane's worktree and is lost if its sandbox is torn down`;
|
|
704
|
+
T.anomalies.push(line);
|
|
705
|
+
if (!["invalid", "needs-human", "budget-exceeded"].includes(reason)) { try { await post("FORGE:LANE_BRANCH", `## Lane branch\n${line}`); } catch { /* best effort */ } }
|
|
666
706
|
}
|
|
667
707
|
const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
|
|
668
708
|
const recoverNote = () => (recover ? `\n\nRecovery pass (${recover.code}): ${(RECOVER_HOW[recover.code] ?? "an earlier run ended needs-human for a mechanical reason; resolve it").replaceAll("BASE", lane)}.` : "");
|
|
@@ -1054,6 +1094,10 @@ export async function runCore(o) {
|
|
|
1054
1094
|
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
|
|
1055
1095
|
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
1056
1096
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
1097
|
+
if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
|
|
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);
|
|
1099
|
+
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 }); }
|
|
1100
|
+
}
|
|
1057
1101
|
} catch { /* no resume info: start fresh */ }
|
|
1058
1102
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
1059
1103
|
// #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
|
|
@@ -2087,7 +2131,7 @@ export async function runCore(o) {
|
|
|
2087
2131
|
async function amend() {
|
|
2088
2132
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
2089
2133
|
const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
|
|
2090
|
-
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\
|
|
2134
|
+
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : null;
|
|
2091
2135
|
const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
|
|
2092
2136
|
const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
|
|
2093
2137
|
const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
|
|
@@ -2181,6 +2225,8 @@ export async function runCore(o) {
|
|
|
2181
2225
|
}
|
|
2182
2226
|
const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
|
|
2183
2227
|
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
2228
|
+
/** #5005: a frozen forgedock entry-point path missing on this host is replaced by this install's own. */
|
|
2229
|
+
const forgedockResolved = (c) => resolveForgedockPaths(String(c), { exists: existsSync, checksCli: CHECKS_CLI, forgedockCli: FORGEDOCK_CLI });
|
|
2184
2230
|
async function rerunFailedValidatesRaw(contract) {
|
|
2185
2231
|
if (!io.runCommand) return null;
|
|
2186
2232
|
const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
|
|
@@ -2189,11 +2235,11 @@ export async function runCore(o) {
|
|
|
2189
2235
|
const r = runs.find((x) => x?.id === v.id);
|
|
2190
2236
|
if (!r || r.exit === undefined || r.exit === 0) continue;
|
|
2191
2237
|
let x;
|
|
2192
|
-
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command:
|
|
2238
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(v.command), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2193
2239
|
// #4761: a runner usage error (vitest -q) is the frozen command's defect: try the normalised command once
|
|
2194
2240
|
const fixed = cliUsageError(x ?? {}) ? normalizeRunnerCommand(v.command) : null;
|
|
2195
2241
|
if (fixed && fixed !== String(v.command)) {
|
|
2196
|
-
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: fixed, cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2242
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(fixed), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2197
2243
|
const note = `Validate ${v.id} hit a runner usage error; reran normalised (\`${fixed}\`): exit ${x?.rc}`;
|
|
2198
2244
|
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
2199
2245
|
}
|
|
@@ -2210,7 +2256,7 @@ export async function runCore(o) {
|
|
|
2210
2256
|
for (const v of contract.validate ?? []) {
|
|
2211
2257
|
if (!v.substituted?.from) continue;
|
|
2212
2258
|
let x;
|
|
2213
|
-
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command:
|
|
2259
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(v.substituted.from), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2214
2260
|
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
2215
2261
|
}
|
|
2216
2262
|
if (Object.keys(out).length) ev({ event: "SUBSTITUTE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
@@ -2267,7 +2313,8 @@ export async function runCore(o) {
|
|
|
2267
2313
|
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2268
2314
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2269
2315
|
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2270
|
-
const
|
|
2316
|
+
const subBaseFiles = (effective.validate ?? []).some((v) => v.substituted) ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : []; // #5003
|
|
2317
|
+
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 });
|
|
2271
2318
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2272
2319
|
if (pc.superseded?.length) {
|
|
2273
2320
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
@@ -2937,6 +2984,7 @@ export async function runCore(o) {
|
|
|
2937
2984
|
if (owner) return finish("deferred", `git push was rejected (non-fast-forward): ${owner.detail}; another lane owns ${branch}`, { code: OUTCOME_CODES.LEASE_HELD, branch }, OUTCOME_CODES.LEASE_HELD);
|
|
2938
2985
|
}
|
|
2939
2986
|
if (e?.pushRejected) return await needsHuman(`git push was rejected (no retry): ${String(e?.stderr ?? e?.message ?? e).slice(0, 400)}`);
|
|
2987
|
+
if (e?.engineCode) return finish("engine-error", e?.message ?? String(e), { code: e.engineCode }, e.engineCode); // #4997
|
|
2940
2988
|
return finish("engine-error", e?.message ?? String(e));
|
|
2941
2989
|
}
|
|
2942
2990
|
}
|
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 = [];
|
|
@@ -3819,7 +3850,7 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3819
3850
|
const w = world({ pushErrors: Array(10).fill("fatal: the remote end hung up unexpectedly\nerror: RPC failed; HTTP 503") });
|
|
3820
3851
|
const r = await run(w);
|
|
3821
3852
|
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "push.transient");
|
|
3822
|
-
assert.equal(w.pushes,
|
|
3853
|
+
assert.equal(w.pushes, 10, "4 for the PR push, 4 by the exit checkpoint that never discards unpushed work (#4179), 1 lane-branch rescue, 1 salvage rescue (#4958)");
|
|
3823
3854
|
assert.equal(w.prs.length, 0);
|
|
3824
3855
|
});
|
|
3825
3856
|
|
|
@@ -3827,12 +3858,63 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3827
3858
|
for (const msg of ["remote: error: GH006: Protected branch update failed for refs/heads/x", "fatal: unable to access: The requested URL returned error: 403", "! [remote rejected] x -> x (pre-receive hook declined)"]) {
|
|
3828
3859
|
const w = world({ pushErrors: [msg, msg, msg] });
|
|
3829
3860
|
const r = await run(w);
|
|
3830
|
-
assert.equal(w.pushes,
|
|
3861
|
+
assert.equal(w.pushes, 3, `${msg}: one rejected PR push, one rejected exit checkpoint (#4179), no retries; the third error rejects the salvage rescue (#4958)`);
|
|
3831
3862
|
assert.equal(r.terminalReason, "needs-human");
|
|
3832
3863
|
assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length, 1, "the unpushed worktree is kept: only the setup remove ran");
|
|
3833
3864
|
}
|
|
3834
3865
|
});
|
|
3835
3866
|
|
|
3867
|
+
it("#4958 a failed exit push is rescued to the lane branch by sha and the branch and sha are reported", async () => {
|
|
3868
|
+
const odd = "fatal: unexpected sandbox git failure";
|
|
3869
|
+
const w = world({ pushErrors: [odd, odd] });
|
|
3870
|
+
const r = await run(w);
|
|
3871
|
+
assert.equal(w.pushes, 3, "PR push, failed exit checkpoint, rescue push");
|
|
3872
|
+
const rescue = w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1);
|
|
3873
|
+
assert.match(rescue.join(" "), /origin [0-9a-z]+:refs\/heads\/feat\/issue-7-lean$/);
|
|
3874
|
+
assert.equal(r.laneHead.pushedTo, "feat/issue-7-lean"); assert.ok(r.laneHead.sha);
|
|
3875
|
+
assert.ok(w.telemetry.some((t) => t.lane_branch === "feat/issue-7-lean" && t.lane_sha === r.laneHead.sha));
|
|
3876
|
+
});
|
|
3877
|
+
|
|
3878
|
+
it("#5014 the rescue pushes and reports the head after the pre-push sign-off rewrite, not the sha captured before it", async () => {
|
|
3879
|
+
const odd = "fatal: unexpected sandbox git failure";
|
|
3880
|
+
const w = world({ pushErrors: [odd, odd] });
|
|
3881
|
+
const g = w.io.git;
|
|
3882
|
+
let pushes = 0;
|
|
3883
|
+
w.io.git = async (a, o2) => { if (a[0] === "push" && ++pushes === 2) w.head = "rewritten0000000000000000000000000000000"; return g(a, o2); }; // the exit push rewrote HEAD (DCO) then failed
|
|
3884
|
+
const r = await run(w);
|
|
3885
|
+
const rescue = w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1);
|
|
3886
|
+
assert.match(rescue.join(" "), /origin rewritten0+:refs\/heads\/feat\/issue-7-lean$/);
|
|
3887
|
+
assert.equal(r.laneHead.sha, "rewritten0000000000000000000000000000000");
|
|
3888
|
+
assert.ok(w.telemetry.some((t) => t.lane_sha === "rewritten0000000000000000000000000000000"));
|
|
3889
|
+
});
|
|
3890
|
+
|
|
3891
|
+
it("#4958 a rejected lane-branch push is rescued to a forge-salvage branch and reported as pushed there", async () => {
|
|
3892
|
+
const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
|
|
3893
|
+
const w = world({ pushErrors: [msg, msg] });
|
|
3894
|
+
const r = await run(w);
|
|
3895
|
+
assert.equal(w.pushes, 3, "PR push, rejected exit checkpoint, one salvage push (no second lane-branch attempt)");
|
|
3896
|
+
assert.match(w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1).join(" "), /:refs\/heads\/forge-salvage\/7-/);
|
|
3897
|
+
assert.match(r.laneHead.pushedTo, /^forge-salvage\/7-/);
|
|
3898
|
+
assert.ok(traj(w).some((c) => /Lane commits pushed: branch `forge-salvage\/7-/.test(c)));
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
it("#4958 when every rescue push is rejected the head is reported as NOT pushed", async () => {
|
|
3902
|
+
const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
|
|
3903
|
+
const w = world({ pushErrors: [msg, msg, msg, msg] });
|
|
3904
|
+
const r = await run(w);
|
|
3905
|
+
assert.equal(r.laneHead.pushedTo, null);
|
|
3906
|
+
assert.ok(traj(w).some((c) => /Lane commits NOT pushed/.test(c)));
|
|
3907
|
+
});
|
|
3908
|
+
|
|
3909
|
+
it("#4958 a rerun with only a forge-salvage branch on origin resumes from it", async () => {
|
|
3910
|
+
const w = world({ prList: [] });
|
|
3911
|
+
const g = w.io.git;
|
|
3912
|
+
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));
|
|
3913
|
+
await run(w);
|
|
3914
|
+
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")));
|
|
3915
|
+
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");
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3836
3918
|
it("#4118 GitHub's transient '[remote rejected] ... (failed)' and pack write errors are retried, then merge", async () => {
|
|
3837
3919
|
for (const msg of ["To github.com:o/r.git\n ! [remote rejected] feat/issue-7-lean -> feat/issue-7-lean (failed)",
|
|
3838
3920
|
"remote: error: unable to write file ./objects/pack/pack-1.pack: No such file or directory\n ! [remote rejected] x -> x (failed)"]) {
|
|
@@ -69,3 +69,31 @@ describe("#4773: Blocked by edges and the blocked label", () => {
|
|
|
69
69
|
assert.deepEqual(started, [1]);
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
describe("#4917: a deferred issue still waiting when the last running lane ends", () => {
|
|
74
|
+
it("stays pending in the saved state (future notBefore) while the batch stops on the breaker", async () => {
|
|
75
|
+
let failLast; const gate = new Promise((r) => { failLast = r; });
|
|
76
|
+
let saved = null;
|
|
77
|
+
const store = { load: () => null, save: (st) => { saved = st; } };
|
|
78
|
+
await orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", breaker: { errors: 1, window_min: 30 }, maxDeferrals: 3, store,
|
|
79
|
+
runOne: async (n) => {
|
|
80
|
+
if (n === 1) { setTimeout(failLast, 20); return { terminalReason: "deferred", code: "run.interrupted", retryInSeconds: 3600, cost_usd: 0 }; }
|
|
81
|
+
await gate; return { terminalReason: "engine-error", detail: "boom" };
|
|
82
|
+
} });
|
|
83
|
+
assert.ok(saved.pending.includes(1), JSON.stringify(saved.pending));
|
|
84
|
+
assert.ok(Number(saved.notBefore?.[1]) > Date.now(), JSON.stringify(saved.notBefore));
|
|
85
|
+
assert.deepEqual(saved.running, {});
|
|
86
|
+
});
|
|
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
|
+
});
|
|
@@ -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);
|