forgedock 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -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}`);
@@ -420,6 +450,11 @@ export async function runFromCli(argv, deps = {}) {
420
450
  console.log(res.detail ?? "run is not retryable");
421
451
  return res;
422
452
  }
453
+ // forge#3932: refused before any state write (open or unreadable **Depends on**); nothing ran.
454
+ if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable") {
455
+ console.log(res.detail ?? "run refused: dependency check");
456
+ return res;
457
+ }
423
458
  // forge#2175: a non-success termination previously printed nothing beyond
424
459
  // the bare reason above — the actual failing phase/attempt/reason was only
425
460
  // recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
package/bin/engine.mjs CHANGED
@@ -4,15 +4,15 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, rmSync } from "node:fs";
8
8
  import { createHash, scryptSync } from "node:crypto";
9
9
  import { hostname as osHostname, homedir } from "node:os";
10
10
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
11
- import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
11
+ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
12
12
  import { reconcileState } from "./engine/reconcile.mjs";
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
- import { VALID_BACKENDS } from "./runner.mjs";
15
- import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason } from "./engine/outcome-codes.mjs";
14
+ import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
15
+ import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText, isTransientSpawnError } from "./engine/outcome-codes.mjs";
16
16
 
17
17
  // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
18
18
  // diagnostics without duplicating the retry budget constant.
@@ -38,6 +38,39 @@ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
38
38
  // reset epoch. The runner never fabricates a reset time; this default lives in
39
39
  // the engine and is still capped by `maxSessionLimitPauses`.
40
40
  export const DEFAULT_SESSION_LIMIT_WAIT_MS = 30 * 60 * 1000;
41
+ // CI still pending at the merge gate: total wait budget (engine.ci_pending_max_minutes) and the poll scheduler bounds.
42
+ export const DEFAULT_CI_PENDING_MAX_MINUTES = 90;
43
+ export const CI_BACKOFF_START_MS = 60 * 1000;
44
+ export const CI_BACKOFF_MAX_MS = 5 * 60 * 1000;
45
+ export const CI_JITTER_FRACTION = 0.2;
46
+ /** Below this fraction of the GitHub REST budget the scheduler sleeps until the window resets instead of polling. */
47
+ export const CI_RATE_RESERVE_FRACTION = 0.1;
48
+ const CI_RESET_SPREAD_MS = 30 * 1000;
49
+ const MAX_CI_PENDING_WAITS = 3;
50
+
51
+ /**
52
+ * Pure CI-poll scheduler: delay before poll number `pollIndex` (0-based). 60 s, x2 per poll, capped at 5 min,
53
+ * +/-20% jitter so lanes waiting on the same CI do not poll in lockstep. `rand` is injectable (returns [0,1)).
54
+ */
55
+ export function ciPollDelayMs(pollIndex, rand = Math.random) {
56
+ const base = Math.min(CI_BACKOFF_START_MS * 2 ** Math.max(0, pollIndex), CI_BACKOFF_MAX_MS);
57
+ const r = Math.min(Math.max(Number(rand()) || 0, 0), 1);
58
+ return Math.round(base * (1 + (r * 2 - 1) * CI_JITTER_FRACTION));
59
+ }
60
+
61
+ /**
62
+ * Pure budget check: ms to sleep before the next poll so the shared REST budget keeps its reserve.
63
+ * `rl` is `{ remaining, limit, reset }` (reset in epoch seconds, as `gh api rate_limit` reports it). Returns 0 when the
64
+ * budget is healthy or unreadable (a failed read never blocks polling); otherwise the time until reset plus a random
65
+ * spread so lanes released by the same reset do not stampede.
66
+ */
67
+ export function ciRateLimitSleepMs(rl, nowMs, rand = Math.random) {
68
+ const remaining = Number(rl?.remaining), limit = Number(rl?.limit), reset = Number(rl?.reset);
69
+ if (![remaining, limit, reset].every(Number.isFinite) || limit <= 0) return 0;
70
+ if (remaining > limit * CI_RATE_RESERVE_FRACTION) return 0;
71
+ const untilReset = Math.max(0, reset * 1000 - nowMs);
72
+ return untilReset + Math.round(Math.min(Math.max(Number(rand()) || 0, 0), 1) * CI_RESET_SPREAD_MS);
73
+ }
41
74
 
42
75
  /**
43
76
  * forge#3791: non-secret identity of the backend/account a usage-limit pause belongs to.
@@ -326,6 +359,11 @@ export async function runIssue(opts) {
326
359
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
327
360
  maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
328
361
  sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
362
+ // CI still pending at the merge gate: engine-side wait budget in minutes (forge.yaml
363
+ // `engine.ci_pending_max_minutes`). Invalid values fall back to the default.
364
+ ciPendingMaxMinutes: ciPendingMaxMinutesOpt,
365
+ // CI-poll jitter source (tests inject a deterministic value).
366
+ random = Math.random,
329
367
  retry = false,
330
368
  waitLease = false,
331
369
  hostname = osHostname(),
@@ -338,6 +376,8 @@ export async function runIssue(opts) {
338
376
  // forge#2452: extracted into makeProgressEmitter() — see its docstring for
339
377
  // the forge#2240 rationale this wrapper implements.
340
378
  const emitProgress = makeProgressEmitter(onProgress);
379
+ const ciPendingMaxMinutes = Number.isFinite(ciPendingMaxMinutesOpt) && ciPendingMaxMinutesOpt > 0
380
+ ? ciPendingMaxMinutesOpt : DEFAULT_CI_PENDING_MAX_MINUTES;
341
381
 
342
382
  // forge#3833: one seam for coded outcome records that are not run-log phase events. Appends the
343
383
  // event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
@@ -390,6 +430,27 @@ export async function runIssue(opts) {
390
430
  remote = cur;
391
431
  ({ state, action } = reconcileState(local, remote));
392
432
  }
433
+ // forge#3932: `Depends on` admission. A trusted `**Depends on**: #N` line whose dependency is still OPEN
434
+ // refuses the run BEFORE any state/lease write (mirrors deferredResult), so building on top of an
435
+ // unmerged dependency cannot conflict with it. Checked on every start; a closed dependency, no
436
+ // declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
437
+ {
438
+ let dep = null;
439
+ try { dep = await openDependency(issue, io); } catch (e) {
440
+ dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
441
+ }
442
+ if (dep?.unreadable) {
443
+ return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
444
+ }
445
+ if (dep?.open?.length) {
446
+ return {
447
+ terminalReason: "dependency-open",
448
+ code: OUTCOME_CODES.DEPENDENCY_OPEN,
449
+ dependencies: dep.open,
450
+ detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed`,
451
+ };
452
+ }
453
+ }
393
454
  // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
394
455
  let repoPromise = null;
395
456
  const resolveRepo = () => (repoPromise ??= (async () => {
@@ -412,6 +473,20 @@ export async function runIssue(opts) {
412
473
  // and regress committed/issue/v (C2).
413
474
  rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
414
475
  state = deriveState(readLog(dir, issue));
476
+ // forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
477
+ // trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
478
+ // belongs to an older cycle ONLY when provably so (cycleResetProvable: terminal index, issue OPEN, no needs-human/remediating label, no open PR): reset via the existing RUN_NEXT_CYCLE event so build runs again instead of
479
+ // terminating. An unreadable trail resets nothing (fail closed). merged/decomposed/invalid terminals stay.
480
+ if (state.committed.some((p) => ["build", "review", "remediate", "close"].includes(p)) &&
481
+ state.terminal && ["needs-human", "phase-complete", "engine-error"].includes(state.terminalReason) &&
482
+ await cycleResetProvable(state, io)) {
483
+ let cyc = 0;
484
+ try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
485
+ if (cyc > 0) {
486
+ appendEvent(dir, issue, { event: "RUN_NEXT_CYCLE", issue });
487
+ state = deriveState(readLog(dir, issue));
488
+ }
489
+ }
415
490
  }
416
491
 
417
492
  // forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
@@ -424,12 +499,19 @@ export async function runIssue(opts) {
424
499
  // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
425
500
  // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
426
501
  let reopened = false;
502
+ let reopenUncommit = null;
427
503
  if (retry && state?.terminal) {
428
- if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
504
+ // forge#3862 / forge#3932: a `decomposed` terminal is reopened at build when trusted GitHub state shows
505
+ // the decomposition revoked (a trusted FORGE:DECOMPOSED:REVOKED newer than FORGE:DECOMPOSED); the
506
+ // revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
507
+ // unreadable comment trail fails closed.
508
+ if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
509
+ reopenUncommit = ["build", "decompose"];
510
+ } else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
429
511
  state.terminalReason !== "needs-human") {
430
512
  return {
431
513
  terminalReason: "not-retryable",
432
- detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
514
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run (or a decomposed run whose FORGE:DECOMPOSED was revoked by a trusted FORGE:DECOMPOSED:REVOKED)`,
433
515
  };
434
516
  }
435
517
  // forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
@@ -457,6 +539,13 @@ export async function runIssue(opts) {
457
539
  };
458
540
  }
