forgedock 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +92 -0
- package/bin/engine/phases.mjs +86 -26
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +12 -1
- package/bin/engine.mjs +145 -40
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +17 -2
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +102 -25
- package/commands/review-pr.md +119 -103
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +3 -2
- package/commands/work-on/build.md +95 -64
- package/commands/work-on/close.md +17 -6
- package/commands/work-on/decompose.md +45 -4
- package/commands/work-on/investigate.md +6 -2
- package/commands/work-on/remediate.md +22 -6
- package/commands/work-on/review.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +13 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +5 -0
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +8 -3
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed, versioned enum of engine outcome codes (forge#3833).
|
|
3
|
+
*
|
|
4
|
+
* Every PHASE_FAILED, blocked PHASE_COMMIT and RUN_TERMINAL run-log event carries
|
|
5
|
+
* one `code` from this enum so stall causes can be counted and grouped without a
|
|
6
|
+
* model reading prose. Codes are derived in code from structured evidence
|
|
7
|
+
* (REVIEW_RESULT fields, FORGE markers, `err.code`), never from free-text `detail`.
|
|
8
|
+
* The free-text `reason`/`detail` stays alongside for humans. Documented in
|
|
9
|
+
* docs/OUTCOME-CODES.md (a test asserts enum/doc parity). Pure module: no I/O.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bump when a code is renamed or removed; adding a code is additive. */
|
|
13
|
+
export const OUTCOME_CODES_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/** Fallback for an absent or unknown code; the raw reason is kept alongside. */
|
|
16
|
+
export const UNCLASSIFIED = "unclassified";
|
|
17
|
+
|
|
18
|
+
export const OUTCOME_CODES = Object.freeze({
|
|
19
|
+
REVIEW_CHANGES_REQUESTED: "review.changes_requested",
|
|
20
|
+
REVIEW_ESCALATED: "review.escalated",
|
|
21
|
+
REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
|
|
22
|
+
BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
|
|
23
|
+
BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
|
|
24
|
+
BUILD_SIZE_OVER_GATE: "build.size_over_gate",
|
|
25
|
+
BUILD_COHESIVE: "build.cohesive",
|
|
26
|
+
REMEDIATE_NO_MARKER: "remediate.no_marker",
|
|
27
|
+
REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
|
|
28
|
+
REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
|
|
29
|
+
REMEDIATE_UNFIXABLE: "remediate.unfixable",
|
|
30
|
+
CI_RED: "ci.red",
|
|
31
|
+
CI_TIMEOUT: "ci.timeout",
|
|
32
|
+
BASE_CONFLICT: "base.conflict",
|
|
33
|
+
RATE_LIMITED: "rate_limited",
|
|
34
|
+
ENGINE_ERROR: "engine.error",
|
|
35
|
+
LEASE_HELD: "lease.held",
|
|
36
|
+
INVALID: "invalid",
|
|
37
|
+
DECOMPOSED: "decomposed",
|
|
38
|
+
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
39
|
+
COMPLETED: "completed",
|
|
40
|
+
AWAITING_MERGE: "awaiting_merge",
|
|
41
|
+
UNCLASSIFIED,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const CODE_SET = new Set(Object.values(OUTCOME_CODES));
|
|
45
|
+
|
|
46
|
+
/** Every code, in declaration order. */
|
|
47
|
+
export const ALL_OUTCOME_CODES = Object.freeze([...CODE_SET]);
|
|
48
|
+
|
|
49
|
+
export function isOutcomeCode(code) {
|
|
50
|
+
return typeof code === "string" && CODE_SET.has(code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Absent or unknown -> `unclassified`; never throws. */
|
|
54
|
+
export function normalizeCode(code) {
|
|
55
|
+
return isOutcomeCode(code) ? code : UNCLASSIFIED;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
|
|
59
|
+
export function codeForRunnerError(err) {
|
|
60
|
+
if (!err || typeof err !== "object") return UNCLASSIFIED;
|
|
61
|
+
if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
|
|
62
|
+
return OUTCOME_CODES.RATE_LIMITED;
|
|
63
|
+
switch (err.code) {
|
|
64
|
+
case "NO_API_KEY":
|
|
65
|
+
case "NO_SDK":
|
|
66
|
+
case "CLI_BACKEND_FAILED":
|
|
67
|
+
case "PHASE_ARGS_INVALID":
|
|
68
|
+
case "CLI_MAX_TURNS":
|
|
69
|
+
return OUTCOME_CODES.ENGINE_ERROR;
|
|
70
|
+
default:
|
|
71
|
+
return UNCLASSIFIED;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default code for a terminal reason when the producer supplied none. Only reasons that
|
|
77
|
+
* name one cause map; `needs-human` has many causes, so it stays `unclassified`.
|
|
78
|
+
*/
|
|
79
|
+
export function codeForTerminalReason(reason) {
|
|
80
|
+
switch (reason) {
|
|
81
|
+
case "merged":
|
|
82
|
+
case "phase-complete":
|
|
83
|
+
case "investigation":
|
|
84
|
+
return OUTCOME_CODES.COMPLETED;
|
|
85
|
+
case "awaiting-merge": return OUTCOME_CODES.AWAITING_MERGE;
|
|
86
|
+
case "invalid": return OUTCOME_CODES.INVALID;
|
|
87
|
+
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
88
|
+
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
|
89
|
+
case "deferred": return OUTCOME_CODES.LEASE_HELD;
|
|
90
|
+
default: return UNCLASSIFIED;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// imports the identical registry, so the two can no longer drift apart the way
|
|
13
13
|
// they did in forge#2375/PR#2395.
|
|
14
14
|
import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
|
|
15
|
+
// forge#3833: closed outcome-code enum; every code below comes from structured evidence, never `detail` prose.
|
|
16
|
+
import { OUTCOME_CODES as OC } from "./outcome-codes.mjs";
|
|
15
17
|
|
|
16
18
|
// forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
|
|
17
19
|
// failures (e.g. an exhausted retry loop where the runner itself never once
|
|
@@ -48,12 +50,22 @@ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed
|
|
|
48
50
|
*/
|
|
49
51
|
export function isTrustedComment(c) {
|
|
50
52
|
if (!c || typeof c !== "object") return false;
|
|
53
|
+
return isTrustedActor({ association: c.author_association, type: c.user?.type, login: c.user?.login });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The ONE actor predicate behind `isTrustedComment` and the issue-body
|
|
58
|
+
* FORGE:STATE gate (projector). `association` is the GitHub author association,
|
|
59
|
+
* `type` the actor's `__typename`/`user.type` ("Bot", "User"...), `login` its
|
|
60
|
+
* login. Missing fields fail closed.
|
|
61
|
+
*/
|
|
62
|
+
export function isTrustedActor({ association, type, login } = {}) {
|
|
51
63
|
const list = (v) => String(v).split(",").map((x) => x.trim()).filter(Boolean);
|
|
52
64
|
const assoc = list(process.env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR");
|
|
53
65
|
const logins = list(process.env.FORGE_TRAIL_TRUSTED_LOGINS ?? "");
|
|
54
|
-
return assoc.includes(
|
|
55
|
-
|| (
|
|
56
|
-
|| logins.includes(
|
|
66
|
+
return assoc.includes(association ?? "")
|
|
67
|
+
|| (type ?? "") === "Bot"
|
|
68
|
+
|| logins.includes(login ?? "");
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
@@ -99,6 +111,8 @@ async function issueMarkers(issue, io) {
|
|
|
99
111
|
const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
|
|
100
112
|
/** Non-terminal deferral record (remediate.md Phase M0 liveness step): a live earlier run holds the lease. Never a COMPLETE/trail marker. */
|
|
101
113
|
const REMEDIATION_DEFERRED_MARKER = "FORGE:REMEDIATION_DEFERRED";
|
|
114
|
+
/** Per-dispatch record the engine posts on every remediate dispatch (forge#3807): scopes a DEFERRED record to its own dispatch. Never a COMPLETE/trail marker. */
|
|
115
|
+
const REMEDIATION_DISPATCH_MARKER = "FORGE:REMEDIATION_DISPATCH";
|
|
102
116
|
/** Lease holder ids the engine mints (`cli_<host>_<pid>`) are passed to remediate only when they match this shape (same as remediate-liveness.sh). */
|
|
103
117
|
const LEASE_HOLDER_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
104
118
|
const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -186,15 +200,32 @@ export async function postCycleMarker(issue, n, io) {
|
|
|
186
200
|
* ALREADY_DONE when the newest gate marker is blocked (forge#3725).
|
|
187
201
|
*/
|
|
188
202
|
function latestGateState(comments) {
|
|
203
|
+
return latestGateMarker(comments).state;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Single newest-first scan shared by latestGateState and latestBlockedIndex so their
|
|
207
|
+
* predicates cannot drift. Returns the newest gate marker's state and its comment index
|
|
208
|
+
* (`{state: "none", index: -1}` when there is none).
|
|
209
|
+
*/
|
|
210
|
+
function latestGateMarker(comments) {
|
|
189
211
|
const gate = "FORGE:ACCEPTANCE_GATE";
|
|
190
212
|
for (let i = comments.length - 1; i >= 0; i--) {
|
|
191
213
|
const b = comments[i];
|
|
192
|
-
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
|
|
214
|
+
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
|
|
193
215
|
if (!startsWithHeader(b, gate)) continue;
|
|
194
|
-
if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
|
|
195
|
-
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
|
|
216
|
+
if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
|
|
217
|
+
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
|
|
196
218
|
}
|
|
197
|
-
return "none";
|
|
219
|
+
return { state: "none", index: -1 };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Comment index of the newest blocked gate marker, or -1 when the newest gate marker is
|
|
223
|
+
* not blocked (a newer PASSED clears it). Recency anchor for build.detectOutcome so an
|
|
224
|
+
* older blocked marker cannot shadow a strictly newer size-gate routing marker.
|
|
225
|
+
*/
|
|
226
|
+
function latestBlockedIndex(comments) {
|
|
227
|
+
const m = latestGateMarker(comments);
|
|
228
|
+
return m.state === "blocked" ? m.index : -1;
|
|
198
229
|
}
|
|
199
230
|
/**
|
|
200
231
|
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
@@ -265,18 +296,22 @@ function overrideHasJustification(body) {
|
|
|
265
296
|
* chronological order (see trustedCommentBodies); an untrusted comment never
|
|
266
297
|
* reaches this function.
|
|
267
298
|
*/
|
|
268
|
-
function
|
|
299
|
+
function sizeGateRouteIndex(comments) {
|
|
269
300
|
const gate = PHASE_MARKERS.build.sizeGateMarker;
|
|
270
301
|
const override = PHASE_MARKERS.build.sizeOverrideMarker;
|
|
271
302
|
const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
|
|
272
|
-
if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return
|
|
303
|
+
if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return -1;
|
|
273
304
|
let idx = -1;
|
|
274
305
|
for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
|
|
275
|
-
if (idx < 0) return
|
|
306
|
+
if (idx < 0) return -1;
|
|
276
307
|
const body = comments[idx].replace(/\r\n/g, "\n");
|
|
277
308
|
const m = /^result:[ \t]*(OK|OVERRIDDEN|COHESIVE|OVER)[ \t]*$/m.exec(body);
|
|
278
|
-
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return
|
|
279
|
-
return
|
|
309
|
+
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return -1;
|
|
310
|
+
return comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c)) ? -1 : idx;
|
|
311
|
+
}
|
|
312
|
+
/** Boolean form of sizeGateRouteIndex (index of the routing DIFF_SIZE comment, or -1). */
|
|
313
|
+
function sizeGateRoutesToDecompose(comments) {
|
|
314
|
+
return sizeGateRouteIndex(comments) >= 0;
|
|
280
315
|
}
|
|
281
316
|
/**
|
|
282
317
|
* Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
|
|
@@ -625,7 +660,7 @@ export const PHASES = [
|
|
|
625
660
|
const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
|
|
626
661
|
const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
|
|
627
662
|
if (closing === invalid)
|
|
628
|
-
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
663
|
+
return { status: "committed", terminalReason: "invalid", code: OC.INVALID, outputs: { verdict: "INVALID" } };
|
|
629
664
|
if (closing === complete) {
|
|
630
665
|
// investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
|
|
631
666
|
if (isDecomposed(report)) {
|
|
@@ -636,7 +671,7 @@ export const PHASES = [
|
|
|
636
671
|
console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
|
|
637
672
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
638
673
|
}
|
|
639
|
-
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
674
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: { decompose: true } };
|
|
640
675
|
}
|
|
641
676
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
642
677
|
}
|
|
@@ -665,7 +700,7 @@ export const PHASES = [
|
|
|
665
700
|
async detectOutcome(state, io) {
|
|
666
701
|
const { comments } = await issueMarkers(state.issue, io);
|
|
667
702
|
if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
|
|
668
|
-
return { status: "committed", terminalReason: "decomposed", outputs: {} };
|
|
703
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: {} };
|
|
669
704
|
return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
|
|
670
705
|
},
|
|
671
706
|
// Always terminal: decomposition spawns independent sub-issues, each of
|
|
@@ -722,8 +757,15 @@ export const PHASES = [
|
|
|
722
757
|
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
723
758
|
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
724
759
|
// build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
|
|
760
|
+
// forge#3712: an OVER + Split Proposal size-gate marker STRICTLY NEWER (comment order)
|
|
761
|
+
// than the newest blocked marker is the retry's real outcome; route to decompose.
|
|
762
|
+
// Equal/absent index stays fail-closed, as does a newer blocked marker.
|
|
763
|
+
if (!complete && latestGateState(comments) === "blocked" && mayRouteToDecompose(comments)
|
|
764
|
+
&& sizeGateRouteIndex(comments) > latestBlockedIndex(comments)) {
|
|
765
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
|
|
766
|
+
}
|
|
725
767
|
if (latestGateState(comments) === "blocked") {
|
|
726
|
-
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
|
|
768
|
+
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
|
|
727
769
|
}
|
|
728
770
|
if (complete && ahead > 0) {
|
|
729
771
|
return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
|
|
@@ -739,7 +781,7 @@ export const PHASES = [
|
|
|
739
781
|
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
740
782
|
// re-emits the same exit.
|
|
741
783
|
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
742
|
-
return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
|
|
784
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
|
|
743
785
|
}
|
|
744
786
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
745
787
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
@@ -803,7 +845,7 @@ export const PHASES = [
|
|
|
803
845
|
// The PR number from GitHub (openPrFor) always wins over parsed text.
|
|
804
846
|
const outputs = { pr: pr.number };
|
|
805
847
|
// Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
|
|
806
|
-
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
|
|
848
|
+
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true, code: OC.REVIEW_ESCALATED };
|
|
807
849
|
const rr = parseReviewResult(result?.text);
|
|
808
850
|
// forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
|
|
809
851
|
// remediate phase can post the matching bound marker. Untrusted model output: only
|
|
@@ -812,7 +854,7 @@ export const PHASES = [
|
|
|
812
854
|
if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
|
|
813
855
|
outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
|
|
814
856
|
if (pr.needsHuman || pr.remediating) return escalated;
|
|
815
|
-
if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
|
|
857
|
+
if (rr && rr.status === "NEXT" && rr.next === "remediate") return { ...escalated, code: OC.REVIEW_CHANGES_REQUESTED };
|
|
816
858
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
817
859
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
818
860
|
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
@@ -854,7 +896,17 @@ export const PHASES = [
|
|
|
854
896
|
// Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
|
|
855
897
|
// is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
|
|
856
898
|
// single-attempt guard. A present-but-unknown kind is never dispatched.
|
|
857
|
-
|
|
899
|
+
// forge#3807: every dispatch (kind-bound or legacy) posts a dispatch marker AFTER the bound marker,
|
|
900
|
+
// never deduped, so detectOutcome can tell a stale deferral from an earlier dispatch from this one's.
|
|
901
|
+
const postDispatchMarker = async () => {
|
|
902
|
+
try {
|
|
903
|
+
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
904
|
+
`<!-- ${REMEDIATION_DISPATCH_MARKER}: pr=${pr} -->\nEngine dispatching remediation for PR #${pr}.`]);
|
|
905
|
+
} catch (e) {
|
|
906
|
+
throw new PhaseArgsError(`github-unavailable: could not post ${REMEDIATION_DISPATCH_MARKER}; remediation not invoked (${e?.message || e})`);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
if (kind === null) { await postDispatchMarker(); return legacyArgs(); }
|
|
858
910
|
if (!isRemediationKind(kind))
|
|
859
911
|
throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
|
|
860
912
|
const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
|
|
@@ -870,6 +922,7 @@ export const PHASES = [
|
|
|
870
922
|
} catch (e) {
|
|
871
923
|
throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
|
|
872
924
|
}
|
|
925
|
+
await postDispatchMarker();
|
|
873
926
|
return legacyArgs();
|
|
874
927
|
},
|
|
875
928
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
@@ -935,16 +988,20 @@ export const PHASES = [
|
|
|
935
988
|
// lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
|
|
936
989
|
// PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
|
|
937
990
|
// that proceeded posts one, which retires the deferral.
|
|
938
|
-
|
|
991
|
+
// forge#3807: a deferral older than this dispatch's marker is stale (a later dispatch proceeded and
|
|
992
|
+
// crashed before posting a trail), so it is a failed attempt. No marker (older runs) => dispatchAt -1.
|
|
993
|
+
let deferredAt = -1, trailAt = -1, dispatchAt = -1, deferred = null;
|
|
994
|
+
const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
|
|
939
995
|
scoped.forEach((b, i) => {
|
|
996
|
+
if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
|
|
940
997
|
if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
|
|
941
998
|
else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
|
|
942
999
|
});
|
|
943
|
-
if (deferred !== null && deferredAt > trailAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
1000
|
+
if (deferred !== null && deferredAt > trailAt && deferredAt > dispatchAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
944
1001
|
const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
|
|
945
|
-
return { status: "blocked", reason: "deferred", retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
1002
|
+
return { status: "blocked", reason: "deferred", code: OC.REMEDIATE_DEFERRED_LIVE_RUN, retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
946
1003
|
}
|
|
947
|
-
return { status: "failed", detail: `no ${completion} marker` };
|
|
1004
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `no ${completion} marker` };
|
|
948
1005
|
}
|
|
949
1006
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
950
1007
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
@@ -972,8 +1029,11 @@ export const PHASES = [
|
|
|
972
1029
|
// policy judgment call respectively) — reuse the existing
|
|
973
1030
|
// "needs-human" terminal reason rather than inventing two more,
|
|
974
1031
|
// matching the #2352/#2353 precedent of reusing an existing
|
|
975
|
-
// TERMINAL_REASONS value where semantically equivalent.
|
|
976
|
-
|
|
1032
|
+
// TERMINAL_REASONS value where semantically equivalent. forge#3833:
|
|
1033
|
+
// the two stay distinguishable by outcome code.
|
|
1034
|
+
return { status: "committed", terminalReason: "needs-human",
|
|
1035
|
+
code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
|
|
1036
|
+
outputs: { reGateOutcome } };
|
|
977
1037
|
default:
|
|
978
1038
|
return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
|
|
979
1039
|
}
|
package/bin/engine/projector.mjs
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
|
|
4
4
|
*/
|
|
5
5
|
import { parseState, upsertStateBlock } from "./state.mjs";
|
|
6
|
+
import { isTrustedActor } from "./phases.mjs";
|
|
7
|
+
|
|
8
|
+
// Defence in depth: a lease further out than 3x the engine's 10 min TTL (same
|
|
9
|
+
// bound as scripts/remediate-liveness.sh MAX_LEASE_TTL_MS) is never honoured.
|
|
10
|
+
export const MAX_LEASE_TTL_MS = 3 * 600_000;
|
|
11
|
+
|
|
12
|
+
// Body + provenance in ONE call. `gh issue view --json` cannot return the last
|
|
13
|
+
// editor or authorAssociation, so GraphQL is used. -F variables, never interpolation.
|
|
14
|
+
const PROVENANCE_QUERY = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){"
|
|
15
|
+
+ "issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}";
|
|
6
16
|
|
|
7
17
|
export function makeProjector(io) {
|
|
8
18
|
const gh = io.gh;
|
|
@@ -12,9 +22,46 @@ export function makeProjector(io) {
|
|
|
12
22
|
try { return JSON.parse(out).body ?? ""; } catch { return ""; }
|
|
13
23
|
}
|
|
14
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Body of the issue iff its LAST EDITOR (`editor ?? author`) is trusted, else
|
|
27
|
+
* null. The reporter can edit their own body, so creator-only trust is wrong
|
|
28
|
+
* and engine writes (bot editor) must stay readable. Any error, malformed
|
|
29
|
+
* JSON or missing provenance fails closed (null).
|
|
30
|
+
*/
|
|
31
|
+
async function getTrustedBody(issue) {
|
|
32
|
+
try {
|
|
33
|
+
const out = await gh(["api", "graphql", "-f", `query=${PROVENANCE_QUERY}`,
|
|
34
|
+
"-F", "owner={owner}", "-F", "name={repo}", "-F", `number=${Number(issue)}`]);
|
|
35
|
+
const i = JSON.parse(out)?.data?.repository?.issue;
|
|
36
|
+
if (!i || typeof i.body !== "string") return null;
|
|
37
|
+
const actor = i.editor ?? i.author;
|
|
38
|
+
if (!actor || typeof actor !== "object") return null;
|
|
39
|
+
// authorAssociation describes the CREATOR: valid for the actor only when
|
|
40
|
+
// the actor IS the creator. A distinct human editor's standing comes from
|
|
41
|
+
// the collaborator permission endpoint (fail closed to NONE).
|
|
42
|
+
let association = "NONE";
|
|
43
|
+
if (!i.editor || i.editor.login === i.author?.login) association = i.authorAssociation;
|
|
44
|
+
else if (actor.__typename !== "Bot" && typeof actor.login === "string" && /^[A-Za-z0-9-]+(\[bot\])?$/.test(actor.login)) {
|
|
45
|
+
try {
|
|
46
|
+
const perm = JSON.parse(await gh(["api", `repos/{owner}/{repo}/collaborators/${actor.login}/permission`, "--jq", "{permission}"])).permission;
|
|
47
|
+
association = perm === "admin" ? "OWNER" : (perm === "maintain" || perm === "write") ? "COLLABORATOR" : "NONE";
|
|
48
|
+
} catch { /* fail closed */ }
|
|
49
|
+
}
|
|
50
|
+
return isTrustedActor({ association, type: actor.__typename, login: actor.login }) ? i.body : null;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
return {
|
|
16
55
|
/** @returns {Promise<import("./phases.mjs").RunState|null>} */
|
|
17
|
-
async readState(issue) {
|
|
56
|
+
async readState(issue) {
|
|
57
|
+
const body = await getTrustedBody(issue);
|
|
58
|
+
if (body === null) return null;
|
|
59
|
+
const st = parseState(body);
|
|
60
|
+
if (st?.lease && !(Number.isFinite(st.lease.until) && st.lease.until <= Date.now() + MAX_LEASE_TTL_MS)) {
|
|
61
|
+
return { ...st, lease: null };
|
|
62
|
+
}
|
|
63
|
+
return st;
|
|
64
|
+
},
|
|
18
65
|
|
|
19
66
|
async writeState(issue, index) {
|
|
20
67
|
const body = upsertStateBlock(await getBody(issue), index);
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -12,7 +12,10 @@ export function appendEvent(dir, issue, event) {
|
|
|
12
12
|
mkdirSync(dir, { recursive: true });
|
|
13
13
|
const path = logPath(dir, issue);
|
|
14
14
|
const seq = readLog(dir, issue).length + 1;
|
|
15
|
-
|
|
15
|
+
// forge#3696: wall-clock ISO-8601 `ts` on every appended event. Placed before the
|
|
16
|
+
// spread so a caller-supplied `ts` wins. rewriteLog() deliberately does not stamp:
|
|
17
|
+
// a hydrated rewrite time would be a false timestamp. Old logs lack `ts` and still parse.
|
|
18
|
+
appendFileSync(path, JSON.stringify({ seq, ts: new Date().toISOString(), ...event }) + "\n");
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -58,7 +61,9 @@ export function deriveState(events) {
|
|
|
58
61
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
62
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
63
|
terminalReason: null, lease: null, lastRateLimit: null,
|
|
61
|
-
remediationKind: null };
|
|
64
|
+
remediationKind: null, outcomeCode: null };
|
|
65
|
+
// forge#3833: outcome code of the LAST PHASE_COMMIT only (replaced, like lastCommitReason).
|
|
66
|
+
let lastCommitCode = null;
|
|
62
67
|
// forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
|
|
63
68
|
let lastCommitReason = null;
|
|
64
69
|
// forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
|
|
@@ -80,6 +85,8 @@ export function deriveState(events) {
|
|
|
80
85
|
// remediate instead of defaulting to a "merged" close.
|
|
81
86
|
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
82
87
|
lastCommitReason = e.terminalReason ?? null;
|
|
88
|
+
lastCommitCode = e.code ?? null;
|
|
89
|
+
if (e.code) s.outcomeCode = e.code;
|
|
83
90
|
lastCommitPhase = e.phase ?? null;
|
|
84
91
|
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
85
92
|
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
@@ -89,6 +96,7 @@ export function deriveState(events) {
|
|
|
89
96
|
case "RUN_TERMINAL":
|
|
90
97
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
91
98
|
lastTerminalReason = s.terminalReason;
|
|
99
|
+
if (e.code) s.outcomeCode = e.code;
|
|
92
100
|
break;
|
|
93
101
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
94
102
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
@@ -97,7 +105,7 @@ export function deriveState(events) {
|
|
|
97
105
|
// (needs-human / decomposed); restore the last commit's reason so the
|
|
98
106
|
// retry re-enters remediate/decompose instead of close/build.
|
|
99
107
|
case "RUN_REOPEN":
|
|
100
|
-
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
108
|
+
s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
|
|
101
109
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
102
110
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
103
111
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
@@ -119,9 +127,9 @@ export function deriveState(events) {
|
|
|
119
127
|
// lastCommitReason is reset so a stale handoff reason cannot leak into
|
|
120
128
|
// the new cycle through a later RUN_REOPEN.
|
|
121
129
|
case "RUN_NEXT_CYCLE":
|
|
122
|
-
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
130
|
+
s.terminal = false; s.terminalReason = null; s.committed = []; s.outcomeCode = null;
|
|
123
131
|
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
124
|
-
lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
|
|
132
|
+
lastCommitReason = null; lastCommitPhase = null; lastCommitCode = null; lastTerminalReason = null; s.v = e.seq;
|
|
125
133
|
s.lastRateLimit = null; // forge#3791
|
|
126
134
|
break;
|
|
127
135
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
|
|
5
|
+
* seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
|
|
6
|
+
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, turns, duration_ms, cost_usd, *_tokens)
|
|
7
|
+
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
|
|
8
|
+
* Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
|
|
9
|
+
* are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { emit } from "./telemetry.mjs";
|
|
13
|
+
|
|
14
|
+
export function defaultResolveRepo() {
|
|
15
|
+
try {
|
|
16
|
+
const r = execFileSync("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
17
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).trim();
|
|
18
|
+
return /^[\w.-]+\/[\w.-]+$/.test(r) ? r : null;
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "string" && v !== "");
|
|
23
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
|
|
24
|
+
const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
|
|
25
|
+
|
|
26
|
+
/** Pure mapping, exported for tests: returns { event, payload, phase } or null when the record is not mapped. */
|
|
27
|
+
export function mapRecord(rec, wallMs) {
|
|
28
|
+
if (!rec || typeof rec !== "object") return null;
|
|
29
|
+
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
30
|
+
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
31
|
+
attempt: rec.attempt, model: rec.model, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
32
|
+
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
33
|
+
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
34
|
+
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
|
35
|
+
}) };
|
|
36
|
+
}
|
|
37
|
+
if (rec.event === "RUN_TERMINAL") {
|
|
38
|
+
return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{repo?: string|null, emitFn?: Function, now?: () => number, resolveRepo?: () => string|null, env?: object}} o
|
|
45
|
+
* @returns {(rec: object) => Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now, resolveRepo = defaultResolveRepo, env = process.env } = {}) {
|
|
48
|
+
const startedAt = now();
|
|
49
|
+
let resolved = repo || env.FORGE_REPO || null;
|
|
50
|
+
let tried = Boolean(resolved);
|
|
51
|
+
return async (rec) => {
|
|
52
|
+
try {
|
|
53
|
+
if ((env.FORGE_TELEMETRY || "on") === "off") return;
|
|
54
|
+
const mapped = mapRecord(rec, now() - startedAt);
|
|
55
|
+
if (!mapped || rec.issue == null) return;
|
|
56
|
+
if (!tried) { tried = true; resolved = resolveRepo() || null; }
|
|
57
|
+
if (!resolved) return; // no destination: the Node emitter would drop it anyway
|
|
58
|
+
// ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
|
|
59
|
+
emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined });
|
|
60
|
+
} catch { /* telemetry never fails a run */ }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { makeEmitTelemetry, mapRecord } from "./telemetry-seam.mjs";
|
|
6
|
+
|
|
7
|
+
const attempt = { event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 2, issue: 7, duration_ms: 1234, cost_usd: 0.123456789, model: "sonnet", turns: null,
|
|
8
|
+
usage: { input_tokens: 1, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4 } };
|
|
9
|
+
|
|
10
|
+
function harness(over = {}) {
|
|
11
|
+
const calls = [];
|
|
12
|
+
const emit = makeEmitTelemetry({ repo: "o/r", emitFn: (...a) => { calls.push(a); }, now: () => 5000, env: {}, ...over });
|
|
13
|
+
return { calls, emit };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("engine telemetry seam", () => {
|
|
17
|
+
it("flattens usage, omits nulls and passes repo/issue/run/phase as ctx (not payload)", async () => {
|
|
18
|
+
const { calls, emit } = harness();
|
|
19
|
+
await emit(attempt);
|
|
20
|
+
const [event, payload, ctx] = calls[0];
|
|
21
|
+
assert.equal(event, "phase_attempt");
|
|
22
|
+
assert.deepEqual(payload, { attempt: 2, model: "sonnet", duration_ms: 1234, cost_usd: 0.123457, input_tokens: 1, output_tokens: 2, cache_read_tokens: 3, cache_creation_tokens: 4 });
|
|
23
|
+
assert.equal(ctx.repo, "o/r");
|
|
24
|
+
assert.equal(ctx.issue, 7);
|
|
25
|
+
assert.equal(ctx.phase, "build");
|
|
26
|
+
assert.match(ctx.run, /^r_7_\d+$/);
|
|
27
|
+
for (const v of Object.values(payload)) assert.notEqual(typeof v, "object");
|
|
28
|
+
});
|
|
29
|
+
it("maps RUN_TERMINAL with reason, code and wall time; ignores other records", async () => {
|
|
30
|
+
let t = 1000;
|
|
31
|
+
const { calls, emit } = harness({ now: () => t });
|
|
32
|
+
t = 61000;
|
|
33
|
+
await emit({ event: "RUN_TERMINAL", reason: "needs-human", code: "review.escalated", issue: 7 });
|
|
34
|
+
await emit({ event: "PHASE_START", phase: "build", issue: 7 });
|
|
35
|
+
assert.equal(calls.length, 1);
|
|
36
|
+
assert.equal(calls[0][0], "run_terminal");
|
|
37
|
+
assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
|
|
38
|
+
assert.equal(calls[0][2].phase, undefined);
|
|
39
|
+
});
|
|
40
|
+
it("null usage yields a record with no token fields", () => {
|
|
41
|
+
assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
|
|
42
|
+
});
|
|
43
|
+
it("never throws, even when the emitter throws, and honours FORGE_TELEMETRY=off", async () => {
|
|
44
|
+
const boom = harness({ emitFn: () => { throw new Error("down"); } });
|
|
45
|
+
await boom.emit(attempt);
|
|
46
|
+
const off = harness({ env: { FORGE_TELEMETRY: "off" } });
|
|
47
|
+
await off.emit(attempt);
|
|
48
|
+
assert.equal(off.calls.length, 0);
|
|
49
|
+
});
|
|
50
|
+
it("drops records with no resolvable repo (the Node emitter would drop them) and resolves lazily once", async () => {
|
|
51
|
+
let resolves = 0;
|
|
52
|
+
const none = harness({ repo: null, resolveRepo: () => { resolves++; return null; } });
|
|
53
|
+
await none.emit(attempt); await none.emit(attempt);
|
|
54
|
+
assert.equal(none.calls.length, 0);
|
|
55
|
+
assert.equal(resolves, 1);
|
|
56
|
+
const some = harness({ repo: null, resolveRepo: () => "a/b" });
|
|
57
|
+
await some.emit(attempt);
|
|
58
|
+
assert.equal(some.calls[0][2].repo, "a/b");
|
|
59
|
+
});
|
|
60
|
+
});
|