forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. package/scripts/with-run-tmpdir.sh +20 -0
package/README.md CHANGED
@@ -303,6 +303,8 @@ If you're carrying an older `--global` habit in scripts or CI, it's harmless to
303
303
 
304
304
  </details>
305
305
 
306
+ **After upgrading**: run `npx forgedock labels setup` once per repo. Repos created before the `workflow:remediating` state (forge#3541) lack that label; the pipeline now creates it on demand (best-effort), but the setup command is the supported way to sync all labels.
307
+
306
308
  ---
307
309
 
308
310
  ## For companies
@@ -21,6 +21,7 @@ export const OUTCOME_CODES = Object.freeze({
21
21
  REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
22
22
  REVIEW_BLOCKING_ISSUE: "review.blocking_issue",
23
23
  REVIEW_PR_OPEN: "review.pr_open",
24
+ REVIEW_CI_PENDING_TIMEOUT: "review.ci_pending_timeout",
24
25
  BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
25
26
  BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
26
27
  BUILD_SIZE_OVER_GATE: "build.size_over_gate", // retired (forge#3862): never emitted; kept so historic runs parse
@@ -29,12 +30,15 @@ export const OUTCOME_CODES = Object.freeze({
29
30
  REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
30
31
  REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
31
32
  REMEDIATE_UNFIXABLE: "remediate.unfixable",
33
+ REMEDIATE_INPR_REQUESTED: "remediate.inpr_requested",
32
34
  CI_RED: "ci.red",
33
35
  CI_TIMEOUT: "ci.timeout",
34
36
  BASE_CONFLICT: "base.conflict",
35
37
  RATE_LIMITED: "rate_limited",
36
38
  ENGINE_ERROR: "engine.error",
37
39
  INFRA_DISK_QUOTA: "infra.disk_quota",
40
+ INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
41
+ CLI_TIMEOUT: "cli.timeout",
38
42
  LEASE_HELD: "lease.held",
39
43
  DEPENDENCY_OPEN: "dependency.open",
40
44
  DEPENDENCY_UNREADABLE: "dependency.unreadable",
@@ -74,6 +78,11 @@ export function isDiskQuotaError(err) {
74
78
  return isDiskQuotaText(err.message) || isDiskQuotaText(err.stderr);
75
79
  }
76
80
 
81
+ /** True when a thrown runner error is a transient CLI spawn failure (ETIMEDOUT/EAGAIN/ENOMEM/EMFILE at spawn), forge#4021. */
82
+ export function isTransientSpawnError(err) {
83
+ return !!err && typeof err === "object" && err.code === "CLI_SPAWN_TRANSIENT";
84
+ }
85
+
77
86
  /** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
78
87
  export function codeForRunnerError(err) {
79
88
  if (!err || typeof err !== "object") return UNCLASSIFIED;
@@ -81,6 +90,10 @@ export function codeForRunnerError(err) {
81
90
  if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
82
91
  return OUTCOME_CODES.RATE_LIMITED;
83
92
  switch (err.code) {
93
+ case "CLI_SPAWN_TRANSIENT":
94
+ return OUTCOME_CODES.INFRA_CLI_SPAWN_TRANSIENT;
95
+ case "CLI_TIMEOUT":
96
+ return OUTCOME_CODES.CLI_TIMEOUT;
84
97
  case "NO_API_KEY":
85
98
  case "NO_SDK":
86
99
  case "CLI_BACKEND_FAILED":
@@ -220,7 +220,8 @@ function latestGateMarker(comments) {
220
220
  const b = comments[i];
221
221
  if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
222
222
  if (!startsWithHeader(b, gate)) continue;
223
- if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
223
+ // :DEGRADED is pass-class (forge#3628): the build completed, and review gates the merge on the degraded paths.
224
+ if (hasSentinelLine(b, `${gate}:PASSED`) || hasSentinelLine(b, `${gate}:DEGRADED`)) return { state: "pass", index: i };
224
225
  if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
225
226
  }
226
227
  return { state: "none", index: -1 };
@@ -553,6 +554,9 @@ function isRemediationKind(k) {
553
554
  return typeof k === "string" && Object.hasOwn(REMEDIATION_BOUND_MARKERS, k);
554
555
  }
555
556
 
557
+ // forge#4021: the newest remediation trail reported an in-PR fix request (remediate.md M8).
558
+ const INPR_REQUESTED_RE = /\*\*Re-gate outcome\*\*:\s*INPR-FIX-REQUESTED\b/;
559
+
556
560
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
557
561
  const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
558
562
  const LANE_RE = BRANCH_RE;
@@ -842,6 +846,11 @@ export const PHASES = [
842
846
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
843
847
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
844
848
  // needs-human but are NOT remediation handoffs, so skip the label fallback.
849
+ // CI still running when the merge gate's wait window ended: nothing failed, so this is a wait the engine
850
+ // polls (runIssue), not a block. Structured `blocker` field only, never PR/comment prose; "ci gate"
851
+ // (red CI) is a disjoint phrase and keeps the REVIEW_PR_OPEN path below.
852
+ if (rr && rr.status === "BLOCKED" && isCiPendingBlocker(rr.blocker))
853
+ return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending", outputs, code: OC.REVIEW_PR_OPEN };
845
854
  if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
846
855
  let snap = null;
847
856
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
@@ -902,7 +911,15 @@ export const PHASES = [
902
911
  const { comments } = await issueMarkers(state.issue, io);
903
912
  // Resume idempotency: a handoff interrupted after the marker post must not double-count.
904
913
  // forge#3542: only a full-line marker in a trusted comment counts.
905
- if (!comments.some((b) => hasSentinelLine(b, `FORGE:${boundMarker}: pr=${pr}`))) {
914
+ // forge#4021: an in-PR fix re-armed by the re-gate (the newest trail after the newest marker reports
915
+ // INPR-FIX-REQUESTED) needs a FRESH marker, or M0's per-kind guard sees the first trail as this dispatch's
916
+ // and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm).
917
+ const sentinel = `FORGE:${boundMarker}: pr=${pr}`;
918
+ let markerAt = -1;
919
+ comments.forEach((b, i) => { if (hasSentinelLine(b, sentinel)) markerAt = i; });
920
+ const trailAfter = markerAt < 0 ? null : latestMarked(comments.slice(markerAt + 1), PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
921
+ const rearmed = kind === "inpr-fix" && trailAfter !== null && INPR_REQUESTED_RE.test(trailAfter);
922
+ if (markerAt < 0 || rearmed) {
906
923
  await io.gh(["issue", "comment", String(state.issue), "--body",
907
924
  `${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
908
925
  }
@@ -912,7 +929,8 @@ export const PHASES = [
912
929
  await postDispatchMarker();
913
930
  return legacyArgs();
914
931
  },
915
- entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
932
+ // forge#4021: also re-entered (with remediationKind "inpr-fix") after a re-gate that requested one in-PR fix round.
933
+ entryCondition: (s) => s.committed.includes("review") && (s.terminalReason === "needs-human" || s.remediationKind === "inpr-fix"),
916
934
  async detectOutcome(state, io) {
917
935
  // forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
918
936
  // returned a stale outcome from an earlier remediation when a later one posted nothing.
@@ -966,6 +984,12 @@ export const PHASES = [
966
984
  if (heldAt >= 0 && heldAt > completeAt) {
967
985
  // Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
968
986
  if (await prMerged()) return mergedResult("AUTO-LANDED");
987
+ // CI still pending at M7 is a wait, not a park: the engine polls CI (no model) and re-dispatches this phase, which
988
+ // resumes at M7. Only the structured `Hold reason` line of the newest trusted held record counts; the stale-head
989
+ // and deploy-gate holds keep the terminal awaiting-merge result below.
990
+ if (/^\*\*Hold reason\*\*:[ \t]*CI pending\b/m.test(scoped[heldAt]))
991
+ return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending",
992
+ outputs: { reGateOutcome: "HELD-AWAITING-MERGE" }, code: OC.REVIEW_PR_OPEN };
969
993
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
970
994
  }
971
995
  if (latest === null) {
@@ -1024,11 +1048,30 @@ export const PHASES = [
1024
1048
  return { status: "committed", terminalReason: "needs-human",
1025
1049
  code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
1026
1050
  outputs: { reGateOutcome } };
1051
+ case "INPR-FIX-REQUESTED": {
1052
+ // forge#4021: the re-review approved but posted a FORGE:INPR_FIX work order. One further in-PR fix
1053
+ // remediation is allowed (never needs-human on the first request); the bound marker count caps it.
1054
+ if (await remediationHeadStale(state, io))
1055
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
1056
+ if (await prMerged()) return mergedResult(reGateOutcome);
1057
+ const round = /\*\*INPR round\*\*:\s*(\d+)/.exec(latest)?.[1] ?? null;
1058
+ const outputs = { reGateOutcome, ...(round ? { inprRound: Number(round) } : {}) };
1059
+ const boundSentinel = `FORGE:${REMEDIATION_BOUND_MARKERS["inpr-fix"]}: pr=${state.pr}`;
1060
+ const priorMarkers = state.pr == null ? 2 : comments.filter((b) => hasSentinelLine(b, boundSentinel)).length;
1061
+ if (priorMarkers >= 2)
1062
+ return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs };
1063
+ return { status: "committed", code: OC.REMEDIATE_INPR_REQUESTED, outputs: { ...outputs, remediation: "inpr-fix" } };
1064
+ }
1065
+ case "REREVIEW-REQUIRED":
1066
+ if (await remediationHeadStale(state, io))
1067
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
1068
+ return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs: { reGateOutcome } };
1027
1069
  default:
1028
1070
  return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
1029
1071
  }
1030
1072
  },
1031
- isTerminalAfter: () => true,
1073
+ // forge#4021: a committed in-PR fix request continues the run (remediate re-arms) instead of ending it.
1074
+ isTerminalAfter: (s) => s.remediationKind !== "inpr-fix",
1032
1075
  },
1033
1076
  {
1034
1077
  id: "close",
@@ -1164,6 +1207,10 @@ export async function detectTrustedOutcome(phaseId, state, io) {
1164
1207
  * Untrusted model output: only the fixed keys below are read, via anchored
1165
1208
  * line matches; `pr_number` must be digits-only. Returns null when absent.
1166
1209
  */
1210
+ export function isCiPendingBlocker(blocker) {
1211
+ return typeof blocker === "string" && /checks still pending/i.test(blocker) && !/ci gate/i.test(blocker);
1212
+ }
1213
+
1167
1214
  export function parseReviewResult(text) {
1168
1215
  if (typeof text !== "string" || !text) return null;
1169
1216
  const lines = text.split(/\r?\n/);
@@ -1174,6 +1221,9 @@ export function parseReviewResult(text) {
1174
1221
  for (let i = start + 1; i < lines.length; i++) {
1175
1222
  const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
1176
1223
  if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
1224
+ // `blocker` is free text (spaces): rest of the anchored line, first wins, only ever substring-tested.
1225
+ const b = /^\s+blocker:[ \t]*(.*?)[ \t]*$/.exec(lines[i]);
1226
+ if (b) { if (!("blocker" in out)) out.blocker = b[1].slice(0, 500); continue; }
1177
1227
  if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
1178
1228
  break;
1179
1229
  }
@@ -91,7 +91,13 @@ export function deriveState(events) {
91
91
  // forge#3530: the review's remediation kind must survive resume. A later review
92
92
  // commit replaces it; a committed remediate consumes it, so it is never reused.
93
93
  if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
94
- else if (e.phase === "remediate") s.remediationKind = null;
94
+ else if (e.phase === "remediate") {
95
+ // forge#4021: a re-gate that requested an in-PR fix re-arms remediate once (bounded in
96
+ // phases.mjs by the INPR_REMEDIATION marker count); every other remediate commit consumes the kind.
97
+ const rearm = e.outputs?.remediation === "inpr-fix";
98
+ s.remediationKind = rearm ? "inpr-fix" : null;
99
+ if (rearm) s.committed = s.committed.filter((ph) => ph !== "remediate");
100
+ }
95
101
  break;
96
102
  case "RUN_TERMINAL":
97
103
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
@@ -112,6 +118,8 @@ export function deriveState(events) {
112
118
  if (Array.isArray(e.uncommit) && e.uncommit.length) {
113
119
  s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
114
120
  s.terminalReason = null; s.outcomeCode = null;
121
+ // the revoked handoff reason must not be restored by a later RUN_REOPEN (e.g. after an engine-error)
122
+ lastCommitReason = null; lastCommitCode = null;
115
123
  }
116
124
  // forge#3754: a remediate that ended needs-human is re-run after a human clears the
117
125
  // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
@@ -121,7 +129,7 @@ export function deriveState(events) {
121
129
  // keeps remediate committed so the resume guard is not bypassed.
122
130
  if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human"
123
131
  && lastTerminalReason === "needs-human") {
124
- s.committed = s.committed.filter((p) => p !== "remediate");
132
+ s.committed = s.committed.filter((ph) => ph !== "remediate");
125
133
  }
126
134
  lastTerminalReason = null;
127
135
  s.lastRateLimit = null; // forge#3791: an explicit reopen never re-enters a stale pause
@@ -37,6 +37,9 @@ export function mapRecord(rec, wallMs) {
37
37
  if (rec.event === "RUN_TERMINAL") {
38
38
  return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
39
39
  }
40
+ if (rec.event === "CI_PENDING_WAIT") {
41
+ return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
42
+ }
40
43
  return null;
41
44
  }
42
45
 
@@ -1,8 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { tmpDir } from "../tests/_tmp.mjs";
3
4
  import { describe, it, beforeEach, afterEach } from "node:test";
4
5
  import assert from "node:assert/strict";
5
- import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { tmpdir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { spawnSync } from "node:child_process";
@@ -31,7 +32,7 @@ const setComments = (bodies) => writeFileSync(join(td, "comments.json"),
31
32
 
32
33
  beforeEach(() => {
33
34
  saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
34
- td = mkdtempSync(join(tmpdir(), "tel-"));
35
+ td = tmpDir("tel-");
35
36
  Object.assign(process.env, { TD: td, FORGE_TELEMETRY_BUFFER_DIR: join(td, "buf"), FORGE_REPO: "o/r", FORGE_ISSUE: "5" });
36
37
  for (const k of ["FORGE_TELEMETRY", "FORGE_PR", "FORGE_RUN", "FORGE_PHASE", "FORGE_TELEMETRY_MAX_CHARS"]) delete process.env[k];
37
38
  });
@@ -6,13 +6,15 @@
6
6
  */
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
- import { readdirSync, statSync } from "node:fs";
9
+ import { readdirSync, statSync, readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { homedir, hostname } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
13
14
  import { makeProjector } from "./engine/projector.mjs";
14
15
  import { readLog, deriveState } from "./engine/runlog.mjs";
15
16
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
17
+ import { parseForgeYaml } from "./forge-utils.mjs";
16
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
17
19
 
18
20
  /**
@@ -53,7 +55,34 @@ export function makeIo() {
53
55
  const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024, timeout: 10000 });
54
56
  return stdout;
55
57
  };
56
- return { gh: run("gh"), git: run("git") };
58
+ // CI wait seam (scripts/wait-ci-green.sh, argv only, no shell). Resolves { rc, text } for any exit code.
59
+ const waitCiGreen = async (pr, repo, timeoutSec) => {
60
+ const script = fileURLToPath(new URL("../scripts/wait-ci-green.sh", import.meta.url));
61
+ try {
62
+ // The engine owns the backoff and the budget: each call is ONE read (no in-script polling, no settle hold;
63
+ // the re-dispatched phase re-runs the full gate before any merge).
64
+ const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec)],
65
+ { maxBuffer: 10 * 1024 * 1024, timeout: (timeoutSec + 120) * 1000, env: { ...process.env, FORGE_CI_SETTLE: "0" } });
66
+ return { rc: 0, text: stdout };
67
+ } catch (e) { return { rc: Number.isInteger(e?.code) ? e.code : 2, text: String(e?.stdout ?? "") }; }
68
+ };
69
+ // REST budget seam (`gh api rate_limit`, argv only; the call itself is not rate-limited). Resolves { remaining, limit, reset } or null.
70
+ const rateLimit = async () => {
71
+ try {
72
+ const { stdout } = await pexec("gh", ["api", "rate_limit", "--jq", ".resources.core"], { maxBuffer: 1024 * 1024, timeout: 10000 });
73
+ const j = JSON.parse(stdout);
74
+ return { remaining: j.remaining, limit: j.limit, reset: j.reset };
75
+ } catch { return null; }
76
+ };
77
+ return { gh: run("gh"), git: run("git"), waitCiGreen, rateLimit };
78
+ }
79
+
80
+ /** forge.yaml `engine.ci_pending_max_minutes` from the cwd checkout; undefined when absent or invalid. */
81
+ export function readCiPendingMaxMinutes(cwd = process.cwd()) {
82
+ try {
83
+ const n = Number(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.ci_pending_max_minutes);
84
+ return Number.isInteger(n) && n > 0 ? n : undefined;
85
+ } catch { return undefined; }
57
86
  }
58
87
 
59
88
  export function runDir() { return join(homedir(), ".forge", "runs"); }
@@ -408,6 +437,7 @@ export async function runFromCli(argv, deps = {}) {
408
437
  // runIssue's/runner.mjs's existing defaults (forge#2028).
409
438
  ...(backend ? { backend } : {}),
410
439
  ...(model ? { model } : {}),
440
+ ...(readCiPendingMaxMinutes() ? { ciPendingMaxMinutes: readCiPendingMaxMinutes() } : {}),
411
441
  ...(retry ? { retry: true } : {}),
412
442
  ...(waitLease ? { waitLease: true } : {}) });
413
443
  console.log(`issue #${issue} → ${res.terminalReason}`);
package/bin/engine.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
- import { readFileSync, rmSync } from "node:fs";
7
+ import { readFileSync, rmSync, readdirSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createHash, scryptSync } from "node:crypto";
9
10
  import { hostname as osHostname, homedir } from "node:os";
10
11
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
@@ -12,7 +13,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
12
13
  import { reconcileState } from "./engine/reconcile.mjs";
13
14
  import { makeProjector } from "./engine/projector.mjs";
14
15
  import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
15
- import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText } from "./engine/outcome-codes.mjs";
16
+ import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText, isTransientSpawnError } from "./engine/outcome-codes.mjs";
16
17
 
17
18
  // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
18
19
  // diagnostics without duplicating the retry budget constant.
@@ -38,6 +39,39 @@ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
38
39
  // reset epoch. The runner never fabricates a reset time; this default lives in
39
40
  // the engine and is still capped by `maxSessionLimitPauses`.
40
41
  export const DEFAULT_SESSION_LIMIT_WAIT_MS = 30 * 60 * 1000;
42
+ // CI still pending at the merge gate: total wait budget (engine.ci_pending_max_minutes) and the poll scheduler bounds.
43
+ export const DEFAULT_CI_PENDING_MAX_MINUTES = 90;
44
+ export const CI_BACKOFF_START_MS = 60 * 1000;
45
+ export const CI_BACKOFF_MAX_MS = 5 * 60 * 1000;
46
+ export const CI_JITTER_FRACTION = 0.2;
47
+ /** Below this fraction of the GitHub REST budget the scheduler sleeps until the window resets instead of polling. */
48
+ export const CI_RATE_RESERVE_FRACTION = 0.1;
49
+ const CI_RESET_SPREAD_MS = 30 * 1000;
50
+ const MAX_CI_PENDING_WAITS = 3;
51
+
52
+ /**
53
+ * Pure CI-poll scheduler: delay before poll number `pollIndex` (0-based). 60 s, x2 per poll, capped at 5 min,
54
+ * +/-20% jitter so lanes waiting on the same CI do not poll in lockstep. `rand` is injectable (returns [0,1)).
55
+ */
56
+ export function ciPollDelayMs(pollIndex, rand = Math.random) {
57
+ const base = Math.min(CI_BACKOFF_START_MS * 2 ** Math.max(0, pollIndex), CI_BACKOFF_MAX_MS);
58
+ const r = Math.min(Math.max(Number(rand()) || 0, 0), 1);
59
+ return Math.round(base * (1 + (r * 2 - 1) * CI_JITTER_FRACTION));
60
+ }
61
+
62
+ /**
63
+ * Pure budget check: ms to sleep before the next poll so the shared REST budget keeps its reserve.
64
+ * `rl` is `{ remaining, limit, reset }` (reset in epoch seconds, as `gh api rate_limit` reports it). Returns 0 when the
65
+ * budget is healthy or unreadable (a failed read never blocks polling); otherwise the time until reset plus a random
66
+ * spread so lanes released by the same reset do not stampede.
67
+ */
68
+ export function ciRateLimitSleepMs(rl, nowMs, rand = Math.random) {
69
+ const remaining = Number(rl?.remaining), limit = Number(rl?.limit), reset = Number(rl?.reset);
70
+ if (![remaining, limit, reset].every(Number.isFinite) || limit <= 0) return 0;
71
+ if (remaining > limit * CI_RATE_RESERVE_FRACTION) return 0;
72
+ const untilReset = Math.max(0, reset * 1000 - nowMs);
73
+ return untilReset + Math.round(Math.min(Math.max(Number(rand()) || 0, 0), 1) * CI_RESET_SPREAD_MS);
74
+ }
41
75
 
42
76
  /**
43
77
  * forge#3791: non-secret identity of the backend/account a usage-limit pause belongs to.
@@ -235,6 +269,46 @@ export function isLeaseHolderDead({ by, host = osHostname(), killFn = process.ki
235
269
  }
236
270
  }
237
271
 
272
+ /**
273
+ * forge#3724: a dead lease-holder pid proves the engine is dead, not that its phase child is. The runner
274
+ * stamps every phase child with `FORGE_LEASE_HOLDER=<by>` (inherited by grandchildren), so a surviving
275
+ * orphan is found by scanning process environments. Returns FALSE only when the probe affirmatively finds
276
+ * no live process carrying the marker; unsupported platform, unreadable /proc, a failed `ps`, or any thrown
277
+ * error returns TRUE (fail-safe: the caller keeps waiting for `lease.until`). Per-process EACCES/ENOENT is
278
+ * skipped (exited, or another uid). Residual: a child that scrubs its env (`env -i`) is not detected.
279
+ */
280
+ export function leaseChildAlive({ by, platform = process.platform, procRoot = "/proc", ownPid = process.pid,
281
+ readdirFn = readdirSync, readFileFn = readFileSync, psFn } = {}) {
282
+ try {
283
+ const marker = `FORGE_LEASE_HOLDER=${by}`;
284
+ if (!parseLeaseHolder(by)) return true;
285
+ if (platform === "linux") {
286
+ for (const name of readdirFn(procRoot)) {
287
+ if (!/^[0-9]+$/.test(name) || Number(name) === ownPid) continue;
288
+ let env;
289
+ try { env = readFileFn(`${procRoot}/${name}/environ`, "latin1"); }
290
+ catch (e) { if (e?.code === "EACCES" || e?.code === "ENOENT" || e?.code === "ESRCH" || e?.code === "EPERM") continue; return true; }
291
+ if (String(env).split("\0").includes(marker)) return true;
292
+ }
293
+ return false;
294
+ }
295
+ if (platform === "darwin") {
296
+ const run = psFn || (() => execFileSync("ps", ["eww", "-Ao", "pid,command"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 10000 }));
297
+ const out = run();
298
+ if (typeof out !== "string" || !out.trim()) return true;
299
+ for (const line of out.split("\n")) {
300
+ const m = /^\s*(\d+)\s/.exec(line);
301
+ if (!m || Number(m[1]) === ownPid) continue;
302
+ if (line.split(/\s+/).includes(marker)) return true;
303
+ }
304
+ return false;
305
+ }
306
+ return true;
307
+ } catch {
308
+ return true;
309
+ }
310
+ }
311
+
238
312
  /** Max startup wait for a persisted rate-limit reset (guards a corrupt far-future resetAt). */
239
313
  export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
240
314
 
@@ -297,7 +371,8 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
297
371
  * the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
298
372
  * its holder is reclaimable, then continue; `deferred` on timeout.
299
373
  * @param {string} [opts.hostname] - injectable host name (tests).
300
- * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
374
+ * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
375
+ * @param {Function} [opts.childAliveFn] - forge#3724: injectable `leaseChildAlive` (orphaned phase-child probe; tests).
301
376
  */
302
377
  // forge#3824: stamped on RUN_START for per-release cost attribution; null when unreadable.
303
378
  export const ENGINE_VERSION = (() => {
@@ -326,10 +401,17 @@ export async function runIssue(opts) {
326
401
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
327
402
  maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
328
403
  sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
404
+ // CI still pending at the merge gate: engine-side wait budget in minutes (forge.yaml
405
+ // `engine.ci_pending_max_minutes`). Invalid values fall back to the default.
406
+ ciPendingMaxMinutes: ciPendingMaxMinutesOpt,
407
+ // CI-poll jitter source (tests inject a deterministic value).
408
+ random = Math.random,
329
409
  retry = false,
330
410
  waitLease = false,
331
411
  hostname = osHostname(),
332
412
  killFn = process.kill.bind(process),
413
+ // forge#3724: injectable orphaned-phase-child probe (tests).
414
+ childAliveFn = leaseChildAlive,
333
415
  onProgress = () => {},
334
416
  // forge#3696: per-attempt telemetry seam (FORGE:TELEMETRY emitter, #3834). No-op by
335
417
  // default; failures are swallowed so telemetry can never fail a phase.
@@ -338,6 +420,8 @@ export async function runIssue(opts) {
338
420
  // forge#2452: extracted into makeProgressEmitter() — see its docstring for
339
421
  // the forge#2240 rationale this wrapper implements.
340
422
  const emitProgress = makeProgressEmitter(onProgress);
423
+ const ciPendingMaxMinutes = Number.isFinite(ciPendingMaxMinutesOpt) && ciPendingMaxMinutesOpt > 0
424
+ ? ciPendingMaxMinutesOpt : DEFAULT_CI_PENDING_MAX_MINUTES;
341
425
 
342
426
  // forge#3833: one seam for coded outcome records that are not run-log phase events. Appends the
343
427
  // event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
@@ -360,7 +444,7 @@ export async function runIssue(opts) {
360
444
  let remote = await projector.readState(issue);
361
445
  let { state, action } = reconcileState(local, remote);
362
446
  const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
363
- !isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
447
+ !(isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }) && !childAliveFn({ by: r.lease.by })));
364
448
  const deferredResult = (r) => ({
365
449
  terminalReason: "deferred",
366
450
  code: OUTCOME_CODES.LEASE_HELD,
@@ -770,9 +854,13 @@ export async function runIssue(opts) {
770
854
  // the entire duration of this loop, including any `sleep()` below —
771
855
  // no separate heartbeat mechanism is needed for the wait itself.
772
856
  let pauseCount = 0;
857
+ let ciWaits = 0; // bounded: a poll that reads green while the phase still sees pending cannot loop forever
858
+ // CI-pending wait: the review phase may return BLOCKED "checks still pending" (nothing failed). The outer
859
+ // loop re-dispatches the phase after each deterministic wait below (at most MAX_CI_PENDING_WAITS times) (no model calls while waiting).
860
+ for (;;) {
773
861
  for (;;) {
774
862
  try {
775
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
863
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
776
864
  break;
777
865
  } catch (e) {
778
866
  const resetAtMs = e.resetAtEpochMs;
@@ -815,6 +903,14 @@ export async function runIssue(opts) {
815
903
  // the exhausted one) — AC2.
816
904
  }
817
905
  }
906
+ if (ciWaits < MAX_CI_PENDING_WAITS && (phase.id === "review" || phase.id === "remediate") && outcome?.status === "blocked" && outcome.wait === "ci-pending") {
907
+ ciWaits++;
908
+ const w = await waitForCiGreen(outcome, phase.id);
909
+ if (w.redispatch) continue;
910
+ if (w.outcome) outcome = w.outcome;
911
+ }
912
+ break;
913
+ }
818
914
  } catch (e) {
819
915
  // forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
820
916
  // rethrown by runPhaseWithRetry() (see its own catch below) instead
@@ -996,6 +1092,68 @@ export async function runIssue(opts) {
996
1092
  return true;
997
1093
  }
998
1094
 
1095
+ // CI still pending at the merge gate (review AND remediate's M7 re-gate). Polls `io.waitCiGreen`
1096
+ // (scripts/wait-ci-green.sh one-shot, no model) on the ciPollDelayMs schedule inside `ciPendingMaxMinutes`; before
1097
+ // each poll `io.rateLimit` (when present) is read and, below the reserve, the wait sleeps until the budget resets.
1098
+ // The in-flight `renewTimer` keeps the lease alive. Resolves to
1099
+ // `{ redispatch: true }` (green, red or a genuine error: the phase runs once more and merges or reproduces the
1100
+ // block), `{ outcome }` (budget exhausted: needs-human `review.ci_pending_timeout`), or `{}` (no seam/PR/repo:
1101
+ // the original outcome stands). Rate-limit and transient results (rc 4, a failed read) are waits: they retry the poll
1102
+ // and NEVER redispatch. The start time is a run-log event so a crash resumes the remaining budget.
1103
+ async function waitForCiGreen(outcome, phaseId = "review") {
1104
+ const pr = outcome.outputs?.pr ?? state.pr;
1105
+ const repoName = await resolveRepo();
1106
+ if (typeof io.waitCiGreen !== "function" || pr == null || !repoName) return {};
1107
+ const budgetMs = ciPendingMaxMinutes * 60000;
1108
+ const open = readLog(dir, issue).filter((ev) => ev.event === "CI_PENDING_WAIT" && ev.pr === pr).pop();
1109
+ const startedAt = open && open.state === "start" && Number.isFinite(open.startedAt) ? open.startedAt : now();
1110
+ if (!(open && open.state === "start")) appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "start", pr, startedAt });
1111
+ let slept = 0, polls = 0, rateLimitSleeps = 0, pollIndex = 0;
1112
+ const spentMs = () => Math.max(now() - startedAt, slept);
1113
+ const report = async (result) => {
1114
+ const minutes = Math.round(spentMs() / 600) / 100;
1115
+ appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "end", pr, startedAt, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps });
1116
+ try { await emitTelemetry?.({ event: "CI_PENDING_WAIT", phase: phaseId, issue, pr, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps }); } catch { /* telemetry never fails a run */ }
1117
+ return minutes;
1118
+ };
1119
+ const nap = async (ms, reason, detail) => {
1120
+ emitProgress({ event: "phase_paused", phase: phaseId, reason, detail });
1121
+ if (ms > 0) { await sleep(ms); slept += ms; }
1122
+ };
1123
+ for (;;) {
1124
+ const remainingMs = budgetMs - spentMs();
1125
+ if (remainingMs <= 0) break;
1126
+ // Budget first: a read failure or absent seam never blocks the poll.
1127
+ if (typeof io.rateLimit === "function") {
1128
+ let holdMs = 0;
1129
+ try { holdMs = ciRateLimitSleepMs(await io.rateLimit(), now(), random); } catch { holdMs = 0; }
1130
+ if (holdMs > 0) {
1131
+ rateLimitSleeps++;
1132
+ await nap(Math.min(holdMs, remainingMs), "ci-rate-limit",
1133
+ `GitHub API budget below reserve — sleeping ${Math.round(Math.min(holdMs, remainingMs) / 1000)}s until reset before polling PR #${pr}`);
1134
+ continue;
1135
+ }
1136
+ }
1137
+ let rc = 2;
1138
+ polls++;
1139
+ try { rc = (await io.waitCiGreen(pr, repoName, 0))?.rc ?? 2; } catch { rc = 2; }
1140
+ if (rc !== 3 && rc !== 4) {
1141
+ // 0 green, 1 red, 2 genuine error or head moved: one phase dispatch resolves each (merge, ci-gate handoff, re-review).
1142
+ await report(rc === 0 ? "green" : rc === 1 ? "red" : "error");
1143
+ return { redispatch: true };
1144
+ }
1145
+ if (rc === 4) rateLimitSleeps++;
1146
+ const waitMs = Math.min(ciPollDelayMs(pollIndex++, random), Math.max(0, budgetMs - spentMs()));
1147
+ // ci_pending_wait: the progress observer sees each wait cycle (distinct from a failure).
1148
+ await nap(waitMs, rc === 4 ? "ci-rate-limit" : "ci-pending",
1149
+ `${rc === 4 ? "GitHub rate-limited or unavailable; retrying" : "CI still pending"} on PR #${pr} — waited ${Math.round(spentMs() / 60000)} of ${ciPendingMaxMinutes} min`);
1150
+ }
1151
+ const minutes = await report("timeout");
1152
+ return { outcome: { status: "blocked", reason: "needs-human", handoff: false, outputs: outcome.outputs,
1153
+ detail: `checks still pending after ${minutes} min (engine.ci_pending_max_minutes=${ciPendingMaxMinutes}); re-run with --retry once CI finishes`,
1154
+ code: OUTCOME_CODES.REVIEW_CI_PENDING_TIMEOUT, usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null } };
1155
+ }
1156
+
999
1157
  async function terminate(s, reason, detail, code) {
1000
1158
  // forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
1001
1159
  const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
@@ -1044,15 +1202,18 @@ export async function runIssue(opts) {
1044
1202
  export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
1045
1203
  try {
1046
1204
  const root = resolveRunTmpRoot(cwd, env);
1047
- const dir = runTmpDir(root, issue);
1205
+ const dir = runTmpDir(root, issue, cwd);
1048
1206
  if (!dir || dir === root) return false;
1049
1207
  rm(dir, { recursive: true, force: true });
1050
1208
  return true;
1051
1209
  } catch { return false; }
1052
1210
  }
1053
1211
 
1212
+ /** forge#4021: backoff before each transient-spawn retry; its length is the cap (3). */
1213
+ export const INFRA_SPAWN_BACKOFF_MS = Object.freeze([10_000, 30_000, 60_000]);
1214
+
1054
1215
  async function runPhaseWithRetry(phase, state, ctx) {
1055
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
1216
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
1056
1217
  // forge#3499: build the phase's full argument set ONCE, up front. Every
1057
1218
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
1058
1219
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -1105,6 +1266,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
1105
1266
  appendEvent(dir, issue, rec);
1106
1267
  try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
1107
1268
  };
1269
+ // forge#4021: transient CLI spawn failures (ETIMEDOUT/EAGAIN/ENOMEM at spawn) retry on their own bounded
1270
+ // counter and never consume a phase attempt; once spent they fall through to normal attempt accounting,
1271
+ // so a CLI that merely runs long cannot loop forever.
1272
+ let infraRetries = 0;
1108
1273
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1109
1274
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
1110
1275
  let result;
@@ -1112,6 +1277,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1112
1277
  try {
1113
1278
  result = await runner({
1114
1279
  commandsDir, commandName: phase.command, args: phaseArgs,
1280
+ leaseHolder: agentId,
1115
1281
  // Only forwarded when explicitly provided — omitting them preserves
1116
1282
  // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
1117
1283
  ...(backend ? { backend } : {}),
@@ -1154,14 +1320,25 @@ async function runPhaseWithRetry(phase, state, ctx) {
1154
1320
  appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1155
1321
  throw e;
1156
1322
  }
1323
+ // Transient spawn class: e.code === "CLI_SPAWN_TRANSIENT" (see isTransientSpawnError); retried with infra backoff.
1324
+ if (isTransientSpawnError(e) && infraRetries < INFRA_SPAWN_BACKOFF_MS.length) {
1325
+ await recordAttempt(attempt, null, t0, null);
1326
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, infraRetry: infraRetries + 1 });
1327
+ await sleep(INFRA_SPAWN_BACKOFF_MS[infraRetries++]);
1328
+ attempt--;
1329
+ continue;
1330
+ }
1157
1331
  if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
1158
1332
  // forge#2377: no `usage` field here — the runner threw, so no result
1159
1333
  // (and therefore no usage data) was ever produced for this attempt.
1160
1334
  // Do not fabricate a value; omitting the field (rather than a stale
1161
1335
  // `lastUsage` from a prior attempt) keeps this event's usage
1162
1336
  // trustworthy as "usage actually observed on this attempt".
1163
- await recordAttempt(attempt, null, t0, null);
1164
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1337
+ // forge#4037: a wall-clock kill (CLI_TIMEOUT) consumes this attempt; its usage is recorded when the CLI emitted any.
1338
+ const killUsage = e.code === "CLI_TIMEOUT" ? e.usage ?? null : null;
1339
+ if (killUsage) addUsage(killUsage);
1340
+ await recordAttempt(attempt, killUsage, t0, null);
1341
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, ...(killUsage ? { usage: killUsage } : {}) });
1165
1342
  lastCode = OUTCOME_CODES.ENGINE_ERROR;
1166
1343
  continue;
1167
1344
  }
@@ -1190,7 +1367,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1190
1367
  // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
1191
1368
  // forward `handoff` so only an explicit opt-in escalation is honoured; a
1192
1369
  // no-signal failure carries no handoff and stops at needs-human.
1193
- return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1370
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, ...(outcome.wait ? { wait: outcome.wait } : {}), code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1194
1371
  }
1195
1372
  }
1196
1373
  // Exhausted transient retries → escalate (spec §7).