459
541
  }
542
+ // forge#3931: a remediate that ended needs-human was recorded against an older PR head. When the
543
+ // live head differs from the head in its trusted trail, un-commit remediate AND review so review
544
+ // re-runs at the new head instead of the old RE-ESCALATED result replaying. Unreadable => no change.
545
+ if (state.terminalReason === "needs-human" && !reopenUncommit &&
546
+ state.committed[state.committed.length - 1] === "remediate" && await remediationHeadStale(state, io)) {
547
+ reopenUncommit = ["remediate", "review"];
548
+ }
460
549
  // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
461
550
  // issue first. A CLOSED issue has nothing left to run; an unreadable
462
551
  // snapshot fails closed rather than resetting a possibly-finished run.
@@ -486,6 +575,7 @@ export async function runIssue(opts) {
486
575
  appendEvent(dir, issue, {
487
576
  event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
488
577
  issue,
578
+ ...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
489
579
  });
490
580
  state = deriveState(readLog(dir, issue));
491
581
  reopened = true;
@@ -720,9 +810,13 @@ export async function runIssue(opts) {
720
810
  // the entire duration of this loop, including any `sleep()` below —
721
811
  // no separate heartbeat mechanism is needed for the wait itself.
722
812
  let pauseCount = 0;
813
+ let ciWaits = 0; // bounded: a poll that reads green while the phase still sees pending cannot loop forever
814
+ // CI-pending wait: the review phase may return BLOCKED "checks still pending" (nothing failed). The outer
815
+ // loop re-dispatches the phase after each deterministic wait below (at most MAX_CI_PENDING_WAITS times) (no model calls while waiting).
816
+ for (;;) {
723
817
  for (;;) {
724
818
  try {
725
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
819
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
726
820
  break;
727
821
  } catch (e) {
728
822
  const resetAtMs = e.resetAtEpochMs;
@@ -765,6 +859,14 @@ export async function runIssue(opts) {
765
859
  // the exhausted one) — AC2.
766
860
  }
767
861
  }
862
+ if (ciWaits < MAX_CI_PENDING_WAITS && (phase.id === "review" || phase.id === "remediate") && outcome?.status === "blocked" && outcome.wait === "ci-pending") {
863
+ ciWaits++;
864
+ const w = await waitForCiGreen(outcome, phase.id);
865
+ if (w.redispatch) continue;
866
+ if (w.outcome) outcome = w.outcome;
867
+ }
868
+ break;
869
+ }
768
870
  } catch (e) {
769
871
  // forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
770
872
  // rethrown by runPhaseWithRetry() (see its own catch below) instead
@@ -779,7 +881,7 @@ export async function runIssue(opts) {
779
881
  // "needs-human" — this is the engine/tool breaking, not a genuine
780
882
  // human-judgment block (see #2244/#2261). Any other thrown error is
781
883
  // a true unexpected crash and keeps propagating unchanged.
782
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
884
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
783
885
  // forge#2241: when the runner attached a session-limit reset time
784
886
  // (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
785
887
  // for a genuine session-limit CLI_BACKEND_FAILED — never
@@ -789,7 +891,7 @@ export async function runIssue(opts) {
789
891
  const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
790
892
  const detail = e.code === "CLI_MAX_TURNS"
791
893
  ? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
792
- : `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
894
+ : `phase ${phase.id}: ${e.code ?? "DISK_QUOTA"} - ${e.message}${resetSuffix}`;
793
895
  // forge#2240 (review finding): this fail-fast path previously left
794
896
  // phase_exit unreported — a caller tailing progress output would see
795
897
  // "→ phase X started" and then nothing, dangling exactly on the
@@ -845,6 +947,9 @@ export async function runIssue(opts) {
845
947
  await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
846
948
  return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
847
949
  }
950
+ // forge#3572: a phase that returned blocked because the disk/quota ran out is a retryable infra failure.
951
+ if (isDiskQuotaText(outcome.detail))
952
+ return await terminate(state, "engine-error", outcome.detail, OUTCOME_CODES.INFRA_DISK_QUOTA);
848
953
  if (!isRemediationHandoff)
849
954
  return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
850
955
  } else {
@@ -910,8 +1015,8 @@ export async function runIssue(opts) {
910
1015
  // is seen), phase.id is "decompose" — not "investigate" — so this
911
1016
  // exemption does not apply and the normal terminate() path below fires,
912
1017
  // ending the run for real.
913
- // build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
914
- const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
1018
+ // forge#3862: only investigate hands off (decomposition is a planning decision); build never reports "decomposed".
1019
+ const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
915
1020
  // forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
916
1021
  // to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
917
1022
  const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
@@ -943,6 +1048,68 @@ export async function runIssue(opts) {
943
1048
  return true;
944
1049
  }
945
1050
 
1051
+ // CI still pending at the merge gate (review AND remediate's M7 re-gate). Polls `io.waitCiGreen`
1052
+ // (scripts/wait-ci-green.sh one-shot, no model) on the ciPollDelayMs schedule inside `ciPendingMaxMinutes`; before
1053
+ // each poll `io.rateLimit` (when present) is read and, below the reserve, the wait sleeps until the budget resets.
1054
+ // The in-flight `renewTimer` keeps the lease alive. Resolves to
1055
+ // `{ redispatch: true }` (green, red or a genuine error: the phase runs once more and merges or reproduces the
1056
+ // block), `{ outcome }` (budget exhausted: needs-human `review.ci_pending_timeout`), or `{}` (no seam/PR/repo:
1057
+ // the original outcome stands). Rate-limit and transient results (rc 4, a failed read) are waits: they retry the poll
1058
+ // and NEVER redispatch. The start time is a run-log event so a crash resumes the remaining budget.
1059
+ async function waitForCiGreen(outcome, phaseId = "review") {
1060
+ const pr = outcome.outputs?.pr ?? state.pr;
1061
+ const repoName = await resolveRepo();
1062
+ if (typeof io.waitCiGreen !== "function" || pr == null || !repoName) return {};
1063
+ const budgetMs = ciPendingMaxMinutes * 60000;
1064
+ const open = readLog(dir, issue).filter((ev) => ev.event === "CI_PENDING_WAIT" && ev.pr === pr).pop();
1065
+ const startedAt = open && open.state === "start" && Number.isFinite(open.startedAt) ? open.startedAt : now();
1066
+ if (!(open && open.state === "start")) appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "start", pr, startedAt });
1067
+ let slept = 0, polls = 0, rateLimitSleeps = 0, pollIndex = 0;
1068
+ const spentMs = () => Math.max(now() - startedAt, slept);
1069
+ const report = async (result) => {
1070
+ const minutes = Math.round(spentMs() / 600) / 100;
1071
+ appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "end", pr, startedAt, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps });
1072
+ 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 */ }
1073
+ return minutes;
1074
+ };
1075
+ const nap = async (ms, reason, detail) => {
1076
+ emitProgress({ event: "phase_paused", phase: phaseId, reason, detail });
1077
+ if (ms > 0) { await sleep(ms); slept += ms; }
1078
+ };
1079
+ for (;;) {
1080
+ const remainingMs = budgetMs - spentMs();
1081
+ if (remainingMs <= 0) break;
1082
+ // Budget first: a read failure or absent seam never blocks the poll.
1083
+ if (typeof io.rateLimit === "function") {
1084
+ let holdMs = 0;
1085
+ try { holdMs = ciRateLimitSleepMs(await io.rateLimit(), now(), random); } catch { holdMs = 0; }
1086
+ if (holdMs > 0) {
1087
+ rateLimitSleeps++;
1088
+ await nap(Math.min(holdMs, remainingMs), "ci-rate-limit",
1089
+ `GitHub API budget below reserve — sleeping ${Math.round(Math.min(holdMs, remainingMs) / 1000)}s until reset before polling PR #${pr}`);
1090
+ continue;
1091
+ }
1092
+ }
1093
+ let rc = 2;
1094
+ polls++;
1095
+ try { rc = (await io.waitCiGreen(pr, repoName, 0))?.rc ?? 2; } catch { rc = 2; }
1096
+ if (rc !== 3 && rc !== 4) {
1097
+ // 0 green, 1 red, 2 genuine error or head moved: one phase dispatch resolves each (merge, ci-gate handoff, re-review).
1098
+ await report(rc === 0 ? "green" : rc === 1 ? "red" : "error");
1099
+ return { redispatch: true };
1100
+ }
1101
+ if (rc === 4) rateLimitSleeps++;
1102
+ const waitMs = Math.min(ciPollDelayMs(pollIndex++, random), Math.max(0, budgetMs - spentMs()));
1103
+ // ci_pending_wait: the progress observer sees each wait cycle (distinct from a failure).
1104
+ await nap(waitMs, rc === 4 ? "ci-rate-limit" : "ci-pending",
1105
+ `${rc === 4 ? "GitHub rate-limited or unavailable; retrying" : "CI still pending"} on PR #${pr} — waited ${Math.round(spentMs() / 60000)} of ${ciPendingMaxMinutes} min`);
1106
+ }
1107
+ const minutes = await report("timeout");
1108
+ return { outcome: { status: "blocked", reason: "needs-human", handoff: false, outputs: outcome.outputs,
1109
+ detail: `checks still pending after ${minutes} min (engine.ci_pending_max_minutes=${ciPendingMaxMinutes}); re-run with --retry once CI finishes`,
1110
+ code: OUTCOME_CODES.REVIEW_CI_PENDING_TIMEOUT, usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null } };
1111
+ }
1112
+
946
1113
  async function terminate(s, reason, detail, code) {
947
1114
  // forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
948
1115
  const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
@@ -980,12 +1147,29 @@ export async function runIssue(opts) {
980
1147
  try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
981
1148
  }
982
1149
  }
1150
+ // forge#3572: drop the per-run scratch dir (venvs, session data) on a terminal outcome. Kept for
1151
+ // `engine-error` so `--retry` resumes with it, and when another live engine owns the issue.
1152
+ if (reason !== "engine-error" && foreign === undefined) cleanupRunTmpdir(issue);
983
1153
  return { terminalReason: reason, code: outcomeCode, detail };
984
1154
  }
