forgedock 1.16.1 → 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 +105 -25
- 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 +151 -39
- 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 +6 -3
- package/commands/orchestrate/phase-4-execution.md +25 -10
- 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/quality-gate.md +4 -4
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +134 -57
- package/commands/review-pr.md +135 -119
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +5 -4
- package/commands/work-on/build.md +143 -80
- package/commands/work-on/close.md +19 -8
- package/commands/work-on/decompose.md +50 -8
- package/commands/work-on/investigate.md +14 -7
- package/commands/work-on/remediate.md +40 -14
- package/commands/work-on/review.md +22 -5
- package/commands/work-on.md +6 -5
- package/docs/CONFIG.md +37 -2
- 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 +14 -1
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.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 +4 -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 +41 -3
- package/scripts/diff-size.test.sh +47 -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 +18 -5
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/forge-resolve.sh +13 -4
- 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 +132 -0
- package/scripts/review-acceptance-gate.sh +141 -0
- package/scripts/review-acceptance-gate.test.sh +103 -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 });
|
|
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 {
|
|
@@ -814,8 +837,16 @@ export async function runIssue(opts) {
|
|
|
814
837
|
(outcome.outputs?.pr ?? state.pr) != null;
|
|
815
838
|
if (outcome.status === "blocked") {
|
|
816
839
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
840
|
+
// forge#3792: remediate deferring to a live earlier run is neither a commit nor a terminal: a RUN_TERMINAL or
|
|
841
|
+
// PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
|
|
842
|
+
// an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
|
|
843
|
+
if (phase.id === "remediate" && outcome.reason === "deferred") {
|
|
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 };
|
|
847
|
+
}
|
|
817
848
|
if (!isRemediationHandoff)
|
|
818
|
-
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
849
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
|
|
819
850
|
} else {
|
|
820
851
|
// committed
|
|
821
852
|
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
@@ -847,11 +878,15 @@ export async function runIssue(opts) {
|
|
|
847
878
|
// `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
|
|
848
879
|
// commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
|
|
849
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;
|
|
850
883
|
appendEvent(dir, issue, {
|
|
851
|
-
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,
|
|
852
887
|
// forge#3589: model + turn count per phase (null when the backend does not report them).
|
|
853
888
|
model: outcome.model ?? null, turns: outcome.turns ?? null,
|
|
854
|
-
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
889
|
+
...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
|
|
855
890
|
});
|
|
856
891
|
state = deriveState(readLog(dir, issue));
|
|
857
892
|
const terminalReason = blockedReason ?? outcome.terminalReason;
|
|
@@ -883,29 +918,74 @@ export async function runIssue(opts) {
|
|
|
883
918
|
outcome.outputs?.closeHandoff === true && terminalReason === "merged";
|
|
884
919
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
885
920
|
!isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
|
|
886
|
-
return await terminate(state, terminalReason, outcome.detail);
|
|
921
|
+
return await terminate(state, terminalReason, outcome.detail, commitCode);
|
|
887
922
|
if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
|
|
888
923
|
return await terminate(state, state.terminalReason || "merged");
|
|
889
924
|
}
|
|
890
925
|
return await terminate(state, state.terminalReason || "merged");
|
|
891
926
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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 };
|
|
895
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
|
+
}
|
|
896
970
|
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
897
971
|
// forge#2261: a distinct label for engine/tool-level failures (broken CLI
|
|
898
972
|
// invocation, exhausted retries where the runner itself never once
|
|
899
973
|
// succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
|
|
900
974
|
// (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
|
|
901
975
|
// there is no human decision pending, just a tool that needs to be re-run.
|
|
902
|
-
else if (reason === "engine-error")
|
|
903
|
-
|
|
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 };
|
|
904
984
|
}
|
|
905
985
|
}
|
|
906
986
|
|
|
907
987
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
908
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
|
|
988
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
|
|
909
989
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
910
990
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
911
991
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -913,12 +993,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
913
993
|
// `[issue]`-only invocation that burns the attempt budget.
|
|
914
994
|
let phaseArgs;
|
|
915
995
|
try {
|
|
916
|
-
phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
|
|
996
|
+
phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo(), agentId }, io);
|
|
917
997
|
} catch (e) {
|
|
918
998
|
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
919
999
|
const detail = `phase ${phase.id} args unresolved: ${e.message}`;
|
|
920
|
-
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
|
|
921
|
-
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 };
|
|
922
1002
|
}
|
|
923
1003
|
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
924
1004
|
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
@@ -929,6 +1009,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
929
1009
|
// Flips to false the instant any attempt's runner() call succeeds, even if
|
|
930
1010
|
// that attempt's detectOutcome() itself reports failure.
|
|
931
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;
|
|
932
1014
|
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
933
1015
|
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
934
1016
|
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
@@ -938,9 +1020,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
938
1020
|
// attempt (if any) set it to, which is correct: it always reflects the
|
|
939
1021
|
// most recent attempt that actually produced a result.
|
|
940
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
|
+
};
|
|
941
1041
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
942
1042
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
943
1043
|
let result;
|
|
1044
|
+
const t0 = performance.now();
|
|
944
1045
|
try {
|
|
945
1046
|
result = await runner({
|
|
946
1047
|
commandsDir, commandName: phase.command, args: phaseArgs,
|
|
@@ -971,8 +1072,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
971
1072
|
// NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
|
|
972
1073
|
// `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
|
|
973
1074
|
if (e.code === "CLI_MAX_TURNS") {
|
|
1075
|
+
addUsage(e.usage);
|
|
1076
|
+
await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
|
|
974
1077
|
appendEvent(dir, issue, {
|
|
975
|
-
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,
|
|
976
1079
|
turns: e.numTurns ?? null, usage: e.usage ?? null,
|
|
977
1080
|
});
|
|
978
1081
|
throw e;
|
|
@@ -983,7 +1086,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
983
1086
|
// Do not fabricate a value; omitting the field (rather than a stale
|
|
984
1087
|
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
985
1088
|
// trustworthy as "usage actually observed on this attempt".
|
|
986
|
-
|
|
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;
|
|
987
1092
|
continue;
|
|
988
1093
|
}
|
|
989
1094
|
allAttemptsThrew = false;
|
|
@@ -991,9 +1096,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
991
1096
|
// forge#3589: model/turns recorded alongside usage (additive; null when absent).
|
|
992
1097
|
const lastModel = result?.cliModel ?? result?.model ?? null;
|
|
993
1098
|
const lastTurns = result?.turns ?? null;
|
|
1099
|
+
addUsage(lastUsage);
|
|
1100
|
+
await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
|
|
994
1101
|
const outcome = await phase.detectOutcome(state, io, result);
|
|
995
|
-
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
|
|
996
|
-
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 });
|
|
997
1104
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
998
1105
|
// state-derived fixed point — re-running the phase's runner is
|
|
999
1106
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -1004,11 +1111,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1004
1111
|
// Absent/undefined `retryable` defaults to retryable (existing behavior
|
|
1005
1112
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
1006
1113
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
1114
|
+
lastCode = normalizeCode(outcome.code);
|
|
1007
1115
|
if (outcome.retryable === false) {
|
|
1008
1116
|
// forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
|
|
1009
1117
|
// forward `handoff` so only an explicit opt-in escalation is honoured; a
|
|
1010
1118
|
// no-signal failure carries no handoff and stops at needs-human.
|
|
1011
|
-
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 };
|
|
1012
1120
|
}
|
|
1013
1121
|
}
|
|
1014
1122
|
// Exhausted transient retries → escalate (spec §7).
|
|
@@ -1026,18 +1134,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1026
1134
|
status: "blocked",
|
|
1027
1135
|
detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
|
|
1028
1136
|
reason: "engine-error",
|
|
1137
|
+
code: OUTCOME_CODES.ENGINE_ERROR,
|
|
1029
1138
|
usage: null,
|
|
1030
1139
|
};
|
|
1031
1140
|
}
|
|
1032
|
-
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 };
|
|
1033
1142
|
}
|
|
1034
1143
|
|
|
1035
1144
|
/**
|
|
1036
1145
|
* Build a run-log event sequence that reproduces a compact FORGE:STATE index,
|
|
1037
1146
|
* used to reconstruct the local run-log on hydrate (C2).
|
|
1038
1147
|
*/
|
|
1039
|
-
function eventsFromIndex(idx) {
|
|
1040
|
-
|
|
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 }];
|
|
1041
1153
|
for (const phase of idx.committed) {
|
|
1042
1154
|
const outputs = {};
|
|
1043
1155
|
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
@@ -1050,15 +1162,15 @@ function eventsFromIndex(idx) {
|
|
|
1050
1162
|
// would terminate the run. Nothing to attach it to when no phase is committed.
|
|
1051
1163
|
if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
|
|
1052
1164
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
1053
|
-
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; }
|
|
1054
1166
|
}
|
|
1055
1167
|
}
|
|
1056
|
-
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)) });
|
|
1057
1169
|
return events;
|
|
1058
1170
|
}
|
|
1059
1171
|
|
|
1060
1172
|
function freshState(issue, lane) {
|
|
1061
1173
|
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
1062
1174
|
branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
|
|
1063
|
-
lastRateLimit: null };
|
|
1175
|
+
lastRateLimit: null, outcomeCode: null };
|
|
1064
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));
|