forgedock 1.18.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 +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- 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.test.sh +2 -0
- 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-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 +2 -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/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 +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -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 +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -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":
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -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")
|
|
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((
|
|
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,
|
|
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}`);
|
package/bin/engine.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
|
|
|
12
12
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
|
|
15
|
-
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText } from "./engine/outcome-codes.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
|
|
@@ -770,9 +810,13 @@ export async function runIssue(opts) {
|
|
|
770
810
|
// the entire duration of this loop, including any `sleep()` below —
|
|
771
811
|
// no separate heartbeat mechanism is needed for the wait itself.
|
|
772
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 (;;) {
|
|
773
817
|
for (;;) {
|
|
774
818
|
try {
|
|
775
|
-
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 });
|
|
776
820
|
break;
|
|
777
821
|
} catch (e) {
|
|
778
822
|
const resetAtMs = e.resetAtEpochMs;
|
|
@@ -815,6 +859,14 @@ export async function runIssue(opts) {
|
|
|
815
859
|
// the exhausted one) — AC2.
|
|
816
860
|
}
|
|
817
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
|
+
}
|
|
818
870
|
} catch (e) {
|
|
819
871
|
// forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
|
|
820
872
|
// rethrown by runPhaseWithRetry() (see its own catch below) instead
|
|
@@ -996,6 +1048,68 @@ export async function runIssue(opts) {
|
|
|
996
1048
|
return true;
|
|
997
1049
|
}
|
|
998
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
|
+
|
|
999
1113
|
async function terminate(s, reason, detail, code) {
|
|
1000
1114
|
// forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
|
|
1001
1115
|
const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
|
|
@@ -1044,15 +1158,18 @@ export async function runIssue(opts) {
|
|
|
1044
1158
|
export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
|
|
1045
1159
|
try {
|
|
1046
1160
|
const root = resolveRunTmpRoot(cwd, env);
|
|
1047
|
-
const dir = runTmpDir(root, issue);
|
|
1161
|
+
const dir = runTmpDir(root, issue, cwd);
|
|
1048
1162
|
if (!dir || dir === root) return false;
|
|
1049
1163
|
rm(dir, { recursive: true, force: true });
|
|
1050
1164
|
return true;
|
|
1051
1165
|
} catch { return false; }
|
|
1052
1166
|
}
|
|
1053
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
|
+
|
|
1054
1171
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
1055
|
-
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;
|
|
1056
1173
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
1057
1174
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
1058
1175
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -1105,6 +1222,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1105
1222
|
appendEvent(dir, issue, rec);
|
|
1106
1223
|
try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
|
|
1107
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;
|
|
1108
1229
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1109
1230
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
1110
1231
|
let result;
|
|
@@ -1154,14 +1275,25 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1154
1275
|
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
|
|
1155
1276
|
throw e;
|
|
1156
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
|
+
}
|
|
1157
1286
|
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
1158
1287
|
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
1159
1288
|
// (and therefore no usage data) was ever produced for this attempt.
|
|
1160
1289
|
// Do not fabricate a value; omitting the field (rather than a stale
|
|
1161
1290
|
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
1162
1291
|
// trustworthy as "usage actually observed on this attempt".
|
|
1163
|
-
|
|
1164
|
-
|
|
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 } : {}) });
|
|
1165
1297
|
lastCode = OUTCOME_CODES.ENGINE_ERROR;
|
|
1166
1298
|
continue;
|
|
1167
1299
|
}
|
|
@@ -1190,7 +1322,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1190
1322
|
// forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
|
|
1191
1323
|
// forward `handoff` so only an explicit opt-in escalation is honoured; a
|
|
1192
1324
|
// 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 };
|
|
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 };
|
|
1194
1326
|
}
|
|
1195
1327
|
}
|
|
1196
1328
|
// Exhausted transient retries → escalate (spec §7).
|