forgedock 1.20.0 → 1.22.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/phases.mjs +19 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +81 -1
- package/bin/forgedock.mjs +15 -3
- package/bin/lean/effects.mjs +56 -0
- package/bin/lean/lean.mjs +284 -0
- package/bin/lean/lean.test.mjs +485 -0
- package/bin/lean/orchestrate.mjs +69 -0
- package/bin/lean/prompts.mjs +55 -0
- package/bin/lean/render.mjs +66 -0
- package/bin/runner.mjs +79 -2
- package/commands/work-on/build/validate.md +13 -6
- package/commands/work-on/review.md +1 -1
- package/docs/CONFIG.md +4 -0
- package/docs/RELEASE-NOTES.md +15 -0
- package/package.json +2 -2
- package/scripts/ci-local.test.sh +2 -2
- package/scripts/review-roster.sh +14 -1
- package/scripts/review-roster.test.sh +13 -0
package/bin/engine/phases.mjs
CHANGED
|
@@ -913,12 +913,14 @@ export const PHASES = [
|
|
|
913
913
|
// forge#3542: only a full-line marker in a trusted comment counts.
|
|
914
914
|
// forge#4021: an in-PR fix re-armed by the re-gate (the newest trail after the newest marker reports
|
|
915
915
|
// INPR-FIX-REQUESTED) needs a FRESH marker, or M0's per-kind guard sees the first trail as this dispatch's
|
|
916
|
-
// and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm)
|
|
916
|
+
// and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm); it is
|
|
917
|
+
// enforced here too, where the marker is posted, so a crash-resume after round 2 never posts marker 3.
|
|
917
918
|
const sentinel = `FORGE:${boundMarker}: pr=${pr}`;
|
|
918
919
|
let markerAt = -1;
|
|
919
920
|
comments.forEach((b, i) => { if (hasSentinelLine(b, sentinel)) markerAt = i; });
|
|
920
921
|
const trailAfter = markerAt < 0 ? null : latestMarked(comments.slice(markerAt + 1), PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
|
|
921
|
-
const
|
|
922
|
+
const priorMarkers = comments.filter((b) => hasSentinelLine(b, sentinel)).length;
|
|
923
|
+
const rearmed = kind === "inpr-fix" && trailAfter !== null && INPR_REQUESTED_RE.test(trailAfter) && priorMarkers < 2;
|
|
922
924
|
if (markerAt < 0 || rearmed) {
|
|
923
925
|
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
924
926
|
`${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
|
|
@@ -975,6 +977,18 @@ export const PHASES = [
|
|
|
975
977
|
};
|
|
976
978
|
// forge#3685: a resume that ends HELD posts a non-terminal FORGE:REMEDIATION_HELD record (no COMPLETE).
|
|
977
979
|
// Newest-wins between the held record and COMPLETE, by position in the (trusted, scoped) comment list.
|
|
980
|
+
// forge#3947: a trail posted after THIS dispatch's marker is this run's outcome; a push that moved the PR
|
|
981
|
+
// head after it was posted must not reclassify it. Only an older trail is judged against the live head.
|
|
982
|
+
const headStale = async () => {
|
|
983
|
+
let dispatchAt = -1, trailAt = -1;
|
|
984
|
+
const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
|
|
985
|
+
scoped.forEach((b, i) => {
|
|
986
|
+
if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
|
|
987
|
+
if (startsWithHeader(b, PHASE_MARKERS.remediate.header) && hasSentinelLine(b, completion)) trailAt = i;
|
|
988
|
+
});
|
|
989
|
+
if (dispatchAt >= 0 && trailAt > dispatchAt) return false;
|
|
990
|
+
return remediationHeadStale(state, io);
|
|
991
|
+
};
|
|
978
992
|
const isHeld = (b) => startsWithHeader(b, REMEDIATION_HELD_MARKER) && hasSentinelLine(b, REMEDIATION_HELD_MARKER);
|
|
979
993
|
let heldAt = -1, completeAt = -1;
|
|
980
994
|
scoped.forEach((b, i) => {
|
|
@@ -1037,7 +1051,7 @@ export const PHASES = [
|
|
|
1037
1051
|
case "RE-ESCALATED":
|
|
1038
1052
|
case "UNFIXABLE":
|
|
1039
1053
|
// forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
|
|
1040
|
-
if (await
|
|
1054
|
+
if (await headStale())
|
|
1041
1055
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1042
1056
|
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
1043
1057
|
// policy judgment call respectively) — reuse the existing
|
|
@@ -1051,7 +1065,7 @@ export const PHASES = [
|
|
|
1051
1065
|
case "INPR-FIX-REQUESTED": {
|
|
1052
1066
|
// forge#4021: the re-review approved but posted a FORGE:INPR_FIX work order. One further in-PR fix
|
|
1053
1067
|
// remediation is allowed (never needs-human on the first request); the bound marker count caps it.
|
|
1054
|
-
if (await
|
|
1068
|
+
if (await headStale())
|
|
1055
1069
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1056
1070
|
if (await prMerged()) return mergedResult(reGateOutcome);
|
|
1057
1071
|
const round = /\*\*INPR round\*\*:\s*(\d+)/.exec(latest)?.[1] ?? null;
|
|
@@ -1063,7 +1077,7 @@ export const PHASES = [
|
|
|
1063
1077
|
return { status: "committed", code: OC.REMEDIATE_INPR_REQUESTED, outputs: { ...outputs, remediation: "inpr-fix" } };
|
|
1064
1078
|
}
|
|
1065
1079
|
case "REREVIEW-REQUIRED":
|
|
1066
|
-
if (await
|
|
1080
|
+
if (await headStale())
|
|
1067
1081
|
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1068
1082
|
return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs: { reGateOutcome } };
|
|
1069
1083
|
default:
|
|
@@ -28,7 +28,7 @@ export function mapRecord(rec, wallMs) {
|
|
|
28
28
|
if (!rec || typeof rec !== "object") return null;
|
|
29
29
|
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
30
30
|
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
31
|
-
attempt: rec.attempt, model: rec.model, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
31
|
+
attempt: rec.attempt, model: rec.model, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
32
32
|
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
33
33
|
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
34
34
|
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -85,6 +85,29 @@ export function readCiPendingMaxMinutes(cwd = process.cwd()) {
|
|
|
85
85
|
} catch { return undefined; }
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** forge.yaml `engine.path` (core|full|lean|auto) from the cwd checkout; undefined when absent or invalid. */
|
|
89
|
+
export function readEnginePath(cwd = process.cwd()) {
|
|
90
|
+
try {
|
|
91
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.path ?? "").trim();
|
|
92
|
+
return ENGINE_PATHS.includes(v) ? v : undefined;
|
|
93
|
+
} catch { return undefined; }
|
|
94
|
+
}
|
|
95
|
+
function readLeanSetting(key, cwd = process.cwd()) {
|
|
96
|
+
try {
|
|
97
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
98
|
+
return /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : undefined;
|
|
99
|
+
} catch { return undefined; }
|
|
100
|
+
}
|
|
101
|
+
// core is the default; lean and auto are aliases of core. full is the legacy multi-phase pipeline.
|
|
102
|
+
/** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
|
|
103
|
+
export function readMergeWait(cwd = process.cwd()) {
|
|
104
|
+
try {
|
|
105
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.merge_wait ?? "").trim();
|
|
106
|
+
return ["required", "all", "none"].includes(v) ? v : undefined;
|
|
107
|
+
} catch { return undefined; }
|
|
108
|
+
}
|
|
109
|
+
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
110
|
+
|
|
88
111
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
89
112
|
|
|
90
113
|
/**
|
|
@@ -380,6 +403,7 @@ export function lastLocalRun(dir) {
|
|
|
380
403
|
* run's lease, wait (bounded by the lease TTL) until it
|
|
381
404
|
* expires or its same-host pid is dead, then continue.
|
|
382
405
|
* Without it, a held lease exits `deferred` (code 75).
|
|
406
|
+
* --path <core|full|lean|auto> Engine path; overrides forge.yaml engine.path. Default core (lean work-on); lean/auto are aliases; full is the legacy multi-phase pipeline.
|
|
383
407
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
384
408
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
385
409
|
* phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
|
|
@@ -396,10 +420,17 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
396
420
|
const backend = flag(argv, "--backend");
|
|
397
421
|
const model = flag(argv, "--model");
|
|
398
422
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
399
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
423
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path"].includes(argv[i - 1]));
|
|
400
424
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
401
425
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
402
426
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
427
|
+
const pathFlag = flag(argv, "--path");
|
|
428
|
+
if (argv.includes("--path") && (!pathFlag || pathFlag.startsWith("--"))) throw new Error(`--path requires a value: ${ENGINE_PATHS.join("|")}`);
|
|
429
|
+
if (pathFlag && !ENGINE_PATHS.includes(pathFlag)) throw new Error(`--path must be one of ${ENGINE_PATHS.join("|")}`);
|
|
430
|
+
const enginePath = pathFlag ?? readEnginePath() ?? "core";
|
|
431
|
+
if (enginePath !== "full" && (retry || waitLease)) {
|
|
432
|
+
throw new Error("--retry and --wait-lease are not supported with the core path (a rerun resumes from the open PR or remote branch automatically); use --path full for them");
|
|
433
|
+
}
|
|
403
434
|
const io = deps.io ?? makeIo();
|
|
404
435
|
await assertRepoMatchesCwd(io, repo);
|
|
405
436
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
@@ -427,6 +458,19 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
427
458
|
// from "phase actually failed".
|
|
428
459
|
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
429
460
|
};
|
|
461
|
+
if (enginePath !== "full") {
|
|
462
|
+
// Core work-on (lean): one builder session, one reviewer, GitHub as the register.
|
|
463
|
+
const leanRepo = repo ?? (await resolveDefaultRepo(io));
|
|
464
|
+
if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
465
|
+
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
466
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
467
|
+
const lres = await runCore({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
468
|
+
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
469
|
+
...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
470
|
+
console.log(`issue #${issue} → ${lres.terminalReason}`);
|
|
471
|
+
if (!["merged", "invalid"].includes(lres.terminalReason)) console.log(lres.detail ?? "");
|
|
472
|
+
return lres;
|
|
473
|
+
}
|
|
430
474
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
431
475
|
...(repo ? { repo } : {}),
|
|
432
476
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
@@ -584,3 +628,39 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]`
|
|
634
|
+
* Runs core work-on per issue with K concurrency (default 3), honouring `**Depends on**: #X` among the given issues.
|
|
635
|
+
* Prints a compact summary table. Lane defaults to staging.
|
|
636
|
+
*/
|
|
637
|
+
export async function orchestrateFromCli(argv, deps = {}) {
|
|
638
|
+
const valued = ["--parallel", "--lane", "--repo", "--model"];
|
|
639
|
+
const issues = [];
|
|
640
|
+
for (let i = 0; i < argv.length; i++) {
|
|
641
|
+
if (valued.includes(argv[i])) { i++; continue; }
|
|
642
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
643
|
+
}
|
|
644
|
+
if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]");
|
|
645
|
+
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
646
|
+
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
647
|
+
const lane = flag(argv, "--lane") ?? "staging";
|
|
648
|
+
const model = flag(argv, "--model");
|
|
649
|
+
const io = deps.io ?? makeIo();
|
|
650
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
651
|
+
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
652
|
+
const dir = deps.dir ?? runDir();
|
|
653
|
+
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
654
|
+
const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
|
|
655
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
656
|
+
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
657
|
+
const rows = await orchestrate({
|
|
658
|
+
issues, parallel,
|
|
659
|
+
readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
660
|
+
runOne: (n) => runCore({ issue: n, repo, lane, cwd: process.cwd(), dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
|
|
661
|
+
...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
|
|
662
|
+
onUpdate: (r) => console.log(`#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
|
|
663
|
+
});
|
|
664
|
+
console.log("\n" + formatTable(rows));
|
|
665
|
+
return { rows, ok: rows.every((r) => ["merged", "invalid"].includes(r.outcome)) };
|
|
666
|
+
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3207,7 +3207,8 @@ function help() {
|
|
|
3207
3207
|
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
3208
3208
|
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
3209
3209
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3210
|
-
["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "
|
|
3210
|
+
["npx forgedock run-issue <issue> --lane <lane> [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3211
|
+
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering"],
|
|
3211
3212
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3212
3213
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3213
3214
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
@@ -3781,10 +3782,10 @@ async function watch() {
|
|
|
3781
3782
|
// The journey-routed commands render their own branded marks (hero/compact);
|
|
3782
3783
|
// splash() would double-brand them. Engine-surface commands, help, and
|
|
3783
3784
|
// unknown-command output keep the logo.
|
|
3784
|
-
const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3785
|
+
const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3785
3786
|
const KNOWN_COMMANDS = new Set([
|
|
3786
3787
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3787
|
-
"run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
|
|
3788
|
+
"run", "backend-check", "run-issue", "orchestrate", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
|
|
3788
3789
|
"version", "--version", "-v",
|
|
3789
3790
|
]);
|
|
3790
3791
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -3982,6 +3983,17 @@ switch (command) {
|
|
|
3982
3983
|
}
|
|
3983
3984
|
break;
|
|
3984
3985
|
}
|
|
3986
|
+
case "orchestrate": {
|
|
3987
|
+
const { orchestrateFromCli } = await import("./engine-cli.mjs");
|
|
3988
|
+
try {
|
|
3989
|
+
const result = await orchestrateFromCli(restArgs);
|
|
3990
|
+
if (result && !result.ok) exitCode = 1;
|
|
3991
|
+
} catch (err) {
|
|
3992
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3993
|
+
exitCode = 1;
|
|
3994
|
+
}
|
|
3995
|
+
break;
|
|
3996
|
+
}
|
|
3985
3997
|
case "resume-stalled": {
|
|
3986
3998
|
const { resumeStalledFromCli } = await import("./engine-cli.mjs");
|
|
3987
3999
|
try {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { runCliBackend, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
|
|
10
|
+
import { makeIo } from "../engine-cli.mjs";
|
|
11
|
+
import { makeProjector } from "../engine/projector.mjs";
|
|
12
|
+
|
|
13
|
+
const pexec = promisify(execFile);
|
|
14
|
+
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
|
|
17
|
+
export function makeLeanRunner({ runCli = runCliBackend } = {}) {
|
|
18
|
+
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false }) => {
|
|
19
|
+
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
20
|
+
const caps = getClaudeCliCapabilities(cwd);
|
|
21
|
+
const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
|
|
22
|
+
const r = runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
23
|
+
args: [String(issue)], cwd, bin, model, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
24
|
+
logger: { log() {} } });
|
|
25
|
+
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function makeLeanIo({ repo }) {
|
|
30
|
+
const base = makeIo();
|
|
31
|
+
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
32
|
+
return {
|
|
33
|
+
gh: run("gh"), git: run("git"), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
34
|
+
/** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
|
|
35
|
+
async requiredChecks(lane) {
|
|
36
|
+
try {
|
|
37
|
+
const j = JSON.parse(await run("gh")(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`]));
|
|
38
|
+
return [...new Set([...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c.context)].filter(Boolean))];
|
|
39
|
+
} catch { return []; }
|
|
40
|
+
},
|
|
41
|
+
/** [{name, state: success|failure|pending}] for the PR's checks. */
|
|
42
|
+
async checkStates(pr) {
|
|
43
|
+
const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
|
|
44
|
+
return (j.statusCheckRollup ?? []).map((c) => {
|
|
45
|
+
const v = String(c.conclusion || c.state || "").toUpperCase();
|
|
46
|
+
const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(v) ? "failure" : "pending";
|
|
47
|
+
return { name: c.name ?? c.context, state };
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
51
|
+
fs: {
|
|
52
|
+
read: async (p) => readFileSync(p, "utf8"),
|
|
53
|
+
append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Core work-on: ONE plain builder session does the work, ONE read-only reviewer answers two questions, and
|
|
5
|
+
* ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
|
|
6
|
+
* severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { appendEvent } from "../engine/runlog.mjs";
|
|
10
|
+
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
11
|
+
import { ciPollDelayMs, ENGINE_VERSION } from "../engine.mjs";
|
|
12
|
+
import { openDependency } from "../engine/phases.mjs";
|
|
13
|
+
import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody } from "./render.mjs";
|
|
14
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
17
|
+
export const REGISTER_MARKER = "FORGE:WORK-ON";
|
|
18
|
+
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} o
|
|
22
|
+
* @param {number} o.issue
|
|
23
|
+
* @param {string} o.repo "owner/repo"
|
|
24
|
+
* @param {string} o.lane base branch (staging|main)
|
|
25
|
+
* @param {string} o.cwd main checkout (worktrees are added from here)
|
|
26
|
+
* @param {string} o.dir run-log dir
|
|
27
|
+
* @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
|
|
28
|
+
* @param {string} [o.builderModel] default: CLI default
|
|
29
|
+
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
30
|
+
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
31
|
+
*/
|
|
32
|
+
export async function runCore(o) {
|
|
33
|
+
const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
34
|
+
ciMaxMinutes = 360, mergeWait = "required", onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
35
|
+
const now = io.now ?? (() => Date.now());
|
|
36
|
+
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
37
|
+
const base = `origin/${lane}`;
|
|
38
|
+
const branch = `feat/issue-${issue}-lean`;
|
|
39
|
+
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
40
|
+
const t0 = now();
|
|
41
|
+
const usage = { cost_usd: 0, input: 0, output: 0, cache: 0 };
|
|
42
|
+
let sessionId = null;
|
|
43
|
+
|
|
44
|
+
const ev = (e) => appendEvent(dir, issue, e);
|
|
45
|
+
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
46
|
+
const ghc = (args) => io.gh(args);
|
|
47
|
+
const git = (args) => io.git(args, { cwd: wt });
|
|
48
|
+
|
|
49
|
+
async function attempt(phase, n, r) {
|
|
50
|
+
const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
|
|
51
|
+
cost_usd: num(r?.costUsd), model: r?.model ?? null, turns: num(r?.turns) };
|
|
52
|
+
ev(rec); await emit(rec);
|
|
53
|
+
const u = r?.usage ?? {};
|
|
54
|
+
usage.cost_usd += r?.costUsd ?? 0;
|
|
55
|
+
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
56
|
+
usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
57
|
+
}
|
|
58
|
+
const post = async (marker, body) => {
|
|
59
|
+
const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${marker} -->\n${body}`])).trim();
|
|
60
|
+
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
61
|
+
};
|
|
62
|
+
const label = async (state) => { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } };
|
|
63
|
+
const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
|
|
64
|
+
|
|
65
|
+
// ---- lease (same FORGE:STATE lease the full path uses) ----
|
|
66
|
+
const agentId = o.agentId ?? `core_${process.pid}`;
|
|
67
|
+
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
|
|
68
|
+
let leaseState = null, leaseTimer = null;
|
|
69
|
+
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
70
|
+
const renew = async () => { if (io.projector && leaseState) { try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ } } };
|
|
71
|
+
async function releaseLease(reason) {
|
|
72
|
+
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
73
|
+
if (!io.projector || !leaseState) return;
|
|
74
|
+
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
75
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let done = null, wtCreated = false;
|
|
79
|
+
async function finish(reason, detail = "", extra = {}) {
|
|
80
|
+
if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
|
|
81
|
+
const code = codeForTerminalReason(reason);
|
|
82
|
+
ev({ event: "RUN_TERMINAL", reason, code });
|
|
83
|
+
await emit({ event: "RUN_TERMINAL", reason, code });
|
|
84
|
+
if (["needs-human", "engine-error"].includes(reason)) {
|
|
85
|
+
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
86
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
87
|
+
}
|
|
88
|
+
await releaseLease(reason);
|
|
89
|
+
if (wtCreated) {
|
|
90
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
91
|
+
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
92
|
+
}
|
|
93
|
+
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, ...extra };
|
|
94
|
+
return done;
|
|
95
|
+
}
|
|
96
|
+
async function needsHuman(why) {
|
|
97
|
+
try {
|
|
98
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
|
|
99
|
+
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
100
|
+
} catch { /* best effort */ }
|
|
101
|
+
return finish("needs-human", why);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function builder(n, prompt, resume) {
|
|
105
|
+
onProgress({ event: "phase_enter", phase: "build" });
|
|
106
|
+
await renew();
|
|
107
|
+
const r = await io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt });
|
|
108
|
+
await renew();
|
|
109
|
+
if (r?.sessionId) sessionId = r.sessionId;
|
|
110
|
+
await attempt("implement", n, r);
|
|
111
|
+
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
112
|
+
return r;
|
|
113
|
+
}
|
|
114
|
+
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
115
|
+
|
|
116
|
+
// ---- admission: Depends-on refusal, then lease ----
|
|
117
|
+
{
|
|
118
|
+
let dep = null;
|
|
119
|
+
try { dep = await openDependency(issue, io); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
|
|
120
|
+
if (dep?.unreadable) return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
|
|
121
|
+
if (dep?.open?.length) {
|
|
122
|
+
return { terminalReason: "dependency-open", code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: dep.open,
|
|
123
|
+
detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (io.projector) {
|
|
127
|
+
let st = null;
|
|
128
|
+
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
129
|
+
if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
130
|
+
return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
|
|
131
|
+
}
|
|
132
|
+
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
|
|
133
|
+
try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ }
|
|
134
|
+
leaseTimer = setInterval(() => { writeLease({ by: agentId, until: now() + leaseTtlMs }).catch(() => {}); }, o.leaseRenewMs ?? 120000);
|
|
135
|
+
leaseTimer.unref?.();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
139
|
+
let iss;
|
|
140
|
+
try {
|
|
141
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
|
|
142
|
+
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
143
|
+
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// ---- 1. claim ----
|
|
147
|
+
await label("investigating");
|
|
148
|
+
await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
|
|
149
|
+
|
|
150
|
+
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
151
|
+
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
152
|
+
let resumed = null;
|
|
153
|
+
try {
|
|
154
|
+
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid"])) || "[]");
|
|
155
|
+
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
156
|
+
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null };
|
|
157
|
+
} catch { /* no resume info: start fresh */ }
|
|
158
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
159
|
+
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
160
|
+
if (resumed) {
|
|
161
|
+
await io.git(["fetch", "origin", branch], { cwd });
|
|
162
|
+
await io.git(["worktree", "add", "-B", branch, wt, `origin/${branch}`], { cwd });
|
|
163
|
+
} else {
|
|
164
|
+
await io.git(["worktree", "add", "-B", branch, wt, base], { cwd });
|
|
165
|
+
}
|
|
166
|
+
wtCreated = true;
|
|
167
|
+
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
168
|
+
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
169
|
+
const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
170
|
+
|
|
171
|
+
// ---- 3. builder ----
|
|
172
|
+
let result = null, attemptN = 0, validateUrl = null, planUrl = null, reviewUrl = null;
|
|
173
|
+
if (!(resumed && (await changedFiles()).length > 0)) {
|
|
174
|
+
await label("building");
|
|
175
|
+
await builder(++attemptN, builderPrompt(issue), null);
|
|
176
|
+
result = await readResult();
|
|
177
|
+
const files0 = await changedFiles();
|
|
178
|
+
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
179
|
+
const verdict = result ? (result.status === "fixed" ? "valid" : result.status) : null;
|
|
180
|
+
const closing = ["invalid", "already-fixed"].includes(result?.status);
|
|
181
|
+
if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`);
|
|
182
|
+
// The validation comment (no model call): verdict, evidence, one-line summary. For invalid/already-fixed it IS the evidence comment.
|
|
183
|
+
if (verdict) validateUrl = await post("FORGE:VALIDATE", renderValidate(result, verdict));
|
|
184
|
+
if (closing) {
|
|
185
|
+
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
186
|
+
await label("invalid");
|
|
187
|
+
return await finish("invalid", result.summary ?? result.status);
|
|
188
|
+
}
|
|
189
|
+
if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`);
|
|
190
|
+
if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`);
|
|
191
|
+
planUrl = await post("FORGE:PLAN", renderPlan(result, files0));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- 4. review (one retry through the same builder session) ----
|
|
195
|
+
await label("in-review");
|
|
196
|
+
let review = null, reviewN = 0;
|
|
197
|
+
const doReview = async () => {
|
|
198
|
+
for (let t = 0; t < 2; t++) {
|
|
199
|
+
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
200
|
+
await renew();
|
|
201
|
+
const rr = await io.runner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
202
|
+
prompt: reviewerPrompt({ issue: iss, diff }) });
|
|
203
|
+
await renew();
|
|
204
|
+
await attempt("review", ++reviewN, rr);
|
|
205
|
+
const v = parseVerdict(rr?.text);
|
|
206
|
+
if (v) return v;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
};
|
|
210
|
+
review = await doReview();
|
|
211
|
+
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.");
|
|
212
|
+
if (review.fail) {
|
|
213
|
+
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`);
|
|
214
|
+
await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
|
|
215
|
+
result = (await readResult()) ?? result;
|
|
216
|
+
review = await doReview();
|
|
217
|
+
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).");
|
|
218
|
+
if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`);
|
|
219
|
+
}
|
|
220
|
+
reviewUrl = await post("FORGE:REVIEW", renderReview(review));
|
|
221
|
+
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
222
|
+
|
|
223
|
+
// ---- 5. push + PR ----
|
|
224
|
+
const files = await changedFiles();
|
|
225
|
+
await git(["push", "-u", "origin", branch]);
|
|
226
|
+
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghc(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
227
|
+
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, result, gitFiles: files })])).trim();
|
|
228
|
+
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
229
|
+
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
230
|
+
ev({ event: "PR_OPENED", pr });
|
|
231
|
+
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
232
|
+
const localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
233
|
+
|
|
234
|
+
// ---- 6. the register comment ----
|
|
235
|
+
// merge policy: required (default) | all | none
|
|
236
|
+
let policy = mergeWait, required = [];
|
|
237
|
+
if (mergeWait === "required") {
|
|
238
|
+
try { required = (await io.requiredChecks?.(lane)) ?? []; } catch { required = []; }
|
|
239
|
+
}
|
|
240
|
+
const policyText = mergeWait === "required" ? (required.length ? `required (${required.join(", ")})` : "required (no required checks: merged right after review)") : mergeWait;
|
|
241
|
+
await post(REGISTER_MARKER, renderRegister({ result, gitFiles: files, review, links: { validate: validateUrl, plan: planUrl, review: reviewUrl }, prUrl, usage: usageLine(), policy: policyText }));
|
|
242
|
+
|
|
243
|
+
// ---- 7. wait per policy, merge at the reviewed head ----
|
|
244
|
+
if (policy === "none" || (policy === "required" && !required.length)) {
|
|
245
|
+
// merge right after review
|
|
246
|
+
} else {
|
|
247
|
+
const startedAt = now();
|
|
248
|
+
let pollIndex = 0, gateHead = null;
|
|
249
|
+
for (;;) {
|
|
250
|
+
await renew();
|
|
251
|
+
if (policy === "all") {
|
|
252
|
+
const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
|
|
253
|
+
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
254
|
+
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`);
|
|
255
|
+
} else {
|
|
256
|
+
let states = [];
|
|
257
|
+
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
258
|
+
const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
|
|
259
|
+
const failed = required.filter((n) => st(n) === "failure");
|
|
260
|
+
if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`);
|
|
261
|
+
if (required.every((n) => st(n) === "success")) break;
|
|
262
|
+
}
|
|
263
|
+
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
264
|
+
// Slow CI is never a needs-human: leave the PR open and resumable.
|
|
265
|
+
ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
|
|
266
|
+
return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
|
|
267
|
+
}
|
|
268
|
+
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
269
|
+
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
270
|
+
}
|
|
271
|
+
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
272
|
+
return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
276
|
+
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
277
|
+
await label("merged");
|
|
278
|
+
return await finish("merged", `PR #${pr}`, { pr });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return finish("engine-error", e?.message ?? String(e));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const runLean = runCore;
|