forgedock 1.16.2 → 1.18.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 +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -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 +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -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 +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- 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 +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- 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-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -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 +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- 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 -11
- 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/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
package/bin/engine.mjs
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { readFileSync, rmSync } 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";
|
|
10
|
-
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
|
|
11
|
+
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
|
|
11
12
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
12
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
13
|
-
import { VALID_BACKENDS } from "./runner.mjs";
|
|
14
|
+
import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
|
|
15
|
+
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText } 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,17 +390,63 @@ export async function runIssue(opts) {
|
|
|
370
390
|
remote = cur;
|
|
371
391
|
({ state, action } = reconcileState(local, remote));
|
|
372
392
|
}
|
|
393
|
+
// forge#3932: `Depends on` admission. A trusted `**Depends on**: #N` line whose dependency is still OPEN
|
|
394
|
+
// refuses the run BEFORE any state/lease write (mirrors deferredResult), so building on top of an
|
|
395
|
+
// unmerged dependency cannot conflict with it. Checked on every start; a closed dependency, no
|
|
396
|
+
// declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
|
|
397
|
+
{
|
|
398
|
+
let dep = null;
|
|
399
|
+
try { dep = await openDependency(issue, io); } catch (e) {
|
|
400
|
+
dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
|
|
401
|
+
}
|
|
402
|
+
if (dep?.unreadable) {
|
|
403
|
+
return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
|
|
404
|
+
}
|
|
405
|
+
if (dep?.open?.length) {
|
|
406
|
+
return {
|
|
407
|
+
terminalReason: "dependency-open",
|
|
408
|
+
code: OUTCOME_CODES.DEPENDENCY_OPEN,
|
|
409
|
+
dependencies: dep.open,
|
|
410
|
+
detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
|
|
415
|
+
let repoPromise = null;
|
|
416
|
+
const resolveRepo = () => (repoPromise ??= (async () => {
|
|
417
|
+
if (repo) return String(repo).trim();
|
|
418
|
+
try {
|
|
419
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
420
|
+
return String(out || "").trim() || null;
|
|
421
|
+
} catch { return null; }
|
|
422
|
+
})());
|
|
423
|
+
|
|
424
|
+
const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
|
|
373
425
|
if (!state) {
|
|
374
426
|
state = freshState(issue, lane);
|
|
375
|
-
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
427
|
+
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
|
|
376
428
|
} else if (action === "hydrate") {
|
|
377
429
|
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
378
430
|
// local run-log to match the remote compact index so downstream
|
|
379
431
|
// `deriveState(readLog(...))` calls stay consistent — otherwise the
|
|
380
432
|
// post-commit re-derive below would fold over an empty/stale local log
|
|
381
433
|
// and regress committed/issue/v (C2).
|
|
382
|
-
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
434
|
+
rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
|
|
383
435
|
state = deriveState(readLog(dir, issue));
|
|
436
|
+
// forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
|
|
437
|
+
// trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
|
|
438
|
+
// 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
|
|
439
|
+
// terminating. An unreadable trail resets nothing (fail closed). merged/decomposed/invalid terminals stay.
|
|
440
|
+
if (state.committed.some((p) => ["build", "review", "remediate", "close"].includes(p)) &&
|
|
441
|
+
state.terminal && ["needs-human", "phase-complete", "engine-error"].includes(state.terminalReason) &&
|
|
442
|
+
await cycleResetProvable(state, io)) {
|
|
443
|
+
let cyc = 0;
|
|
444
|
+
try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
|
|
445
|
+
if (cyc > 0) {
|
|
446
|
+
appendEvent(dir, issue, { event: "RUN_NEXT_CYCLE", issue });
|
|
447
|
+
state = deriveState(readLog(dir, issue));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
384
450
|
}
|
|
385
451
|
|
|
386
452
|
// forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
|
|
@@ -393,12 +459,19 @@ export async function runIssue(opts) {
|
|
|
393
459
|
// forge#3562: `phase-complete` is also retryable, but as a NEW cycle
|
|
394
460
|
// (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
|
|
395
461
|
let reopened = false;
|
|
462
|
+
let reopenUncommit = null;
|
|
396
463
|
if (retry && state?.terminal) {
|
|
397
|
-
|
|
464
|
+
// forge#3862 / forge#3932: a `decomposed` terminal is reopened at build when trusted GitHub state shows
|
|
465
|
+
// the decomposition revoked (a trusted FORGE:DECOMPOSED:REVOKED newer than FORGE:DECOMPOSED); the
|
|
466
|
+
// revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
|
|
467
|
+
// unreadable comment trail fails closed.
|
|
468
|
+
if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
|
|
469
|
+
reopenUncommit = ["build", "decompose"];
|
|
470
|
+
} else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
|
|
398
471
|
state.terminalReason !== "needs-human") {
|
|
399
472
|
return {
|
|
400
473
|
terminalReason: "not-retryable",
|
|
401
|
-
detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
|
|
474
|
+
detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run (or a decomposed run whose FORGE:DECOMPOSED was revoked by a trusted FORGE:DECOMPOSED:REVOKED)`,
|
|
402
475
|
};
|
|
403
476
|
}
|
|
404
477
|
// forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
|
|
@@ -426,6 +499,13 @@ export async function runIssue(opts) {
|
|
|
426
499
|
};
|
|
427
500
|
}
|
|
428
501
|
}
|
|
502
|
+
// forge#3931: a remediate that ended needs-human was recorded against an older PR head. When the
|
|
503
|
+
// live head differs from the head in its trusted trail, un-commit remediate AND review so review
|
|
504
|
+
// re-runs at the new head instead of the old RE-ESCALATED result replaying. Unreadable => no change.
|
|
505
|
+
if (state.terminalReason === "needs-human" && !reopenUncommit &&
|
|
506
|
+
state.committed[state.committed.length - 1] === "remediate" && await remediationHeadStale(state, io)) {
|
|
507
|
+
reopenUncommit = ["remediate", "review"];
|
|
508
|
+
}
|
|
429
509
|
// forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
|
|
430
510
|
// issue first. A CLOSED issue has nothing left to run; an unreadable
|
|
431
511
|
// snapshot fails closed rather than resetting a possibly-finished run.
|
|
@@ -455,6 +535,7 @@ export async function runIssue(opts) {
|
|
|
455
535
|
appendEvent(dir, issue, {
|
|
456
536
|
event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
|
|
457
537
|
issue,
|
|
538
|
+
...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
|
|
458
539
|
});
|
|
459
540
|
state = deriveState(readLog(dir, issue));
|
|
460
541
|
reopened = true;
|
|
@@ -494,14 +575,6 @@ export async function runIssue(opts) {
|
|
|
494
575
|
// against a nonexistent ref and silently evaluated to 0 (forge#2174). The
|
|
495
576
|
// build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
|
|
496
577
|
// 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
578
|
|
|
506
579
|
// forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
|
|
507
580
|
// the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
|
|
@@ -550,7 +623,7 @@ export async function runIssue(opts) {
|
|
|
550
623
|
// forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
|
|
551
624
|
// the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
|
|
552
625
|
(state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
|
|
553
|
-
return await terminate(state, state.terminalReason);
|
|
626
|
+
return await terminate(state, state.terminalReason, undefined, state.outcomeCode);
|
|
554
627
|
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
555
628
|
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
556
629
|
// progress) — never the issue's live GitHub state/labels — so a phase
|
|
@@ -593,7 +666,7 @@ export async function runIssue(opts) {
|
|
|
593
666
|
if (isDead) {
|
|
594
667
|
const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
|
|
595
668
|
`with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
|
|
596
|
-
return await terminate(state, "invalid", detail);
|
|
669
|
+
return await terminate(state, "invalid", detail, OUTCOME_CODES.INVALID);
|
|
597
670
|
}
|
|
598
671
|
// `needs-human` is a PAUSE, not a death sentence — a human may still
|
|
599
672
|
// resolve it, and /orchestrate's classify_predecessor_state() already
|
|
@@ -699,7 +772,7 @@ export async function runIssue(opts) {
|
|
|
699
772
|
let pauseCount = 0;
|
|
700
773
|
for (;;) {
|
|
701
774
|
try {
|
|
702
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId });
|
|
775
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
|
|
703
776
|
break;
|
|
704
777
|
} catch (e) {
|
|
705
778
|
const resetAtMs = e.resetAtEpochMs;
|
|
@@ -756,7 +829,7 @@ export async function runIssue(opts) {
|
|
|
756
829
|
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
757
830
|
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
758
831
|
// a true unexpected crash and keeps propagating unchanged.
|
|
759
|
-
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
|
|
832
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
|
|
760
833
|
// forge#2241: when the runner attached a session-limit reset time
|
|
761
834
|
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
762
835
|
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
@@ -766,7 +839,7 @@ export async function runIssue(opts) {
|
|
|
766
839
|
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
767
840
|
const detail = e.code === "CLI_MAX_TURNS"
|
|
768
841
|
? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
|
|
769
|
-
: `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
|
|
842
|
+
: `phase ${phase.id}: ${e.code ?? "DISK_QUOTA"} - ${e.message}${resetSuffix}`;
|
|
770
843
|
// forge#2240 (review finding): this fail-fast path previously left
|
|
771
844
|
// phase_exit unreported — a caller tailing progress output would see
|
|
772
845
|
// "→ phase X started" and then nothing, dangling exactly on the
|
|
@@ -788,7 +861,7 @@ export async function runIssue(opts) {
|
|
|
788
861
|
// harmless idempotent safety net for the `throw e;` path.
|
|
789
862
|
clearInterval(renewTimer);
|
|
790
863
|
if (pendingRenewal) await pendingRenewal;
|
|
791
|
-
return await terminate(state, "engine-error", detail);
|
|
864
|
+
return await terminate(state, "engine-error", detail, codeForRunnerError(e));
|
|
792
865
|
}
|
|
793
866
|
throw e;
|
|
794
867
|
} finally {
|
|
@@ -818,11 +891,15 @@ export async function runIssue(opts) {
|
|
|
818
891
|
// PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
|
|
819
892
|
// an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
|
|
820
893
|
if (phase.id === "remediate" && outcome.reason === "deferred") {
|
|
821
|
-
await
|
|
822
|
-
|
|
894
|
+
await releaseLease(state);
|
|
895
|
+
await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
|
|
896
|
+
return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
|
|
823
897
|
}
|
|
898
|
+
// forge#3572: a phase that returned blocked because the disk/quota ran out is a retryable infra failure.
|
|
899
|
+
if (isDiskQuotaText(outcome.detail))
|
|
900
|
+
return await terminate(state, "engine-error", outcome.detail, OUTCOME_CODES.INFRA_DISK_QUOTA);
|
|
824
901
|
if (!isRemediationHandoff)
|
|
825
|
-
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
902
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
|
|
826
903
|
} else {
|
|
827
904
|
// committed
|
|
828
905
|
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
@@ -854,11 +931,15 @@ export async function runIssue(opts) {
|
|
|
854
931
|
// `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
|
|
855
932
|
// commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
|
|
856
933
|
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
934
|
+
// forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
|
|
935
|
+
const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
|
|
857
936
|
appendEvent(dir, issue, {
|
|
858
|
-
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
|
|
937
|
+
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
|
|
938
|
+
// forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
|
|
939
|
+
usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
|
|
859
940
|
// forge#3589: model + turn count per phase (null when the backend does not report them).
|
|
860
941
|
model: outcome.model ?? null, turns: outcome.turns ?? null,
|
|
861
|
-
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
942
|
+
...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
|
|
862
943
|
});
|
|
863
944
|
state = deriveState(readLog(dir, issue));
|
|
864
945
|
const terminalReason = blockedReason ?? outcome.terminalReason;
|
|
@@ -882,37 +963,96 @@ export async function runIssue(opts) {
|
|
|
882
963
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
883
964
|
// exemption does not apply and the normal terminate() path below fires,
|
|
884
965
|
// ending the run for real.
|
|
885
|
-
//
|
|
886
|
-
const isDecomposeHandoff =
|
|
966
|
+
// forge#3862: only investigate hands off (decomposition is a planning decision); build never reports "decomposed".
|
|
967
|
+
const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
|
|
887
968
|
// forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
|
|
888
969
|
// to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
|
|
889
970
|
const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
|
|
890
971
|
outcome.outputs?.closeHandoff === true && terminalReason === "merged";
|
|
891
972
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
892
973
|
!isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
|
|
893
|
-
return await terminate(state, terminalReason, outcome.detail);
|
|
974
|
+
return await terminate(state, terminalReason, outcome.detail, commitCode);
|
|
894
975
|
if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
|
|
895
976
|
return await terminate(state, state.terminalReason || "merged");
|
|
896
977
|
}
|
|
897
978
|
return await terminate(state, state.terminalReason || "merged");
|
|
898
979
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
980
|
+
// Re-read remote state and return the lease another LIVE agent holds, if any. `undefined` = none;
|
|
981
|
+
// `null` = the read failed (unknown). Our stale in-memory snapshot must never overwrite a live
|
|
982
|
+
// holder's lease. Residual TOCTOU: projector.writeState has no CAS, so this narrows the window
|
|
983
|
+
// from a whole phase to one read/write round trip.
|
|
984
|
+
async function foreignLiveLease() {
|
|
985
|
+
try {
|
|
986
|
+
const r = await projector.readState(issue);
|
|
987
|
+
return leaseHeld(r) ? r.lease : undefined;
|
|
988
|
+
} catch { return null; }
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Guarded lease release (race: another engine claimed the lease after ours expired).
|
|
992
|
+
// Writes `lease: null` only when no other live agent holds it; on read failure, fail safe (skip).
|
|
993
|
+
async function releaseLease(s) {
|
|
994
|
+
if (await foreignLiveLease() !== undefined) return false;
|
|
995
|
+
await projector.writeState(issue, { ...s, lease: null });
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function terminate(s, reason, detail, code) {
|
|
1000
|
+
// forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
|
|
1001
|
+
const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
|
|
1002
|
+
appendEvent(dir, issue, { event: "RUN_TERMINAL", reason, code: outcomeCode,
|
|
1003
|
+
...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
|
|
1004
|
+
// forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
|
|
1005
|
+
try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
|
|
1006
|
+
// The terminal write must always land; only the lease field is preserved when another live
|
|
1007
|
+
// agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
|
|
1008
|
+
const foreign = await foreignLiveLease();
|
|
1009
|
+
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
|
|
902
1010
|
await projector.writeState(issue, final);
|
|
1011
|
+
// forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
|
|
1012
|
+
// next to the terminal label. Best-effort: runs after the terminal write and never throws.
|
|
1013
|
+
// forge#3876: skipped when another live engine holds the lease (it owns the labels); an unreadable lease (null) falls back to cleanup.
|
|
1014
|
+
let wasRemediating = false;
|
|
1015
|
+
if (foreign == null && (reason === "needs-human" || reason === "engine-error")) {
|
|
1016
|
+
try {
|
|
1017
|
+
const lv = JSON.parse(await io.gh(["issue", "view", String(issue), "--json", "labels"]));
|
|
1018
|
+
wasRemediating = (lv?.labels ?? []).some((l) => (typeof l === "string" ? l : l?.name) === "workflow:remediating");
|
|
1019
|
+
} catch { /* unreadable: treat as not remediating */ }
|
|
1020
|
+
try { await projector.removeLabel(issue, "workflow:remediating"); }
|
|
1021
|
+
catch { /* best-effort: a transient label failure must not lose the terminal state */ }
|
|
1022
|
+
}
|
|
903
1023
|
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
904
1024
|
// forge#2261: a distinct label for engine/tool-level failures (broken CLI
|
|
905
1025
|
// invocation, exhausted retries where the runner itself never once
|
|
906
1026
|
// succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
|
|
907
1027
|
// (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
|
|
908
1028
|
// there is no human decision pending, just a tool that needs to be re-run.
|
|
909
|
-
else if (reason === "engine-error")
|
|
910
|
-
|
|
1029
|
+
else if (reason === "engine-error") {
|
|
1030
|
+
await projector.setLabel(issue, "workflow:engine-error");
|
|
1031
|
+
// A crashed remediate has no human signal otherwise (remediate.md Stranded-state rule).
|
|
1032
|
+
if (wasRemediating) {
|
|
1033
|
+
try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// forge#3572: drop the per-run scratch dir (venvs, session data) on a terminal outcome. Kept for
|
|
1037
|
+
// `engine-error` so `--retry` resumes with it, and when another live engine owns the issue.
|
|
1038
|
+
if (reason !== "engine-error" && foreign === undefined) cleanupRunTmpdir(issue);
|
|
1039
|
+
return { terminalReason: reason, code: outcomeCode, detail };
|
|
911
1040
|
}
|
|
912
1041
|
}
|
|
913
1042
|
|
|
1043
|
+
/** Best-effort removal of the issue's per-run scratch dir. Confined to the resolved tmp root; never throws. */
|
|
1044
|
+
export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
|
|
1045
|
+
try {
|
|
1046
|
+
const root = resolveRunTmpRoot(cwd, env);
|
|
1047
|
+
const dir = runTmpDir(root, issue);
|
|
1048
|
+
if (!dir || dir === root) return false;
|
|
1049
|
+
rm(dir, { recursive: true, force: true });
|
|
1050
|
+
return true;
|
|
1051
|
+
} catch { return false; }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
914
1054
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
915
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId } = ctx;
|
|
1055
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
|
|
916
1056
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
917
1057
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
918
1058
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -924,8 +1064,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
924
1064
|
} catch (e) {
|
|
925
1065
|
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
926
1066
|
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 };
|
|
1067
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
|
|
1068
|
+
return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
|
|
929
1069
|
}
|
|
930
1070
|
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
931
1071
|
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
@@ -936,6 +1076,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
936
1076
|
// Flips to false the instant any attempt's runner() call succeeds, even if
|
|
937
1077
|
// that attempt's detectOutcome() itself reports failure.
|
|
938
1078
|
let allAttemptsThrew = true;
|
|
1079
|
+
// forge#3833: code of the most recent failed attempt (an attempt whose runner threw counts as engine.error).
|
|
1080
|
+
let lastCode = UNCLASSIFIED;
|
|
939
1081
|
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
940
1082
|
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
941
1083
|
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
@@ -945,9 +1087,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
945
1087
|
// attempt (if any) set it to, which is correct: it always reflects the
|
|
946
1088
|
// most recent attempt that actually produced a result.
|
|
947
1089
|
let lastUsage = null;
|
|
1090
|
+
// forge#3696: usage summed over every attempt (null if none reported usage — never 0).
|
|
1091
|
+
let cumulativeUsage = null;
|
|
1092
|
+
const addUsage = (u) => {
|
|
1093
|
+
if (!u || typeof u !== "object") return;
|
|
1094
|
+
cumulativeUsage ??= { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
1095
|
+
for (const k of Object.keys(cumulativeUsage)) cumulativeUsage[k] += Number.isFinite(u[k]) ? u[k] : 0;
|
|
1096
|
+
};
|
|
1097
|
+
// One PHASE_ATTEMPT_USAGE event per attempt (null for anything unreported), then the
|
|
1098
|
+
// telemetry seam, which must never throw into the phase.
|
|
1099
|
+
const recordAttempt = async (attempt, usage, t0, extra) => {
|
|
1100
|
+
const rec = {
|
|
1101
|
+
event: "PHASE_ATTEMPT_USAGE", phase: phase.id, attempt,
|
|
1102
|
+
usage: usage ?? null, duration_ms: Math.round(performance.now() - t0),
|
|
1103
|
+
cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
|
|
1104
|
+
};
|
|
1105
|
+
appendEvent(dir, issue, rec);
|
|
1106
|
+
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
|
|
1107
|
+
};
|
|
948
1108
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
949
1109
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
950
1110
|
let result;
|
|
1111
|
+
const t0 = performance.now();
|
|
951
1112
|
try {
|
|
952
1113
|
result = await runner({
|
|
953
1114
|
commandsDir, commandName: phase.command, args: phaseArgs,
|
|
@@ -978,19 +1139,30 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
978
1139
|
// NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
|
|
979
1140
|
// `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
|
|
980
1141
|
if (e.code === "CLI_MAX_TURNS") {
|
|
1142
|
+
addUsage(e.usage);
|
|
1143
|
+
await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
|
|
981
1144
|
appendEvent(dir, issue, {
|
|
982
|
-
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
|
|
1145
|
+
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
|
|
983
1146
|
turns: e.numTurns ?? null, usage: e.usage ?? null,
|
|
984
1147
|
});
|
|
985
1148
|
throw e;
|
|
986
1149
|
}
|
|
1150
|
+
// forge#3572: a full disk / exhausted quota (EDQUOT/ENOSPC) is infra, not content: retrying burns the budget on the same
|
|
1151
|
+
// failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
|
|
1152
|
+
if (isDiskQuotaError(e)) {
|
|
1153
|
+
await recordAttempt(attempt, null, t0, null);
|
|
1154
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1155
|
+
throw e;
|
|
1156
|
+
}
|
|
987
1157
|
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
988
1158
|
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
989
1159
|
// (and therefore no usage data) was ever produced for this attempt.
|
|
990
1160
|
// Do not fabricate a value; omitting the field (rather than a stale
|
|
991
1161
|
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
992
1162
|
// trustworthy as "usage actually observed on this attempt".
|
|
993
|
-
|
|
1163
|
+
await recordAttempt(attempt, null, t0, null);
|
|
1164
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1165
|
+
lastCode = OUTCOME_CODES.ENGINE_ERROR;
|
|
994
1166
|
continue;
|
|
995
1167
|
}
|
|
996
1168
|
allAttemptsThrew = false;
|
|
@@ -998,9 +1170,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
998
1170
|
// forge#3589: model/turns recorded alongside usage (additive; null when absent).
|
|
999
1171
|
const lastModel = result?.cliModel ?? result?.model ?? null;
|
|
1000
1172
|
const lastTurns = result?.turns ?? null;
|
|
1173
|
+
addUsage(lastUsage);
|
|
1174
|
+
await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
|
|
1001
1175
|
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 });
|
|
1176
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns };
|
|
1177
|
+
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
1178
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
1005
1179
|
// state-derived fixed point — re-running the phase's runner is
|
|
1006
1180
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -1011,11 +1185,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1011
1185
|
// Absent/undefined `retryable` defaults to retryable (existing behavior
|
|
1012
1186
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
1013
1187
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
1188
|
+
lastCode = normalizeCode(outcome.code);
|
|
1014
1189
|
if (outcome.retryable === false) {
|
|
1015
1190
|
// forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
|
|
1016
1191
|
// forward `handoff` so only an explicit opt-in escalation is honoured; a
|
|
1017
1192
|
// 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 };
|
|
1193
|
+
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
|
|
1019
1194
|
}
|
|
1020
1195
|
}
|
|
1021
1196
|
// Exhausted transient retries → escalate (spec §7).
|
|
@@ -1033,18 +1208,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1033
1208
|
status: "blocked",
|
|
1034
1209
|
detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
|
|
1035
1210
|
reason: "engine-error",
|
|
1211
|
+
code: OUTCOME_CODES.ENGINE_ERROR,
|
|
1036
1212
|
usage: null,
|
|
1037
1213
|
};
|
|
1038
1214
|
}
|
|
1039
|
-
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
|
|
1215
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
|
|
1040
1216
|
}
|
|
1041
1217
|
|
|
1042
1218
|
/**
|
|
1043
1219
|
* Build a run-log event sequence that reproduces a compact FORGE:STATE index,
|
|
1044
1220
|
* used to reconstruct the local run-log on hydrate (C2).
|
|
1045
1221
|
*/
|
|
1046
|
-
function eventsFromIndex(idx) {
|
|
1047
|
-
|
|
1222
|
+
function eventsFromIndex(idx, meta = {}) {
|
|
1223
|
+
// forge#3824: `hydrated` marks a reconstructed log (no usage data) so cost-report can exclude it.
|
|
1224
|
+
const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane,
|
|
1225
|
+
version: meta.version ?? null, forge_version: meta.forge_version ?? meta.version ?? null,
|
|
1226
|
+
repo: meta.repo ?? null, hydrated: true }];
|
|
1048
1227
|
for (const phase of idx.committed) {
|
|
1049
1228
|
const outputs = {};
|
|
1050
1229
|
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
@@ -1057,15 +1236,15 @@ function eventsFromIndex(idx) {
|
|
|
1057
1236
|
// would terminate the run. Nothing to attach it to when no phase is committed.
|
|
1058
1237
|
if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
|
|
1059
1238
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
1060
|
-
if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
|
|
1239
|
+
if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; if (idx.outcomeCode) events[i].code = idx.outcomeCode; break; }
|
|
1061
1240
|
}
|
|
1062
1241
|
}
|
|
1063
|
-
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
|
|
1242
|
+
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason, code: normalizeCode(idx.outcomeCode ?? codeForTerminalReason(idx.terminalReason)) });
|
|
1064
1243
|
return events;
|
|
1065
1244
|
}
|
|
1066
1245
|
|
|
1067
1246
|
function freshState(issue, lane) {
|
|
1068
1247
|
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
1069
1248
|
branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
|
|
1070
|
-
lastRateLimit: null };
|
|
1249
|
+
lastRateLimit: null, outcomeCode: null };
|
|
1071
1250
|
}
|
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);
|
|
@@ -3971,7 +3972,7 @@ switch (command) {
|
|
|
3971
3972
|
// an uncaught throw, which already set exitCode = 1 below. Now that
|
|
3972
3973
|
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3973
3974
|
// preserve that same non-zero exit code for this reason too.
|
|
3974
|
-
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
|
|
3975
|
+
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable")) exitCode = 1;
|
|
3975
3976
|
// forge#3574: a lease-held deferral did NOT run the issue; exit 75
|
|
3976
3977
|
// (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
|
|
3977
3978
|
if (result && result.terminalReason === "deferred") exitCode = 75;
|
|
@@ -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));
|