forgedock 1.3.0 → 1.5.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/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { classifyInputPattern, shouldReResolve, foldNewMatches } from "./resolve.mjs";
|
|
4
|
+
|
|
5
|
+
describe("classifyInputPattern — literal sets", () => {
|
|
6
|
+
it("classifies bare issue numbers as literal", () => {
|
|
7
|
+
assert.deepEqual(classifyInputPattern("1 2 3"), {
|
|
8
|
+
kind: "literal",
|
|
9
|
+
pattern: "literal-numbers",
|
|
10
|
+
args: ["1", "2", "3"],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("classifies #-prefixed issue numbers as literal", () => {
|
|
15
|
+
const r = classifyInputPattern("#1 #2 #3");
|
|
16
|
+
assert.equal(r.kind, "literal");
|
|
17
|
+
assert.equal(r.pattern, "literal-numbers");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("classifies repo-prefixed literal numbers as literal", () => {
|
|
21
|
+
const r = classifyInputPattern("#123 mcp:5 n8n:12");
|
|
22
|
+
assert.equal(r.kind, "literal");
|
|
23
|
+
assert.equal(r.pattern, "literal-numbers");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("classifies a single bare number as literal", () => {
|
|
27
|
+
const r = classifyInputPattern("42");
|
|
28
|
+
assert.equal(r.kind, "literal");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("classifyInputPattern — query patterns", () => {
|
|
33
|
+
it("classifies milestone <slug> as a query", () => {
|
|
34
|
+
const r = classifyInputPattern("milestone modular-pipeline-architecture");
|
|
35
|
+
assert.equal(r.kind, "query");
|
|
36
|
+
assert.equal(r.pattern, "milestone");
|
|
37
|
+
assert.deepEqual(r.args, ["modular-pipeline-architecture"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("classifies next <N> as a query", () => {
|
|
41
|
+
const r = classifyInputPattern("next 5");
|
|
42
|
+
assert.equal(r.kind, "query");
|
|
43
|
+
assert.equal(r.pattern, "next-n");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("classifies next <N> all-repos as a query", () => {
|
|
47
|
+
const r = classifyInputPattern("next 5 all-repos");
|
|
48
|
+
assert.equal(r.kind, "query");
|
|
49
|
+
assert.equal(r.pattern, "next-n-all-repos");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("classifies fast-lane / fast as a query", () => {
|
|
53
|
+
assert.equal(classifyInputPattern("fast-lane").pattern, "fast-lane");
|
|
54
|
+
assert.equal(classifyInputPattern("fast").pattern, "fast-lane");
|
|
55
|
+
assert.equal(classifyInputPattern("fast-lane").kind, "query");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
|
|
59
|
+
assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
|
|
60
|
+
assert.equal(classifyInputPattern("priority:P1").kind, "query");
|
|
61
|
+
assert.equal(classifyInputPattern("mcp:priority:P2").pattern, "priority");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("classifies repo-scoped queries as a query", () => {
|
|
65
|
+
assert.equal(classifyInputPattern("mcp:fast").pattern, "repo-scoped");
|
|
66
|
+
assert.equal(classifyInputPattern("n8n:next 3").pattern, "repo-scoped");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("classifies cascade/review-findings/findings as a query", () => {
|
|
70
|
+
assert.equal(classifyInputPattern("cascade").pattern, "cascade");
|
|
71
|
+
assert.equal(classifyInputPattern("review-findings").pattern, "cascade");
|
|
72
|
+
assert.equal(classifyInputPattern("findings --include-deferred").pattern, "cascade");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("classifies a bare slug as a query", () => {
|
|
76
|
+
const r = classifyInputPattern("some-milestone-slug");
|
|
77
|
+
assert.equal(r.kind, "query");
|
|
78
|
+
assert.equal(r.pattern, "bare-slug");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("classifies empty/unrecognized input as an unknown query, never literal", () => {
|
|
82
|
+
assert.equal(classifyInputPattern("").kind, "query");
|
|
83
|
+
assert.equal(classifyInputPattern("").pattern, "unknown");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("shouldReResolve", () => {
|
|
88
|
+
it("never re-resolves a literal set, regardless of config", () => {
|
|
89
|
+
const classified = { kind: "literal", pattern: "literal-numbers", args: ["1"] };
|
|
90
|
+
const r = shouldReResolve(classified, { enabled: true, maxRounds: 100 }, 0);
|
|
91
|
+
assert.equal(r.reResolve, false);
|
|
92
|
+
assert.match(r.reason, /never re-resolve/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("re-resolves a query pattern by default (no config)", () => {
|
|
96
|
+
const classified = { kind: "query", pattern: "priority", args: [] };
|
|
97
|
+
const r = shouldReResolve(classified);
|
|
98
|
+
assert.equal(r.reResolve, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("respects an explicit off switch (boolean false)", () => {
|
|
102
|
+
const classified = { kind: "query", pattern: "fast-lane", args: [] };
|
|
103
|
+
const r = shouldReResolve(classified, { enabled: false });
|
|
104
|
+
assert.equal(r.reResolve, false);
|
|
105
|
+
assert.match(r.reason, /off/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("respects an explicit off switch (string 'off', case-insensitive)", () => {
|
|
109
|
+
const classified = { kind: "query", pattern: "fast-lane", args: [] };
|
|
110
|
+
assert.equal(shouldReResolve(classified, { enabled: "off" }).reResolve, false);
|
|
111
|
+
assert.equal(shouldReResolve(classified, { enabled: "OFF" }).reResolve, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("bounds termination via maxRounds", () => {
|
|
115
|
+
const classified = { kind: "query", pattern: "milestone", args: [] };
|
|
116
|
+
assert.equal(shouldReResolve(classified, { maxRounds: 3 }, 2).reResolve, true);
|
|
117
|
+
const atLimit = shouldReResolve(classified, { maxRounds: 3 }, 3);
|
|
118
|
+
assert.equal(atLimit.reResolve, false);
|
|
119
|
+
assert.match(atLimit.reason, /max_rounds/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("foldNewMatches", () => {
|
|
124
|
+
it("returns issues not already in the processed registry", () => {
|
|
125
|
+
const r = foldNewMatches([10, 11, 12], [10, 11]);
|
|
126
|
+
assert.deepEqual(r.newMatches, [12]);
|
|
127
|
+
assert.deepEqual(r.alreadyProcessed, [10, 11]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("is idempotent — re-running with an updated registry returns nothing new", () => {
|
|
131
|
+
const first = foldNewMatches([10, 11, 12], [10, 11]);
|
|
132
|
+
assert.deepEqual(first.newMatches, [12]);
|
|
133
|
+
const registryAfterFold = new Set([10, 11, ...first.newMatches]);
|
|
134
|
+
const second = foldNewMatches([10, 11, 12], registryAfterFold);
|
|
135
|
+
assert.deepEqual(second.newMatches, []);
|
|
136
|
+
assert.deepEqual(second.alreadyProcessed, [10, 11, 12]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("de-dupes duplicate numbers within the same re-resolution result", () => {
|
|
140
|
+
const r = foldNewMatches([5, 5, 6], []);
|
|
141
|
+
assert.deepEqual(r.newMatches, [5, 6]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles an empty re-resolution result", () => {
|
|
145
|
+
const r = foldNewMatches([], [1, 2, 3]);
|
|
146
|
+
assert.deepEqual(r.newMatches, []);
|
|
147
|
+
assert.deepEqual(r.alreadyProcessed, []);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -57,7 +57,7 @@ export function deriveState(events) {
|
|
|
57
57
|
/** @type {import("./phases.mjs").RunState} */
|
|
58
58
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
59
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
|
-
terminalReason: null, lease: null };
|
|
60
|
+
terminalReason: null, lease: null, lastRateLimit: null };
|
|
61
61
|
for (const e of events) {
|
|
62
62
|
switch (e.event) {
|
|
63
63
|
case "RUN_START":
|
|
@@ -72,6 +72,16 @@ export function deriveState(events) {
|
|
|
72
72
|
case "RUN_TERMINAL":
|
|
73
73
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
74
74
|
break;
|
|
75
|
+
// forge#2524: a session-limit pause is purely informational — it does
|
|
76
|
+
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
77
|
+
// pause is, by construction, not yet committed (the engine is about to
|
|
78
|
+
// retry it from scratch once the wait elapses), so a resumed/hydrated
|
|
79
|
+
// run correctly re-enters pickPhase at the same phase with no special
|
|
80
|
+
// casing needed here beyond recording the diagnostic below.
|
|
81
|
+
case "PHASE_RATE_LIMITED":
|
|
82
|
+
s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null };
|
|
83
|
+
s.v = e.seq;
|
|
84
|
+
break;
|
|
75
85
|
// PHASE_START / PHASE_FAILED do not change committed state
|
|
76
86
|
}
|
|
77
87
|
}
|
package/bin/engine/state.mjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Codec for the single machine-readable FORGE:STATE HTML-comment block on an
|
|
3
3
|
* issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
|
|
4
4
|
*/
|
|
5
|
+
import {
|
|
6
|
+
escapeHtmlCommentDelimiters,
|
|
7
|
+
unescapeHtmlCommentDelimiters,
|
|
8
|
+
} from "../../packages/protocol/src/html-comment-escape.js";
|
|
9
|
+
|
|
5
10
|
const OPEN = "<!-- FORGE:STATE";
|
|
6
11
|
const CLOSE = "-->";
|
|
7
12
|
const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
|
|
@@ -12,25 +17,18 @@ export function serializeState(index) {
|
|
|
12
17
|
// open or close an HTML comment in GitHub's renderer:
|
|
13
18
|
// - "<!--" the comment opener — an unescaped opener would start a new
|
|
14
19
|
// (nested-looking) comment, visually swallowing subsequent content
|
|
15
|
-
// (mirrors packages/protocol/src/emit.js's sanitizeFieldValue, forge#1638/#2119)
|
|
16
20
|
// - "-->" and "--!>" the two comment-close forms browsers/HTML parsers
|
|
17
21
|
// honour. JSON.stringify does not escape any of these, so a payload
|
|
18
22
|
// value containing them could otherwise open/close the
|
|
19
23
|
// <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
|
|
20
24
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
// doubly-escaped, e.g. "&" -> "&" -> untouched by the delimiter pass
|
|
29
|
-
// that only matches literal "<!--"/"--"+">" character sequences).
|
|
30
|
-
const payload = JSON.stringify(index)
|
|
31
|
-
.replace(/&/g, "&")
|
|
32
|
-
.replace(/<!--/g, "<!--")
|
|
33
|
-
.replace(/--(!?)>/g, "--$1>");
|
|
25
|
+
// Delegated to the shared escapeHtmlCommentDelimiters() helper (forge#2225),
|
|
26
|
+
// which is also used by packages/protocol/src/emit.js's sanitizeFieldValue —
|
|
27
|
+
// this scheme previously had two hand-duplicated copies kept in sync only by
|
|
28
|
+
// cross-referencing code comments, and had already drifted once (forge#1638's
|
|
29
|
+
// fix landed here only after forge#2119). A single shared implementation
|
|
30
|
+
// removes that drift vector entirely.
|
|
31
|
+
const payload = escapeHtmlCommentDelimiters(JSON.stringify(index));
|
|
34
32
|
return `${OPEN}\n${payload}\n${CLOSE}`;
|
|
35
33
|
}
|
|
36
34
|
|
|
@@ -38,21 +36,15 @@ export function serializeState(index) {
|
|
|
38
36
|
export function parseState(issueBody) {
|
|
39
37
|
const m = BLOCK_RE.exec(issueBody || "");
|
|
40
38
|
if (!m) return null;
|
|
41
|
-
// Inverse of serializeState
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// sequence (forge#2137).
|
|
39
|
+
// Inverse of serializeState — delegated to the shared
|
|
40
|
+
// unescapeHtmlCommentDelimiters() helper (forge#2225), same ordering
|
|
41
|
+
// rationale as packages/protocol/src/parse.js's unescapeFieldValue: closer
|
|
42
|
+
// forms restored first (most-specific "--!>" before "-->"), opener
|
|
43
|
+
// restored next, and "&" -> "&" unescaped LAST so it never touches the
|
|
44
|
+
// "&" that is part of a just-restored "<!--"/"-->"/"--!>" sequence
|
|
45
|
+
// (forge#2137).
|
|
49
46
|
try {
|
|
50
|
-
return JSON.parse(
|
|
51
|
-
m[1].trim()
|
|
52
|
-
.replace(/--(!?)>/g, "--$1>")
|
|
53
|
-
.replace(/<!--/g, "<!--")
|
|
54
|
-
.replace(/&/g, "&"),
|
|
55
|
-
);
|
|
47
|
+
return JSON.parse(unescapeHtmlCommentDelimiters(m[1].trim()));
|
|
56
48
|
} catch { return null; }
|
|
57
49
|
}
|
|
58
50
|
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -55,15 +55,60 @@ export function makeIo() {
|
|
|
55
55
|
|
|
56
56
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Sums the four token-usage fields across every `PHASE_COMMIT`/`PHASE_FAILED`
|
|
60
|
+
* run-log event that carries a non-null `usage` object. Mirrors the writer
|
|
61
|
+
* shape and `?? 0` accumulation convention used by `bin/engine.mjs:411,500`
|
|
62
|
+
* (`usage: outcome.usage ?? null`) and `bin/runner.mjs:1699-1723` (the
|
|
63
|
+
* canonical per-run accumulator) — same field names, same null-safe adds.
|
|
64
|
+
*
|
|
65
|
+
* Only `PHASE_COMMIT`/`PHASE_FAILED` events carry `usage`; other event types
|
|
66
|
+
* are skipped without inspecting their shape.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<object>} events - run-log events (from readLog())
|
|
69
|
+
* @returns {{input_tokens: number, output_tokens: number, cache_creation_input_tokens: number, cache_read_input_tokens: number} | null}
|
|
70
|
+
* `null` when no event carried usage data (e.g. an all-CLI-backend run) —
|
|
71
|
+
* callers MUST treat `null` as "omit the line", never substitute zeros
|
|
72
|
+
* (a `0 tokens` line would misleadingly imply usage was measured and was
|
|
73
|
+
* zero, rather than "not measured at all").
|
|
74
|
+
*/
|
|
75
|
+
export function aggregateUsage(events) {
|
|
76
|
+
let found = false;
|
|
77
|
+
const total = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
78
|
+
for (const e of events) {
|
|
79
|
+
if (e.event !== "PHASE_COMMIT" && e.event !== "PHASE_FAILED") continue;
|
|
80
|
+
if (!e.usage || typeof e.usage !== "object") continue;
|
|
81
|
+
found = true;
|
|
82
|
+
total.input_tokens += e.usage.input_tokens ?? 0;
|
|
83
|
+
total.output_tokens += e.usage.output_tokens ?? 0;
|
|
84
|
+
total.cache_creation_input_tokens += e.usage.cache_creation_input_tokens ?? 0;
|
|
85
|
+
total.cache_read_input_tokens += e.usage.cache_read_input_tokens ?? 0;
|
|
86
|
+
}
|
|
87
|
+
return found ? total : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Renders a single usage-summary line from an `aggregateUsage()` result, or
|
|
92
|
+
* `""` when `usage` is `null` — callers must skip printing entirely in that
|
|
93
|
+
* case rather than print a placeholder/zero line.
|
|
94
|
+
* @param {{input_tokens:number, output_tokens:number, cache_creation_input_tokens:number, cache_read_input_tokens:number} | null} usage
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatUsageLine(usage) {
|
|
98
|
+
if (!usage) return "";
|
|
99
|
+
return ` usage: ${usage.input_tokens} in / ${usage.output_tokens} out (${usage.cache_read_input_tokens} cache-read / ${usage.cache_creation_input_tokens} cache-write)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
58
102
|
/**
|
|
59
103
|
* Renders the diagnostic block printed below the bare terminal line whenever
|
|
60
104
|
* a run does not terminate `merged` (forge#2175). Reconstructs the failing
|
|
61
105
|
* phase, attempt count, and `PHASE_FAILED.reason` from the durable run-log
|
|
62
106
|
* (the same data `bin/engine.mjs`'s `runPhaseWithRetry()` already appends via
|
|
63
|
-
* `appendEvent()`), plus the final committed/branch/pr state
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
107
|
+
* `appendEvent()`), plus the final committed/branch/pr state, the aggregate
|
|
108
|
+
* per-run token usage (forge#2399 — omitted when no event carries usage
|
|
109
|
+
* data), and the run-log path — closing the gap where an operator previously
|
|
110
|
+
* had to manually open `~/.forge/runs/{issue}.jsonl` and read engine source
|
|
111
|
+
* to interpret a bare `issue #N -> needs-human` line.
|
|
67
112
|
*
|
|
68
113
|
* Best-effort: if the run-log is empty/unreadable (e.g. a `deferred` early
|
|
69
114
|
* return before any event was appended), only the run-log path line is
|
|
@@ -95,13 +140,15 @@ export function formatTerminalDiagnostics(dir, issue) {
|
|
|
95
140
|
const state = deriveState(events);
|
|
96
141
|
const lastFailure = [...events].reverse().find((e) => e.event === "PHASE_FAILED");
|
|
97
142
|
if (lastFailure) {
|
|
98
|
-
lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${DEFAULT_MAX_ATTEMPTS} attempts)`);
|
|
143
|
+
lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${lastFailure.maxAttempts ?? DEFAULT_MAX_ATTEMPTS} attempts)`);
|
|
99
144
|
lines.push(` reason: ${lastFailure.reason}`);
|
|
100
145
|
}
|
|
101
146
|
const committed = state.committed.length ? state.committed.join(",") : "";
|
|
102
147
|
lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
|
|
103
148
|
}
|
|
104
149
|
lines.push(` run-log: ${runLogPath}`);
|
|
150
|
+
const usageLine = formatUsageLine(aggregateUsage(events));
|
|
151
|
+
if (usageLine) lines.push(usageLine);
|
|
105
152
|
return lines.join("\n");
|
|
106
153
|
}
|
|
107
154
|
|
|
@@ -304,8 +351,30 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
304
351
|
const agentId = `cli_${process.pid}`;
|
|
305
352
|
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
306
353
|
const dir = deps.dir ?? runDir();
|
|
354
|
+
// forge#2240: print the run-log path at the very start of the run, not only
|
|
355
|
+
// in the non-merged completion diagnostics (formatTerminalDiagnostics prints
|
|
356
|
+
// it too, but only post-completion and only for non-"merged" outcomes). A
|
|
357
|
+
// caller tailing this process's stdout should be able to find the run-log
|
|
358
|
+
// to inspect immediately, without waiting for the run to finish.
|
|
359
|
+
console.log(`run-log: ${join(dir, `${issue}.jsonl`)}`);
|
|
360
|
+
// forge#2240: phase-boundary progress lines — the only stdout emitted
|
|
361
|
+
// during the run itself. `runIssue()`'s `onProgress` callback defaults to a
|
|
362
|
+
// no-op, so this is purely additive; engine.mjs never calls console.log
|
|
363
|
+
// directly (keeps it injectable/testable — see its onProgress param doc).
|
|
364
|
+
const onProgress = (e) => {
|
|
365
|
+
if (e.event === "phase_enter") console.log(`→ phase ${e.phase} started`);
|
|
366
|
+
else if (e.event === "phase_exit" && e.status === "committed") console.log(`✓ phase ${e.phase} committed`);
|
|
367
|
+
else if (e.event === "phase_exit" && e.status === "blocked") console.log(`✗ phase ${e.phase} blocked: ${e.detail ?? "no detail"}`);
|
|
368
|
+
// forge#2524: session-limit pause — the engine is waiting in-process
|
|
369
|
+
// until the reported reset time before retrying this same phase, rather
|
|
370
|
+
// than terminating. Distinct glyph from the exit/blocked lines above so
|
|
371
|
+
// an operator tailing this stdout can tell "still alive, just waiting"
|
|
372
|
+
// from "phase actually failed".
|
|
373
|
+
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
374
|
+
};
|
|
307
375
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
308
376
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
377
|
+
onProgress,
|
|
309
378
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
310
379
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
311
380
|
...(backend ? { backend } : {}),
|
|
@@ -319,6 +388,18 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
319
388
|
// to avoid regressing/cluttering the existing happy-path output.
|
|
320
389
|
if (res.terminalReason !== "merged") {
|
|
321
390
|
console.log(formatTerminalDiagnostics(dir, issue));
|
|
391
|
+
} else {
|
|
392
|
+
// forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
|
|
393
|
+
// block) intentionally stays gated to non-merged outcomes above, but the
|
|
394
|
+
// aggregate token-usage total should print for "total tokens per run" to
|
|
395
|
+
// hold on the common success case too — surface just the usage line here
|
|
396
|
+
// rather than the full diagnostic block.
|
|
397
|
+
try {
|
|
398
|
+
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
|
|
399
|
+
if (usageLine) console.log(usageLine);
|
|
400
|
+
} catch {
|
|
401
|
+
// Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
|
|
402
|
+
}
|
|
322
403
|
}
|
|
323
404
|
return res;
|
|
324
405
|
}
|