forgedock 1.13.1 → 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.
Files changed (34) hide show
  1. package/bin/engine/phases.mjs +160 -18
  2. package/bin/engine-cli.mjs +18 -5
  3. package/bin/engine.mjs +109 -6
  4. package/bin/forgedock.mjs +4 -1
  5. package/commands/orchestrate/phase-3-dependency.md +5 -2
  6. package/commands/orchestrate/phase-4-execution.md +144 -24
  7. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  8. package/commands/review-pr-staging.md +289 -39
  9. package/commands/review-pr.md +604 -42
  10. package/commands/validate.md +11 -3
  11. package/commands/work-on/build/architect.md +11 -8
  12. package/commands/work-on/build.md +27 -6
  13. package/commands/work-on/close.md +5 -4
  14. package/commands/work-on/decompose.md +7 -2
  15. package/commands/work-on/investigate.md +11 -0
  16. package/commands/work-on/remediate.md +135 -74
  17. package/commands/work-on/review.md +3 -3
  18. package/docs/CONFIG.md +14 -1
  19. package/docs/RELEASE-NOTES.md +8 -0
  20. package/package.json +1 -1
  21. package/scripts/architect-resume-match.test.sh +53 -0
  22. package/scripts/check-spec-bash.sh +73 -3
  23. package/scripts/check-spec-bash.test.sh +50 -0
  24. package/scripts/checkbox-sections.test.sh +19 -0
  25. package/scripts/classify-finding.sh +96 -1
  26. package/scripts/classify-finding.test.sh +47 -0
  27. package/scripts/forge-root.test.sh +48 -1
  28. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  29. package/scripts/remediate-basesync.test.sh +62 -3
  30. package/scripts/review-delta.sh +3 -2
  31. package/scripts/review-delta.test.sh +1 -1
  32. package/scripts/review-pr-delta-wiring.test.sh +266 -0
  33. package/scripts/review-pr-staging-scope.test.sh +60 -0
  34. package/scripts/trusted-comments.test.sh +11 -0
@@ -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
- return (verdict ?? marker) === "YES";
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(await trustedCommentBodies(state.issue, io))) {
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
- if (latest === null)
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
- let closed = false;
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
- return snap.state === "CLOSED" ? { satisfied: true } : { satisfied: false };
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,
@@ -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
- const remote = await projector.readState(issue);
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 (remote?.lease && remote.lease.until > now() && remote.lease.by !== agentId) {
281
- return { terminalReason: "deferred", detail: `issue ${issue} leased by ${remote.lease.by}` };
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