forgedock 1.16.2 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
REVIEW_BLOCKING_ISSUE: "review.blocking_issue",
|
|
23
|
+
REVIEW_PR_OPEN: "review.pr_open",
|
|
24
|
+
BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
|
|
25
|
+
BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
|
|
26
|
+
BUILD_SIZE_OVER_GATE: "build.size_over_gate", // retired (forge#3862): never emitted; kept so historic runs parse
|
|
27
|
+
BUILD_COHESIVE: "build.cohesive", // retired (forge#3862): never emitted
|
|
28
|
+
REMEDIATE_NO_MARKER: "remediate.no_marker",
|
|
29
|
+
REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
|
|
30
|
+
REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
|
|
31
|
+
REMEDIATE_UNFIXABLE: "remediate.unfixable",
|
|
32
|
+
CI_RED: "ci.red",
|
|
33
|
+
CI_TIMEOUT: "ci.timeout",
|
|
34
|
+
BASE_CONFLICT: "base.conflict",
|
|
35
|
+
RATE_LIMITED: "rate_limited",
|
|
36
|
+
ENGINE_ERROR: "engine.error",
|
|
37
|
+
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
38
|
+
LEASE_HELD: "lease.held",
|
|
39
|
+
DEPENDENCY_OPEN: "dependency.open",
|
|
40
|
+
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
41
|
+
INVALID: "invalid",
|
|
42
|
+
DECOMPOSED: "decomposed",
|
|
43
|
+
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
44
|
+
COMPLETED: "completed",
|
|
45
|
+
AWAITING_MERGE: "awaiting_merge",
|
|
46
|
+
UNCLASSIFIED,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const CODE_SET = new Set(Object.values(OUTCOME_CODES));
|
|
50
|
+
|
|
51
|
+
/** Every code, in declaration order. */
|
|
52
|
+
export const ALL_OUTCOME_CODES = Object.freeze([...CODE_SET]);
|
|
53
|
+
|
|
54
|
+
export function isOutcomeCode(code) {
|
|
55
|
+
return typeof code === "string" && CODE_SET.has(code);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Absent or unknown -> `unclassified`; never throws. */
|
|
59
|
+
export function normalizeCode(code) {
|
|
60
|
+
return isOutcomeCode(code) ? code : UNCLASSIFIED;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DISK_QUOTA_RE = /EDQUOT|ENOSPC|(?:errno|system error) -?(?:28|122)\b|No space left|Disk quota exceeded/i;
|
|
64
|
+
|
|
65
|
+
/** True when runner/stderr text reports a full disk or exhausted quota (forge#3572). Match runner error text only, never review prose. */
|
|
66
|
+
export function isDiskQuotaText(text) {
|
|
67
|
+
return typeof text === "string" && DISK_QUOTA_RE.test(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when a thrown runner error is a disk-full/quota failure (message, errno or stderr). */
|
|
71
|
+
export function isDiskQuotaError(err) {
|
|
72
|
+
if (!err || typeof err !== "object") return false;
|
|
73
|
+
if (err.code === "ENOSPC" || err.code === "EDQUOT" || err.errno === -28 || err.errno === -122) return true;
|
|
74
|
+
return isDiskQuotaText(err.message) || isDiskQuotaText(err.stderr);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
|
|
78
|
+
export function codeForRunnerError(err) {
|
|
79
|
+
if (!err || typeof err !== "object") return UNCLASSIFIED;
|
|
80
|
+
if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
|
|
81
|
+
if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
|
|
82
|
+
return OUTCOME_CODES.RATE_LIMITED;
|
|
83
|
+
switch (err.code) {
|
|
84
|
+
case "NO_API_KEY":
|
|
85
|
+
case "NO_SDK":
|
|
86
|
+
case "CLI_BACKEND_FAILED":
|
|
87
|
+
case "PHASE_ARGS_INVALID":
|
|
88
|
+
case "CLI_MAX_TURNS":
|
|
89
|
+
return OUTCOME_CODES.ENGINE_ERROR;
|
|
90
|
+
default:
|
|
91
|
+
return UNCLASSIFIED;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Default code for a terminal reason when the producer supplied none. Only reasons that
|
|
97
|
+
* name one cause map; `needs-human` has many causes, so it stays `unclassified`.
|
|
98
|
+
*/
|
|
99
|
+
export function codeForTerminalReason(reason) {
|
|
100
|
+
switch (reason) {
|
|
101
|
+
case "merged":
|
|
102
|
+
case "phase-complete":
|
|
103
|
+
case "investigation":
|
|
104
|
+
return OUTCOME_CODES.COMPLETED;
|
|
105
|
+
case "awaiting-merge": return OUTCOME_CODES.AWAITING_MERGE;
|
|
106
|
+
case "invalid": return OUTCOME_CODES.INVALID;
|
|
107
|
+
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
108
|
+
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
|
109
|
+
case "deferred": return OUTCOME_CODES.LEASE_HELD;
|
|
110
|
+
case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
|
|
111
|
+
case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
|
|
112
|
+
default: return UNCLASSIFIED;
|
|
113
|
+
}
|
|
114
|
+
}
|
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, "\\$&");
|
|
@@ -151,13 +165,21 @@ export function sliceToCurrentCycle(comments) {
|
|
|
151
165
|
* GitHub already carries markers. Errors propagate (caller fails closed).
|
|
152
166
|
*/
|
|
153
167
|
export async function nextCycleNumber(issue, localCycles, io) {
|
|
168
|
+
return Math.max(await newestCycle(issue, io), localCycles) + 1;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Highest trusted FORGE:CYCLE number on the issue (0 when none). Reads only
|
|
172
|
+
* trusted comments via issueMarkers/cycleNumber; errors propagate so the caller
|
|
173
|
+
* fails closed (forge#3931: hydration must not reset on an unreadable trail).
|
|
174
|
+
*/
|
|
175
|
+
export async function newestCycle(issue, io) {
|
|
154
176
|
const { comments } = await issueMarkers(issue, io);
|
|
155
177
|
let highest = 0;
|
|
156
178
|
for (const body of comments) {
|
|
157
179
|
const c = cycleNumber(body);
|
|
158
180
|
if (c !== null && c > highest) highest = c;
|
|
159
181
|
}
|
|
160
|
-
return
|
|
182
|
+
return highest;
|
|
161
183
|
}
|
|
162
184
|
/**
|
|
163
185
|
* Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
|
|
@@ -186,15 +208,22 @@ export async function postCycleMarker(issue, n, io) {
|
|
|
186
208
|
* ALREADY_DONE when the newest gate marker is blocked (forge#3725).
|
|
187
209
|
*/
|
|
188
210
|
function latestGateState(comments) {
|
|
211
|
+
return latestGateMarker(comments).state;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Single newest-first scan used by latestGateState. Returns the newest gate marker's state and its comment index
|
|
215
|
+
* (`{state: "none", index: -1}` when there is none).
|
|
216
|
+
*/
|
|
217
|
+
function latestGateMarker(comments) {
|
|
189
218
|
const gate = "FORGE:ACCEPTANCE_GATE";
|
|
190
219
|
for (let i = comments.length - 1; i >= 0; i--) {
|
|
191
220
|
const b = comments[i];
|
|
192
|
-
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
|
|
221
|
+
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
|
|
193
222
|
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";
|
|
223
|
+
if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
|
|
224
|
+
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
|
|
196
225
|
}
|
|
197
|
-
return "none";
|
|
226
|
+
return { state: "none", index: -1 };
|
|
198
227
|
}
|
|
199
228
|
/**
|
|
200
229
|
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
@@ -242,44 +271,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
|
|
|
242
271
|
|| hasPhaseComplete(sliceToCurrentCycle(comments));
|
|
243
272
|
}
|
|
244
273
|
|
|
274
|
+
/** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
|
|
275
|
+
function overrideHasJustification(body) {
|
|
276
|
+
const lines = String(body).replace(/\r\n/g, "\n").split("\n");
|
|
277
|
+
return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
|
|
278
|
+
}
|
|
279
|
+
|
|
245
280
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
281
|
+
* forge#3862 / forge#3932: does trusted GitHub state show a REVOKED decomposition?
|
|
282
|
+
* Revoked = a trusted `<!-- FORGE:DECOMPOSED:REVOKED -->` comment newer than the newest
|
|
283
|
+
* trusted `FORGE:DECOMPOSED` comment (or one with no `FORGE:DECOMPOSED` left). The revocation
|
|
284
|
+
* alone is the operator's explicit signal: the size gate that once required a justified
|
|
285
|
+
* `FORGE:SIZE_OVERRIDE` alongside it is gone, so no override clause remains. An untrusted
|
|
286
|
+
* revocation never counts. Fail closed: an unreadable comment trail is `false`, never "revoked".
|
|
248
287
|
*/
|
|
249
|
-
async function
|
|
250
|
-
|
|
288
|
+
export async function decompositionRevoked(issue, io) {
|
|
289
|
+
let comments;
|
|
290
|
+
try { comments = (await issueMarkers(issue, io)).comments; } catch { return false; }
|
|
291
|
+
const last = (pred) => { for (let i = comments.length - 1; i >= 0; i--) if (pred(comments[i])) return i; return -1; };
|
|
292
|
+
const revokedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
|
|
293
|
+
if (revokedAt < 0) return false;
|
|
294
|
+
// `startsWithHeader` also matches `FORGE:DECOMPOSED:REVOKED` for the header `FORGE:DECOMPOSED` (":" ends
|
|
295
|
+
// the identifier), so the live decomposition record is the newest one that is not the revocation itself.
|
|
296
|
+
const decomposedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED") && !startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
|
|
297
|
+
return decomposedAt <= revokedAt;
|
|
251
298
|
}
|
|
252
299
|
|
|
253
|
-
/**
|
|
254
|
-
function
|
|
255
|
-
const
|
|
256
|
-
|
|
300
|
+
/** Read one issue's REST record (`state`, `body`, author provenance). Throws on a failed read; null on unparsable/non-object output. */
|
|
301
|
+
async function readIssueRecord(n, io) {
|
|
302
|
+
const out = String((await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) ?? "").trim();
|
|
303
|
+
if (!out) return { empty: true };
|
|
304
|
+
try {
|
|
305
|
+
const j = JSON.parse(out);
|
|
306
|
+
return j && typeof j === "object" && !Array.isArray(j) ? j : null;
|
|
307
|
+
} catch { return null; }
|
|
257
308
|
}
|
|
258
309
|
|
|
259
310
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
311
|
+
* forge#3932: does the issue declare a `**Depends on**: #N` dependency that is still OPEN?
|
|
312
|
+
* Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
|
|
313
|
+
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for the exact line decompose.md emits
|
|
314
|
+
* (`**Depends on**: #N` at line start; loose "depends on/after" prose is ignored). A self-reference is
|
|
315
|
+
* ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
316
|
+
* when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
|
|
317
|
+
* An empty read of the issue itself means "no body to trust" and yields `null`.
|
|
267
318
|
*/
|
|
268
|
-
function
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
319
|
+
export async function openDependency(issue, io) {
|
|
320
|
+
let rec;
|
|
321
|
+
try { rec = await readIssueRecord(issue, io); } catch (e) {
|
|
322
|
+
return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
|
|
323
|
+
}
|
|
324
|
+
if (rec === null) return { unreadable: true, detail: `issue ${issue} could not be parsed for its **Depends on** line` };
|
|
325
|
+
if (rec.empty || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
|
|
326
|
+
const deps = [...new Set([...rec.body.replace(/\r\n/g, "\n").matchAll(/^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm)].map((m) => Number(m[1])))]
|
|
327
|
+
.filter((n) => n > 0 && n !== Number(issue));
|
|
328
|
+
const open = [];
|
|
329
|
+
for (const dep of deps) {
|
|
330
|
+
let d;
|
|
331
|
+
try { d = await readIssueRecord(dep, io); } catch (e) {
|
|
332
|
+
return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
|
|
333
|
+
}
|
|
334
|
+
const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
|
|
335
|
+
if (st === "open") open.push(dep);
|
|
336
|
+
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
337
|
+
}
|
|
338
|
+
return open.length ? { open } : null;
|
|
280
339
|
}
|
|
340
|
+
|
|
281
341
|
/**
|
|
282
|
-
* Decompose loop guard (mirrors
|
|
342
|
+
* Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
|
|
283
343
|
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
284
344
|
* a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
|
|
285
345
|
* TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
|
|
@@ -305,11 +365,6 @@ async function decomposeLoopGuard(issue, comments, io) {
|
|
|
305
365
|
return null;
|
|
306
366
|
}
|
|
307
367
|
|
|
308
|
-
/** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
|
|
309
|
-
function mayRouteToDecompose(comments) {
|
|
310
|
-
const gate = PHASE_MARKERS.build.sizeGateMarker;
|
|
311
|
-
return comments.some((c) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${gate}`) && /^result:[ \t]*OVER[ \t]*\r?$/m.test(c));
|
|
312
|
-
}
|
|
313
368
|
/**
|
|
314
369
|
* Count commits on `branch` ahead of `lane`'s base. On the first build the
|
|
315
370
|
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
@@ -542,11 +597,6 @@ async function reviewArgs(state, ctx, io) {
|
|
|
542
597
|
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
543
598
|
}
|
|
544
599
|
|
|
545
|
-
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
|
|
546
|
-
const DEFAULT_ESTIMATE_FACTOR = 1.2;
|
|
547
|
-
/** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
|
|
548
|
-
const DEFAULT_BATCH_SEAM_MIN = 3;
|
|
549
|
-
|
|
550
600
|
/**
|
|
551
601
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
552
602
|
* trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
|
|
@@ -567,42 +617,24 @@ function isDecomposed(report) {
|
|
|
567
617
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
568
618
|
return false;
|
|
569
619
|
}
|
|
570
|
-
// forge#
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
577
|
-
const parsedFactor = fac ? Number(fac[1]) : NaN;
|
|
578
|
-
const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
|
|
579
|
-
const sized = est && gate && Number(gate[1]) > 0;
|
|
580
|
-
const estimate = sized ? Number(est[1]) : 0;
|
|
581
|
-
const threshold = sized ? Number(gate[1]) : 0;
|
|
620
|
+
// forge#3862: decomposition is a planning decision on SEPARABILITY, never on size. The engine has
|
|
621
|
+
// no line-count rule and no estimate net: an estimate, however large, never routes to decompose.
|
|
622
|
+
// A YES must be backed by the separability evidence the investigator records: an own-line
|
|
623
|
+
// `INDEPENDENT_SEAMS:` of at least 2 and an own-line `DECOMPOSE_JUSTIFICATION:`. A report that
|
|
624
|
+
// carries the seam field but not that evidence is downgraded to NO (fail toward building).
|
|
625
|
+
// A YES with no `INDEPENDENT_SEAMS:` field at all (an older or malformed report) is kept.
|
|
582
626
|
if ((verdict ?? marker) === "YES") {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
// threshold / 2). The engine cannot see the `batch` label or the configured
|
|
587
|
-
// `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
|
|
588
|
-
// numeric fields keep the YES (no silent downgrade of a malformed report).
|
|
589
|
-
if (sized) {
|
|
590
|
-
const seams = seamsM ? Number(seamsM[1]) : 0;
|
|
591
|
-
const required = estimate > threshold * factor || (seams >= DEFAULT_BATCH_SEAM_MIN && estimate > threshold / 2);
|
|
627
|
+
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
628
|
+
if (seamsM) {
|
|
629
|
+
const seams = Number(seamsM[1]);
|
|
592
630
|
const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
|
|
593
|
-
if (
|
|
594
|
-
console.error(`[engine] unsupported DECOMPOSE:YES (
|
|
631
|
+
if (seams < 2 || !justified) {
|
|
632
|
+
console.error(`[engine] unsupported DECOMPOSE:YES (separable seams ${seams}${justified ? "" : ", no DECOMPOSE_JUSTIFICATION"}); treating as not decomposed`);
|
|
595
633
|
return false;
|
|
596
634
|
}
|
|
597
635
|
}
|
|
598
636
|
return true;
|
|
599
637
|
}
|
|
600
|
-
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
601
|
-
// the gate (estimate > threshold x factor) fails toward decompose.
|
|
602
|
-
if (sized && estimate > threshold * factor) {
|
|
603
|
-
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
606
638
|
return false;
|
|
607
639
|
}
|
|
608
640
|
|
|
@@ -625,7 +657,7 @@ export const PHASES = [
|
|
|
625
657
|
const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
|
|
626
658
|
const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
|
|
627
659
|
if (closing === invalid)
|
|
628
|
-
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
660
|
+
return { status: "committed", terminalReason: "invalid", code: OC.INVALID, outputs: { verdict: "INVALID" } };
|
|
629
661
|
if (closing === complete) {
|
|
630
662
|
// investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
|
|
631
663
|
if (isDecomposed(report)) {
|
|
@@ -636,7 +668,7 @@ export const PHASES = [
|
|
|
636
668
|
console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
|
|
637
669
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
638
670
|
}
|
|
639
|
-
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
671
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: { decompose: true } };
|
|
640
672
|
}
|
|
641
673
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
642
674
|
}
|
|
@@ -665,7 +697,7 @@ export const PHASES = [
|
|
|
665
697
|
async detectOutcome(state, io) {
|
|
666
698
|
const { comments } = await issueMarkers(state.issue, io);
|
|
667
699
|
if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
|
|
668
|
-
return { status: "committed", terminalReason: "decomposed", outputs: {} };
|
|
700
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: {} };
|
|
669
701
|
return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
|
|
670
702
|
},
|
|
671
703
|
// Always terminal: decomposition spawns independent sub-issues, each of
|
|
@@ -722,8 +754,9 @@ export const PHASES = [
|
|
|
722
754
|
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
723
755
|
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
724
756
|
// build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
|
|
757
|
+
// forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
|
|
725
758
|
if (latestGateState(comments) === "blocked") {
|
|
726
|
-
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
|
|
759
|
+
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
|
|
727
760
|
}
|
|
728
761
|
if (complete && ahead > 0) {
|
|
729
762
|
return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
|
|
@@ -734,13 +767,6 @@ export const PHASES = [
|
|
|
734
767
|
if (hasInvestigationMarker(comments)) {
|
|
735
768
|
return { status: "committed", terminalReason: "investigation", outputs: {} };
|
|
736
769
|
}
|
|
737
|
-
// B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
|
|
738
|
-
// build.md says the router (not build) dispatches decompose with no
|
|
739
|
-
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
740
|
-
// re-emits the same exit.
|
|
741
|
-
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
742
|
-
return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
|
|
743
|
-
}
|
|
744
770
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
745
771
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
746
772
|
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
@@ -803,7 +829,7 @@ export const PHASES = [
|
|
|
803
829
|
// The PR number from GitHub (openPrFor) always wins over parsed text.
|
|
804
830
|
const outputs = { pr: pr.number };
|
|
805
831
|
// 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 };
|
|
832
|
+
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true, code: OC.REVIEW_ESCALATED };
|
|
807
833
|
const rr = parseReviewResult(result?.text);
|
|
808
834
|
// forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
|
|
809
835
|
// remediate phase can post the matching bound marker. Untrusted model output: only
|
|
@@ -812,15 +838,18 @@ export const PHASES = [
|
|
|
812
838
|
if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
|
|
813
839
|
outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
|
|
814
840
|
if (pr.needsHuman || pr.remediating) return escalated;
|
|
815
|
-
if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
|
|
841
|
+
if (rr && rr.status === "NEXT" && rr.next === "remediate") return { ...escalated, code: OC.REVIEW_CHANGES_REQUESTED };
|
|
816
842
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
817
843
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
818
844
|
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
819
|
-
if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
|
|
845
|
+
if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
|
|
820
846
|
let snap = null;
|
|
821
847
|
try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
|
|
822
848
|
if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
|
|
823
|
-
|
|
849
|
+
// forge#3931: an idempotent re-review at an unchanged head posts nothing new, so the only evidence of a
|
|
850
|
+
// blocking finding is the trusted FORGE:NOTE_DISPOSITION already on the PR for this head. Hand off to remediate.
|
|
851
|
+
if (await blockingDispositionAtHead(pr, io)) return { ...escalated, detail: "blocking ISSUE finding at unchanged head", code: OC.REVIEW_BLOCKING_ISSUE };
|
|
852
|
+
return { status: "failed", detail: "PR open, not merged", retryable: false, outputs, code: OC.REVIEW_PR_OPEN };
|
|
824
853
|
},
|
|
825
854
|
},
|
|
826
855
|
{
|
|
@@ -854,7 +883,17 @@ export const PHASES = [
|
|
|
854
883
|
// Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
|
|
855
884
|
// is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
|
|
856
885
|
// single-attempt guard. A present-but-unknown kind is never dispatched.
|
|
857
|
-
|
|
886
|
+
// forge#3807: every dispatch (kind-bound or legacy) posts a dispatch marker AFTER the bound marker,
|
|
887
|
+
// never deduped, so detectOutcome can tell a stale deferral from an earlier dispatch from this one's.
|
|
888
|
+
const postDispatchMarker = async () => {
|
|
889
|
+
try {
|
|
890
|
+
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
891
|
+
`<!-- ${REMEDIATION_DISPATCH_MARKER}: pr=${pr} -->\nEngine dispatching remediation for PR #${pr}.`]);
|
|
892
|
+
} catch (e) {
|
|
893
|
+
throw new PhaseArgsError(`github-unavailable: could not post ${REMEDIATION_DISPATCH_MARKER}; remediation not invoked (${e?.message || e})`);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
if (kind === null) { await postDispatchMarker(); return legacyArgs(); }
|
|
858
897
|
if (!isRemediationKind(kind))
|
|
859
898
|
throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
|
|
860
899
|
const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
|
|
@@ -870,6 +909,7 @@ export const PHASES = [
|
|
|
870
909
|
} catch (e) {
|
|
871
910
|
throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
|
|
872
911
|
}
|
|
912
|
+
await postDispatchMarker();
|
|
873
913
|
return legacyArgs();
|
|
874
914
|
},
|
|
875
915
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
@@ -935,16 +975,20 @@ export const PHASES = [
|
|
|
935
975
|
// lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
|
|
936
976
|
// PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
|
|
937
977
|
// that proceeded posts one, which retires the deferral.
|
|
938
|
-
|
|
978
|
+
// forge#3807: a deferral older than this dispatch's marker is stale (a later dispatch proceeded and
|
|
979
|
+
// crashed before posting a trail), so it is a failed attempt. No marker (older runs) => dispatchAt -1.
|
|
980
|
+
let deferredAt = -1, trailAt = -1, dispatchAt = -1, deferred = null;
|
|
981
|
+
const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
|
|
939
982
|
scoped.forEach((b, i) => {
|
|
983
|
+
if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
|
|
940
984
|
if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
|
|
941
985
|
else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
|
|
942
986
|
});
|
|
943
|
-
if (deferred !== null && deferredAt > trailAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
987
|
+
if (deferred !== null && deferredAt > trailAt && deferredAt > dispatchAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
944
988
|
const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
|
|
945
|
-
return { status: "blocked", reason: "deferred", retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
989
|
+
return { status: "blocked", reason: "deferred", code: OC.REMEDIATE_DEFERRED_LIVE_RUN, retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
946
990
|
}
|
|
947
|
-
return { status: "failed", detail: `no ${completion} marker` };
|
|
991
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `no ${completion} marker` };
|
|
948
992
|
}
|
|
949
993
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
950
994
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
@@ -968,12 +1012,18 @@ export const PHASES = [
|
|
|
968
1012
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
969
1013
|
case "RE-ESCALATED":
|
|
970
1014
|
case "UNFIXABLE":
|
|
1015
|
+
// forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
|
|
1016
|
+
if (await remediationHeadStale(state, io))
|
|
1017
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
971
1018
|
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
972
1019
|
// policy judgment call respectively) — reuse the existing
|
|
973
1020
|
// "needs-human" terminal reason rather than inventing two more,
|
|
974
1021
|
// matching the #2352/#2353 precedent of reusing an existing
|
|
975
|
-
// TERMINAL_REASONS value where semantically equivalent.
|
|
976
|
-
|
|
1022
|
+
// TERMINAL_REASONS value where semantically equivalent. forge#3833:
|
|
1023
|
+
// the two stay distinguishable by outcome code.
|
|
1024
|
+
return { status: "committed", terminalReason: "needs-human",
|
|
1025
|
+
code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
|
|
1026
|
+
outputs: { reGateOutcome } };
|
|
977
1027
|
default:
|
|
978
1028
|
return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
|
|
979
1029
|
}
|
|
@@ -1051,7 +1101,11 @@ export const PHASES = [
|
|
|
1051
1101
|
// that label is still a real terminal state (nothing left for this phase
|
|
1052
1102
|
// to do), just not a "merged" one — reuse the existing "invalid" reason
|
|
1053
1103
|
// (already in TERMINAL_REASONS) rather than inventing a new one.
|
|
1054
|
-
|
|
1104
|
+
// SEC-1: the label alone is not proof either. A reopened or never-closed issue can still
|
|
1105
|
+
// carry a stale `workflow:merged`, so "merged" requires CLOSED together with the label
|
|
1106
|
+
// (same predicate as the remediate `mergedResult`). A label without CLOSED falls through
|
|
1107
|
+
// to the OPEN / PHASE_COMPLETE / "issue not closed" paths below.
|
|
1108
|
+
if (snap.state === "CLOSED" && snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
1055
1109
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
1056
1110
|
// forge#3534: an investigation exit closes the issue itself; keep that reason honest.
|
|
1057
1111
|
if (snap.state === "CLOSED") {
|
|
@@ -1152,16 +1206,73 @@ async function mergedPrForBranch(branch, lane, io) {
|
|
|
1152
1206
|
return hit ? { number: hit.number } : null;
|
|
1153
1207
|
} catch { return null; }
|
|
1154
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* forge#3931: true when the newest trusted FORGE:NOTE_DISPOSITION on the PR covers the live head
|
|
1211
|
+
* (`reviewed_sha`) and filed at least one ISSUE (`findings_filed` > 0). Trusted comments only; an
|
|
1212
|
+
* unreadable PR, missing head or unparsable record returns false (fail closed: no handoff).
|
|
1213
|
+
*/
|
|
1214
|
+
async function blockingDispositionAtHead(pr, io) {
|
|
1215
|
+
if (!pr?.head) return false;
|
|
1216
|
+
try {
|
|
1217
|
+
const { comments } = await issueMarkers(pr.number, io);
|
|
1218
|
+
const recs = comments.filter((b) => /^\s*<!--\s*FORGE:NOTE_DISPOSITION:/.test(b));
|
|
1219
|
+
if (!recs.length) return false;
|
|
1220
|
+
const first = recs[recs.length - 1].split("\n", 1)[0];
|
|
1221
|
+
const sha = /\breviewed_sha=([0-9a-f]{40,64})\b/.exec(first)?.[1];
|
|
1222
|
+
const filed = /\bfindings_filed=(\d+)\b/.exec(first)?.[1];
|
|
1223
|
+
return sha === pr.head && Number(filed) > 0;
|
|
1224
|
+
} catch { return false; }
|
|
1225
|
+
}
|
|
1155
1226
|
async function prStatusFor(state, io) {
|
|
1156
1227
|
const n = await openPrFor(state, io);
|
|
1157
1228
|
if (!n) return null;
|
|
1158
|
-
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
|
|
1229
|
+
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt,headRefOid"]);
|
|
1159
1230
|
let j;
|
|
1160
1231
|
try { j = JSON.parse(out || "{}"); } catch { return null; }
|
|
1161
1232
|
const labels = (j.labels || []).map((l) => l.name || l);
|
|
1162
1233
|
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
1163
1234
|
needsHuman: labels.includes("needs-human"),
|
|
1164
|
-
remediating: labels.includes("workflow:remediating")
|
|
1235
|
+
remediating: labels.includes("workflow:remediating"),
|
|
1236
|
+
head: typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) ? j.headRefOid : null };
|
|
1237
|
+
}
|
|
1238
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* forge#3931: true only when the newest trusted FORGE:REMEDIATION:COMPLETE names a
|
|
1242
|
+
* `**Head**:` SHA that differs from the live PR head, i.e. the trail was produced for
|
|
1243
|
+
* an older head. Any unreadable input (no pr, no trail, `unknown`/missing head, gh or
|
|
1244
|
+
* parse failure) returns false so callers keep today's behaviour (fail closed).
|
|
1245
|
+
*/
|
|
1246
|
+
export async function remediationHeadStale(state, io) {
|
|
1247
|
+
if (state?.pr == null) return false;
|
|
1248
|
+
try {
|
|
1249
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
1250
|
+
const latest = latestMarked(comments, PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
|
|
1251
|
+
const recorded = latest === null ? null : /\*\*Head\*\*:[ \t]*([0-9a-f]{40,64})\b/.exec(latest)?.[1] ?? null;
|
|
1252
|
+
if (!recorded) return false;
|
|
1253
|
+
const j = JSON.parse((await io.gh(["pr", "view", String(state.pr), "--json", "headRefOid"])) || "{}");
|
|
1254
|
+
return typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) && j.headRefOid !== recorded;
|
|
1255
|
+
} catch { return false; }
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* forge#3931 (CONC-1): hydrate-time cycle reset is safe only when the hydrated progress is provably from an
|
|
1260
|
+
* OLDER cycle. The index carries no cycle number, so a same-cycle mid-flight or blocked run must not reset.
|
|
1261
|
+
* Provable = a terminal index AND live GitHub state showing the issue OPEN with `needs-human` and
|
|
1262
|
+
* `workflow:remediating` both clear AND no OPEN PR on the branch. Any unreadable input returns false.
|
|
1263
|
+
*/
|
|
1264
|
+
export async function cycleResetProvable(state, io) {
|
|
1265
|
+
if (!state?.terminal) return false;
|
|
1266
|
+
try {
|
|
1267
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
1268
|
+
if (!snap.ok || snap.state !== "OPEN") return false;
|
|
1269
|
+
if (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating")) return false;
|
|
1270
|
+
if (state.branch) {
|
|
1271
|
+
const a = JSON.parse((await io.gh(["pr", "list", "--head", state.branch, "--json", "number,state", "--state", "all"])) || "[]");
|
|
1272
|
+
if (!Array.isArray(a) || a.some((p) => p && p.state === "OPEN")) return false;
|
|
1273
|
+
}
|
|
1274
|
+
return true;
|
|
1275
|
+
} catch { return false; }
|
|
1165
1276
|
}
|
|
1166
1277
|
|
|
1167
1278
|
/** The engine's transition function: first uncommitted phase whose gate holds. */
|