forgedock 1.17.0 → 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 +24 -2
- package/bin/engine/phases.mjs +153 -102
- package/bin/engine/runlog.mjs +7 -0
- package/bin/engine-cli.mjs +5 -0
- package/bin/engine.mjs +84 -10
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +63 -4
- package/bin/stats/github-source.mjs +89 -18
- package/bin/stats/stats.test.mjs +89 -1
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +142 -519
- package/commands/review-pr.md +490 -3128
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +2 -1
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +107 -86
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +9 -12
- package/commands/work-on/remediate.md +27 -13
- package/commands/work-on/review.md +5 -3
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +44 -25
- package/docs/OUTCOME-CODES.md +7 -2
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +2 -1
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +6 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +1 -1
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/close-parent-c3.test.sh +69 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/forge-root.test.sh +13 -11
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- 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 +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +48 -12
- package/scripts/review-prepare.test.sh +19 -1
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.test.sh +3 -3
|
@@ -19,10 +19,12 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
19
19
|
REVIEW_CHANGES_REQUESTED: "review.changes_requested",
|
|
20
20
|
REVIEW_ESCALATED: "review.escalated",
|
|
21
21
|
REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
|
|
22
|
+
REVIEW_BLOCKING_ISSUE: "review.blocking_issue",
|
|
23
|
+
REVIEW_PR_OPEN: "review.pr_open",
|
|
22
24
|
BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
|
|
23
25
|
BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
|
|
24
|
-
BUILD_SIZE_OVER_GATE: "build.size_over_gate",
|
|
25
|
-
BUILD_COHESIVE: "build.cohesive",
|
|
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
|
|
26
28
|
REMEDIATE_NO_MARKER: "remediate.no_marker",
|
|
27
29
|
REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
|
|
28
30
|
REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
|
|
@@ -32,7 +34,10 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
32
34
|
BASE_CONFLICT: "base.conflict",
|
|
33
35
|
RATE_LIMITED: "rate_limited",
|
|
34
36
|
ENGINE_ERROR: "engine.error",
|
|
37
|
+
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
35
38
|
LEASE_HELD: "lease.held",
|
|
39
|
+
DEPENDENCY_OPEN: "dependency.open",
|
|
40
|
+
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
36
41
|
INVALID: "invalid",
|
|
37
42
|
DECOMPOSED: "decomposed",
|
|
38
43
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
@@ -55,9 +60,24 @@ export function normalizeCode(code) {
|
|
|
55
60
|
return isOutcomeCode(code) ? code : UNCLASSIFIED;
|
|
56
61
|
}
|
|
57
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
|
+
|
|
58
77
|
/** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
|
|
59
78
|
export function codeForRunnerError(err) {
|
|
60
79
|
if (!err || typeof err !== "object") return UNCLASSIFIED;
|
|
80
|
+
if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
|
|
61
81
|
if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
|
|
62
82
|
return OUTCOME_CODES.RATE_LIMITED;
|
|
63
83
|
switch (err.code) {
|
|
@@ -87,6 +107,8 @@ export function codeForTerminalReason(reason) {
|
|
|
87
107
|
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
88
108
|
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
|
89
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;
|
|
90
112
|
default: return UNCLASSIFIED;
|
|
91
113
|
}
|
|
92
114
|
}
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -165,13 +165,21 @@ export function sliceToCurrentCycle(comments) {
|
|
|
165
165
|
* GitHub already carries markers. Errors propagate (caller fails closed).
|
|
166
166
|
*/
|
|
167
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) {
|
|
168
176
|
const { comments } = await issueMarkers(issue, io);
|
|
169
177
|
let highest = 0;
|
|
170
178
|
for (const body of comments) {
|
|
171
179
|
const c = cycleNumber(body);
|
|
172
180
|
if (c !== null && c > highest) highest = c;
|
|
173
181
|
}
|
|
174
|
-
return
|
|
182
|
+
return highest;
|
|
175
183
|
}
|
|
176
184
|
/**
|
|
177
185
|
* Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
|
|
@@ -203,8 +211,7 @@ function latestGateState(comments) {
|
|
|
203
211
|
return latestGateMarker(comments).state;
|
|
204
212
|
}
|
|
205
213
|
/**
|
|
206
|
-
* Single newest-first scan
|
|
207
|
-
* predicates cannot drift. Returns the newest gate marker's state and its comment index
|
|
214
|
+
* Single newest-first scan used by latestGateState. Returns the newest gate marker's state and its comment index
|
|
208
215
|
* (`{state: "none", index: -1}` when there is none).
|
|
209
216
|
*/
|
|
210
217
|
function latestGateMarker(comments) {
|
|
@@ -218,15 +225,6 @@ function latestGateMarker(comments) {
|
|
|
218
225
|
}
|
|
219
226
|
return { state: "none", index: -1 };
|
|
220
227
|
}
|
|
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;
|
|
229
|
-
}
|
|
230
228
|
/**
|
|
231
229
|
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
232
230
|
* `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
|
|
@@ -273,48 +271,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
|
|
|
273
271
|
|| hasPhaseComplete(sliceToCurrentCycle(comments));
|
|
274
272
|
}
|
|
275
273
|
|
|
276
|
-
/**
|
|
277
|
-
* Trusted comment bodies, chronological — the same single fetch + trust predicate
|
|
278
|
-
* as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
|
|
279
|
-
*/
|
|
280
|
-
async function trustedCommentBodies(issue, io) {
|
|
281
|
-
try { return (await issueMarkers(issue, io)).comments; } catch { return []; }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** build.md B5.5 Step 2: a SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
|
|
274
|
+
/** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
|
|
285
275
|
function overrideHasJustification(body) {
|
|
286
276
|
const lines = String(body).replace(/\r\n/g, "\n").split("\n");
|
|
287
277
|
return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
|
|
288
278
|
}
|
|
289
279
|
|
|
290
280
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* reaches this function.
|
|
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".
|
|
298
287
|
*/
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
298
|
+
}
|
|
299
|
+
|
|
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; }
|
|
311
308
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
309
|
+
|
|
310
|
+
/**
|
|
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`.
|
|
318
|
+
*/
|
|
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;
|
|
315
339
|
}
|
|
340
|
+
|
|
316
341
|
/**
|
|
317
|
-
* Decompose loop guard (mirrors
|
|
342
|
+
* Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
|
|
318
343
|
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
319
344
|
* a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
|
|
320
345
|
* TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
|
|
@@ -340,11 +365,6 @@ async function decomposeLoopGuard(issue, comments, io) {
|
|
|
340
365
|
return null;
|
|
341
366
|
}
|
|
342
367
|
|
|
343
|
-
/** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
|
|
344
|
-
function mayRouteToDecompose(comments) {
|
|
345
|
-
const gate = PHASE_MARKERS.build.sizeGateMarker;
|
|
346
|
-
return comments.some((c) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${gate}`) && /^result:[ \t]*OVER[ \t]*\r?$/m.test(c));
|
|
347
|
-
}
|
|
348
368
|
/**
|
|
349
369
|
* Count commits on `branch` ahead of `lane`'s base. On the first build the
|
|
350
370
|
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
@@ -577,11 +597,6 @@ async function reviewArgs(state, ctx, io) {
|
|
|
577
597
|
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
578
598
|
}
|
|
579
599
|
|
|
580
|
-
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
|
|
581
|
-
const DEFAULT_ESTIMATE_FACTOR = 1.2;
|
|
582
|
-
/** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
|
|
583
|
-
const DEFAULT_BATCH_SEAM_MIN = 3;
|
|
584
|
-
|
|
585
600
|
/**
|
|
586
601
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
587
602
|
* trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
|
|
@@ -602,42 +617,24 @@ function isDecomposed(report) {
|
|
|
602
617
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
603
618
|
return false;
|
|
604
619
|
}
|
|
605
|
-
// forge#
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
612
|
-
const parsedFactor = fac ? Number(fac[1]) : NaN;
|
|
613
|
-
const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
|
|
614
|
-
const sized = est && gate && Number(gate[1]) > 0;
|
|
615
|
-
const estimate = sized ? Number(est[1]) : 0;
|
|
616
|
-
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.
|
|
617
626
|
if ((verdict ?? marker) === "YES") {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
// threshold / 2). The engine cannot see the `batch` label or the configured
|
|
622
|
-
// `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
|
|
623
|
-
// numeric fields keep the YES (no silent downgrade of a malformed report).
|
|
624
|
-
if (sized) {
|
|
625
|
-
const seams = seamsM ? Number(seamsM[1]) : 0;
|
|
626
|
-
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]);
|
|
627
630
|
const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
|
|
628
|
-
if (
|
|
629
|
-
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`);
|
|
630
633
|
return false;
|
|
631
634
|
}
|
|
632
635
|
}
|
|
633
636
|
return true;
|
|
634
637
|
}
|
|
635
|
-
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
636
|
-
// the gate (estimate > threshold x factor) fails toward decompose.
|
|
637
|
-
if (sized && estimate > threshold * factor) {
|
|
638
|
-
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
|
|
639
|
-
return true;
|
|
640
|
-
}
|
|
641
638
|
return false;
|
|
642
639
|
}
|
|
643
640
|
|
|
@@ -757,13 +754,7 @@ export const PHASES = [
|
|
|
757
754
|
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
758
755
|
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
759
756
|
// build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
|
|
760
|
-
// forge#
|
|
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
|
-
}
|
|
757
|
+
// forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
|
|
767
758
|
if (latestGateState(comments) === "blocked") {
|
|
768
759
|
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
|
|
769
760
|
}
|
|
@@ -776,13 +767,6 @@ export const PHASES = [
|
|
|
776
767
|
if (hasInvestigationMarker(comments)) {
|
|
777
768
|
return { status: "committed", terminalReason: "investigation", outputs: {} };
|
|
778
769
|
}
|
|
779
|
-
// B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
|
|
780
|
-
// build.md says the router (not build) dispatches decompose with no
|
|
781
|
-
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
782
|
-
// re-emits the same exit.
|
|
783
|
-
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
784
|
-
return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
|
|
785
|
-
}
|
|
786
770
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
787
771
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
788
772
|
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
@@ -858,11 +842,14 @@ export const PHASES = [
|
|
|
858
842
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
859
843
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
860
844
|
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
861
|
-
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 };
|
|
862
846
|
let snap = null;
|
|
863
847
|
try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
|
|
864
848
|
if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
|
|
865
|
-
|
|
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 };
|
|
866
853
|
},
|
|
867
854
|
},
|
|
868
855
|
{
|
|
@@ -1025,6 +1012,9 @@ export const PHASES = [
|
|
|
1025
1012
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
1026
1013
|
case "RE-ESCALATED":
|
|
1027
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` };
|
|
1028
1018
|
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
1029
1019
|
// policy judgment call respectively) — reuse the existing
|
|
1030
1020
|
// "needs-human" terminal reason rather than inventing two more,
|
|
@@ -1111,7 +1101,11 @@ export const PHASES = [
|
|
|
1111
1101
|
// that label is still a real terminal state (nothing left for this phase
|
|
1112
1102
|
// to do), just not a "merged" one — reuse the existing "invalid" reason
|
|
1113
1103
|
// (already in TERMINAL_REASONS) rather than inventing a new one.
|
|
1114
|
-
|
|
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))
|
|
1115
1109
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
1116
1110
|
// forge#3534: an investigation exit closes the issue itself; keep that reason honest.
|
|
1117
1111
|
if (snap.state === "CLOSED") {
|
|
@@ -1212,16 +1206,73 @@ async function mergedPrForBranch(branch, lane, io) {
|
|
|
1212
1206
|
return hit ? { number: hit.number } : null;
|
|
1213
1207
|
} catch { return null; }
|
|
1214
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
|
+
}
|
|
1215
1226
|
async function prStatusFor(state, io) {
|
|
1216
1227
|
const n = await openPrFor(state, io);
|
|
1217
1228
|
if (!n) return null;
|
|
1218
|
-
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"]);
|
|
1219
1230
|
let j;
|
|
1220
1231
|
try { j = JSON.parse(out || "{}"); } catch { return null; }
|
|
1221
1232
|
const labels = (j.labels || []).map((l) => l.name || l);
|
|
1222
1233
|
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
1223
1234
|
needsHuman: labels.includes("needs-human"),
|
|
1224
|
-
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; }
|
|
1225
1276
|
}
|
|
1226
1277
|
|
|
1227
1278
|
/** The engine's transition function: first uncommitted phase whose gate holds. */
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -106,6 +106,13 @@ export function deriveState(events) {
|
|
|
106
106
|
// retry re-enters remediate/decompose instead of close/build.
|
|
107
107
|
case "RUN_REOPEN":
|
|
108
108
|
s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
|
|
109
|
+
// forge#3862: a `decomposed` terminal whose decomposition was revoked (trusted GitHub state,
|
|
110
|
+
// checked by the engine before it appends this event) reopens at build: un-commit the
|
|
111
|
+
// phases named in `uncommit` and drop the stale handoff reason so pickPhase re-selects build.
|
|
112
|
+
if (Array.isArray(e.uncommit) && e.uncommit.length) {
|
|
113
|
+
s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
|
|
114
|
+
s.terminalReason = null; s.outcomeCode = null;
|
|
115
|
+
}
|
|
109
116
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
110
117
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
111
118
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -420,6 +420,11 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
420
420
|
console.log(res.detail ?? "run is not retryable");
|
|
421
421
|
return res;
|
|
422
422
|
}
|
|
423
|
+
// forge#3932: refused before any state write (open or unreadable **Depends on**); nothing ran.
|
|
424
|
+
if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable") {
|
|
425
|
+
console.log(res.detail ?? "run refused: dependency check");
|
|
426
|
+
return res;
|
|
427
|
+
}
|
|
423
428
|
// forge#2175: a non-success termination previously printed nothing beyond
|
|
424
429
|
// the bare reason above — the actual failing phase/attempt/reason was only
|
|
425
430
|
// recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
|