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.
- package/bin/engine/outcome-codes.mjs +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +71 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- 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,
|
|
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 =
|
|
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
|
});
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
914
|
-
const isDecomposeHandoff =
|
|
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
|
-
|
|
1090
|
-
|
|
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;
|