forgedock 1.17.0 → 1.19.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 +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- 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 +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -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/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +71 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -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 +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -19,20 +19,29 @@ 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",
|
|
24
|
+
REVIEW_CI_PENDING_TIMEOUT: "review.ci_pending_timeout",
|
|
22
25
|
BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
|
|
23
26
|
BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
|
|
24
|
-
BUILD_SIZE_OVER_GATE: "build.size_over_gate",
|
|
25
|
-
BUILD_COHESIVE: "build.cohesive",
|
|
27
|
+
BUILD_SIZE_OVER_GATE: "build.size_over_gate", // retired (forge#3862): never emitted; kept so historic runs parse
|
|
28
|
+
BUILD_COHESIVE: "build.cohesive", // retired (forge#3862): never emitted
|
|
26
29
|
REMEDIATE_NO_MARKER: "remediate.no_marker",
|
|
27
30
|
REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
|
|
28
31
|
REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
|
|
29
32
|
REMEDIATE_UNFIXABLE: "remediate.unfixable",
|
|
33
|
+
REMEDIATE_INPR_REQUESTED: "remediate.inpr_requested",
|
|
30
34
|
CI_RED: "ci.red",
|
|
31
35
|
CI_TIMEOUT: "ci.timeout",
|
|
32
36
|
BASE_CONFLICT: "base.conflict",
|
|
33
37
|
RATE_LIMITED: "rate_limited",
|
|
34
38
|
ENGINE_ERROR: "engine.error",
|
|
39
|
+
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
40
|
+
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
41
|
+
CLI_TIMEOUT: "cli.timeout",
|
|
35
42
|
LEASE_HELD: "lease.held",
|
|
43
|
+
DEPENDENCY_OPEN: "dependency.open",
|
|
44
|
+
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
36
45
|
INVALID: "invalid",
|
|
37
46
|
DECOMPOSED: "decomposed",
|
|
38
47
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
@@ -55,12 +64,36 @@ export function normalizeCode(code) {
|
|
|
55
64
|
return isOutcomeCode(code) ? code : UNCLASSIFIED;
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
const DISK_QUOTA_RE = /EDQUOT|ENOSPC|(?:errno|system error) -?(?:28|122)\b|No space left|Disk quota exceeded/i;
|
|
68
|
+
|
|
69
|
+
/** True when runner/stderr text reports a full disk or exhausted quota (forge#3572). Match runner error text only, never review prose. */
|
|
70
|
+
export function isDiskQuotaText(text) {
|
|
71
|
+
return typeof text === "string" && DISK_QUOTA_RE.test(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when a thrown runner error is a disk-full/quota failure (message, errno or stderr). */
|
|
75
|
+
export function isDiskQuotaError(err) {
|
|
76
|
+
if (!err || typeof err !== "object") return false;
|
|
77
|
+
if (err.code === "ENOSPC" || err.code === "EDQUOT" || err.errno === -28 || err.errno === -122) return true;
|
|
78
|
+
return isDiskQuotaText(err.message) || isDiskQuotaText(err.stderr);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when a thrown runner error is a transient CLI spawn failure (ETIMEDOUT/EAGAIN/ENOMEM/EMFILE at spawn), forge#4021. */
|
|
82
|
+
export function isTransientSpawnError(err) {
|
|
83
|
+
return !!err && typeof err === "object" && err.code === "CLI_SPAWN_TRANSIENT";
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
/** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
|
|
59
87
|
export function codeForRunnerError(err) {
|
|
60
88
|
if (!err || typeof err !== "object") return UNCLASSIFIED;
|
|
89
|
+
if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
|
|
61
90
|
if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
|
|
62
91
|
return OUTCOME_CODES.RATE_LIMITED;
|
|
63
92
|
switch (err.code) {
|
|
93
|
+
case "CLI_SPAWN_TRANSIENT":
|
|
94
|
+
return OUTCOME_CODES.INFRA_CLI_SPAWN_TRANSIENT;
|
|
95
|
+
case "CLI_TIMEOUT":
|
|
96
|
+
return OUTCOME_CODES.CLI_TIMEOUT;
|
|
64
97
|
case "NO_API_KEY":
|
|
65
98
|
case "NO_SDK":
|
|
66
99
|
case "CLI_BACKEND_FAILED":
|
|
@@ -87,6 +120,8 @@ export function codeForTerminalReason(reason) {
|
|
|
87
120
|
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
88
121
|
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
|
89
122
|
case "deferred": return OUTCOME_CODES.LEASE_HELD;
|
|
123
|
+
case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
|
|
124
|
+
case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
|
|
90
125
|
default: return UNCLASSIFIED;
|
|
91
126
|
}
|
|
92
127
|
}
|
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) {
|
|
@@ -213,20 +220,12 @@ function latestGateMarker(comments) {
|
|
|
213
220
|
const b = comments[i];
|
|
214
221
|
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
|
|
215
222
|
if (!startsWithHeader(b, gate)) continue;
|
|
216
|
-
|
|
223
|
+
// :DEGRADED is pass-class (forge#3628): the build completed, and review gates the merge on the degraded paths.
|
|
224
|
+
if (hasSentinelLine(b, `${gate}:PASSED`) || hasSentinelLine(b, `${gate}:DEGRADED`)) return { state: "pass", index: i };
|
|
217
225
|
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
|
|
218
226
|
}
|
|
219
227
|
return { state: "none", index: -1 };
|
|
220
228
|
}
|
|
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
229
|
/**
|
|
231
230
|
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
232
231
|
* `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
|
|
@@ -273,48 +272,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
|
|
|
273
272
|
|| hasPhaseComplete(sliceToCurrentCycle(comments));
|
|
274
273
|
}
|
|
275
274
|
|
|
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. */
|
|
275
|
+
/** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
|
|
285
276
|
function overrideHasJustification(body) {
|
|
286
277
|
const lines = String(body).replace(/\r\n/g, "\n").split("\n");
|
|
287
278
|
return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
|
|
288
279
|
}
|
|
289
280
|
|
|
290
281
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* reaches this function.
|
|
282
|
+
* forge#3862 / forge#3932: does trusted GitHub state show a REVOKED decomposition?
|
|
283
|
+
* Revoked = a trusted `<!-- FORGE:DECOMPOSED:REVOKED -->` comment newer than the newest
|
|
284
|
+
* trusted `FORGE:DECOMPOSED` comment (or one with no `FORGE:DECOMPOSED` left). The revocation
|
|
285
|
+
* alone is the operator's explicit signal: the size gate that once required a justified
|
|
286
|
+
* `FORGE:SIZE_OVERRIDE` alongside it is gone, so no override clause remains. An untrusted
|
|
287
|
+
* revocation never counts. Fail closed: an unreadable comment trail is `false`, never "revoked".
|
|
298
288
|
*/
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
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;
|
|
289
|
+
export async function decompositionRevoked(issue, io) {
|
|
290
|
+
let comments;
|
|
291
|
+
try { comments = (await issueMarkers(issue, io)).comments; } catch { return false; }
|
|
292
|
+
const last = (pred) => { for (let i = comments.length - 1; i >= 0; i--) if (pred(comments[i])) return i; return -1; };
|
|
293
|
+
const revokedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
|
|
294
|
+
if (revokedAt < 0) return false;
|
|
295
|
+
// `startsWithHeader` also matches `FORGE:DECOMPOSED:REVOKED` for the header `FORGE:DECOMPOSED` (":" ends
|
|
296
|
+
// the identifier), so the live decomposition record is the newest one that is not the revocation itself.
|
|
297
|
+
const decomposedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED") && !startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
|
|
298
|
+
return decomposedAt <= revokedAt;
|
|
311
299
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
300
|
+
|
|
301
|
+
/** Read one issue's REST record (`state`, `body`, author provenance). Throws on a failed read; null on unparsable/non-object output. */
|
|
302
|
+
async function readIssueRecord(n, io) {
|
|
303
|
+
const out = String((await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) ?? "").trim();
|
|
304
|
+
if (!out) return { empty: true };
|
|
305
|
+
try {
|
|
306
|
+
const j = JSON.parse(out);
|
|
307
|
+
return j && typeof j === "object" && !Array.isArray(j) ? j : null;
|
|
308
|
+
} catch { return null; }
|
|
315
309
|
}
|
|
310
|
+
|
|
316
311
|
/**
|
|
317
|
-
*
|
|
312
|
+
* forge#3932: does the issue declare a `**Depends on**: #N` dependency that is still OPEN?
|
|
313
|
+
* Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
|
|
314
|
+
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for the exact line decompose.md emits
|
|
315
|
+
* (`**Depends on**: #N` at line start; loose "depends on/after" prose is ignored). A self-reference is
|
|
316
|
+
* ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
317
|
+
* when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
|
|
318
|
+
* An empty read of the issue itself means "no body to trust" and yields `null`.
|
|
319
|
+
*/
|
|
320
|
+
export async function openDependency(issue, io) {
|
|
321
|
+
let rec;
|
|
322
|
+
try { rec = await readIssueRecord(issue, io); } catch (e) {
|
|
323
|
+
return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
|
|
324
|
+
}
|
|
325
|
+
if (rec === null) return { unreadable: true, detail: `issue ${issue} could not be parsed for its **Depends on** line` };
|
|
326
|
+
if (rec.empty || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
|
|
327
|
+
const deps = [...new Set([...rec.body.replace(/\r\n/g, "\n").matchAll(/^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm)].map((m) => Number(m[1])))]
|
|
328
|
+
.filter((n) => n > 0 && n !== Number(issue));
|
|
329
|
+
const open = [];
|
|
330
|
+
for (const dep of deps) {
|
|
331
|
+
let d;
|
|
332
|
+
try { d = await readIssueRecord(dep, io); } catch (e) {
|
|
333
|
+
return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
|
|
334
|
+
}
|
|
335
|
+
const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
|
|
336
|
+
if (st === "open") open.push(dep);
|
|
337
|
+
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
338
|
+
}
|
|
339
|
+
return open.length ? { open } : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
|
|
318
344
|
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
319
345
|
* a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
|
|
320
346
|
* TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
|
|
@@ -340,11 +366,6 @@ async function decomposeLoopGuard(issue, comments, io) {
|
|
|
340
366
|
return null;
|
|
341
367
|
}
|
|
342
368
|
|
|
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
369
|
/**
|
|
349
370
|
* Count commits on `branch` ahead of `lane`'s base. On the first build the
|
|
350
371
|
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
@@ -533,6 +554,9 @@ function isRemediationKind(k) {
|
|
|
533
554
|
return typeof k === "string" && Object.hasOwn(REMEDIATION_BOUND_MARKERS, k);
|
|
534
555
|
}
|
|
535
556
|
|
|
557
|
+
// forge#4021: the newest remediation trail reported an in-PR fix request (remediate.md M8).
|
|
558
|
+
const INPR_REQUESTED_RE = /\*\*Re-gate outcome\*\*:\s*INPR-FIX-REQUESTED\b/;
|
|
559
|
+
|
|
536
560
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
537
561
|
const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
|
|
538
562
|
const LANE_RE = BRANCH_RE;
|
|
@@ -577,11 +601,6 @@ async function reviewArgs(state, ctx, io) {
|
|
|
577
601
|
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
578
602
|
}
|
|
579
603
|
|
|
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
604
|
/**
|
|
586
605
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
587
606
|
* trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
|
|
@@ -602,42 +621,24 @@ function isDecomposed(report) {
|
|
|
602
621
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
603
622
|
return false;
|
|
604
623
|
}
|
|
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;
|
|
624
|
+
// forge#3862: decomposition is a planning decision on SEPARABILITY, never on size. The engine has
|
|
625
|
+
// no line-count rule and no estimate net: an estimate, however large, never routes to decompose.
|
|
626
|
+
// A YES must be backed by the separability evidence the investigator records: an own-line
|
|
627
|
+
// `INDEPENDENT_SEAMS:` of at least 2 and an own-line `DECOMPOSE_JUSTIFICATION:`. A report that
|
|
628
|
+
// carries the seam field but not that evidence is downgraded to NO (fail toward building).
|
|
629
|
+
// A YES with no `INDEPENDENT_SEAMS:` field at all (an older or malformed report) is kept.
|
|
617
630
|
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);
|
|
631
|
+
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
632
|
+
if (seamsM) {
|
|
633
|
+
const seams = Number(seamsM[1]);
|
|
627
634
|
const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
|
|
628
|
-
if (
|
|
629
|
-
console.error(`[engine] unsupported DECOMPOSE:YES (
|
|
635
|
+
if (seams < 2 || !justified) {
|
|
636
|
+
console.error(`[engine] unsupported DECOMPOSE:YES (separable seams ${seams}${justified ? "" : ", no DECOMPOSE_JUSTIFICATION"}); treating as not decomposed`);
|
|
630
637
|
return false;
|
|
631
638
|
}
|
|
632
639
|
}
|
|
633
640
|
return true;
|
|
634
641
|
}
|
|
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
642
|
return false;
|
|
642
643
|
}
|
|
643
644
|
|
|
@@ -757,13 +758,7 @@ export const PHASES = [
|
|
|
757
758
|
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
758
759
|
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
759
760
|
// 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
|
-
}
|
|
761
|
+
// forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
|
|
767
762
|
if (latestGateState(comments) === "blocked") {
|
|
768
763
|
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
|
|
769
764
|
}
|
|
@@ -776,13 +771,6 @@ export const PHASES = [
|
|
|
776
771
|
if (hasInvestigationMarker(comments)) {
|
|
777
772
|
return { status: "committed", terminalReason: "investigation", outputs: {} };
|
|
778
773
|
}
|
|
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
774
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
787
775
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
788
776
|
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
@@ -858,11 +846,19 @@ export const PHASES = [
|
|
|
858
846
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
859
847
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
860
848
|
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
861
|
-
|
|
849
|
+
// CI still running when the merge gate's wait window ended: nothing failed, so this is a wait the engine
|
|
850
|
+
// polls (runIssue), not a block. Structured `blocker` field only, never PR/comment prose; "ci gate"
|
|
851
|
+
// (red CI) is a disjoint phrase and keeps the REVIEW_PR_OPEN path below.
|
|
852
|
+
if (rr && rr.status === "BLOCKED" && isCiPendingBlocker(rr.blocker))
|
|
853
|
+
return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending", outputs, code: OC.REVIEW_PR_OPEN };
|
|
854
|
+
if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
|
|
862
855
|
let snap = null;
|
|
863
856
|
try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
|
|
864
857
|
if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
|
|
865
|
-
|
|
858
|
+
// forge#3931: an idempotent re-review at an unchanged head posts nothing new, so the only evidence of a
|
|
859
|
+
// blocking finding is the trusted FORGE:NOTE_DISPOSITION already on the PR for this head. Hand off to remediate.
|
|
860
|
+
if (await blockingDispositionAtHead(pr, io)) return { ...escalated, detail: "blocking ISSUE finding at unchanged head", code: OC.REVIEW_BLOCKING_ISSUE };
|
|
861
|
+
return { status: "failed", detail: "PR open, not merged", retryable: false, outputs, code: OC.REVIEW_PR_OPEN };
|
|
866
862
|
},
|
|
867
863
|
},
|
|
868
864
|
{
|
|
@@ -915,7 +911,15 @@ export const PHASES = [
|
|
|
915
911
|
const { comments } = await issueMarkers(state.issue, io);
|
|
916
912
|
// Resume idempotency: a handoff interrupted after the marker post must not double-count.
|
|
917
913
|
// forge#3542: only a full-line marker in a trusted comment counts.
|
|
918
|
-
|
|
914
|
+
// forge#4021: an in-PR fix re-armed by the re-gate (the newest trail after the newest marker reports
|
|
915
|
+
// INPR-FIX-REQUESTED) needs a FRESH marker, or M0's per-kind guard sees the first trail as this dispatch's
|
|
916
|
+
// and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm).
|
|
917
|
+
const sentinel = `FORGE:${boundMarker}: pr=${pr}`;
|
|
918
|
+
let markerAt = -1;
|
|
919
|
+
comments.forEach((b, i) => { if (hasSentinelLine(b, sentinel)) markerAt = i; });
|
|
920
|
+
const trailAfter = markerAt < 0 ? null : latestMarked(comments.slice(markerAt + 1), PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
|
|
921
|
+
const rearmed = kind === "inpr-fix" && trailAfter !== null && INPR_REQUESTED_RE.test(trailAfter);
|
|
922
|
+
if (markerAt < 0 || rearmed) {
|
|
919
923
|
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
920
924
|
`${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
|
|
921
925
|
}
|
|
@@ -925,7 +929,8 @@ export const PHASES = [
|
|
|
925
929
|
await postDispatchMarker();
|
|
926
930
|
return legacyArgs();
|
|
927
931
|
},
|
|
928
|
-
|
|
932
|
+
// forge#4021: also re-entered (with remediationKind "inpr-fix") after a re-gate that requested one in-PR fix round.
|
|
933
|
+
entryCondition: (s) => s.committed.includes("review") && (s.terminalReason === "needs-human" || s.remediationKind === "inpr-fix"),
|
|
929
934
|
async detectOutcome(state, io) {
|
|
930
935
|
// forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
|
|
931
936
|
// returned a stale outcome from an earlier remediation when a later one posted nothing.
|
|
@@ -979,6 +984,12 @@ export const PHASES = [
|
|
|
979
984
|
if (heldAt >= 0 && heldAt > completeAt) {
|
|
980
985
|
// Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
|
|
981
986
|
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
987
|
+
// CI still pending at M7 is a wait, not a park: the engine polls CI (no model) and re-dispatches this phase, which
|
|
988
|
+
// resumes at M7. Only the structured `Hold reason` line of the newest trusted held record counts; the stale-head
|
|
989
|
+
// and deploy-gate holds keep the terminal awaiting-merge result below.
|
|
990
|
+
if (/^\*\*Hold reason\*\*:[ \t]*CI pending\b/m.test(scoped[heldAt]))
|
|
991
|
+
return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending",
|
|
992
|
+
outputs: { reGateOutcome: "HELD-AWAITING-MERGE" }, code: OC.REVIEW_PR_OPEN };
|
|
982
993
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
|
|
983
994
|
}
|
|
984
995
|
if (latest === null) {
|
|
@@ -1025,6 +1036,9 @@ export const PHASES = [
|
|
|
1025
1036
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
1026
1037
|
case "RE-ESCALATED":
|
|
1027
1038
|
case "UNFIXABLE":
|
|
1039
|
+
// forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
|
|
1040
|
+
if (await remediationHeadStale(state, io))
|
|
1041
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1028
1042
|
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
1029
1043
|
// policy judgment call respectively) — reuse the existing
|
|
1030
1044
|
// "needs-human" terminal reason rather than inventing two more,
|
|
@@ -1034,11 +1048,30 @@ export const PHASES = [
|
|
|
1034
1048
|
return { status: "committed", terminalReason: "needs-human",
|
|
1035
1049
|
code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
|
|
1036
1050
|
outputs: { reGateOutcome } };
|
|
1051
|
+
case "INPR-FIX-REQUESTED": {
|
|
1052
|
+
// forge#4021: the re-review approved but posted a FORGE:INPR_FIX work order. One further in-PR fix
|
|
1053
|
+
// remediation is allowed (never needs-human on the first request); the bound marker count caps it.
|
|
1054
|
+
if (await remediationHeadStale(state, io))
|
|
1055
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1056
|
+
if (await prMerged()) return mergedResult(reGateOutcome);
|
|
1057
|
+
const round = /\*\*INPR round\*\*:\s*(\d+)/.exec(latest)?.[1] ?? null;
|
|
1058
|
+
const outputs = { reGateOutcome, ...(round ? { inprRound: Number(round) } : {}) };
|
|
1059
|
+
const boundSentinel = `FORGE:${REMEDIATION_BOUND_MARKERS["inpr-fix"]}: pr=${state.pr}`;
|
|
1060
|
+
const priorMarkers = state.pr == null ? 2 : comments.filter((b) => hasSentinelLine(b, boundSentinel)).length;
|
|
1061
|
+
if (priorMarkers >= 2)
|
|
1062
|
+
return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs };
|
|
1063
|
+
return { status: "committed", code: OC.REMEDIATE_INPR_REQUESTED, outputs: { ...outputs, remediation: "inpr-fix" } };
|
|
1064
|
+
}
|
|
1065
|
+
case "REREVIEW-REQUIRED":
|
|
1066
|
+
if (await remediationHeadStale(state, io))
|
|
1067
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
|
|
1068
|
+
return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs: { reGateOutcome } };
|
|
1037
1069
|
default:
|
|
1038
1070
|
return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
|
|
1039
1071
|
}
|
|
1040
1072
|
},
|
|
1041
|
-
|
|
1073
|
+
// forge#4021: a committed in-PR fix request continues the run (remediate re-arms) instead of ending it.
|
|
1074
|
+
isTerminalAfter: (s) => s.remediationKind !== "inpr-fix",
|
|
1042
1075
|
},
|
|
1043
1076
|
{
|
|
1044
1077
|
id: "close",
|
|
@@ -1111,7 +1144,11 @@ export const PHASES = [
|
|
|
1111
1144
|
// that label is still a real terminal state (nothing left for this phase
|
|
1112
1145
|
// to do), just not a "merged" one — reuse the existing "invalid" reason
|
|
1113
1146
|
// (already in TERMINAL_REASONS) rather than inventing a new one.
|
|
1114
|
-
|
|
1147
|
+
// SEC-1: the label alone is not proof either. A reopened or never-closed issue can still
|
|
1148
|
+
// carry a stale `workflow:merged`, so "merged" requires CLOSED together with the label
|
|
1149
|
+
// (same predicate as the remediate `mergedResult`). A label without CLOSED falls through
|
|
1150
|
+
// to the OPEN / PHASE_COMPLETE / "issue not closed" paths below.
|
|
1151
|
+
if (snap.state === "CLOSED" && snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
1115
1152
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
1116
1153
|
// forge#3534: an investigation exit closes the issue itself; keep that reason honest.
|
|
1117
1154
|
if (snap.state === "CLOSED") {
|
|
@@ -1170,6 +1207,10 @@ export async function detectTrustedOutcome(phaseId, state, io) {
|
|
|
1170
1207
|
* Untrusted model output: only the fixed keys below are read, via anchored
|
|
1171
1208
|
* line matches; `pr_number` must be digits-only. Returns null when absent.
|
|
1172
1209
|
*/
|
|
1210
|
+
export function isCiPendingBlocker(blocker) {
|
|
1211
|
+
return typeof blocker === "string" && /checks still pending/i.test(blocker) && !/ci gate/i.test(blocker);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1173
1214
|
export function parseReviewResult(text) {
|
|
1174
1215
|
if (typeof text !== "string" || !text) return null;
|
|
1175
1216
|
const lines = text.split(/\r?\n/);
|
|
@@ -1180,6 +1221,9 @@ export function parseReviewResult(text) {
|
|
|
1180
1221
|
for (let i = start + 1; i < lines.length; i++) {
|
|
1181
1222
|
const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
|
|
1182
1223
|
if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
|
|
1224
|
+
// `blocker` is free text (spaces): rest of the anchored line, first wins, only ever substring-tested.
|
|
1225
|
+
const b = /^\s+blocker:[ \t]*(.*?)[ \t]*$/.exec(lines[i]);
|
|
1226
|
+
if (b) { if (!("blocker" in out)) out.blocker = b[1].slice(0, 500); continue; }
|
|
1183
1227
|
if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
|
|
1184
1228
|
break;
|
|
1185
1229
|
}
|
|
@@ -1212,16 +1256,73 @@ async function mergedPrForBranch(branch, lane, io) {
|
|
|
1212
1256
|
return hit ? { number: hit.number } : null;
|
|
1213
1257
|
} catch { return null; }
|
|
1214
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* forge#3931: true when the newest trusted FORGE:NOTE_DISPOSITION on the PR covers the live head
|
|
1261
|
+
* (`reviewed_sha`) and filed at least one ISSUE (`findings_filed` > 0). Trusted comments only; an
|
|
1262
|
+
* unreadable PR, missing head or unparsable record returns false (fail closed: no handoff).
|
|
1263
|
+
*/
|
|
1264
|
+
async function blockingDispositionAtHead(pr, io) {
|
|
1265
|
+
if (!pr?.head) return false;
|
|
1266
|
+
try {
|
|
1267
|
+
const { comments } = await issueMarkers(pr.number, io);
|
|
1268
|
+
const recs = comments.filter((b) => /^\s*<!--\s*FORGE:NOTE_DISPOSITION:/.test(b));
|
|
1269
|
+
if (!recs.length) return false;
|
|
1270
|
+
const first = recs[recs.length - 1].split("\n", 1)[0];
|
|
1271
|
+
const sha = /\breviewed_sha=([0-9a-f]{40,64})\b/.exec(first)?.[1];
|
|
1272
|
+
const filed = /\bfindings_filed=(\d+)\b/.exec(first)?.[1];
|
|
1273
|
+
return sha === pr.head && Number(filed) > 0;
|
|
1274
|
+
} catch { return false; }
|
|
1275
|
+
}
|
|
1215
1276
|
async function prStatusFor(state, io) {
|
|
1216
1277
|
const n = await openPrFor(state, io);
|
|
1217
1278
|
if (!n) return null;
|
|
1218
|
-
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
|
|
1279
|
+
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt,headRefOid"]);
|
|
1219
1280
|
let j;
|
|
1220
1281
|
try { j = JSON.parse(out || "{}"); } catch { return null; }
|
|
1221
1282
|
const labels = (j.labels || []).map((l) => l.name || l);
|
|
1222
1283
|
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
1223
1284
|
needsHuman: labels.includes("needs-human"),
|
|
1224
|
-
remediating: labels.includes("workflow:remediating")
|
|
1285
|
+
remediating: labels.includes("workflow:remediating"),
|
|
1286
|
+
head: typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) ? j.headRefOid : null };
|
|
1287
|
+
}
|
|
1288
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* forge#3931: true only when the newest trusted FORGE:REMEDIATION:COMPLETE names a
|
|
1292
|
+
* `**Head**:` SHA that differs from the live PR head, i.e. the trail was produced for
|
|
1293
|
+
* an older head. Any unreadable input (no pr, no trail, `unknown`/missing head, gh or
|
|
1294
|
+
* parse failure) returns false so callers keep today's behaviour (fail closed).
|
|
1295
|
+
*/
|
|
1296
|
+
export async function remediationHeadStale(state, io) {
|
|
1297
|
+
if (state?.pr == null) return false;
|
|
1298
|
+
try {
|
|
1299
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
1300
|
+
const latest = latestMarked(comments, PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
|
|
1301
|
+
const recorded = latest === null ? null : /\*\*Head\*\*:[ \t]*([0-9a-f]{40,64})\b/.exec(latest)?.[1] ?? null;
|
|
1302
|
+
if (!recorded) return false;
|
|
1303
|
+
const j = JSON.parse((await io.gh(["pr", "view", String(state.pr), "--json", "headRefOid"])) || "{}");
|
|
1304
|
+
return typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) && j.headRefOid !== recorded;
|
|
1305
|
+
} catch { return false; }
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* forge#3931 (CONC-1): hydrate-time cycle reset is safe only when the hydrated progress is provably from an
|
|
1310
|
+
* OLDER cycle. The index carries no cycle number, so a same-cycle mid-flight or blocked run must not reset.
|
|
1311
|
+
* Provable = a terminal index AND live GitHub state showing the issue OPEN with `needs-human` and
|
|
1312
|
+
* `workflow:remediating` both clear AND no OPEN PR on the branch. Any unreadable input returns false.
|
|
1313
|
+
*/
|
|
1314
|
+
export async function cycleResetProvable(state, io) {
|
|
1315
|
+
if (!state?.terminal) return false;
|
|
1316
|
+
try {
|
|
1317
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
1318
|
+
if (!snap.ok || snap.state !== "OPEN") return false;
|
|
1319
|
+
if (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating")) return false;
|
|
1320
|
+
if (state.branch) {
|
|
1321
|
+
const a = JSON.parse((await io.gh(["pr", "list", "--head", state.branch, "--json", "number,state", "--state", "all"])) || "[]");
|
|
1322
|
+
if (!Array.isArray(a) || a.some((p) => p && p.state === "OPEN")) return false;
|
|
1323
|
+
}
|
|
1324
|
+
return true;
|
|
1325
|
+
} catch { return false; }
|
|
1225
1326
|
}
|
|
1226
1327
|
|
|
1227
1328
|
/** The engine's transition function: first uncommitted phase whose gate holds. */
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -91,7 +91,13 @@ export function deriveState(events) {
|
|
|
91
91
|
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
92
92
|
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
93
93
|
if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
|
|
94
|
-
else if (e.phase === "remediate")
|
|
94
|
+
else if (e.phase === "remediate") {
|
|
95
|
+
// forge#4021: a re-gate that requested an in-PR fix re-arms remediate once (bounded in
|
|
96
|
+
// phases.mjs by the INPR_REMEDIATION marker count); every other remediate commit consumes the kind.
|
|
97
|
+
const rearm = e.outputs?.remediation === "inpr-fix";
|
|
98
|
+
s.remediationKind = rearm ? "inpr-fix" : null;
|
|
99
|
+
if (rearm) s.committed = s.committed.filter((ph) => ph !== "remediate");
|
|
100
|
+
}
|
|
95
101
|
break;
|
|
96
102
|
case "RUN_TERMINAL":
|
|
97
103
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
@@ -106,6 +112,15 @@ export function deriveState(events) {
|
|
|
106
112
|
// retry re-enters remediate/decompose instead of close/build.
|
|
107
113
|
case "RUN_REOPEN":
|
|
108
114
|
s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
|
|
115
|
+
// forge#3862: a `decomposed` terminal whose decomposition was revoked (trusted GitHub state,
|
|
116
|
+
// checked by the engine before it appends this event) reopens at build: un-commit the
|
|
117
|
+
// phases named in `uncommit` and drop the stale handoff reason so pickPhase re-selects build.
|
|
118
|
+
if (Array.isArray(e.uncommit) && e.uncommit.length) {
|
|
119
|
+
s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
|
|
120
|
+
s.terminalReason = null; s.outcomeCode = null;
|
|
121
|
+
// the revoked handoff reason must not be restored by a later RUN_REOPEN (e.g. after an engine-error)
|
|
122
|
+
lastCommitReason = null; lastCommitCode = null;
|
|
123
|
+
}
|
|
109
124
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
110
125
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
111
126
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
@@ -114,7 +129,7 @@ export function deriveState(events) {
|
|
|
114
129
|
// keeps remediate committed so the resume guard is not bypassed.
|
|
115
130
|
if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human"
|
|
116
131
|
&& lastTerminalReason === "needs-human") {
|
|
117
|
-
s.committed = s.committed.filter((
|
|
132
|
+
s.committed = s.committed.filter((ph) => ph !== "remediate");
|
|
118
133
|
}
|
|
119
134
|
lastTerminalReason = null;
|
|
120
135
|
s.lastRateLimit = null; // forge#3791: an explicit reopen never re-enters a stale pause
|
|
@@ -37,6 +37,9 @@ export function mapRecord(rec, wallMs) {
|
|
|
37
37
|
if (rec.event === "RUN_TERMINAL") {
|
|
38
38
|
return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
|
|
39
39
|
}
|
|
40
|
+
if (rec.event === "CI_PENDING_WAIT") {
|
|
41
|
+
return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
|
|
42
|
+
}
|
|
40
43
|
return null;
|
|
41
44
|
}
|
|
42
45
|
|