985
1155
  }
986
1156
 
1157
+ /** Best-effort removal of the issue's per-run scratch dir. Confined to the resolved tmp root; never throws. */
1158
+ export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
1159
+ try {
1160
+ const root = resolveRunTmpRoot(cwd, env);
1161
+ const dir = runTmpDir(root, issue, cwd);
1162
+ if (!dir || dir === root) return false;
1163
+ rm(dir, { recursive: true, force: true });
1164
+ return true;
1165
+ } catch { return false; }
1166
+ }
1167
+
1168
+ /** forge#4021: backoff before each transient-spawn retry; its length is the cap (3). */
1169
+ export const INFRA_SPAWN_BACKOFF_MS = Object.freeze([10_000, 30_000, 60_000]);
1170
+
987
1171
  async function runPhaseWithRetry(phase, state, ctx) {
988
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
1172
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
989
1173
  // forge#3499: build the phase's full argument set ONCE, up front. Every
990
1174
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
991
1175
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -1038,6 +1222,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
1038
1222
  appendEvent(dir, issue, rec);
1039
1223
  try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
1040
1224
  };
1225
+ // forge#4021: transient CLI spawn failures (ETIMEDOUT/EAGAIN/ENOMEM at spawn) retry on their own bounded
1226
+ // counter and never consume a phase attempt; once spent they fall through to normal attempt accounting,
1227
+ // so a CLI that merely runs long cannot loop forever.
1228
+ let infraRetries = 0;
1041
1229
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1042
1230
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
1043
1231
  let result;
