forgedock 1.16.2 → 1.17.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 +92 -0
- package/bin/engine/phases.mjs +86 -26
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +12 -1
- package/bin/engine.mjs +145 -40
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +17 -2
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +102 -25
- package/commands/review-pr.md +119 -103
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +3 -2
- package/commands/work-on/build.md +95 -64
- package/commands/work-on/close.md +17 -6
- package/commands/work-on/decompose.md +45 -4
- package/commands/work-on/investigate.md +6 -2
- package/commands/work-on/remediate.md +22 -6
- package/commands/work-on/review.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +13 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +5 -0
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +8 -3
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
package/bin/engine.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
7
8
|
import { createHash, scryptSync } from "node:crypto";
|
|
8
9
|
import { hostname as osHostname, homedir } from "node:os";
|
|
9
10
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
@@ -11,6 +12,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
|
|
|
11
12
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
12
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
13
14
|
import { VALID_BACKENDS } from "./runner.mjs";
|
|
15
|
+
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason } from "./engine/outcome-codes.mjs";
|
|
14
16
|
|
|
15
17
|
// Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
|
|
16
18
|
// diagnostics without duplicating the retry budget constant.
|
|
@@ -297,6 +299,12 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
|
|
|
297
299
|
* @param {string} [opts.hostname] - injectable host name (tests).
|
|
298
300
|
* @param {Function} [opts.killFn] - injectable `process.kill` (tests).
|
|
299
301
|
*/
|
|
302
|
+
// forge#3824: stamped on RUN_START for per-release cost attribution; null when unreadable.
|
|
303
|
+
export const ENGINE_VERSION = (() => {
|
|
304
|
+
try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || null; }
|
|
305
|
+
catch { return null; }
|
|
306
|
+
})();
|
|
307
|
+
|
|
300
308
|
export async function runIssue(opts) {
|
|
301
309
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
302
310
|
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
@@ -322,12 +330,23 @@ export async function runIssue(opts) {
|
|
|
322
330
|
waitLease = false,
|
|
323
331
|
hostname = osHostname(),
|
|
324
332
|
killFn = process.kill.bind(process),
|
|
325
|
-
onProgress = () => {}
|
|
333
|
+
onProgress = () => {},
|
|
334
|
+
// forge#3696: per-attempt telemetry seam (FORGE:TELEMETRY emitter, #3834). No-op by
|
|
335
|
+
// default; failures are swallowed so telemetry can never fail a phase.
|
|
336
|
+
emitTelemetry = () => {} } = opts;
|
|
326
337
|
|
|
327
338
|
// forge#2452: extracted into makeProgressEmitter() — see its docstring for
|
|
328
339
|
// the forge#2240 rationale this wrapper implements.
|
|
329
340
|
const emitProgress = makeProgressEmitter(onProgress);
|
|
330
341
|
|
|
342
|
+
// forge#3833: one seam for coded outcome records that are not run-log phase events. Appends the
|
|
343
|
+
// event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
|
|
344
|
+
// never fail the run.
|
|
345
|
+
const emitOutcome = async (rec) => {
|
|
346
|
+
appendEvent(dir, issue, rec);
|
|
347
|
+
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a run */ }
|
|
348
|
+
};
|
|
349
|
+
|
|
331
350
|
// forge#2452: extracted into validateRunIssueOptions() — see its docstring
|
|
332
351
|
// for the forge#2054/#2313/#2329 rationale and required call-order (before
|
|
333
352
|
// any state I/O or timer creation, unchanged from the inline block this
|
|
@@ -344,6 +363,7 @@ export async function runIssue(opts) {
|
|
|
344
363
|
!isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
|
|
345
364
|
const deferredResult = (r) => ({
|
|
346
365
|
terminalReason: "deferred",
|
|
366
|
+
code: OUTCOME_CODES.LEASE_HELD,
|
|
347
367
|
leaseUntil: r.lease.until,
|
|
348
368
|
detail: `issue ${issue} leased by ${r.lease.by} until ${new Date(r.lease.until).toISOString()}`,
|
|
349
369
|
});
|
|
@@ -370,16 +390,27 @@ export async function runIssue(opts) {
|
|
|
370
390
|
remote = cur;
|
|
371
391
|
({ state, action } = reconcileState(local, remote));
|
|
372
392
|
}
|
|
393
|
+
// forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
|
|
394
|
+
let repoPromise = null;
|
|
395
|
+
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
396
|
+
if (repo) return String(repo).trim();
|
|
397
|
+
try {
|
|
398
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
399
|
+
return String(out || "").trim() || null;
|
|
400
|
+
} catch { return null; }
|
|
401
|
+
})());
|
|
402
|
+
|
|
403
|
+
const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
|
|
373
404
|
if (!state) {
|
|
374
405
|
state = freshState(issue, lane);
|
|
375
|
-
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
406
|
+
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
|
|
376
407
|
} else if (action === "hydrate") {
|
|
377
408
|
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
378
409
|
// local run-log to match the remote compact index so downstream
|
|
379
410
|
// `deriveState(readLog(...))` calls stay consistent — otherwise the
|
|
380
411
|
// post-commit re-derive below would fold over an empty/stale local log
|
|
381
412
|
// and regress committed/issue/v (C2).
|
|
382
|
-
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
413
|
+
rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
|
|
383
414
|
state = deriveState(readLog(dir, issue));
|
|
384
415
|
}
|
|
385
416
|
|
|
@@ -494,14 +525,6 @@ export async function runIssue(opts) {
|
|
|
494
525
|
// against a nonexistent ref and silently evaluated to 0 (forge#2174). The
|
|
495
526
|
// build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
|
|
496
527
|
// the real branch from the `FORGE:BUILDER` comment instead.
|
|
497
|
-
let repoPromise = null;
|
|
498
|
-
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
499
|
-
if (repo) return String(repo).trim();
|
|
500
|
-
try {
|
|
501
|
-
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
502
|
-
return String(out || "").trim() || null;
|
|
503
|
-
} catch { return null; }
|
|
504
|
-
})());
|
|
505
528
|
|
|
506
529
|
// forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
|
|
507
530
|
// the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
|
|
@@ -550,7 +573,7 @@ export async function runIssue(opts) {
|
|
|
550
573
|
// forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
|
|
551
574
|
// the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
|
|
552
575
|
(state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
|
|
553
|
-
return await terminate(state, state.terminalReason);
|
|
576
|
+
return await terminate(state, state.terminalReason, undefined, state.outcomeCode);
|
|
554
577
|
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
555
578
|
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
556
579
|
// progress) — never the issue's live GitHub state/labels — so a phase
|
|
@@ -593,7 +616,7 @@ export async function runIssue(opts) {
|
|
|
593
616
|
if (isDead) {
|
|
594
617
|
const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
|
|
595
618
|
`with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
|
|
596
|
-
return await terminate(state, "invalid", detail);
|
|
619
|
+
return await terminate(state, "invalid", detail, OUTCOME_CODES.INVALID);
|
|
597
620
|
}
|
|
598
621
|
// `needs-human` is a PAUSE, not a death sentence — a human may still
|
|
599
622
|
// resolve it, and /orchestrate's classify_predecessor_state() already
|
|
@@ -699,7 +722,7 @@ export async function runIssue(opts) {
|
|
|
699
722
|
let pauseCount = 0;
|
|
700
723
|
for (;;) {
|
|
701
724
|
try {
|
|
702
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId });
|
|
725
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
|
|
703
726
|
break;
|
|
704
727
|
} catch (e) {
|
|
705
728
|
const resetAtMs = e.resetAtEpochMs;
|
|
@@ -788,7 +811,7 @@ export async function runIssue(opts) {
|
|
|
788
811
|
// harmless idempotent safety net for the `throw e;` path.
|
|
789
812
|
clearInterval(renewTimer);
|
|
790
813
|
if (pendingRenewal) await pendingRenewal;
|
|
791
|
-
return await terminate(state, "engine-error", detail);
|
|
814
|
+
return await terminate(state, "engine-error", detail, codeForRunnerError(e));
|
|
792
815
|
}
|
|
793
816
|
throw e;
|
|
794
817
|
} finally {
|
|
@@ -818,11 +841,12 @@ export async function runIssue(opts) {
|
|
|
818
841
|
// PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
|
|
819
842
|
// an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
|
|
820
843
|
if (phase.id === "remediate" && outcome.reason === "deferred") {
|
|
821
|
-
await
|
|
822
|
-
|
|
844
|
+
await releaseLease(state);
|
|
845
|
+
await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
|
|
846
|
+
return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
|
|
823
847
|
}
|
|
824
848
|
if (!isRemediationHandoff)
|
|
825
|
-
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
849
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
|
|
826
850
|
} else {
|
|
827
851
|
// committed
|
|
828
852
|
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
@@ -854,11 +878,15 @@ export async function runIssue(opts) {
|
|
|
854
878
|
// `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
|
|
855
879
|
// commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
|
|
856
880
|
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
881
|
+
// forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
|
|
882
|
+
const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
|
|
857
883
|
appendEvent(dir, issue, {
|
|
858
|
-
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
|
|
884
|
+
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
|
|
885
|
+
// forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
|
|
886
|
+
usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
|
|
859
887
|
// forge#3589: model + turn count per phase (null when the backend does not report them).
|
|
860
888
|
model: outcome.model ?? null, turns: outcome.turns ?? null,
|
|
861
|
-
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
889
|
+
...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
|
|
862
890
|
});
|
|
863
891
|
state = deriveState(readLog(dir, issue));
|
|
864
892
|
const terminalReason = blockedReason ?? outcome.terminalReason;
|
|
@@ -890,29 +918,74 @@ export async function runIssue(opts) {
|
|
|
890
918
|
outcome.outputs?.closeHandoff === true && terminalReason === "merged";
|
|
891
919
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
892
920
|
!isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
|
|
893
|
-
return await terminate(state, terminalReason, outcome.detail);
|
|
921
|
+
return await terminate(state, terminalReason, outcome.detail, commitCode);
|
|
894
922
|
if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
|
|
895
923
|
return await terminate(state, state.terminalReason || "merged");
|
|
896
924
|
}
|
|
897
925
|
return await terminate(state, state.terminalReason || "merged");
|
|
898
926
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
927
|
+
// Re-read remote state and return the lease another LIVE agent holds, if any. `undefined` = none;
|
|
928
|
+
// `null` = the read failed (unknown). Our stale in-memory snapshot must never overwrite a live
|
|
929
|
+
// holder's lease. Residual TOCTOU: projector.writeState has no CAS, so this narrows the window
|
|
930
|
+
// from a whole phase to one read/write round trip.
|
|
931
|
+
async function foreignLiveLease() {
|
|
932
|
+
try {
|
|
933
|
+
const r = await projector.readState(issue);
|
|
934
|
+
return leaseHeld(r) ? r.lease : undefined;
|
|
935
|
+
} catch { return null; }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Guarded lease release (race: another engine claimed the lease after ours expired).
|
|
939
|
+
// Writes `lease: null` only when no other live agent holds it; on read failure, fail safe (skip).
|
|
940
|
+
async function releaseLease(s) {
|
|
941
|
+
if (await foreignLiveLease() !== undefined) return false;
|
|
942
|
+
await projector.writeState(issue, { ...s, lease: null });
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function terminate(s, reason, detail, code) {
|
|
947
|
+
// forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
|
|
948
|
+
const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
|
|
949
|
+
appendEvent(dir, issue, { event: "RUN_TERMINAL", reason, code: outcomeCode,
|
|
950
|
+
...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
|
|
951
|
+
// forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
|
|
952
|
+
try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
|
|
953
|
+
// The terminal write must always land; only the lease field is preserved when another live
|
|
954
|
+
// agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
|
|
955
|
+
const foreign = await foreignLiveLease();
|
|
956
|
+
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
|
|
902
957
|
await projector.writeState(issue, final);
|
|
958
|
+
// forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
|
|
959
|
+
// next to the terminal label. Best-effort: runs after the terminal write and never throws.
|
|
960
|
+
// forge#3876: skipped when another live engine holds the lease (it owns the labels); an unreadable lease (null) falls back to cleanup.
|
|
961
|
+
let wasRemediating = false;
|
|
962
|
+
if (foreign == null && (reason === "needs-human" || reason === "engine-error")) {
|
|
963
|
+
try {
|
|
964
|
+
const lv = JSON.parse(await io.gh(["issue", "view", String(issue), "--json", "labels"]));
|
|
965
|
+
wasRemediating = (lv?.labels ?? []).some((l) => (typeof l === "string" ? l : l?.name) === "workflow:remediating");
|
|
966
|
+
} catch { /* unreadable: treat as not remediating */ }
|
|
967
|
+
try { await projector.removeLabel(issue, "workflow:remediating"); }
|
|
968
|
+
catch { /* best-effort: a transient label failure must not lose the terminal state */ }
|
|
969
|
+
}
|
|
903
970
|
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
904
971
|
// forge#2261: a distinct label for engine/tool-level failures (broken CLI
|
|
905
972
|
// invocation, exhausted retries where the runner itself never once
|
|
906
973
|
// succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
|
|
907
974
|
// (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
|
|
908
975
|
// there is no human decision pending, just a tool that needs to be re-run.
|
|
909
|
-
else if (reason === "engine-error")
|
|
910
|
-
|
|
976
|
+
else if (reason === "engine-error") {
|
|
977
|
+
await projector.setLabel(issue, "workflow:engine-error");
|
|
978
|
+
// A crashed remediate has no human signal otherwise (remediate.md Stranded-state rule).
|
|
979
|
+
if (wasRemediating) {
|
|
980
|
+
try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return { terminalReason: reason, code: outcomeCode, detail };
|
|
911
984
|
}
|
|
912
985
|
}
|
|
913
986
|
|
|
914
987
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
915
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId } = ctx;
|
|
988
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
|
|
916
989
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
917
990
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
918
991
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -924,8 +997,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
924
997
|
} catch (e) {
|
|
925
998
|
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
926
999
|
const detail = `phase ${phase.id} args unresolved: ${e.message}`;
|
|
927
|
-
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
|
|
928
|
-
return { status: "blocked", detail, reason: "engine-error", usage: null };
|
|
1000
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
|
|
1001
|
+
return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
|
|
929
1002
|
}
|
|
930
1003
|
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
931
1004
|
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
@@ -936,6 +1009,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
936
1009
|
// Flips to false the instant any attempt's runner() call succeeds, even if
|
|
937
1010
|
// that attempt's detectOutcome() itself reports failure.
|
|
938
1011
|
let allAttemptsThrew = true;
|
|
1012
|
+
// forge#3833: code of the most recent failed attempt (an attempt whose runner threw counts as engine.error).
|
|
1013
|
+
let lastCode = UNCLASSIFIED;
|
|
939
1014
|
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
940
1015
|
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
941
1016
|
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
@@ -945,9 +1020,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
945
1020
|
// attempt (if any) set it to, which is correct: it always reflects the
|
|
946
1021
|
// most recent attempt that actually produced a result.
|
|
947
1022
|
let lastUsage = null;
|
|
1023
|
+
// forge#3696: usage summed over every attempt (null if none reported usage — never 0).
|
|
1024
|
+
let cumulativeUsage = null;
|
|
1025
|
+
const addUsage = (u) => {
|
|
1026
|
+
if (!u || typeof u !== "object") return;
|
|
1027
|
+
cumulativeUsage ??= { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
1028
|
+
for (const k of Object.keys(cumulativeUsage)) cumulativeUsage[k] += Number.isFinite(u[k]) ? u[k] : 0;
|
|
1029
|
+
};
|
|
1030
|
+
// One PHASE_ATTEMPT_USAGE event per attempt (null for anything unreported), then the
|
|
1031
|
+
// telemetry seam, which must never throw into the phase.
|
|
1032
|
+
const recordAttempt = async (attempt, usage, t0, extra) => {
|
|
1033
|
+
const rec = {
|
|
1034
|
+
event: "PHASE_ATTEMPT_USAGE", phase: phase.id, attempt,
|
|
1035
|
+
usage: usage ?? null, duration_ms: Math.round(performance.now() - t0),
|
|
1036
|
+
cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
|
|
1037
|
+
};
|
|
1038
|
+
appendEvent(dir, issue, rec);
|
|
1039
|
+
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
|
|
1040
|
+
};
|
|
948
1041
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
949
1042
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
950
1043
|
let result;
|
|
1044
|
+
const t0 = performance.now();
|
|
951
1045
|
try {
|
|
952
1046
|
result = await runner({
|
|
953
1047
|
commandsDir, commandName: phase.command, args: phaseArgs,
|
|
@@ -978,8 +1072,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
978
1072
|
// NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
|
|
979
1073
|
// `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
|
|
980
1074
|
if (e.code === "CLI_MAX_TURNS") {
|
|
1075
|
+
addUsage(e.usage);
|
|
1076
|
+
await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
|
|
981
1077
|
appendEvent(dir, issue, {
|
|
982
|
-
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
|
|
1078
|
+
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
|
|
983
1079
|
turns: e.numTurns ?? null, usage: e.usage ?? null,
|
|
984
1080
|
});
|
|
985
1081
|
throw e;
|
|
@@ -990,7 +1086,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
990
1086
|
// Do not fabricate a value; omitting the field (rather than a stale
|
|
991
1087
|
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
992
1088
|
// trustworthy as "usage actually observed on this attempt".
|
|
993
|
-
|
|
1089
|
+
await recordAttempt(attempt, null, t0, null);
|
|
1090
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1091
|
+
lastCode = OUTCOME_CODES.ENGINE_ERROR;
|
|
994
1092
|
continue;
|
|
995
1093
|
}
|
|
996
1094
|
allAttemptsThrew = false;
|
|
@@ -998,9 +1096,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
998
1096
|
// forge#3589: model/turns recorded alongside usage (additive; null when absent).
|
|
999
1097
|
const lastModel = result?.cliModel ?? result?.model ?? null;
|
|
1000
1098
|
const lastTurns = result?.turns ?? null;
|
|
1099
|
+
addUsage(lastUsage);
|
|
1100
|
+
await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
|
|
1001
1101
|
const outcome = await phase.detectOutcome(state, io, result);
|
|
1002
|
-
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
|
|
1003
|
-
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
|
|
1102
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns };
|
|
1103
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
|
|
1004
1104
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
1005
1105
|
// state-derived fixed point — re-running the phase's runner is
|
|
1006
1106
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -1011,11 +1111,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1011
1111
|
// Absent/undefined `retryable` defaults to retryable (existing behavior
|
|
1012
1112
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
1013
1113
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
1114
|
+
lastCode = normalizeCode(outcome.code);
|
|
1014
1115
|
if (outcome.retryable === false) {
|
|
1015
1116
|
// forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
|
|
1016
1117
|
// forward `handoff` so only an explicit opt-in escalation is honoured; a
|
|
1017
1118
|
// no-signal failure carries no handoff and stops at needs-human.
|
|
1018
|
-
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
|
|
1119
|
+
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
|
|
1019
1120
|
}
|
|
1020
1121
|
}
|
|
1021
1122
|
// Exhausted transient retries → escalate (spec §7).
|
|
@@ -1033,18 +1134,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1033
1134
|
status: "blocked",
|
|
1034
1135
|
detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
|
|
1035
1136
|
reason: "engine-error",
|
|
1137
|
+
code: OUTCOME_CODES.ENGINE_ERROR,
|
|
1036
1138
|
usage: null,
|
|
1037
1139
|
};
|
|
1038
1140
|
}
|
|
1039
|
-
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
|
|
1141
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
|
|
1040
1142
|
}
|
|
1041
1143
|
|
|
1042
1144
|
/**
|
|
1043
1145
|
* Build a run-log event sequence that reproduces a compact FORGE:STATE index,
|
|
1044
1146
|
* used to reconstruct the local run-log on hydrate (C2).
|
|
1045
1147
|
*/
|
|
1046
|
-
function eventsFromIndex(idx) {
|
|
1047
|
-
|
|
1148
|
+
function eventsFromIndex(idx, meta = {}) {
|
|
1149
|
+
// forge#3824: `hydrated` marks a reconstructed log (no usage data) so cost-report can exclude it.
|
|
1150
|
+
const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane,
|
|
1151
|
+
version: meta.version ?? null, forge_version: meta.forge_version ?? meta.version ?? null,
|
|
1152
|
+
repo: meta.repo ?? null, hydrated: true }];
|
|
1048
1153
|
for (const phase of idx.committed) {
|
|
1049
1154
|
const outputs = {};
|
|
1050
1155
|
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
@@ -1057,15 +1162,15 @@ function eventsFromIndex(idx) {
|
|
|
1057
1162
|
// would terminate the run. Nothing to attach it to when no phase is committed.
|
|
1058
1163
|
if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
|
|
1059
1164
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
1060
|
-
if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
|
|
1165
|
+
if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; if (idx.outcomeCode) events[i].code = idx.outcomeCode; break; }
|
|
1061
1166
|
}
|
|
1062
1167
|
}
|
|
1063
|
-
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
|
|
1168
|
+
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason, code: normalizeCode(idx.outcomeCode ?? codeForTerminalReason(idx.terminalReason)) });
|
|
1064
1169
|
return events;
|
|
1065
1170
|
}
|
|
1066
1171
|
|
|
1067
1172
|
function freshState(issue, lane) {
|
|
1068
1173
|
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
1069
1174
|
branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
|
|
1070
|
-
lastRateLimit: null };
|
|
1175
|
+
lastRateLimit: null, outcomeCode: null };
|
|
1071
1176
|
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3214,6 +3214,7 @@ function help() {
|
|
|
3214
3214
|
["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
|
|
3215
3215
|
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
3216
3216
|
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
3217
|
+
["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)"],
|
|
3217
3218
|
["npx forgedock doctor", "Check installation health"],
|
|
3218
3219
|
["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
|
|
3219
3220
|
["npx forgedock update", "Pull latest & reinstall"],
|
|
@@ -3783,7 +3784,7 @@ async function watch() {
|
|
|
3783
3784
|
const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3784
3785
|
const KNOWN_COMMANDS = new Set([
|
|
3785
3786
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3786
|
-
"run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
|
|
3787
|
+
"run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
|
|
3787
3788
|
"version", "--version", "-v",
|
|
3788
3789
|
]);
|
|
3789
3790
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4003,6 +4004,11 @@ switch (command) {
|
|
|
4003
4004
|
await runReport(restArgs);
|
|
4004
4005
|
break;
|
|
4005
4006
|
}
|
|
4007
|
+
case "stats": {
|
|
4008
|
+
const { runStats } = await import("./stats/index.mjs");
|
|
4009
|
+
exitCode = await runStats(restArgs);
|
|
4010
|
+
break;
|
|
4011
|
+
}
|
|
4006
4012
|
case "watch":
|
|
4007
4013
|
await watch();
|
|
4008
4014
|
break;
|
|
@@ -66,6 +66,7 @@ import { dirname, join, resolve } from "path";
|
|
|
66
66
|
import { existsSync, readFileSync } from "fs";
|
|
67
67
|
import { execFileSync } from "child_process";
|
|
68
68
|
import { PHASE_MARKERS as PHASE_MARKER_REGISTRY } from "../../packages/protocol/src/phases.js";
|
|
69
|
+
import { normalizeCode, codeForTerminalReason } from "../engine/outcome-codes.mjs";
|
|
69
70
|
|
|
70
71
|
// ---------------------------------------------------------------------------
|
|
71
72
|
// Bootstrap
|
|
@@ -236,11 +237,18 @@ async function main() {
|
|
|
236
237
|
if (freshLog.length && deriveState(freshLog).committed.includes(phaseId)) return;
|
|
237
238
|
|
|
238
239
|
if (bootstrapped) {
|
|
240
|
+
// forge#3824: attribute the run to a release and repo (best-effort; null when unavailable).
|
|
241
|
+
let version = null, repo = null;
|
|
242
|
+
try { version = JSON.parse(readFileSync(join(FORGE_HOME, "package.json"), "utf8")).version || null; } catch { /* fail-soft */ }
|
|
243
|
+
try { repo = String(await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]) || "").trim() || null; } catch { /* fail-soft */ }
|
|
239
244
|
appendEvent(runLogDir, issueNumber, {
|
|
240
245
|
event: "RUN_START",
|
|
241
246
|
issue: issueNumber,
|
|
242
247
|
run: state.run,
|
|
243
248
|
lane: state.lane,
|
|
249
|
+
version,
|
|
250
|
+
forge_version: version,
|
|
251
|
+
repo,
|
|
244
252
|
source: "interactive",
|
|
245
253
|
});
|
|
246
254
|
}
|
|
@@ -258,6 +266,8 @@ async function main() {
|
|
|
258
266
|
appendEvent(runLogDir, issueNumber, {
|
|
259
267
|
event: "RUN_TERMINAL",
|
|
260
268
|
reason: terminalReason,
|
|
269
|
+
// forge#3833: same closed enum as the headless engine (absent -> the reason's own code).
|
|
270
|
+
code: normalizeCode(outcome.code ?? codeForTerminalReason(terminalReason)),
|
|
261
271
|
source: "interactive",
|
|
262
272
|
});
|
|
263
273
|
state = deriveState(readLog(runLogDir, issueNumber));
|