forgedock 1.13.0 → 1.14.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.mjs +160 -18
- package/bin/engine-cli.mjs +18 -5
- package/bin/engine.mjs +109 -6
- package/bin/forgedock.mjs +4 -1
- package/commands/orchestrate/phase-3-dependency.md +5 -2
- package/commands/orchestrate/phase-4-execution.md +144 -24
- package/commands/pipeline-health/phase-2-metrics.md +5 -3
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +6 -0
- package/commands/review-pr-agents/protocols.md +6 -1
- package/commands/review-pr-agents/scraper.md +3 -0
- package/commands/review-pr-agents/security.md +12 -0
- package/commands/review-pr-staging.md +370 -44
- package/commands/review-pr.md +620 -48
- package/commands/validate.md +11 -3
- package/commands/work-on/build/architect.md +11 -8
- package/commands/work-on/build.md +27 -6
- package/commands/work-on/close.md +5 -4
- package/commands/work-on/decompose.md +7 -2
- package/commands/work-on/investigate.md +11 -0
- package/commands/work-on/remediate.md +135 -74
- package/commands/work-on/review.md +3 -3
- package/docs/CONFIG.md +14 -1
- package/docs/RELEASE-NOTES.md +8 -0
- package/docs/spec/review-protocol.md +6 -1
- package/package.json +1 -1
- 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/checkbox-sections.test.sh +19 -0
- package/scripts/classify-finding.sh +96 -1
- package/scripts/classify-finding.test.sh +47 -0
- package/scripts/forge-root.test.sh +48 -1
- package/scripts/orchestrate-soft-edge.test.sh +58 -0
- package/scripts/remediate-basesync.test.sh +62 -3
- package/scripts/review-delta.sh +3 -2
- package/scripts/review-delta.test.sh +1 -1
- package/scripts/review-pr-delta-wiring.test.sh +266 -0
- package/scripts/review-pr-gate2.test.sh +13 -0
- package/scripts/review-pr-staging-scope.test.sh +60 -0
- package/scripts/trusted-comments.test.sh +11 -0
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
|
|
@@ -114,6 +118,80 @@ function latestMarked(comments, header, sentinel) {
|
|
|
114
118
|
const all = markedComments(comments, header, sentinel);
|
|
115
119
|
return all.length ? all[all.length - 1] : null;
|
|
116
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Cycle boundary (forge#3657). `--retry` on a phase-complete run starts a new
|
|
123
|
+
* cycle; the engine publishes `<!-- FORGE:CYCLE: n -->` so builder evidence from
|
|
124
|
+
* earlier cycles (branch, merged PR) is not re-adopted. Callers pass TRUSTED
|
|
125
|
+
* comments only (issueMarkers), so an untrusted comment cannot move the boundary.
|
|
126
|
+
*/
|
|
127
|
+
export function cycleMarker(n) { return `<!-- FORGE:CYCLE: ${n} -->`; }
|
|
128
|
+
const CYCLE_RE = /^[ \t]*<!--[ \t]*FORGE:CYCLE:[ \t]*(\d+)[ \t]*-->[ \t]*$/m;
|
|
129
|
+
function cycleNumber(body) {
|
|
130
|
+
if (!startsWithHeader(body, "FORGE:CYCLE")) return null;
|
|
131
|
+
const m = String(body).match(CYCLE_RE);
|
|
132
|
+
return m ? Number(m[1]) : null;
|
|
133
|
+
}
|
|
134
|
+
/** Comments after the newest cycle marker; all comments when none is present. */
|
|
135
|
+
export function sliceToCurrentCycle(comments) {
|
|
136
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
137
|
+
if (cycleNumber(comments[i]) !== null) return comments.slice(i + 1);
|
|
138
|
+
}
|
|
139
|
+
return comments;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Next cycle number: one past the higher of the newest trusted FORGE:CYCLE on
|
|
143
|
+
* GitHub and `localCycles` (the local RUN_NEXT_CYCLE count). A rehydrated log
|
|
144
|
+
* never replays RUN_NEXT_CYCLE, so the local count alone can reset to 0 while
|
|
145
|
+
* GitHub already carries markers. Errors propagate (caller fails closed).
|
|
146
|
+
*/
|
|
147
|
+
export async function nextCycleNumber(issue, localCycles, io) {
|
|
148
|
+
const { comments } = await issueMarkers(issue, io);
|
|
149
|
+
let highest = 0;
|
|
150
|
+
for (const body of comments) {
|
|
151
|
+
const c = cycleNumber(body);
|
|
152
|
+
if (c !== null && c > highest) highest = c;
|
|
153
|
+
}
|
|
154
|
+
return Math.max(highest, localCycles) + 1;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
|
|
158
|
+
* newest trusted cycle marker already is `n` (crash between post and the log
|
|
159
|
+
* append retries safely). Errors propagate so the caller can fail closed.
|
|
160
|
+
*/
|
|
161
|
+
export async function postCycleMarker(issue, n, io) {
|
|
162
|
+
const { comments } = await issueMarkers(issue, io);
|
|
163
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
164
|
+
const c = cycleNumber(comments[i]);
|
|
165
|
+
if (c !== null) { if (c === n) return { posted: false }; break; }
|
|
166
|
+
}
|
|
167
|
+
await io.gh(["issue", "comment", String(issue), "--body", cycleMarker(n)]);
|
|
168
|
+
return { posted: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Acceptance-gate state (build.md B6.5; forge#3545). `FORGE:BUILDER:COMPLETE` is
|
|
173
|
+
* appended at validate V5, BEFORE the gate runs, so the marker alone is not proof
|
|
174
|
+
* the build passed. Newest marker wins, by comment ORDER (never blob presence):
|
|
175
|
+
* `FORGE:ACCEPTANCE_GATE:PASSED` => "pass"; `:FAILED`, `:BLOCKED` or a
|
|
176
|
+
* `FORGE:BUILD_BLOCKED` comment => "blocked"; none => "none" (legacy / crash
|
|
177
|
+
* paths keep their prior behaviour). `comments` are trusted bodies, oldest first.
|
|
178
|
+
*/
|
|
179
|
+
function latestGateState(comments) {
|
|
180
|
+
const gate = "FORGE:ACCEPTANCE_GATE";
|
|
181
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
182
|
+
const b = comments[i];
|
|
183
|
+
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
|
|
184
|
+
if (!startsWithHeader(b, gate)) continue;
|
|
185
|
+
if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
|
|
186
|
+
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
|
|
187
|
+
}
|
|
188
|
+
return "none";
|
|
189
|
+
}
|
|
190
|
+
/** close.md C2 PHASE_COMPLETE evidence in the current cycle (forge#3545). */
|
|
191
|
+
function hasPhaseComplete(comments) {
|
|
192
|
+
return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
|
|
193
|
+
}
|
|
194
|
+
|
|
117
195
|
/**
|
|
118
196
|
* Trusted comment bodies, chronological — the same single fetch + trust predicate
|
|
119
197
|
* as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
|
|
@@ -387,6 +465,9 @@ async function reviewArgs(state, ctx, io) {
|
|
|
387
465
|
"--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
|
|
388
466
|
}
|
|
389
467
|
|
|
468
|
+
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); mirrored here for the isDecomposed safety net. */
|
|
469
|
+
const ESTIMATE_FACTOR = 1.2;
|
|
470
|
+
|
|
390
471
|
/**
|
|
391
472
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
392
473
|
* trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
|
|
@@ -407,7 +488,21 @@ function isDecomposed(report) {
|
|
|
407
488
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
408
489
|
return false;
|
|
409
490
|
}
|
|
410
|
-
|
|
491
|
+
if ((verdict ?? marker) === "YES") return true;
|
|
492
|
+
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
493
|
+
// the gate (estimate > threshold x factor) fails toward decompose. Own-line,
|
|
494
|
+
// whole-number fields only; absent/0/non-numeric fields have no effect.
|
|
495
|
+
const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
496
|
+
const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
497
|
+
if (est && gate) {
|
|
498
|
+
const estimate = Number(est[1]);
|
|
499
|
+
const threshold = Number(gate[1]);
|
|
500
|
+
if (threshold > 0 && estimate > threshold * ESTIMATE_FACTOR) {
|
|
501
|
+
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${ESTIMATE_FACTOR}; routing to decompose`);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
411
506
|
}
|
|
412
507
|
|
|
413
508
|
/** @type {Phase[]} */
|
|
@@ -481,8 +576,11 @@ export const PHASES = [
|
|
|
481
576
|
// Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
|
|
482
577
|
// comment) rather than trusting a possibly-stale/absent state.branch, then
|
|
483
578
|
// check it's already ahead of base → treat as done, skip the LLM (forge#2174).
|
|
484
|
-
const { comments } = await issueMarkers(state.issue, io);
|
|
579
|
+
const { comments: allComments } = await issueMarkers(state.issue, io);
|
|
580
|
+
const comments = sliceToCurrentCycle(allComments); // forge#3657: current-cycle evidence only
|
|
485
581
|
const branch = resolveBranch(state, comments);
|
|
582
|
+
// forge#3545: a failed/blocked acceptance gate newer than any PASSED means build is NOT done.
|
|
583
|
+
if (latestGateState(comments) === "blocked") return { satisfied: false };
|
|
486
584
|
if (branch && hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
|
|
487
585
|
return { satisfied: true, outputs: { branch } };
|
|
488
586
|
}
|
|
@@ -495,7 +593,8 @@ export const PHASES = [
|
|
|
495
593
|
return { satisfied: false };
|
|
496
594
|
},
|
|
497
595
|
async detectOutcome(state, io) {
|
|
498
|
-
const { comments } = await issueMarkers(state.issue, io);
|
|
596
|
+
const { comments: allComments } = await issueMarkers(state.issue, io);
|
|
597
|
+
const comments = sliceToCurrentCycle(allComments); // forge#3657
|
|
499
598
|
const complete = hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
|
|
500
599
|
// Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
|
|
501
600
|
// comment (ground truth), scoped to that specific comment — see
|
|
@@ -507,6 +606,13 @@ export const PHASES = [
|
|
|
507
606
|
// from a genuine git-confirmed zero and would wrongly trip the
|
|
508
607
|
// non-retryable guard below on the very first attempt.
|
|
509
608
|
const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
|
|
609
|
+
// forge#3545: fail closed. COMPLETE is written before the B6.5 acceptance gate, so a
|
|
610
|
+
// newer FAILED/BLOCKED gate (or BUILD_BLOCKED) must not be committed and move on to
|
|
611
|
+
// review. Non-retryable: a retry would only re-read the same append-only markers;
|
|
612
|
+
// a repaired build posts a newer PASSED, which clears this.
|
|
613
|
+
if (latestGateState(comments) === "blocked") {
|
|
614
|
+
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
|
|
615
|
+
}
|
|
510
616
|
if (complete && ahead > 0) {
|
|
511
617
|
return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
|
|
512
618
|
}
|
|
@@ -514,7 +620,7 @@ export const PHASES = [
|
|
|
514
620
|
// build.md says the router (not build) dispatches decompose with no
|
|
515
621
|
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
516
622
|
// re-emits the same exit.
|
|
517
|
-
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(
|
|
623
|
+
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
518
624
|
return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
|
|
519
625
|
}
|
|
520
626
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
@@ -663,8 +769,35 @@ export const PHASES = [
|
|
|
663
769
|
if (at >= 0) scoped = comments.slice(at + 1);
|
|
664
770
|
}
|
|
665
771
|
const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
|
|
666
|
-
|
|
772
|
+
// forge#3647: ground-truth PR read. An interrupted or rate-limited remediation can leave the
|
|
773
|
+
// PR already merged with no COMPLETE marker (or a stale HELD-AWAITING-MERGE report). The
|
|
774
|
+
// reported outcome is never upgraded to a merge decision; only the live PR state is. Same
|
|
775
|
+
// merged predicate as reconcile (`mergedAt` or state MERGED). Unreadable => null (fail closed).
|
|
776
|
+
const prMerged = async () => {
|
|
777
|
+
if (state.pr == null) return false;
|
|
778
|
+
try {
|
|
779
|
+
const out = await io.gh(["pr", "view", String(state.pr), "--json", "state,mergedAt,baseRefName"]);
|
|
780
|
+
const j = JSON.parse(out || "{}");
|
|
781
|
+
return !!j.mergedAt || j.state === "MERGED";
|
|
782
|
+
} catch { return false; }
|
|
783
|
+
};
|
|
784
|
+
// Shared with the AUTO-LANDED case: CLOSED + workflow:merged means genuinely finished;
|
|
785
|
+
// anything else (including an unreadable snapshot) hands off to idempotent `close`.
|
|
786
|
+
const mergedResult = async (reGateOutcome) => {
|
|
787
|
+
let closed = false;
|
|
788
|
+
try {
|
|
789
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
790
|
+
closed = snap.ok && snap.state === "CLOSED" &&
|
|
791
|
+
snap.labels.includes(PHASE_MARKERS.close.completionLabel);
|
|
792
|
+
} catch { closed = false; }
|
|
793
|
+
return closed
|
|
794
|
+
? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
|
|
795
|
+
: { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
|
|
796
|
+
};
|
|
797
|
+
if (latest === null) {
|
|
798
|
+
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
667
799
|
return { status: "failed", detail: `no ${completion} marker` };
|
|
800
|
+
}
|
|
668
801
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
669
802
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
670
803
|
// first whitespace-delimited token after the colon).
|
|
@@ -672,7 +805,7 @@ export const PHASES = [
|
|
|
672
805
|
const match = matches.length ? matches[matches.length - 1] : null;
|
|
673
806
|
const reGateOutcome = match ? match[1] : null;
|
|
674
807
|
switch (reGateOutcome) {
|
|
675
|
-
case "AUTO-LANDED":
|
|
808
|
+
case "AUTO-LANDED":
|
|
676
809
|
// forge#3624: remediate.md M8 drives close itself on this outcome, but an
|
|
677
810
|
// engine phase session may skip or fail that delegated close and nothing
|
|
678
811
|
// verified it. Check the issue's real state: CLOSED + workflow:merged means
|
|
@@ -680,17 +813,10 @@ export const PHASES = [
|
|
|
680
813
|
// an unreadable snapshot, hands off to `close` (idempotent via its reconcile).
|
|
681
814
|
// The reason stays "merged" either way so the commit replaces the review's
|
|
682
815
|
// 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
|
-
}
|
|
816
|
+
return mergedResult(reGateOutcome);
|
|
693
817
|
case "HELD-AWAITING-MERGE":
|
|
818
|
+
// forge#3647: a HELD report for a PR that has since merged must still drive close.
|
|
819
|
+
if (await prMerged()) return mergedResult(reGateOutcome);
|
|
694
820
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
695
821
|
case "RE-ESCALATED":
|
|
696
822
|
case "UNFIXABLE":
|
|
@@ -738,7 +864,16 @@ export const PHASES = [
|
|
|
738
864
|
// strict subset of what satisfies this predicate on a CLOSED issue).
|
|
739
865
|
const snap = await issueSnapshot(state.issue, io);
|
|
740
866
|
if (!snap.ok) return { satisfied: false };
|
|
741
|
-
|
|
867
|
+
if (snap.state === "CLOSED") return { satisfied: true };
|
|
868
|
+
// forge#3545: a current-cycle FORGE:PHASE:COMPLETE means close already ran to its
|
|
869
|
+
// PHASE_COMPLETE exit; do not re-run it.
|
|
870
|
+
if (snap.state === "OPEN") {
|
|
871
|
+
try {
|
|
872
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
873
|
+
if (hasPhaseComplete(sliceToCurrentCycle(comments))) return { satisfied: true, terminalReason: "phase-complete" };
|
|
874
|
+
} catch { /* fail closed: not satisfied */ }
|
|
875
|
+
}
|
|
876
|
+
return { satisfied: false };
|
|
742
877
|
},
|
|
743
878
|
async detectOutcome(state, io) {
|
|
744
879
|
const snap = await issueSnapshot(state.issue, io);
|
|
@@ -758,6 +893,13 @@ export const PHASES = [
|
|
|
758
893
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
759
894
|
if (snap.state === "CLOSED")
|
|
760
895
|
return { status: "committed", terminalReason: "invalid", outputs: {} };
|
|
896
|
+
// forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
|
|
897
|
+
// issue deliberately left OPEN). Only the current cycle's marker counts.
|
|
898
|
+
if (snap.state === "OPEN") {
|
|
899
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
900
|
+
if (hasPhaseComplete(sliceToCurrentCycle(comments)))
|
|
901
|
+
return { status: "committed", terminalReason: "phase-complete", outputs: {} };
|
|
902
|
+
}
|
|
761
903
|
return { status: "failed", detail: "issue not closed" };
|
|
762
904
|
},
|
|
763
905
|
isTerminalAfter: () => true,
|
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;
|
package/bin/engine.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { hostname as osHostname } from "node:os";
|
|
7
8
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
8
|
-
import { pickPhase, TERMINAL_REASONS, issueSnapshot } from "./engine/phases.mjs";
|
|
9
|
+
import { pickPhase, TERMINAL_REASONS, issueSnapshot, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
|
|
9
10
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
10
11
|
import { makeProjector } from "./engine/projector.mjs";
|
|
11
12
|
import { VALID_BACKENDS } from "./runner.mjs";
|
|
@@ -177,6 +178,48 @@ function makeProgressEmitter(onProgress) {
|
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
|
|
182
|
+
// forge#3574: lease holder ids are `cli_<host>_<pid>`. The host component lets a
|
|
183
|
+
// relaunch on the SAME host probe whether the holder pid is still alive and
|
|
184
|
+
// reclaim a dead holder's lease without waiting out the TTL. Strict regex: the
|
|
185
|
+
// host cannot contain `_` (the separator) and the pid is digits only, so a
|
|
186
|
+
// malformed or legacy `cli_<pid>` id never parses as reclaimable.
|
|
187
|
+
const LEASE_HOLDER_RE = /^cli_([A-Za-z0-9.-]+)_(\d+)$/;
|
|
188
|
+
|
|
189
|
+
/** Sanitize a hostname for use inside a lease id (no `_`, only `[A-Za-z0-9.-]`). */
|
|
190
|
+
export function sanitizeLeaseHost(host) {
|
|
191
|
+
return String(host || "unknown").replace(/[^A-Za-z0-9.-]/g, "-") || "unknown";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Parse a `cli_<host>_<pid>` lease holder id. Returns null for anything else. */
|
|
195
|
+
export function parseLeaseHolder(by) {
|
|
196
|
+
const m = LEASE_HOLDER_RE.exec(String(by ?? ""));
|
|
197
|
+
if (!m) return null;
|
|
198
|
+
const pid = Number(m[2]);
|
|
199
|
+
return Number.isSafeInteger(pid) && pid > 0 ? { host: m[1], pid } : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* True only when the lease holder is provably dead: id parses, host equals this
|
|
204
|
+
* host, and `kill(pid, 0)` throws ESRCH. EPERM (exists, not ours) and any other
|
|
205
|
+
* result mean alive. Legacy/other-host/unparseable ids are never reclaimed (TTL
|
|
206
|
+
* fallback). Best-effort like the I3 guard itself: PID reuse and containers
|
|
207
|
+
* sharing a hostname are accepted residual risk.
|
|
208
|
+
*/
|
|
209
|
+
export function isLeaseHolderDead({ by, host = osHostname(), killFn = process.kill.bind(process) } = {}) {
|
|
210
|
+
const parsed = parseLeaseHolder(by);
|
|
211
|
+
if (!parsed || parsed.host !== sanitizeLeaseHost(host)) return false;
|
|
212
|
+
try {
|
|
213
|
+
killFn(parsed.pid, 0);
|
|
214
|
+
return false;
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return e?.code === "ESRCH";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Max startup wait for a persisted rate-limit reset (guards a corrupt far-future resetAt). */
|
|
221
|
+
export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
|
|
222
|
+
|
|
180
223
|
/**
|
|
181
224
|
* @param {object} opts
|
|
182
225
|
* @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
|
|
@@ -230,6 +273,11 @@ function makeProgressEmitter(onProgress) {
|
|
|
230
273
|
* cycle (RUN_NEXT_CYCLE) that resets committed/branch/pr and restarts at
|
|
231
274
|
* investigate. Any other terminal reason is left untouched and returns
|
|
232
275
|
* `not-retryable`.
|
|
276
|
+
* @param {boolean} [opts.waitLease] - forge#3574: when another live holder owns
|
|
277
|
+
* the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
|
|
278
|
+
* its holder is reclaimable, then continue; `deferred` on timeout.
|
|
279
|
+
* @param {string} [opts.hostname] - injectable host name (tests).
|
|
280
|
+
* @param {Function} [opts.killFn] - injectable `process.kill` (tests).
|
|
233
281
|
*/
|
|
234
282
|
export async function runIssue(opts) {
|
|
235
283
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
@@ -253,6 +301,9 @@ export async function runIssue(opts) {
|
|
|
253
301
|
maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
|
|
254
302
|
sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
|
|
255
303
|
retry = false,
|
|
304
|
+
waitLease = false,
|
|
305
|
+
hostname = osHostname(),
|
|
306
|
+
killFn = process.kill.bind(process),
|
|
256
307
|
onProgress = () => {} } = opts;
|
|
257
308
|
|
|
258
309
|
// forge#2452: extracted into makeProgressEmitter() — see its docstring for
|
|
@@ -269,18 +320,38 @@ export async function runIssue(opts) {
|
|
|
269
320
|
|
|
270
321
|
// 1. Load + reconcile (GitHub wins).
|
|
271
322
|
const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
|
|
272
|
-
|
|
323
|
+
let remote = await projector.readState(issue);
|
|
273
324
|
let { state, action } = reconcileState(local, remote);
|
|
325
|
+
const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
|
|
326
|
+
!isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
|
|
327
|
+
const deferredResult = (r) => ({
|
|
328
|
+
terminalReason: "deferred",
|
|
329
|
+
leaseUntil: r.lease.until,
|
|
330
|
+
detail: `issue ${issue} leased by ${r.lease.by} until ${new Date(r.lease.until).toISOString()}`,
|
|
331
|
+
});
|
|
274
332
|
|
|
275
333
|
// I3 (best-effort): GitHub issue-edit is not an atomic CAS, so this is a
|
|
276
334
|
// courtesy check, not a hard mutual-exclusion guarantee — a concurrent
|
|
277
335
|
// start can still race between this read and the next lease write.
|
|
278
336
|
// MUST come before any projector.writeState() — remirror/hydrate branches
|
|
279
337
|
// must not overwrite GitHub state when another agent holds a valid lease.
|
|
280
|
-
if (
|
|
281
|
-
|
|
338
|
+
if (leaseHeld(remote)) {
|
|
339
|
+
if (!waitLease) return deferredResult(remote);
|
|
340
|
+
// forge#3574: bounded wait — never longer than one lease TTL.
|
|
341
|
+
const deadline = now() + leaseTtlMs;
|
|
342
|
+
let cur = remote;
|
|
343
|
+
while (leaseHeld(cur)) {
|
|
344
|
+
const remaining = deadline - now();
|
|
345
|
+
if (remaining <= 0) return deferredResult(cur);
|
|
346
|
+
emitProgress({ event: "lease_wait", detail: `waiting for lease held by ${cur.lease.by}` });
|
|
347
|
+
await sleep(Math.min(remaining, Math.max(0, cur.lease.until - now()), 15000) || 1);
|
|
348
|
+
cur = await projector.readState(issue);
|
|
349
|
+
}
|
|
350
|
+
// The holder may have committed phases (or finished) while we waited: re-reconcile
|
|
351
|
+
// against the post-wait remote so the lease write below cannot regress its state.
|
|
352
|
+
remote = cur;
|
|
353
|
+
({ state, action } = reconcileState(local, remote));
|
|
282
354
|
}
|
|
283
|
-
|
|
284
355
|
if (!state) {
|
|
285
356
|
state = freshState(issue, lane);
|
|
286
357
|
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
@@ -325,6 +396,17 @@ export async function runIssue(opts) {
|
|
|
325
396
|
};
|
|
326
397
|
}
|
|
327
398
|
}
|
|
399
|
+
// forge#3657: publish the cycle boundary on GitHub BEFORE the log flips, so
|
|
400
|
+
// build's reconcilers never re-adopt cycle-1 evidence. Fail closed.
|
|
401
|
+
if (state.terminalReason === "phase-complete") {
|
|
402
|
+
const localCycles = readLog(dir, issue).filter((e) => e.event === "RUN_NEXT_CYCLE").length;
|
|
403
|
+
try { await postCycleMarker(issue, await nextCycleNumber(issue, localCycles, io), io); } catch {
|
|
404
|
+
return {
|
|
405
|
+
terminalReason: "not-retryable",
|
|
406
|
+
detail: `issue ${issue}: could not post FORGE:CYCLE marker; --retry did not start a new cycle`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
328
410
|
appendEvent(dir, issue, {
|
|
329
411
|
event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
|
|
330
412
|
issue,
|
|
@@ -376,6 +458,26 @@ export async function runIssue(opts) {
|
|
|
376
458
|
} catch { return null; }
|
|
377
459
|
})());
|
|
378
460
|
|
|
461
|
+
// forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
|
|
462
|
+
// the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
|
|
463
|
+
// (the remote FORGE:STATE does not carry it). The lease is renewed while we
|
|
464
|
+
// wait so a second relaunch still defers.
|
|
465
|
+
const rl = local?.lastRateLimit;
|
|
466
|
+
const pendingPhase = pickPhase(state);
|
|
467
|
+
if (pendingPhase && rl && rl.phase === pendingPhase.id &&
|
|
468
|
+
typeof rl.resetAt === "number" && Number.isFinite(rl.resetAt) && rl.resetAt > now()) {
|
|
469
|
+
const waitMs = Math.min(rl.resetAt - now(), MAX_RESUME_RATE_LIMIT_WAIT_MS);
|
|
470
|
+
emitProgress({
|
|
471
|
+
event: "phase_paused", phase: pendingPhase.id, reason: "session-limit", resetAt: rl.resetAt,
|
|
472
|
+
detail: `resuming session-limit wait — pausing until ${new Date(rl.resetAt).toISOString()}`,
|
|
473
|
+
});
|
|
474
|
+
const resumeRenew = setInterval(() => {
|
|
475
|
+
projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } }).catch(() => {});
|
|
476
|
+
}, leaseRenewIntervalMs);
|
|
477
|
+
if (typeof resumeRenew.unref === "function") resumeRenew.unref();
|
|
478
|
+
try { await sleep(waitMs); } finally { clearInterval(resumeRenew); }
|
|
479
|
+
}
|
|
480
|
+
|
|
379
481
|
let phase;
|
|
380
482
|
while ((phase = pickPhase(state))) {
|
|
381
483
|
// forge#3506 (SPEC-1/SPEC-2): a crash after a terminal-after phase committed (remediate or
|
|
@@ -462,7 +564,8 @@ export async function runIssue(opts) {
|
|
|
462
564
|
}
|
|
463
565
|
let outcome;
|
|
464
566
|
if (reconciled.satisfied) {
|
|
465
|
-
outcome = { status: "committed", outputs: reconciled.outputs || {}
|
|
567
|
+
outcome = { status: "committed", outputs: reconciled.outputs || {},
|
|
568
|
+
...(reconciled.terminalReason ? { terminalReason: reconciled.terminalReason } : {}) };
|
|
466
569
|
} else {
|
|
467
570
|
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
468
571
|
// forge#2240: the phase is actually about to run (not a resume no-op)
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3207,7 +3207,7 @@ function help() {
|
|
|
3207
3207
|
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
3208
3208
|
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
3209
3209
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3210
|
-
["npx forgedock run-issue <issue> --lane <lane> [--retry]", "Drive one issue through the durable engine (--retry reopens an engine-error run)"],
|
|
3210
|
+
["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3211
3211
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3212
3212
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3213
3213
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
@@ -3972,6 +3972,9 @@ switch (command) {
|
|
|
3972
3972
|
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3973
3973
|
// preserve that same non-zero exit code for this reason too.
|
|
3974
3974
|
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
|
|
3975
|
+
// forge#3574: a lease-held deferral did NOT run the issue; exit 75
|
|
3976
|
+
// (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
|
|
3977
|
+
if (result && result.terminalReason === "deferred") exitCode = 75;
|
|
3975
3978
|
} catch (err) {
|
|
3976
3979
|
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3977
3980
|
exitCode = 1;
|
|
@@ -80,6 +80,8 @@ Each issue's extraction also carries a **provenance** tag, in descending order o
|
|
|
80
80
|
LAYER1_FILES=()
|
|
81
81
|
declare -A EDGE_KIND # "{PRED}:{SUCCESSOR}" → same-file | directory | shared-module (forge#1860)
|
|
82
82
|
declare -A EDGE_FILES # "{PRED}:{SUCCESSOR}" → the specific file(s) that triggered the edge (forge#1860)
|
|
83
|
+
declare -A EDGE_SOFT # "{PRED}:{SUCCESSOR}" → true when the Layer 1 edge is SOFT (either endpoint's FILE_SOURCE is body-fallback); unset = hard (forge#3697)
|
|
84
|
+
declare -A EDGE_RELEASED # "{PRED}:{SUCCESSOR}" → true once a soft edge was released early in Step 4B (forge#3697)
|
|
83
85
|
declare -A FILE_SOURCE # {NUM} → contract-deliverables | affected-files-section | body-fallback | none (forge#2436, forge#2848)
|
|
84
86
|
declare -A ISSUE_FILES # {NUM} → newline-separated declared file set (forge#2844)
|
|
85
87
|
|
|
@@ -136,6 +138,7 @@ done
|
|
|
136
138
|
- The issue with lower issue number goes first (stable ordering), unless an explicit `Depends on #` says otherwise
|
|
137
139
|
- Add a conflict note to the DAG plan: "#{A} and #{B} both modify `{file}` — #{A} is predecessor of #{B}"
|
|
138
140
|
- **Record the edge kind and shared file(s)** <!-- Added: forge#1860 -->: alongside the predecessor relationship, set `EDGE_KIND["${A}:${B}"]="same-file"` and `EDGE_FILES["${A}:${B}"]="{file}"` (space-separated if multiple files overlap). Layer 1 is the only point where the specific overlapping file is known — Step 3D's DAG only records that a predecessor relationship exists, not why. `EDGE_KIND`/`EDGE_FILES` have two consumers in `phase-4-execution.md` Step 4B: (1) the DONE-case "same-file current-state brief" forwarding (forge#1860, unchanged), and (2) `verify_file_overlap_edge()` (forge#1904, extended to the DONE path by forge#2848), which re-checks this guessed file list against the predecessor's *actual* PR diff once it reaches FAILED, GATED, **or DONE** — since Layer 1's file list is extracted from a pre-build investigation guess or a raw issue-body parse, never from real code, it can be wrong, and a predecessor that concludes without ever touching the guessed file should not keep a dependent gated/skipped on it, nor hand it a same-file brief about a file it never wrote. See `phase-4-execution.md`'s "File-overlap edge re-verification" section for the full mechanism.
|
|
141
|
+
- **Tag soft edges** <!-- Added: forge#3697 -->: a Layer 1 edge is **soft** when `FILE_SOURCE` of EITHER endpoint is `body-fallback` — set `EDGE_SOFT["${A}:${B}"]=true`. Edges whose both endpoints come from `contract-deliverables` or `affected-files-section` are **hard** (no `EDGE_SOFT` entry). Layer 2/3/4/5 edges, explicit `Depends on`, and the DATABASE chain are never soft. Soft edges rest on a pre-investigation body guess, so Step 4B may release one *early*, while the predecessor is still `IN_PROGRESS`, once the predecessor's open PR diff positively shows no overlap with `EDGE_FILES` (`verify_soft_edge_early` in `phase-4-execution.md`; no PR, a fetch error, or any overlap means HOLD, never drop). A predecessor's `FORGE:ARCHITECT` / `FORGE:BUILDER` planned-files list, once posted, outranks `body-fallback` as evidence about that predecessor's file set. `EDGE_SOFT` is in-memory only and, like `EDGE_KIND`/`EDGE_FILES`, is not retained across wake/compaction, so early release is live-session only and a reconstructed edge fails safe to HOLD.
|
|
139
142
|
|
|
140
143
|
#### Layer 2: Directory-proximity detection
|
|
141
144
|
|
|
@@ -410,7 +413,7 @@ Build a **directed acyclic graph (DAG)** of per-issue dependencies. Each issue g
|
|
|
410
413
|
- **Explicit dependencies**: If issue B says "Depends on #A" or "Blocked by #A", add A to B's predecessors
|
|
411
414
|
- **File-conflict edges**: If two issues share affected files (from Step 3C Layer 1), add a directed edge: lower issue number → higher issue number (unless explicit deps say otherwise). The later issue has the earlier issue in its predecessors.
|
|
412
415
|
- **Domain serialization edges**: DATABASE issues form a linear chain (each has the previous DATABASE issue as its predecessor). Same-small-directory issues (Layer 2) and high-fan-in file issues (Layer 3) get directed edges as per Step 3C rules.
|
|
413
|
-
- **Edge-kind tagging for same-file/directory/shared-module briefing and re-verification** <!-- Added: forge#1860; extended forge#1904 -->: Layer 1/2/3 edges are additionally tagged with `EDGE_KIND` (`same-file` / `directory` / `shared-module`) and `EDGE_FILES` at the point they're added (see Step 3C). Explicit-dependency edges, the DATABASE domain chain, Layer 4 conservative-fallback edges, and Layer 5 co-change edges deliberately do NOT receive one of these three `EDGE_KIND` values. This is what lets Step 4B distinguish "predecessor edge came from Step 3C Layer 1/2/3" from every other edge type, for two purposes: a same-file current-state brief (DONE case, forge#1860) and edge re-verification against the predecessor's actual PR diff once it reaches FAILED, GATED, or DONE (`verify_file_overlap_edge()`, forge#1904, DONE path added by forge#2848 — see `phase-4-execution.md` Step 4B). On the DONE path the two purposes are ordered: re-verification runs first and, on a `DROP`, suppresses the brief entirely. Non-`EDGE_KIND` edges are eligible for neither — they always `KEEP` in the re-verification check and are never candidates for the same-file brief.
|
|
416
|
+
- **Edge-kind tagging for same-file/directory/shared-module briefing and re-verification** <!-- Added: forge#1860; extended forge#1904 -->: Layer 1/2/3 edges are additionally tagged with `EDGE_KIND` (`same-file` / `directory` / `shared-module`) and `EDGE_FILES` at the point they're added (see Step 3C). Explicit-dependency edges, the DATABASE domain chain, Layer 4 conservative-fallback edges, and Layer 5 co-change edges deliberately do NOT receive one of these three `EDGE_KIND` values. This is what lets Step 4B distinguish "predecessor edge came from Step 3C Layer 1/2/3" from every other edge type, for two purposes: a same-file current-state brief (DONE case, forge#1860) and edge re-verification against the predecessor's actual PR diff once it reaches FAILED, GATED, or DONE (`verify_file_overlap_edge()`, forge#1904, DONE path added by forge#2848 — see `phase-4-execution.md` Step 4B). On the DONE path the two purposes are ordered: re-verification runs first and, on a `DROP`, suppresses the brief entirely. Non-`EDGE_KIND` edges are eligible for neither — they always `KEEP` in the re-verification check and are never candidates for the same-file brief. <!-- Added: forge#3697 --> Layer 1 edges backed by `body-fallback` provenance are additionally tagged soft (`EDGE_SOFT`, Step 3C) so Step 4B can release them early against a live predecessor PR diff while the predecessor is still `IN_PROGRESS` (`verify_soft_edge_early()`); hard edges are never released early.
|
|
414
417
|
- **Conservative fallback edges**: Low-confidence issues (Layer 4) get edges to same-domain issues as per Step 3C rules.
|
|
415
418
|
- **Co-change coupling edges** <!-- Added: forge#1196 -->: High co-change file pairs (Layer 5, 3+ shared commits in the bounded window) that span two different issues get a directed edge using the same lower-issue-number-is-predecessor convention as Layer 1. Verified-independent pairs (Layer 5, zero shared commits) may instead REMOVE an edge that Layer 2 or Layer 4 would otherwise have added for that pair — Layer 1 and Layer 3 edges are never removed by a Layer 5 downgrade.
|
|
416
419
|
- **Claims-board downgrade (Layer 2/4 edges only)** <!-- Added: forge#1736 -->: After dispatch begins (Phase 4A), when both issues in a Layer-2 or Layer-4 serialized pair post `FORGE:CLAIM` annotations on the coordination issue and their claimed file sets are **disjoint** (no path appears in both claims), the serialization edge for that pair MAY be relaxed — the blocked issue becomes ready. This downgrade is **never** applied to Layer-1 (same-file) or Layer-3 (high-fan-in) edges. See Step 4B: Claims-board relaxation sweep for the runtime check.
|
|
@@ -1286,7 +1289,7 @@ The orchestrator context window must stay small regardless of how many issues ha
|
|
|
1286
1289
|
|
|
1287
1290
|
This reconstruction MUST use the same three-way **DONE / GATED / FAILED** predecessor classification defined in `phase-4-execution.md` Step 4B ("Predecessor Classification") — not a binary terminal/non-terminal grep. A binary grep is exactly the bug forge#1812 fixed: it let `needs-human` simultaneously satisfy "predecessor is done, dispatch the successor" (this block, pre-fix) and "predecessor failed, skip the successor" (Step 4B's failure handler, pre-fix) — with no way to represent "predecessor is human-gated, its PR is still open, and its dependents should wait but not be abandoned." That third case is exactly what wake/compaction reconstruction hits most often, since a merge approved by a human typically happens *after* the orchestrator session that dispatched the predecessor has already ended — this block, not the live Step 4B loop, is the realistic trigger point for "gating PR merged while nobody was watching."
|
|
1288
1291
|
|
|
1289
|
-
For the same reason, this reconstruction also MUST call `verify_file_overlap_edge()` (also defined in `phase-4-execution.md` Step 4B, alongside `classify_predecessor_state()` — re-declare it here too if this block runs in a fresh context) before treating a GATED or FAILED predecessor's `EDGE_KIND` edge as still blocking. <!-- Added: forge#1904 --> A predecessor that reached `needs-human`/`workflow:invalid` with no PR, or whose PR never actually touched the guessed shared file, most realistically gets *discovered* at wake time — the session that would have caught it live has already ended. Re-verifying only in the live Step 4B loop and not here would leave this exact wake-time case unfixed.
|
|
1292
|
+
For the same reason, this reconstruction also MUST call `verify_file_overlap_edge()` (also defined in `phase-4-execution.md` Step 4B, alongside `classify_predecessor_state()` — re-declare it here too if this block runs in a fresh context) before treating a GATED or FAILED predecessor's `EDGE_KIND` edge as still blocking. <!-- Added: forge#1904 --> A predecessor that reached `needs-human`/`workflow:invalid` with no PR, or whose PR never actually touched the guessed shared file, most realistically gets *discovered* at wake time — the session that would have caught it live has already ended. Re-verifying only in the live Step 4B loop and not here would leave this exact wake-time case unfixed. <!-- Added: forge#3697 --> `EDGE_SOFT` is likewise not retained across wake/compaction: early release (`verify_soft_edge_early()`) is a live-session mechanism only, and a reconstructed edge simply holds until its predecessor concludes.
|
|
1290
1293
|
|
|
1291
1294
|
```bash
|
|
1292
1295
|
# Reconstruct dispatch state from GitHub after compaction / wake
|