@@ -1080,14 +1268,32 @@ async function runPhaseWithRetry(phase, state, ctx) {
1080
1268
  });
1081
1269
  throw e;
1082
1270
  }
1271
+ // forge#3572: a full disk / exhausted quota (EDQUOT/ENOSPC) is infra, not content: retrying burns the budget on the same
1272
+ // failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
1273
+ if (isDiskQuotaError(e)) {
1274
+ await recordAttempt(attempt, null, t0, null);
1275
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1276
+ throw e;
1277
+ }
1278
+ // Transient spawn class: e.code === "CLI_SPAWN_TRANSIENT" (see isTransientSpawnError); retried with infra backoff.
1279
+ if (isTransientSpawnError(e) && infraRetries < INFRA_SPAWN_BACKOFF_MS.length) {
1280
+ await recordAttempt(attempt, null, t0, null);
1281
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, infraRetry: infraRetries + 1 });
1282
+ await sleep(INFRA_SPAWN_BACKOFF_MS[infraRetries++]);
1283
+ attempt--;
1284
+ continue;
1285
+ }
1083
1286
  if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
1084
1287
  // forge#2377: no `usage` field here — the runner threw, so no result
1085
1288
  // (and therefore no usage data) was ever produced for this attempt.
1086
1289
  // Do not fabricate a value; omitting the field (rather than a stale
