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.
Files changed (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. 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
+ }
@@ -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(c.author_association ?? "")
55
- || (c.user?.type ?? "") === "Bot"
56
- || logins.includes(c.user?.login ?? "");
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 sizeGateRoutesToDecompose(comments) {
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 false;
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 false;
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 false;
279
- return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
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
- if (kind === null) return legacyArgs();
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
- let deferredAt = -1, trailAt = -1, deferred = null;
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
- return { status: "committed", terminalReason: "needs-human", outputs: { reGateOutcome } };
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
  }
@@ -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) { return parseState(await getBody(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);
@@ -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
- appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
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
+ });