forgedock 1.30.0 → 1.32.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/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/engine.mjs
CHANGED
|
@@ -9,6 +9,13 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { createHash, scryptSync } from "node:crypto";
|
|
10
10
|
import { hostname as osHostname, homedir } from "node:os";
|
|
11
11
|
import { appendEvent, readLog, deriveState, rewriteLog, scopeEvents } from "./engine/runlog.mjs";
|
|
12
|
+
|
|
13
|
+
/** #4432: the issue's run log in `dir`, keyed by repo + issue (legacy `<issue>.jsonl` when `repo` is null). */
|
|
14
|
+
const makeRunLog = (dir, issue, repo) => ({
|
|
15
|
+
append: (e) => appendEvent(dir, issue, e, { repo }),
|
|
16
|
+
read: () => readLog(dir, issue, { repo }),
|
|
17
|
+
rewrite: (events) => rewriteLog(dir, issue, events, { repo }),
|
|
18
|
+
});
|
|
12
19
|
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency, reopenRevokedDecomposition } from "./engine/phases.mjs";
|
|
13
20
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
14
21
|
import { makeProjector } from "./engine/projector.mjs";
|
|
@@ -431,7 +438,7 @@ export async function runIssue(opts) {
|
|
|
431
438
|
// event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
|
|
432
439
|
// never fail the run.
|
|
433
440
|
const emitOutcome = async (rec) => {
|
|
434
|
-
|
|
441
|
+
runLog.append(rec);
|
|
435
442
|
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a run */ }
|
|
436
443
|
};
|
|
437
444
|
|
|
@@ -443,11 +450,23 @@ export async function runIssue(opts) {
|
|
|
443
450
|
// #4125: no silent lane default; the caller (run-issue --lane) names it, and the CLI refuses the default branch.
|
|
444
451
|
if (typeof lane !== "string" || !lane.trim()) throw new Error("runIssue: lane is required (staging or milestone/<slug>); there is no default");
|
|
445
452
|
|
|
453
|
+
// forge#3824: hoisted so RUN_START can carry the repo; #4432: resolved before the first run-log read, which is keyed by it (lazy, memoised; null when unresolved).
|
|
454
|
+
let repoPromise = null;
|
|
455
|
+
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
456
|
+
if (repo) return String(repo).trim();
|
|
457
|
+
try {
|
|
458
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
459
|
+
return String(out || "").trim() || null;
|
|
460
|
+
} catch { return null; }
|
|
461
|
+
})());
|
|
462
|
+
// #4432: the run log is keyed by repo + issue (issue numbers are per repo); an unresolved repo keeps the legacy name.
|
|
463
|
+
const runLog = makeRunLog(dir, issue, await resolveRepo().catch(() => null));
|
|
464
|
+
|
|
446
465
|
const projector = makeProjector(io);
|
|
447
466
|
|
|
448
467
|
// 1. Load + reconcile (GitHub wins).
|
|
449
468
|
// #4109: only this (full) path's own events: core-path commits and terminals in the shared log are never replayed here.
|
|
450
|
-
const local = scopeEvents(
|
|
469
|
+
const local = scopeEvents(runLog.read()).length ? deriveState(runLog.read()) : null;
|
|
451
470
|
let remote = await projector.readState(issue);
|
|
452
471
|
let { state, action } = reconcileState(local, remote);
|
|
453
472
|
const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
|
|
@@ -515,28 +534,19 @@ export async function runIssue(opts) {
|
|
|
515
534
|
};
|
|
516
535
|
}
|
|
517
536
|
}
|
|
518
|
-
// forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
|
|
519
|
-
let repoPromise = null;
|
|
520
|
-
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
521
|
-
if (repo) return String(repo).trim();
|
|
522
|
-
try {
|
|
523
|
-
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
524
|
-
return String(out || "").trim() || null;
|
|
525
|
-
} catch { return null; }
|
|
526
|
-
})());
|
|
527
537
|
|
|
528
538
|
const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
|
|
529
539
|
if (!state) {
|
|
530
540
|
state = freshState(issue, lane);
|
|
531
|
-
|
|
541
|
+
runLog.append({ event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
|
|
532
542
|
} else if (action === "hydrate") {
|
|
533
543
|
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
534
544
|
// local run-log to match the remote compact index so downstream
|
|
535
545
|
// `deriveState(readLog(...))` calls stay consistent — otherwise the
|
|
536
546
|
// post-commit re-derive below would fold over an empty/stale local log
|
|
537
547
|
// and regress committed/issue/v (C2).
|
|
538
|
-
|
|
539
|
-
state = deriveState(
|
|
548
|
+
runLog.rewrite(eventsFromIndex(state, await runMeta()));
|
|
549
|
+
state = deriveState(runLog.read());
|
|
540
550
|
// forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
|
|
541
551
|
// trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
|
|
542
552
|
// belongs to an older cycle ONLY when provably so (cycleResetProvable: terminal index, issue OPEN, no needs-human/remediating label, no open PR): reset via the existing RUN_NEXT_CYCLE event so build runs again instead of
|
|
@@ -547,8 +557,8 @@ export async function runIssue(opts) {
|
|
|
547
557
|
let cyc = 0;
|
|
548
558
|
try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
|
|
549
559
|
if (cyc > 0) {
|
|
550
|
-
|
|
551
|
-
state = deriveState(
|
|
560
|
+
runLog.append({ event: "RUN_NEXT_CYCLE", issue });
|
|
561
|
+
state = deriveState(runLog.read());
|
|
552
562
|
}
|
|
553
563
|
}
|
|
554
564
|
}
|
|
@@ -630,7 +640,7 @@ export async function runIssue(opts) {
|
|
|
630
640
|
// forge#3657: publish the cycle boundary on GitHub BEFORE the log flips, so
|
|
631
641
|
// build's reconcilers never re-adopt cycle-1 evidence. Fail closed.
|
|
632
642
|
if (state.terminalReason === "phase-complete") {
|
|
633
|
-
const localCycles =
|
|
643
|
+
const localCycles = runLog.read().filter((e) => e.event === "RUN_NEXT_CYCLE").length;
|
|
634
644
|
try { await postCycleMarker(issue, await nextCycleNumber(issue, localCycles, io), io); } catch {
|
|
635
645
|
return {
|
|
636
646
|
terminalReason: "not-retryable",
|
|
@@ -638,12 +648,12 @@ export async function runIssue(opts) {
|
|
|
638
648
|
};
|
|
639
649
|
}
|
|
640
650
|
}
|
|
641
|
-
|
|
651
|
+
runLog.append({
|
|
642
652
|
event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
|
|
643
653
|
issue,
|
|
644
654
|
...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
|
|
645
655
|
});
|
|
646
|
-
state = deriveState(
|
|
656
|
+
state = deriveState(runLog.read());
|
|
647
657
|
reopened = true;
|
|
648
658
|
}
|
|
649
659
|
// forge#2239: claim the lease unconditionally here — before the phase loop
|
|
@@ -882,7 +892,7 @@ export async function runIssue(opts) {
|
|
|
882
892
|
for (;;) {
|
|
883
893
|
for (;;) {
|
|
884
894
|
try {
|
|
885
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
|
|
895
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, runLog, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
|
|
886
896
|
break;
|
|
887
897
|
} catch (e) {
|
|
888
898
|
const resetAtMs = e.resetAtEpochMs;
|
|
@@ -904,7 +914,7 @@ export async function runIssue(opts) {
|
|
|
904
914
|
// hasn't committed yet, so a resumed run correctly re-enters
|
|
905
915
|
// pickPhase at this same phase regardless of whether this event
|
|
906
916
|
// was ever written.
|
|
907
|
-
|
|
917
|
+
runLog.append({
|
|
908
918
|
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
909
919
|
resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
|
|
910
920
|
backendId: backendIdentity({ backend, env: process.env }),
|
|
@@ -1051,7 +1061,7 @@ export async function runIssue(opts) {
|
|
|
1051
1061
|
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
1052
1062
|
// forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
|
|
1053
1063
|
const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
|
|
1054
|
-
|
|
1064
|
+
runLog.append({
|
|
1055
1065
|
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
|
|
1056
1066
|
// forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
|
|
1057
1067
|
usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
|
|
@@ -1061,7 +1071,7 @@ export async function runIssue(opts) {
|
|
|
1061
1071
|
backend: outcome.backend ?? null,
|
|
1062
1072
|
...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
|
|
1063
1073
|
});
|
|
1064
|
-
state = deriveState(
|
|
1074
|
+
state = deriveState(runLog.read());
|
|
1065
1075
|
const terminalReason = blockedReason ?? outcome.terminalReason;
|
|
1066
1076
|
if (terminalReason) state.terminalReason = terminalReason;
|
|
1067
1077
|
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
@@ -1129,14 +1139,14 @@ export async function runIssue(opts) {
|
|
|
1129
1139
|
const repoName = await resolveRepo();
|
|
1130
1140
|
if (typeof io.waitCiGreen !== "function" || pr == null || !repoName) return {};
|
|
1131
1141
|
const budgetMs = ciPendingMaxMinutes * 60000;
|
|
1132
|
-
const open =
|
|
1142
|
+
const open = runLog.read().filter((ev) => ev.event === "CI_PENDING_WAIT" && ev.pr === pr).pop();
|
|
1133
1143
|
const startedAt = open && open.state === "start" && Number.isFinite(open.startedAt) ? open.startedAt : now();
|
|
1134
|
-
if (!(open && open.state === "start"))
|
|
1144
|
+
if (!(open && open.state === "start")) runLog.append({ event: "CI_PENDING_WAIT", state: "start", pr, startedAt });
|
|
1135
1145
|
let slept = 0, polls = 0, rateLimitSleeps = 0, pollIndex = 0;
|
|
1136
1146
|
const spentMs = () => Math.max(now() - startedAt, slept);
|
|
1137
1147
|
const report = async (result) => {
|
|
1138
1148
|
const minutes = Math.round(spentMs() / 600) / 100;
|
|
1139
|
-
|
|
1149
|
+
runLog.append({ event: "CI_PENDING_WAIT", state: "end", pr, startedAt, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps });
|
|
1140
1150
|
try { await emitTelemetry?.({ event: "CI_PENDING_WAIT", phase: phaseId, issue, pr, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps }); } catch { /* telemetry never fails a run */ }
|
|
1141
1151
|
return minutes;
|
|
1142
1152
|
};
|
|
@@ -1181,14 +1191,14 @@ export async function runIssue(opts) {
|
|
|
1181
1191
|
async function terminate(s, reason, detail, code) {
|
|
1182
1192
|
// forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
|
|
1183
1193
|
const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
|
|
1184
|
-
|
|
1194
|
+
runLog.append({ event: "RUN_TERMINAL", reason, code: outcomeCode,
|
|
1185
1195
|
...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
|
|
1186
1196
|
// forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
|
|
1187
1197
|
try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
|
|
1188
1198
|
// The terminal write must always land; only the lease field is preserved when another live
|
|
1189
1199
|
// agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
|
|
1190
1200
|
const foreign = await foreignLiveLease();
|
|
1191
|
-
const final = { ...deriveState(
|
|
1201
|
+
const final = { ...deriveState(runLog.read()), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
|
|
1192
1202
|
await projector.writeState(issue, final);
|
|
1193
1203
|
// forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
|
|
1194
1204
|
// next to the terminal label. Best-effort: runs after the terminal write and never throws.
|
|
@@ -1237,7 +1247,7 @@ export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env
|
|
|
1237
1247
|
export const INFRA_SPAWN_BACKOFF_MS = Object.freeze([10_000, 30_000, 60_000]);
|
|
1238
1248
|
|
|
1239
1249
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
1240
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
|
|
1250
|
+
const { io, runner, dir, issue, runLog, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
|
|
1241
1251
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
1242
1252
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
1243
1253
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -1249,7 +1259,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1249
1259
|
} catch (e) {
|
|
1250
1260
|
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
1251
1261
|
const detail = `phase ${phase.id} args unresolved: ${e.message}`;
|
|
1252
|
-
|
|
1262
|
+
runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
|
|
1253
1263
|
return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
|
|
1254
1264
|
}
|
|
1255
1265
|
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
@@ -1288,7 +1298,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1288
1298
|
cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
|
|
1289
1299
|
backend: extra?.backend ?? null,
|
|
1290
1300
|
};
|
|
1291
|
-
|
|
1301
|
+
runLog.append(rec);
|
|
1292
1302
|
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
|
|
1293
1303
|
};
|
|
1294
1304
|
// forge#4021: transient CLI spawn failures (ETIMEDOUT/EAGAIN/ENOMEM at spawn) retry on their own bounded
|
|
@@ -1296,7 +1306,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1296
1306
|
// so a CLI that merely runs long cannot loop forever.
|
|
1297
1307
|
let infraRetries = 0;
|
|
1298
1308
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1299
|
-
|
|
1309
|
+
runLog.append({ event: "PHASE_START", phase: phase.id, attempt });
|
|
1300
1310
|
let result;
|
|
1301
1311
|
const t0 = performance.now();
|
|
1302
1312
|
try {
|
|
@@ -1332,7 +1342,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1332
1342
|
if (e.code === "CLI_MAX_TURNS") {
|
|
1333
1343
|
addUsage(e.usage);
|
|
1334
1344
|
await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
|
|
1335
|
-
|
|
1345
|
+
runLog.append({
|
|
1336
1346
|
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
|
|
1337
1347
|
turns: e.numTurns ?? null, usage: e.usage ?? null,
|
|
1338
1348
|
});
|
|
@@ -1342,13 +1352,13 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1342
1352
|
// failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
|
|
1343
1353
|
if (isDiskQuotaError(e)) {
|
|
1344
1354
|
await recordAttempt(attempt, null, t0, null);
|
|
1345
|
-
|
|
1355
|
+
runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1346
1356
|
throw e;
|
|
1347
1357
|
}
|
|
1348
1358
|
// Transient spawn class: e.code === "CLI_SPAWN_TRANSIENT" (see isTransientSpawnError); retried with infra backoff.
|
|
1349
1359
|
if (isTransientSpawnError(e) && infraRetries < INFRA_SPAWN_BACKOFF_MS.length) {
|
|
1350
1360
|
await recordAttempt(attempt, null, t0, null);
|
|
1351
|
-
|
|
1361
|
+
runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, infraRetry: infraRetries + 1 });
|
|
1352
1362
|
await sleep(INFRA_SPAWN_BACKOFF_MS[infraRetries++]);
|
|
1353
1363
|
attempt--;
|
|
1354
1364
|
continue;
|
|
@@ -1363,7 +1373,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1363
1373
|
const killUsage = e.code === "CLI_TIMEOUT" ? e.usage ?? null : null;
|
|
1364
1374
|
if (killUsage) addUsage(killUsage);
|
|
1365
1375
|
await recordAttempt(attempt, killUsage, t0, null);
|
|
1366
|
-
|
|
1376
|
+
runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, ...(killUsage ? { usage: killUsage } : {}) });
|
|
1367
1377
|
lastCode = OUTCOME_CODES.ENGINE_ERROR;
|
|
1368
1378
|
continue;
|
|
1369
1379
|
}
|
|
@@ -1377,7 +1387,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1377
1387
|
await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns, backend: lastBackend });
|
|
1378
1388
|
const outcome = await phase.detectOutcome(state, io, result);
|
|
1379
1389
|
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns, backend: lastBackend };
|
|
1380
|
-
|
|
1390
|
+
runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
|
|
1381
1391
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
1382
1392
|
// state-derived fixed point — re-running the phase's runner is
|
|
1383
1393
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3289,8 +3289,10 @@ function help() {
|
|
|
3289
3289
|
["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
|
|
3290
3290
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--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)"],
|
|
3291
3291
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3292
|
-
["npx forgedock orchestrate --resume <id> | --set-parallel <id> K", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted),
|
|
3292
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id>", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume)"],
|
|
3293
3293
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3294
|
+
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3295
|
+
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
3294
3296
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3295
3297
|
["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
|
|
3296
3298
|
["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
|
|
@@ -3301,6 +3303,9 @@ function help() {
|
|
|
3301
3303
|
["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
|
|
3302
3304
|
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
3303
3305
|
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
3306
|
+
["npx forgedock review-staging [<PR>] [--thorough] [--json]", "Staging->main review pre-dispatch: route marker, open-finding gate, scope, prepare, roster; writes one reviewer brief per domain"],
|
|
3307
|
+
["npx forgedock review-staging [<PR>] --finalize --test-gate-output <file>", "Staging->main review post-dispatch: delivery, test-gate verdict, extract/classify/file-prepare; prints the findings to file"],
|
|
3308
|
+
["npx forgedock review-staging [<PR>] --summary [--filed <N>]...", "Staging->main review Phase 8: verdict and summary comment with the FORGE:GATE_PASS / FORGE:GATE_FAILURE marker"],
|
|
3304
3309
|
["npx forgedock stats [--since <ver|date>] [--repo owner/repo] [--json] [--refresh]", "Version-over-version cost/time/flow/review/gate report from GitHub telemetry (no model)"],
|
|
3305
3310
|
["npx forgedock doctor", "Check installation health"],
|
|
3306
3311
|
["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block) and reclaim ForgeDock-owned leftovers (forge.yaml hygiene TTLs)"],
|
|
@@ -3868,10 +3873,10 @@ async function watch() {
|
|
|
3868
3873
|
// The journey-routed commands render their own branded marks (hero/compact);
|
|
3869
3874
|
// splash() would double-brand them. Engine-surface commands, help, and
|
|
3870
3875
|
// unknown-command output keep the logo.
|
|
3871
|
-
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3876
|
+
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3872
3877
|
const KNOWN_COMMANDS = new Set([
|
|
3873
3878
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3874
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "help", "--help", "-h",
|
|
3879
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "check", "help", "--help", "-h",
|
|
3875
3880
|
"version", "--version", "-v",
|
|
3876
3881
|
]);
|
|
3877
3882
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4081,6 +4086,12 @@ switch (command) {
|
|
|
4081
4086
|
}
|
|
4082
4087
|
break;
|
|
4083
4088
|
}
|
|
4089
|
+
case "review-staging": {
|
|
4090
|
+
// #4358: deterministic staging->main review pre-dispatch; writes one reviewer brief per roster domain.
|
|
4091
|
+
const { reviewStagingFromCli } = await import("./review-staging.mjs");
|
|
4092
|
+
exitCode = await reviewStagingFromCli(restArgs);
|
|
4093
|
+
break;
|
|
4094
|
+
}
|
|
4084
4095
|
case "gc": {
|
|
4085
4096
|
// Reaper (#4180): dry-run report by default; --apply reclaims only ForgeDock-owned resources (ledger, label, lane naming, own tmp).
|
|
4086
4097
|
const { runGc } = await import("./gc.mjs");
|
|
@@ -4098,6 +4109,17 @@ switch (command) {
|
|
|
4098
4109
|
}
|
|
4099
4110
|
break;
|
|
4100
4111
|
}
|
|
4112
|
+
case "check": {
|
|
4113
|
+
// #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
|
|
4114
|
+
const { checkFromCli } = await import("./engine-cli.mjs");
|
|
4115
|
+
try {
|
|
4116
|
+
exitCode = (await checkFromCli(restArgs)).exitCode;
|
|
4117
|
+
} catch (err) {
|
|
4118
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4119
|
+
exitCode = err.exitCode ?? 1;
|
|
4120
|
+
}
|
|
4121
|
+
break;
|
|
4122
|
+
}
|
|
4101
4123
|
case "limits": {
|
|
4102
4124
|
const { limitsFromCli } = await import("./engine-cli.mjs");
|
|
4103
4125
|
try {
|
|
@@ -4139,6 +4161,16 @@ switch (command) {
|
|
|
4139
4161
|
}
|
|
4140
4162
|
break;
|
|
4141
4163
|
}
|
|
4164
|
+
case "probe": {
|
|
4165
|
+
const { probeFromCli } = await import("./engine-cli.mjs");
|
|
4166
|
+
try {
|
|
4167
|
+
await probeFromCli(restArgs);
|
|
4168
|
+
} catch (err) {
|
|
4169
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4170
|
+
exitCode = 1;
|
|
4171
|
+
}
|
|
4172
|
+
break;
|
|
4173
|
+
}
|
|
4142
4174
|
case "resume-stalled": {
|
|
4143
4175
|
const { resumeStalledFromCli } = await import("./engine-cli.mjs");
|
|
4144
4176
|
try {
|
package/bin/gc.mjs
CHANGED
|
@@ -223,7 +223,7 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
|
|
|
223
223
|
const sameHost = j.host === P.host;
|
|
224
224
|
// A live owner on this host is a running lane, even past `expires` (long CI waits refresh it only on register).
|
|
225
225
|
const stale = sameHost ? !(Number.isSafeInteger(j.pid) && P.alive(j.pid)) : Number(j.expires) < now;
|
|
226
|
-
ledgers.push({ path, batch: j.batch, lane: j.lane, stale });
|
|
226
|
+
ledgers.push({ path, batch: j.batch, lane: j.lane, repo: j.repo ?? null, stale });
|
|
227
227
|
for (const r of j.resources) {
|
|
228
228
|
const type = TYPE_OF_KIND[r.kind];
|
|
229
229
|
if (!type) continue;
|
|
@@ -343,6 +343,9 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
|
|
|
343
343
|
const id = local ? name : `origin/${name}`;
|
|
344
344
|
if (seen.has(`branch:${id}`) || liveIds.has(`branch:${name}`)) continue;
|
|
345
345
|
const it = { type: "branch", id, branch: name, local, sizeBytes: null, ageMs: age(Number(ts) * 1000), owned: true, action: "report", reason: "" };
|
|
346
|
+
// #4361: a tracking ref is not origin. Missing on origin = a stale ref, pruned locally (no push, no gh lookups).
|
|
347
|
+
if (!local && originHeads === null) { it.reason = "cannot verify origin"; add(it); continue; }
|
|
348
|
+
if (!local && !originHeads.has(name)) { it.gone = true; it.action = "reclaim"; it.reason = "already gone on origin (stale tracking ref)"; add(it); continue; }
|
|
346
349
|
const why = await branchBlock(name, { local });
|
|
347
350
|
if (why) it.reason = why;
|
|
348
351
|
else if (!(it.ageMs >= ttl("branch"))) it.reason = "younger than TTL";
|
|
@@ -355,6 +358,12 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
|
|
|
355
358
|
}
|
|
356
359
|
};
|
|
357
360
|
await scan("refs/heads/", true);
|
|
361
|
+
// #4361: one ls-remote is the truth about origin; the local tracking refs may hold hundreds of long-deleted branches.
|
|
362
|
+
let originHeads = null;
|
|
363
|
+
try {
|
|
364
|
+
originHeads = new Set();
|
|
365
|
+
for (const l of String(await git(["ls-remote", "--heads", "origin"])).split("\n")) { const r = l.split("\t")[1]?.trim(); if (r?.startsWith("refs/heads/")) originHeads.add(r.slice(11)); }
|
|
366
|
+
} catch (e) { originHeads = null; notes.push(`git ls-remote origin failed: ${errText(e)}`); }
|
|
358
367
|
await scan("refs/remotes/origin/", false);
|
|
359
368
|
}
|
|
360
369
|
|
|
@@ -449,14 +458,23 @@ const SANDBOX_TEARDOWN = {
|
|
|
449
458
|
};
|
|
450
459
|
const defaultSandboxTeardown = (P) => async (provider, handle) => (SANDBOX_TEARDOWN[provider] ? SANDBOX_TEARDOWN[provider](P, handle) : false);
|
|
451
460
|
|
|
452
|
-
/**
|
|
453
|
-
export
|
|
461
|
+
/** Remote branch deletes per `git push` (#4361): one round trip per chunk, not per branch. */
|
|
462
|
+
export const GC_PUSH_CHUNK = 50;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Reclaim every owned item marked "reclaim"; release ledger entries that were reclaimed. Never touches anything else.
|
|
466
|
+
* With a `deadline` (#4361) items not started by then are returned in `deferred` for a later run.
|
|
467
|
+
*/
|
|
468
|
+
export async function applyGc(plan, probes, { deadline = null, now = () => Date.now() } = {}) {
|
|
454
469
|
const P = probes, fs = P.fs ?? nodeFs;
|
|
455
|
-
const reclaimed = [], failed = [];
|
|
470
|
+
const reclaimed = [], failed = [], deferred = [], remote = [];
|
|
456
471
|
const git = (args) => P.git(args, { cwd: P.cwd });
|
|
472
|
+
const late = () => deadline != null && now() >= deadline;
|
|
457
473
|
let prune = false;
|
|
458
474
|
for (const it of plan.items) {
|
|
459
475
|
if (!(it.owned === true && it.action === "reclaim")) continue;
|
|
476
|
+
if (it.type === "branch" && !it.local) { remote.push(it); continue; }
|
|
477
|
+
if (late()) { deferred.push(it); continue; }
|
|
460
478
|
try {
|
|
461
479
|
switch (it.type) {
|
|
462
480
|
case "container": await P.docker(["rm", "-f", it.id]); break;
|
|
@@ -481,24 +499,37 @@ export async function applyGc(plan, probes) {
|
|
|
481
499
|
break;
|
|
482
500
|
case "branch":
|
|
483
501
|
if (it.gone) break;
|
|
484
|
-
|
|
485
|
-
else await git(["push", "origin", "--delete", it.branch]);
|
|
502
|
+
await git(["branch", "-D", it.branch]);
|
|
486
503
|
break;
|
|
487
504
|
default: throw new Error(`unknown type ${it.type}`);
|
|
488
505
|
}
|
|
489
506
|
reclaimed.push(it);
|
|
490
507
|
if (it.ledger) {
|
|
491
508
|
const l = plan.ledgers.find((x) => x.path === it.ledger);
|
|
492
|
-
if (l) { const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs }); if (led.path === l.path) led.release(it.kind, it.value); }
|
|
509
|
+
if (l) { const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, repo: l.repo, fs }); if (led.path === l.path) led.release(it.kind, it.value); }
|
|
493
510
|
}
|
|
494
511
|
} catch (e) { failed.push({ ...it, error: errText(e) }); }
|
|
495
512
|
}
|
|
513
|
+
// #4361: stale tracking refs are pruned locally; branches still on origin are deleted GC_PUSH_CHUNK per push. A failed
|
|
514
|
+
// chunk is retried one branch at a time so each failure stays attributed to its branch.
|
|
515
|
+
const flush = async (items, cmd) => {
|
|
516
|
+
for (let i = 0; i < items.length; i += GC_PUSH_CHUNK) {
|
|
517
|
+
const chunk = items.slice(i, i + GC_PUSH_CHUNK);
|
|
518
|
+
if (late()) { deferred.push(...chunk); continue; }
|
|
519
|
+
try { await git(cmd(chunk)); reclaimed.push(...chunk); continue; } catch { /* retry singly */ }
|
|
520
|
+
for (const it of chunk) {
|
|
521
|
+
try { await git(cmd([it])); reclaimed.push(it); } catch (e) { failed.push({ ...it, error: errText(e) }); }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
await flush(remote.filter((it) => it.gone), (c) => ["branch", "-r", "-d", ...c.map((it) => `origin/${it.branch}`)]);
|
|
526
|
+
await flush(remote.filter((it) => !it.gone), (c) => ["push", "origin", "--delete", ...c.map((it) => it.branch)]);
|
|
496
527
|
if (prune) { try { await git(["worktree", "prune"]); } catch { /* best effort */ } }
|
|
497
528
|
for (const l of plan.ledgers.filter((x) => x.stale)) {
|
|
498
|
-
const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs });
|
|
529
|
+
const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, repo: l.repo, fs }); // #4432: repo-keyed file name
|
|
499
530
|
if (led.path === l.path && led.read() && led.entries().length === 0) led.remove();
|
|
500
531
|
}
|
|
501
|
-
return { reclaimed, failed };
|
|
532
|
+
return { reclaimed, failed, deferred };
|
|
502
533
|
}
|
|
503
534
|
|
|
504
535
|
const fmtSize = (b) => (b == null ? "?" : b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`);
|
|
@@ -521,7 +552,7 @@ export function formatGcReport(plan, { applied = null } = {}) {
|
|
|
521
552
|
for (const n of plan.notes) L.push(` note: ${n}`);
|
|
522
553
|
for (const f of applied?.failed ?? []) L.push(` failed: ${f.type} ${f.id}: ${f.error}`);
|
|
523
554
|
const n = plan.items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
524
|
-
L.push(applied ? `gc: reclaimed ${applied.reclaimed.length} of ${n} item(s)` : `gc: ${n} item(s) reclaimable; run with --apply to reclaim`);
|
|
555
|
+
L.push(applied ? `gc: reclaimed ${applied.reclaimed.length} of ${n} item(s)${applied.deferred?.length ? `, ${applied.deferred.length} deferred` : ""}` : `gc: ${n} item(s) reclaimable; run with --apply to reclaim`);
|
|
525
556
|
return L.join("\n") + "\n";
|
|
526
557
|
}
|
|
527
558
|
|
|
@@ -541,18 +572,20 @@ export async function runGc(argv = [], { probes, hygiene = null, now = Date.now(
|
|
|
541
572
|
|
|
542
573
|
/**
|
|
543
574
|
* Plan + apply with forge.yaml hygiene settings (orchestrate preflight/admission GC, #4183). Never throws: a failure
|
|
544
|
-
* becomes a note.
|
|
575
|
+
* becomes a note. With `budgetMs` (#4361) apply stops at the deadline; the rest is counted in `deferred` for the next run.
|
|
576
|
+
* Returns {plan, applied, reclaimed, deferred, bytes}.
|
|
545
577
|
*/
|
|
546
|
-
export async function runOwnedGc({ probes, hygiene = HYGIENE_DEFAULTS, scopes = new Set(GC_SCOPES), now = Date.now() } = {}) {
|
|
547
|
-
let plan = null, applied = { reclaimed: [], failed: [] };
|
|
578
|
+
export async function runOwnedGc({ probes, hygiene = HYGIENE_DEFAULTS, scopes = new Set(GC_SCOPES), now = Date.now(), budgetMs = null, clock = () => Date.now() } = {}) {
|
|
579
|
+
let plan = null, applied = { reclaimed: [], failed: [], deferred: [] };
|
|
580
|
+
const deadline = Number.isFinite(budgetMs) ? clock() + budgetMs : null;
|
|
548
581
|
try {
|
|
549
582
|
plan = await planGc({ probes, scopes, ttlMs: hygieneTtls(hygiene), deleteMergedBranches: hygiene.delete_merged_branches !== false, now });
|
|
550
|
-
applied = await applyGc(plan, probes);
|
|
583
|
+
applied = await applyGc(plan, probes, { deadline, now: clock });
|
|
551
584
|
} catch (e) {
|
|
552
585
|
plan = plan ?? { items: [], summary: {}, notes: [], ledgers: [], scopes: [...scopes], olderThanMs: LEDGER_TTL_MS };
|
|
553
586
|
plan.notes.push(`gc failed: ${errText(e)}`);
|
|
554
587
|
}
|
|
555
|
-
return { plan, applied, reclaimed: applied.reclaimed.length, bytes: applied.reclaimed.reduce((s, i) => s + (i.sizeBytes ?? 0), 0) };
|
|
588
|
+
return { plan, applied, reclaimed: applied.reclaimed.length, deferred: applied.deferred?.length ?? 0, bytes: applied.reclaimed.reduce((s, i) => s + (i.sizeBytes ?? 0), 0) };
|
|
556
589
|
}
|
|
557
590
|
|
|
558
591
|
/**
|
|
@@ -569,10 +602,10 @@ export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygi
|
|
|
569
602
|
const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
570
603
|
const stale = items.filter((i) => !i.owned && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
|
|
571
604
|
const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
|
|
572
|
-
if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}`);
|
|
605
|
+
if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}${applied.deferred?.length ? ` (${applied.deferred.length} deferred)` : ""}`);
|
|
573
606
|
else bits.push(`${reclaimable} reclaimable`);
|
|
574
607
|
if (stale) bits.push(`${stale} unowned stale (report only)`);
|
|
575
|
-
const pending = applied ? applied.failed.length : reclaimable;
|
|
608
|
+
const pending = applied ? applied.failed.length + (applied.deferred?.length ?? 0) : reclaimable;
|
|
576
609
|
const status = pending > 0 || stale > 0 ? "warn" : "pass";
|
|
577
610
|
let detail = bits.join("; ");
|
|
578
611
|
if (!applied && reclaimable > 0) detail += ". Run: npx forgedock doctor --fix (or: npx forgedock gc --apply)";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* Re-read nudge (#4456): a Claude CLI PostToolUse hook the lean engine passes to builder sessions with `--settings`
|
|
6
|
+
* (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
|
|
7
|
+
* definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
|
|
8
|
+
* `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
|
|
9
|
+
* State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { readPaths, REREAD_MIN } from "../lean/toolstats.mjs";
|
|
16
|
+
|
|
17
|
+
/** Paths one hook payload reads, resolved against the payload cwd so `sed -n … a.js` and Read `/abs/a.js` agree. */
|
|
18
|
+
export function payloadReads(payload) {
|
|
19
|
+
const inp = payload?.tool_input ?? {};
|
|
20
|
+
const path = inp.file_path ?? inp.path;
|
|
21
|
+
const u = { name: payload?.tool_name ?? null, path: typeof path === "string" ? path : null, cmd: typeof inp.command === "string" ? inp.command : null };
|
|
22
|
+
const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : null;
|
|
23
|
+
return [...new Set(readPaths(u).map((p) => (cwd ? resolve(cwd, p) : p)))];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pure core: counts after this payload and the reminder to emit (null when no path just reached REREAD_MIN). */
|
|
27
|
+
export function nudgeFor(payload, counts = {}) {
|
|
28
|
+
const next = { ...counts }, hot = [];
|
|
29
|
+
for (const p of payloadReads(payload)) {
|
|
30
|
+
next[p] = (next[p] ?? 0) + 1;
|
|
31
|
+
if (next[p] === REREAD_MIN) hot.push(p);
|
|
32
|
+
}
|
|
33
|
+
const message = hot.length ? `${hot.join(", ")} ${hot.length > 1 ? "have" : "has"} now been read ${REREAD_MIN} times in this session: `
|
|
34
|
+
+ "use your notes or the excerpt already in context; if you need a different part, read only that line range." : null;
|
|
35
|
+
return { counts: next, message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
|
|
39
|
+
join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
|
|
40
|
+
|
|
41
|
+
/** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
|
|
42
|
+
export function main(input, { dir } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
const payload = JSON.parse(input);
|
|
45
|
+
if (!payload?.session_id) return "";
|
|
46
|
+
if (!payloadReads(payload).length) return "";
|
|
47
|
+
const file = stateFile(payload.session_id, dir);
|
|
48
|
+
let counts = {};
|
|
49
|
+
try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
|
|
50
|
+
const r = nudgeFor(payload, counts);
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
52
|
+
writeFileSync(file, JSON.stringify(r.counts));
|
|
53
|
+
return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
60
|
+
let input = "";
|
|
61
|
+
process.stdin.setEncoding("utf8");
|
|
62
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
63
|
+
process.stdin.on("end", () => { process.stdout.write(main(input)); process.exitCode = 0; });
|
|
64
|
+
process.stdin.on("error", () => { process.exitCode = 0; });
|
|
65
|
+
}
|
package/bin/labels.json
CHANGED
|
@@ -74,6 +74,11 @@
|
|
|
74
74
|
"color": "B60205",
|
|
75
75
|
"description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"name": "awaiting-promotion",
|
|
79
|
+
"color": "C5DEF5",
|
|
80
|
+
"description": "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."
|
|
81
|
+
},
|
|
77
82
|
{
|
|
78
83
|
"name": "blocked-on-human-merge",
|
|
79
84
|
"color": "006B75",
|