1087
1290
  // `lastUsage` from a prior attempt) keeps this event's usage
1088
1291
  // trustworthy as "usage actually observed on this attempt".
1089
- await recordAttempt(attempt, null, t0, null);
1090
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1292
+ // forge#4037: a wall-clock kill (CLI_TIMEOUT) consumes this attempt; its usage is recorded when the CLI emitted any.
1293
+ const killUsage = e.code === "CLI_TIMEOUT" ? e.usage ?? null : null;
1294
+ if (killUsage) addUsage(killUsage);
1295
+ await recordAttempt(attempt, killUsage, t0, null);
1296
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, ...(killUsage ? { usage: killUsage } : {}) });
1091
1297
  lastCode = OUTCOME_CODES.ENGINE_ERROR;
1092
1298
  continue;
1093
1299
  }
@@ -1116,7 +1322,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1116
1322
  // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
1117
1323
  // forward `handoff` so only an explicit opt-in escalation is honoured; a
1118
1324
  // no-signal failure carries no handoff and stops at needs-human.
1119
- return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1325
+ 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 };
1120
1326
  }
1121
1327
  }
1122
1328
  // Exhausted transient retries → escalate (spec §7).
package/bin/forgedock.mjs CHANGED
@@ -3972,7 +3972,7 @@ switch (command) {
3972
3972
  // an uncaught throw, which already set exitCode = 1 below. Now that
3973
3973
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3974
3974
  // preserve that same non-zero exit code for this reason too.
3975
- if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
3975
+ if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable")) exitCode = 1;
3976
3976
  // forge#3574: a lease-held deferral did NOT run the issue; exit 75
3977
3977
  // (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
3978
3978
  if (result && result.terminalReason === "deferred") exitCode = 75;