forgedock 1.13.1 → 1.15.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/phases-args.test.mjs +11 -0
- package/bin/engine/phases.mjs +268 -25
- package/bin/engine/phases.test.mjs +24 -0
- package/bin/engine-cli.mjs +18 -5
- package/bin/engine.mjs +149 -12
- package/bin/forgedock.mjs +4 -1
- package/commands/issue.md +17 -2
- package/commands/orchestrate/phase-3-dependency.md +5 -2
- package/commands/orchestrate/phase-4-execution.md +154 -27
- package/commands/pipeline-health/phase-2-metrics.md +5 -3
- package/commands/review-pr-staging.md +554 -54
- package/commands/review-pr.md +662 -51
- package/commands/validate.md +11 -3
- package/commands/work-on/build/architect.md +11 -8
- package/commands/work-on/build/implement.md +15 -1
- package/commands/work-on/build.md +34 -10
- package/commands/work-on/close.md +5 -4
- package/commands/work-on/decompose.md +7 -2
- package/commands/work-on/investigate.md +13 -0
- package/commands/work-on/remediate.md +332 -91
- package/commands/work-on/review.md +3 -3
- package/docs/CONFIG.md +14 -1
- package/docs/RELEASE-NOTES.md +20 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -0
- package/scripts/architect-resume-match.test.sh +53 -0
- package/scripts/check-spec-bash.sh +73 -3
- package/scripts/check-spec-bash.test.sh +50 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/checkbox-sections.test.sh +19 -0
- package/scripts/classify-finding.sh +103 -1
- package/scripts/classify-finding.test.sh +56 -0
- package/scripts/forge-root.test.sh +86 -2
- package/scripts/orchestrate-soft-edge.test.sh +58 -0
- package/scripts/remediate-approved-head.test.sh +43 -0
- package/scripts/remediate-basesync.test.sh +62 -3
- package/scripts/remediation-marker-match.test.sh +6 -0
- package/scripts/resolve-deploy-gate.sh +51 -0
- package/scripts/resolve-deploy-gate.test.sh +59 -0
- package/scripts/review-delta.sh +3 -2
- package/scripts/review-delta.test.sh +1 -1
- package/scripts/review-pr-delta-wiring.test.sh +274 -0
- package/scripts/review-pr-domain-map.test.sh +10 -0
- package/scripts/review-pr-staging-scope.test.sh +159 -0
- package/scripts/trusted-comments.test.sh +11 -0
|
@@ -100,6 +100,17 @@ describe("engine phase args satisfy each target spec's argument-hint (forge#3499
|
|
|
100
100
|
await assert.rejects(() => close.buildArgs({ ...state, pr: null, terminalReason: "merged" }, ctx, io), PhaseArgsError);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("close investigation: --terminal-state investigation, never --pr (forge#3534)", async () => {
|
|
104
|
+
const close = PHASES.find((p) => p.id === "close");
|
|
105
|
+
for (const pr of [null, 12]) {
|
|
106
|
+
const args = await close.buildArgs({ ...state, pr, terminalReason: "investigation" }, ctx, io);
|
|
107
|
+
assert.equal(args[args.indexOf("--terminal-state") + 1], "investigation");
|
|
108
|
+
assert.ok(!args.includes("--pr"));
|
|
109
|
+
}
|
|
110
|
+
for (const reason of ["needs-human", "awaiting-merge", "engine-error"])
|
|
111
|
+
await assert.rejects(() => close.buildArgs({ ...state, terminalReason: reason }, ctx, io), PhaseArgsError);
|
|
112
|
+
});
|
|
113
|
+
|
|
103
114
|
it("close derives --terminal-state from the run's terminal reason", async () => {
|
|
104
115
|
const close = PHASES.find((p) => p.id === "close");
|
|
105
116
|
for (const [reason, expected] of [[null, "merged"], ["invalid", "invalid"], ["decomposed", "decomposed"]]) {
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -27,7 +27,11 @@ import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
|
|
|
27
27
|
// matching engine-level terminal reason to report instead of overloading
|
|
28
28
|
// "needs-human" (which would misrepresent a clean-but-unmet-bar re-review as
|
|
29
29
|
// a fresh human-judgment escalation).
|
|
30
|
-
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge"];
|
|
30
|
+
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge", "phase-complete"];
|
|
31
|
+
// forge#3545: "phase-complete" is close's CLOSE_RESULT PHASE_COMPLETE exit: the PR
|
|
32
|
+
// merged but later phases of a multi-phase issue remain, so the issue is
|
|
33
|
+
// deliberately left OPEN (FORGE:PHASE:COMPLETE posted). Distinct from "merged"
|
|
34
|
+
// (nothing shipped-and-closed) and "awaiting-merge" (no PR merged yet).
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Trust predicate for FORGE marker comments (forge#3542) — a JS port of
|
|
@@ -91,6 +95,8 @@ async function issueMarkers(issue, io) {
|
|
|
91
95
|
return { comments };
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/** Non-terminal held-resume record (remediate.md Phase M8, forge#3685): header and sentinel are the same line; never a COMPLETE marker. */
|
|
99
|
+
const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
|
|
94
100
|
const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
95
101
|
/** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
|
|
96
102
|
function startsWithHeader(body, header) {
|
|
@@ -114,6 +120,104 @@ function latestMarked(comments, header, sentinel) {
|
|
|
114
120
|
const all = markedComments(comments, header, sentinel);
|
|
115
121
|
return all.length ? all[all.length - 1] : null;
|
|
116
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Cycle boundary (forge#3657). `--retry` on a phase-complete run starts a new
|
|
125
|
+
* cycle; the engine publishes `<!-- FORGE:CYCLE: n -->` so builder evidence from
|
|
126
|
+
* earlier cycles (branch, merged PR) is not re-adopted. Callers pass TRUSTED
|
|
127
|
+
* comments only (issueMarkers), so an untrusted comment cannot move the boundary.
|
|
128
|
+
*/
|
|
129
|
+
export function cycleMarker(n) { return `<!-- FORGE:CYCLE: ${n} -->`; }
|
|
130
|
+
const CYCLE_RE = /^[ \t]*<!--[ \t]*FORGE:CYCLE:[ \t]*(\d+)[ \t]*-->[ \t]*$/m;
|
|
131
|
+
function cycleNumber(body) {
|
|
132
|
+
if (!startsWithHeader(body, "FORGE:CYCLE")) return null;
|
|
133
|
+
const m = String(body).match(CYCLE_RE);
|
|
134
|
+
return m ? Number(m[1]) : null;
|
|
135
|
+
}
|
|
136
|
+
/** Comments after the newest cycle marker; all comments when none is present. */
|
|
137
|
+
export function sliceToCurrentCycle(comments) {
|
|
138
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
139
|
+
if (cycleNumber(comments[i]) !== null) return comments.slice(i + 1);
|
|
140
|
+
}
|
|
141
|
+
return comments;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Next cycle number: one past the higher of the newest trusted FORGE:CYCLE on
|
|
145
|
+
* GitHub and `localCycles` (the local RUN_NEXT_CYCLE count). A rehydrated log
|
|
146
|
+
* never replays RUN_NEXT_CYCLE, so the local count alone can reset to 0 while
|
|
147
|
+
* GitHub already carries markers. Errors propagate (caller fails closed).
|
|
148
|
+
*/
|
|
149
|
+
export async function nextCycleNumber(issue, localCycles, io) {
|
|
150
|
+
const { comments } = await issueMarkers(issue, io);
|
|
151
|
+
let highest = 0;
|
|
152
|
+
for (const body of comments) {
|
|
153
|
+
const c = cycleNumber(body);
|
|
154
|
+
if (c !== null && c > highest) highest = c;
|
|
155
|
+
}
|
|
156
|
+
return Math.max(highest, localCycles) + 1;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
|
|
160
|
+
* newest trusted cycle marker already is `n` (crash between post and the log
|
|
161
|
+
* append retries safely). Errors propagate so the caller can fail closed.
|
|
162
|
+
*/
|
|
163
|
+
export async function postCycleMarker(issue, n, io) {
|
|
164
|
+
const { comments } = await issueMarkers(issue, io);
|
|
165
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
166
|
+
const c = cycleNumber(comments[i]);
|
|
167
|
+
if (c !== null) { if (c === n) return { posted: false }; break; }
|
|
168
|
+
}
|
|
169
|
+
await io.gh(["issue", "comment", String(issue), "--body", cycleMarker(n)]);
|
|
170
|
+
return { posted: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Acceptance-gate state (build.md B6.5; forge#3545). `FORGE:BUILDER:COMPLETE` is
|
|
175
|
+
* appended at validate V5, BEFORE the gate runs, so the marker alone is not proof
|
|
176
|
+
* the build passed. Newest marker wins, by comment ORDER (never blob presence):
|
|
177
|
+
* `FORGE:ACCEPTANCE_GATE:PASSED` => "pass"; `:FAILED`, `:BLOCKED` or a
|
|
178
|
+
* `FORGE:BUILD_BLOCKED` comment => "blocked"; none => "none" (legacy / crash
|
|
179
|
+
* paths keep their prior behaviour). `comments` are trusted bodies, oldest first.
|
|
180
|
+
* Stays fail-closed by design. A blocked state clears only via a newer PASSED, which a
|
|
181
|
+
* re-invocation posts: build.md B0 "gate resume" re-enters B6.5 instead of returning
|
|
182
|
+
* ALREADY_DONE when the newest gate marker is blocked (forge#3725).
|
|
183
|
+
*/
|
|
184
|
+
function latestGateState(comments) {
|
|
185
|
+
const gate = "FORGE:ACCEPTANCE_GATE";
|
|
186
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
187
|
+
const b = comments[i];
|
|
188
|
+
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
|
|
189
|
+
if (!startsWithHeader(b, gate)) continue;
|
|
190
|
+
if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
|
|
191
|
+
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
|
|
192
|
+
}
|
|
193
|
+
return "none";
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
197
|
+
* `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
|
|
198
|
+
* other marker, so the string quoted inside another comment never counts.
|
|
199
|
+
*/
|
|
200
|
+
function hasInvestigationMarker(comments) {
|
|
201
|
+
const m = PHASE_MARKERS.build.investigationMarker;
|
|
202
|
+
return hasMarker(comments, m, m);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* forge#3534 (SEC-3716-1): does the issue carry a trusted current-cycle investigation exit marker?
|
|
206
|
+
* The implement child closes an investigation issue itself (CLOSED, no `workflow:merged`), so the
|
|
207
|
+
* engine's divergence guard must not read that as a dead issue before build.reconcile runs.
|
|
208
|
+
* Fail closed: any fetch error means "no marker" (the guard then behaves as before).
|
|
209
|
+
*/
|
|
210
|
+
export async function hasInvestigationExit(issue, io) {
|
|
211
|
+
try {
|
|
212
|
+
const { comments } = await issueMarkers(issue, io);
|
|
213
|
+
return hasInvestigationMarker(sliceToCurrentCycle(comments));
|
|
214
|
+
} catch { return false; }
|
|
215
|
+
}
|
|
216
|
+
/** close.md C2 PHASE_COMPLETE evidence in the current cycle (forge#3545). */
|
|
217
|
+
function hasPhaseComplete(comments) {
|
|
218
|
+
return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
|
|
219
|
+
}
|
|
220
|
+
|
|
117
221
|
/**
|
|
118
222
|
* Trusted comment bodies, chronological — the same single fetch + trust predicate
|
|
119
223
|
* as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
|
|
@@ -150,6 +254,33 @@ function sizeGateRoutesToDecompose(comments) {
|
|
|
150
254
|
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
|
|
151
255
|
return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
|
|
152
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
|
|
259
|
+
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
260
|
+
* a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
|
|
261
|
+
* TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
|
|
262
|
+
* `comments` MUST be trusted bodies. A body-fetch failure fails toward the guard
|
|
263
|
+
* (not decomposed) so an error can never route into a refusing decompose phase.
|
|
264
|
+
*/
|
|
265
|
+
async function decomposeLoopGuard(issue, comments, io) {
|
|
266
|
+
const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
|
|
267
|
+
if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return "already decomposed (FORGE:DECOMPOSED)";
|
|
268
|
+
if (comments.some((c) => starts(c, PHASE_MARKERS.build.sizeOverrideMarker) && overrideHasJustification(c)))
|
|
269
|
+
return "justified FORGE:SIZE_OVERRIDE";
|
|
270
|
+
let body;
|
|
271
|
+
try {
|
|
272
|
+
const out = String((await io.gh(["issue", "view", String(issue), "--json", "body", "--jq", ".body"])) ?? "");
|
|
273
|
+
body = out;
|
|
274
|
+
if (out.trimStart().startsWith("{")) {
|
|
275
|
+
try { const j = JSON.parse(out); if (j && typeof j.body === "string") body = j.body; } catch { /* raw text */ }
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
return "issue body unavailable (failing toward not decomposed)";
|
|
279
|
+
}
|
|
280
|
+
if (/^\*\*Parent\*\*:[ \t]*#\d+/m.test(body.replace(/\r\n/g, "\n"))) return "sub-issue (**Parent**: #N)";
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
153
284
|
/** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
|
|
154
285
|
function mayRouteToDecompose(comments) {
|
|
155
286
|
const gate = PHASE_MARKERS.build.sizeGateMarker;
|
|
@@ -387,6 +518,9 @@ async function reviewArgs(state, ctx, io) {
|
|
|
387
518
|
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
388
519
|
}
|
|
389
520
|
|
|
521
|
+
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
|
|
522
|
+
const DEFAULT_ESTIMATE_FACTOR = 1.2;
|
|
523
|
+
|
|
390
524
|
/**
|
|
391
525
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
392
526
|
* trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
|
|
@@ -407,7 +541,25 @@ function isDecomposed(report) {
|
|
|
407
541
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
408
542
|
return false;
|
|
409
543
|
}
|
|
410
|
-
|
|
544
|
+
if ((verdict ?? marker) === "YES") return true;
|
|
545
|
+
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
546
|
+
// the gate (estimate > threshold x factor) fails toward decompose. Own-line,
|
|
547
|
+
// whole-number fields only; absent/0/non-numeric fields have no effect. The factor is
|
|
548
|
+
// the investigator's effective `ESTIMATE_FACTOR:` (positive decimal); absent/invalid -> 1.2.
|
|
549
|
+
const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
550
|
+
const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
551
|
+
const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
|
|
552
|
+
const parsedFactor = fac ? Number(fac[1]) : NaN;
|
|
553
|
+
const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
|
|
554
|
+
if (est && gate) {
|
|
555
|
+
const estimate = Number(est[1]);
|
|
556
|
+
const threshold = Number(gate[1]);
|
|
557
|
+
if (threshold > 0 && estimate > threshold * factor) {
|
|
558
|
+
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
411
563
|
}
|
|
412
564
|
|
|
413
565
|
/** @type {Phase[]} */
|
|
@@ -432,8 +584,16 @@ export const PHASES = [
|
|
|
432
584
|
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
433
585
|
if (closing === complete) {
|
|
434
586
|
// investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
|
|
435
|
-
if (isDecomposed(report))
|
|
587
|
+
if (isDecomposed(report)) {
|
|
588
|
+
// Lazy: the body fetch only happens on the decompose path. A child / already
|
|
589
|
+
// decomposed / overridden issue cannot be decomposed (decompose Guard 1 BLOCKs).
|
|
590
|
+
const guard = await decomposeLoopGuard(state.issue, comments, io);
|
|
591
|
+
if (guard) {
|
|
592
|
+
console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
|
|
593
|
+
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
594
|
+
}
|
|
436
595
|
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
596
|
+
}
|
|
437
597
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
438
598
|
}
|
|
439
599
|
return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
|
|
@@ -481,8 +641,11 @@ export const PHASES = [
|
|
|
481
641
|
// Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
|
|
482
642
|
// comment) rather than trusting a possibly-stale/absent state.branch, then
|
|
483
643
|
// check it's already ahead of base → treat as done, skip the LLM (forge#2174).
|
|
484
|
-
const { comments } = await issueMarkers(state.issue, io);
|
|
644
|
+
const { comments: allComments } = await issueMarkers(state.issue, io);
|
|
645
|
+
const comments = sliceToCurrentCycle(allComments); // forge#3657: current-cycle evidence only
|
|
485
646
|
const branch = resolveBranch(state, comments);
|
|
647
|
+
// forge#3545: a failed/blocked acceptance gate newer than any PASSED means build is NOT done.
|
|
648
|
+
if (latestGateState(comments) === "blocked") return { satisfied: false };
|
|
486
649
|
if (branch && hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
|
|
487
650
|
return { satisfied: true, outputs: { branch } };
|
|
488
651
|
}
|
|
@@ -492,10 +655,13 @@ export const PHASES = [
|
|
|
492
655
|
const merged = await mergedPrForBranch(branch, state.lane, io);
|
|
493
656
|
if (merged) return { satisfied: true, outputs: { branch, pr: merged.number } };
|
|
494
657
|
}
|
|
658
|
+
// forge#3534: an investigation exit already posted its deliverables marker: build is done.
|
|
659
|
+
if (hasInvestigationMarker(comments)) return { satisfied: true, terminalReason: "investigation", outputs: {} };
|
|
495
660
|
return { satisfied: false };
|
|
496
661
|
},
|
|
497
662
|
async detectOutcome(state, io) {
|
|
498
|
-
const { comments } = await issueMarkers(state.issue, io);
|
|
663
|
+
const { comments: allComments } = await issueMarkers(state.issue, io);
|
|
664
|
+
const comments = sliceToCurrentCycle(allComments); // forge#3657
|
|
499
665
|
const complete = hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
|
|
500
666
|
// Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
|
|
501
667
|
// comment (ground truth), scoped to that specific comment — see
|
|
@@ -507,14 +673,28 @@ export const PHASES = [
|
|
|
507
673
|
// from a genuine git-confirmed zero and would wrongly trip the
|
|
508
674
|
// non-retryable guard below on the very first attempt.
|
|
509
675
|
const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
|
|
676
|
+
// forge#3545: fail closed. COMPLETE is written before the B6.5 acceptance gate, so a
|
|
677
|
+
// newer FAILED/BLOCKED gate (or BUILD_BLOCKED) must not be committed and move on to
|
|
678
|
+
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
679
|
+
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
680
|
+
// build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
|
|
681
|
+
if (latestGateState(comments) === "blocked") {
|
|
682
|
+
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
|
|
683
|
+
}
|
|
510
684
|
if (complete && ahead > 0) {
|
|
511
685
|
return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
|
|
512
686
|
}
|
|
687
|
+
// forge#3534: INVESTIGATION_COMPLETE writes no code and opens no PR, so it never
|
|
688
|
+
// posts FORGE:BUILDER:COMPLETE with commits. The durable deliverables marker is the
|
|
689
|
+
// ground truth. Not retried; review is skipped and close runs `--terminal-state investigation`.
|
|
690
|
+
if (hasInvestigationMarker(comments)) {
|
|
691
|
+
return { status: "committed", terminalReason: "investigation", outputs: {} };
|
|
692
|
+
}
|
|
513
693
|
// B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
|
|
514
694
|
// build.md says the router (not build) dispatches decompose with no
|
|
515
695
|
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
516
696
|
// re-emits the same exit.
|
|
517
|
-
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(
|
|
697
|
+
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
518
698
|
return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
|
|
519
699
|
}
|
|
520
700
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
@@ -561,7 +741,8 @@ export const PHASES = [
|
|
|
561
741
|
id: "review",
|
|
562
742
|
command: "work-on/review",
|
|
563
743
|
buildArgs: reviewArgs,
|
|
564
|
-
|
|
744
|
+
// forge#3534: an investigation build has no PR to review; close runs directly.
|
|
745
|
+
entryCondition: (s) => s.committed.includes("build") && s.terminalReason !== "investigation",
|
|
565
746
|
async reconcile(state, io) {
|
|
566
747
|
const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
|
|
567
748
|
return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
|
|
@@ -663,8 +844,48 @@ export const PHASES = [
|
|
|
663
844
|
if (at >= 0) scoped = comments.slice(at + 1);
|
|
664
845
|
}
|
|
665
846
|
const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
|
|
666
|
-
|
|
847
|
+
// forge#3647: ground-truth PR read. An interrupted or rate-limited remediation can leave the
|
|
848
|
+
// PR already merged with no COMPLETE marker (or a stale HELD-AWAITING-MERGE report). The
|
|
849
|
+
// reported outcome is never upgraded to a merge decision; only the live PR state is. Same
|
|
850
|
+
// merged predicate as reconcile (`mergedAt` or state MERGED). Unreadable => null (fail closed).
|
|
851
|
+
const prMerged = async () => {
|
|
852
|
+
if (state.pr == null) return false;
|
|
853
|
+
try {
|
|
854
|
+
const out = await io.gh(["pr", "view", String(state.pr), "--json", "state,mergedAt,baseRefName"]);
|
|
855
|
+
const j = JSON.parse(out || "{}");
|
|
856
|
+
return !!j.mergedAt || j.state === "MERGED";
|
|
857
|
+
} catch { return false; }
|
|
858
|
+
};
|
|
859
|
+
// Shared with the AUTO-LANDED case: CLOSED + workflow:merged means genuinely finished;
|
|
860
|
+
// anything else (including an unreadable snapshot) hands off to idempotent `close`.
|
|
861
|
+
const mergedResult = async (reGateOutcome) => {
|
|
862
|
+
let closed = false;
|
|
863
|
+
try {
|
|
864
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
865
|
+
closed = snap.ok && snap.state === "CLOSED" &&
|
|
866
|
+
snap.labels.includes(PHASE_MARKERS.close.completionLabel);
|
|
867
|
+
} catch { closed = false; }
|
|
868
|
+
return closed
|
|
869
|
+
? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
|
|
870
|
+
: { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
|
|
871
|
+
};
|
|
872
|
+
// forge#3685: a resume that ends HELD posts a non-terminal FORGE:REMEDIATION_HELD record (no COMPLETE).
|
|
873
|
+
// Newest-wins between the held record and COMPLETE, by position in the (trusted, scoped) comment list.
|
|
874
|
+
const isHeld = (b) => startsWithHeader(b, REMEDIATION_HELD_MARKER) && hasSentinelLine(b, REMEDIATION_HELD_MARKER);
|
|
875
|
+
let heldAt = -1, completeAt = -1;
|
|
876
|
+
scoped.forEach((b, i) => {
|
|
877
|
+
if (isHeld(b)) heldAt = i;
|
|
878
|
+
if (startsWithHeader(b, PHASE_MARKERS.remediate.header) && hasSentinelLine(b, completion)) completeAt = i;
|
|
879
|
+
});
|
|
880
|
+
if (heldAt >= 0 && heldAt > completeAt) {
|
|
881
|
+
// Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
|
|
882
|
+
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
883
|
+
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
|
|
884
|
+
}
|
|
885
|
+
if (latest === null) {
|
|
886
|
+
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
667
887
|
return { status: "failed", detail: `no ${completion} marker` };
|
|
888
|
+
}
|
|
668
889
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
669
890
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
670
891
|
// first whitespace-delimited token after the colon).
|
|
@@ -672,7 +893,7 @@ export const PHASES = [
|
|
|
672
893
|
const match = matches.length ? matches[matches.length - 1] : null;
|
|
673
894
|
const reGateOutcome = match ? match[1] : null;
|
|
674
895
|
switch (reGateOutcome) {
|
|
675
|
-
case "AUTO-LANDED":
|
|
896
|
+
case "AUTO-LANDED":
|
|
676
897
|
// forge#3624: remediate.md M8 drives close itself on this outcome, but an
|
|
677
898
|
// engine phase session may skip or fail that delegated close and nothing
|
|
678
899
|
// verified it. Check the issue's real state: CLOSED + workflow:merged means
|
|
@@ -680,17 +901,10 @@ export const PHASES = [
|
|
|
680
901
|
// an unreadable snapshot, hands off to `close` (idempotent via its reconcile).
|
|
681
902
|
// The reason stays "merged" either way so the commit replaces the review's
|
|
682
903
|
// sticky "needs-human" and close.buildArgs accepts --terminal-state merged.
|
|
683
|
-
|
|
684
|
-
try {
|
|
685
|
-
const snap = await issueSnapshot(state.issue, io);
|
|
686
|
-
closed = snap.ok && snap.state === "CLOSED" &&
|
|
687
|
-
snap.labels.includes(PHASE_MARKERS.close.completionLabel);
|
|
688
|
-
} catch { closed = false; }
|
|
689
|
-
return closed
|
|
690
|
-
? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
|
|
691
|
-
: { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
|
|
692
|
-
}
|
|
904
|
+
return mergedResult(reGateOutcome);
|
|
693
905
|
case "HELD-AWAITING-MERGE":
|
|
906
|
+
// forge#3647: a HELD report for a PR that has since merged must still drive close.
|
|
907
|
+
if (await prMerged()) return mergedResult(reGateOutcome);
|
|
694
908
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
695
909
|
case "RE-ESCALATED":
|
|
696
910
|
case "UNFIXABLE":
|
|
@@ -715,20 +929,23 @@ export const PHASES = [
|
|
|
715
929
|
// non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
|
|
716
930
|
// be closed as merged.
|
|
717
931
|
const reason = state.terminalReason ?? null;
|
|
718
|
-
if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
|
|
932
|
+
if (reason !== null && !["merged", "decomposed", "invalid", "investigation"].includes(reason))
|
|
719
933
|
throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
|
|
720
934
|
const terminal = reason === "decomposed" ? "decomposed"
|
|
721
|
-
: reason === "invalid" ? "invalid"
|
|
935
|
+
: reason === "invalid" ? "invalid"
|
|
936
|
+
: reason === "investigation" ? "investigation" : "merged";
|
|
722
937
|
const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
|
|
723
938
|
// forge#3504: a merged close with no PR is fabricated evidence — require it.
|
|
724
|
-
|
|
939
|
+
// forge#3534: an investigation close is PR-less by definition; never pass --pr.
|
|
940
|
+
if (terminal !== "investigation" && (state.pr != null || terminal === "merged")) args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
|
|
725
941
|
if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
|
|
726
942
|
const wt = await resolveWorktree(state, io);
|
|
727
943
|
if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
|
|
728
944
|
args.push("--terminal-state", terminal);
|
|
729
945
|
return args;
|
|
730
946
|
},
|
|
731
|
-
entryCondition: (s) => s.committed.includes("review")
|
|
947
|
+
entryCondition: (s) => s.committed.includes("review")
|
|
948
|
+
|| (s.committed.includes("build") && s.terminalReason === "investigation"), // forge#3534
|
|
732
949
|
async reconcile(state, io) {
|
|
733
950
|
// Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
|
|
734
951
|
// workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
|
|
@@ -738,7 +955,25 @@ export const PHASES = [
|
|
|
738
955
|
// strict subset of what satisfies this predicate on a CLOSED issue).
|
|
739
956
|
const snap = await issueSnapshot(state.issue, io);
|
|
740
957
|
if (!snap.ok) return { satisfied: false };
|
|
741
|
-
|
|
958
|
+
if (snap.state === "CLOSED" && state.terminalReason === "investigation") {
|
|
959
|
+
// forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
|
|
960
|
+
// proof close.md ran (board, summary, trajectory). Its own idempotency key is the
|
|
961
|
+
// FORGE:TRAJECTORY comment; only that makes this satisfied.
|
|
962
|
+
try {
|
|
963
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
964
|
+
return { satisfied: comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY")), terminalReason: "investigation" };
|
|
965
|
+
} catch { return { satisfied: false }; }
|
|
966
|
+
}
|
|
967
|
+
if (snap.state === "CLOSED") return { satisfied: true };
|
|
968
|
+
// forge#3545: a current-cycle FORGE:PHASE:COMPLETE means close already ran to its
|
|
969
|
+
// PHASE_COMPLETE exit; do not re-run it.
|
|
970
|
+
if (snap.state === "OPEN") {
|
|
971
|
+
try {
|
|
972
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
973
|
+
if (hasPhaseComplete(sliceToCurrentCycle(comments))) return { satisfied: true, terminalReason: "phase-complete" };
|
|
974
|
+
} catch { /* fail closed: not satisfied */ }
|
|
975
|
+
}
|
|
976
|
+
return { satisfied: false };
|
|
742
977
|
},
|
|
743
978
|
async detectOutcome(state, io) {
|
|
744
979
|
const snap = await issueSnapshot(state.issue, io);
|
|
@@ -756,8 +991,16 @@ export const PHASES = [
|
|
|
756
991
|
// (already in TERMINAL_REASONS) rather than inventing a new one.
|
|
757
992
|
if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
758
993
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
994
|
+
// forge#3534: an investigation exit closes the issue itself; keep that reason honest.
|
|
759
995
|
if (snap.state === "CLOSED")
|
|
760
|
-
return { status: "committed", terminalReason: "invalid", outputs: {} };
|
|
996
|
+
return { status: "committed", terminalReason: state.terminalReason === "investigation" ? "investigation" : "invalid", outputs: {} };
|
|
997
|
+
// forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
|
|
998
|
+
// issue deliberately left OPEN). Only the current cycle's marker counts.
|
|
999
|
+
if (snap.state === "OPEN") {
|
|
1000
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
1001
|
+
if (hasPhaseComplete(sliceToCurrentCycle(comments)))
|
|
1002
|
+
return { status: "committed", terminalReason: "phase-complete", outputs: {} };
|
|
1003
|
+
}
|
|
761
1004
|
return { status: "failed", detail: "issue not closed" };
|
|
762
1005
|
},
|
|
763
1006
|
isTerminalAfter: () => true,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Spec-contract test (forge#3725): the build.md B0 resume block must re-enter
|
|
2
|
+
// B6.5 when the newest acceptance-gate marker is blocked (see latestGateState
|
|
3
|
+
// in phases.mjs), instead of returning ALREADY_DONE.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
|
|
12
|
+
describe("latestGateState / ACCEPTANCE_GATE spec contract (forge#3725)", () => {
|
|
13
|
+
it("build.md B0 gate resume re-enters B6.5 before returning ALREADY_DONE", () => {
|
|
14
|
+
const spec = readFileSync(path.join(ROOT, "commands/work-on/build.md"), "utf8");
|
|
15
|
+
const start = spec.indexOf("**Resume check**");
|
|
16
|
+
const end = spec.indexOf("- If there is no `<!-- FORGE:INVESTIGATOR -->`", start);
|
|
17
|
+
assert.ok(start >= 0 && end > start, "B0 resume block found");
|
|
18
|
+
const b0 = spec.slice(start, end);
|
|
19
|
+
assert.match(b0, /ACCEPTANCE_GATE:(FAILED|BLOCKED)/);
|
|
20
|
+
assert.match(b0, /BUILD_BLOCKED/);
|
|
21
|
+
assert.match(b0, /re-enter at \*\*Phase B6\.5\*\*/);
|
|
22
|
+
assert.ok(b0.indexOf("Gate resume") < b0.indexOf("status: ALREADY_DONE"), "gate resume precedes ALREADY_DONE");
|
|
23
|
+
});
|
|
24
|
+
});
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { readdirSync, statSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
10
|
+
import { homedir, hostname } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
|
|
12
|
+
import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
15
|
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
@@ -338,6 +338,10 @@ export function lastLocalRun(dir) {
|
|
|
338
338
|
* `runCommand()` call; only applies on the "api" backend.
|
|
339
339
|
* Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
|
|
340
340
|
* or its built-in default).
|
|
341
|
+
* --wait-lease Optional (forge#3574). If another live holder owns the
|
|
342
|
+
* run's lease, wait (bounded by the lease TTL) until it
|
|
343
|
+
* expires or its same-host pid is dead, then continue.
|
|
344
|
+
* Without it, a held lease exits `deferred` (code 75).
|
|
341
345
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
342
346
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
343
347
|
* phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
|
|
@@ -346,7 +350,7 @@ export function lastLocalRun(dir) {
|
|
|
346
350
|
*/
|
|
347
351
|
export async function runFromCli(argv, deps = {}) {
|
|
348
352
|
const issue = parseInt(argv[0], 10);
|
|
349
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry]");
|
|
353
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry] [--wait-lease]");
|
|
350
354
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
351
355
|
const lane = flag(argv, "--lane");
|
|
352
356
|
if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
|
|
@@ -355,10 +359,13 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
355
359
|
const model = flag(argv, "--model");
|
|
356
360
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
357
361
|
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
362
|
+
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
363
|
+
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
364
|
+
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
358
365
|
const io = deps.io ?? makeIo();
|
|
359
366
|
await assertRepoMatchesCwd(io, repo);
|
|
360
367
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
361
|
-
const agentId = `cli_${process.pid}`;
|
|
368
|
+
const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
|
|
362
369
|
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
363
370
|
const dir = deps.dir ?? runDir();
|
|
364
371
|
// forge#2240: print the run-log path at the very start of the run, not only
|
|
@@ -390,8 +397,14 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
390
397
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
391
398
|
...(backend ? { backend } : {}),
|
|
392
399
|
...(model ? { model } : {}),
|
|
393
|
-
...(retry ? { retry: true } : {})
|
|
400
|
+
...(retry ? { retry: true } : {}),
|
|
401
|
+
...(waitLease ? { waitLease: true } : {}) });
|
|
394
402
|
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
403
|
+
if (res.terminalReason === "deferred") {
|
|
404
|
+
// forge#3574: say WHY (holder + expiry); the empty-log diagnostics add nothing.
|
|
405
|
+
console.log(res.detail ?? "run deferred: another holder owns the lease");
|
|
406
|
+
return res;
|
|
407
|
+
}
|
|
395
408
|
if (res.terminalReason === "not-retryable") {
|
|
396
409
|
console.log(res.detail ?? "run is not retryable");
|
|
397
410
|
return res;
|