forgedock 1.4.0 → 1.5.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/runlog.mjs +11 -1
- package/bin/engine-cli.mjs +6 -0
- package/bin/engine.mjs +194 -59
- package/bin/forgedock.mjs +65 -4
- package/bin/hooks/pre-tool-use.mjs +29 -7
- package/bin/journey.mjs +578 -42
- package/bin/runner.mjs +159 -2
- package/bin/watch-utils.mjs +4 -1
- package/commands/cleanup.md +27 -2
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +3 -1
- package/commands/orchestrate/phase-1-resolve.md +125 -14
- package/commands/orchestrate/phase-3-dependency.md +254 -40
- package/commands/orchestrate/phase-4-execution.md +402 -39
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +97 -32
- package/commands/work-on/remediate.md +53 -4
- package/package.json +1 -1
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-phase-registry-drift.mjs +23 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +4 -0
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/engine/runlog.mjs
CHANGED
|
@@ -57,7 +57,7 @@ export function deriveState(events) {
|
|
|
57
57
|
/** @type {import("./phases.mjs").RunState} */
|
|
58
58
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
59
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
|
-
terminalReason: null, lease: null };
|
|
60
|
+
terminalReason: null, lease: null, lastRateLimit: null };
|
|
61
61
|
for (const e of events) {
|
|
62
62
|
switch (e.event) {
|
|
63
63
|
case "RUN_START":
|
|
@@ -72,6 +72,16 @@ export function deriveState(events) {
|
|
|
72
72
|
case "RUN_TERMINAL":
|
|
73
73
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
74
74
|
break;
|
|
75
|
+
// forge#2524: a session-limit pause is purely informational — it does
|
|
76
|
+
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
77
|
+
// pause is, by construction, not yet committed (the engine is about to
|
|
78
|
+
// retry it from scratch once the wait elapses), so a resumed/hydrated
|
|
79
|
+
// run correctly re-enters pickPhase at the same phase with no special
|
|
80
|
+
// casing needed here beyond recording the diagnostic below.
|
|
81
|
+
case "PHASE_RATE_LIMITED":
|
|
82
|
+
s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null };
|
|
83
|
+
s.v = e.seq;
|
|
84
|
+
break;
|
|
75
85
|
// PHASE_START / PHASE_FAILED do not change committed state
|
|
76
86
|
}
|
|
77
87
|
}
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -365,6 +365,12 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
365
365
|
if (e.event === "phase_enter") console.log(`→ phase ${e.phase} started`);
|
|
366
366
|
else if (e.event === "phase_exit" && e.status === "committed") console.log(`✓ phase ${e.phase} committed`);
|
|
367
367
|
else if (e.event === "phase_exit" && e.status === "blocked") console.log(`✗ phase ${e.phase} blocked: ${e.detail ?? "no detail"}`);
|
|
368
|
+
// forge#2524: session-limit pause — the engine is waiting in-process
|
|
369
|
+
// until the reported reset time before retrying this same phase, rather
|
|
370
|
+
// than terminating. Distinct glyph from the exit/blocked lines above so
|
|
371
|
+
// an operator tailing this stdout can tell "still alive, just waiting"
|
|
372
|
+
// from "phase actually failed".
|
|
373
|
+
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
368
374
|
};
|
|
369
375
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
370
376
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
package/bin/engine.mjs
CHANGED
|
@@ -20,65 +20,31 @@ export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
|
20
20
|
export const DEFAULT_LEASE_TTL_MS = 600000;
|
|
21
21
|
export const DEFAULT_LEASE_RENEW_INTERVAL_MS = 240000;
|
|
22
22
|
|
|
23
|
+
// forge#2524: bounds how many consecutive session-limit pauses a single
|
|
24
|
+
// phase attempt will ride out before falling through to the existing
|
|
25
|
+
// engine-error terminate path. Exists purely as a defensive backstop against
|
|
26
|
+
// a persistently-misparsed/misreported reset time (clock skew, a CLI that
|
|
27
|
+
// always claims "resets in 1 minute" for some unrelated reason) spinning the
|
|
28
|
+
// engine forever — a genuine multi-hour session-limit reset is expected to
|
|
29
|
+
// need at most one or two pauses in practice.
|
|
30
|
+
export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
|
|
31
|
+
|
|
23
32
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* @param {
|
|
33
|
-
*
|
|
34
|
-
* @param {number}
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} [opts.onProgress] -
|
|
38
|
-
* forge#2240: optional phase-boundary observer. Called with
|
|
39
|
-
* `{event: "phase_enter", phase}` right before a phase's runner is about to
|
|
40
|
-
* execute (i.e. the phase was NOT already satisfied on reconcile), and with
|
|
41
|
-
* `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
|
|
42
|
-
* once that phase's outcome is known. Defaults to a no-op so every existing
|
|
43
|
-
* caller/test is unaffected. Deliberately engine.mjs's ONLY new surface for
|
|
44
|
-
* this issue — no `console.log`/stdout write is added here; printing is the
|
|
45
|
-
* CLI layer's job (bin/engine-cli.mjs), preserving the io-injection/testability
|
|
46
|
-
* convention this module already follows. Invocations are wrapped so a
|
|
47
|
-
* throwing callback can never crash a run.
|
|
33
|
+
* Validates the subset of `runIssue()`'s options that must fail fast,
|
|
34
|
+
* synchronously, before any state I/O or timer creation — `backend` and the
|
|
35
|
+
* lease-timing pair. Extracted from `runIssue()` (forge#2452) purely for
|
|
36
|
+
* readability; call-order and thrown-error shape are unchanged from the
|
|
37
|
+
* inline block this replaces, and MUST stay that way — see the per-check
|
|
38
|
+
* rationale comments below, all of which trace back to real review findings.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} params
|
|
41
|
+
* @param {string} [params.backend] - "cli" | "api" | "auto" (forge#2028).
|
|
42
|
+
* @param {number} params.leaseTtlMs - forge#2239: how long a claimed lease is valid for.
|
|
43
|
+
* @param {number} params.leaseRenewIntervalMs - forge#2239: how often the lease is re-written.
|
|
44
|
+
* @param {number} params.maxSessionLimitPauses - forge#2524: bound on consecutive session-limit pauses.
|
|
45
|
+
* @throws {Error & {code: "INVALID_BACKEND"|"INVALID_LEASE_CONFIG"|"INVALID_SESSION_LIMIT_CONFIG"}}
|
|
48
46
|
*/
|
|
49
|
-
|
|
50
|
-
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
51
|
-
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
52
|
-
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
|
|
53
|
-
// Optional execution-backend override for every phase's `runner()`
|
|
54
|
-
// call (forge#2028 / MAT-3). Left undefined by default so existing
|
|
55
|
-
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
56
|
-
// this is purely additive pass-through, not a new default.
|
|
57
|
-
backend, model,
|
|
58
|
-
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
59
|
-
leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
|
|
60
|
-
onProgress = () => {} } = opts;
|
|
61
|
-
|
|
62
|
-
// forge#2240: defensive wrapper — a caller's onProgress must never be able
|
|
63
|
-
// to crash an otherwise-healthy run. A plain try/catch only guards a
|
|
64
|
-
// *synchronous* throw — if onProgress is (or becomes) async and its
|
|
65
|
-
// returned promise rejects, that rejection is never awaited/caught here,
|
|
66
|
-
// producing an unhandled promise rejection that terminates the whole
|
|
67
|
-
// Node process (Node >=15 default behavior) well after runIssue() itself
|
|
68
|
-
// has already resolved. That is strictly worse than the silent-hang bug
|
|
69
|
-
// this issue fixes. Detect a thenable return value and attach a no-op
|
|
70
|
-
// .catch() to it (fire-and-forget — onProgress is not awaited either way,
|
|
71
|
-
// consistent with its documented synchronous-observer contract) so a
|
|
72
|
-
// rejection can never escape as unhandled. (review finding, #2240)
|
|
73
|
-
const emitProgress = (event) => {
|
|
74
|
-
try {
|
|
75
|
-
const result = onProgress(event);
|
|
76
|
-
if (result && typeof result.then === "function") {
|
|
77
|
-
result.catch(() => { /* best-effort observer, never fatal */ });
|
|
78
|
-
}
|
|
79
|
-
} catch { /* best-effort observer, never fatal */ }
|
|
80
|
-
};
|
|
81
|
-
|
|
47
|
+
function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses }) {
|
|
82
48
|
// forge#2054: validate `backend` before anything else — before state is
|
|
83
49
|
// read/written and before the phase/retry loop begins below. An invalid
|
|
84
50
|
// value must fail fast and non-retryably. Without this check, an invalid
|
|
@@ -147,6 +113,125 @@ export async function runIssue(opts) {
|
|
|
147
113
|
);
|
|
148
114
|
}
|
|
149
115
|
|
|
116
|
+
// forge#2524: same finite-number + non-negative guard precedent as the
|
|
117
|
+
// lease-timing pair above (forge#2329) — a NaN/negative pause bound would
|
|
118
|
+
// either silently disable the pause-count backstop (every relational
|
|
119
|
+
// comparison against NaN is false, so `pauseCount < maxSessionLimitPauses`
|
|
120
|
+
// would never trip) or reject every pause immediately (a negative bound).
|
|
121
|
+
// Validated eagerly here, synchronously, before any state I/O or timer
|
|
122
|
+
// creation — identical placement rationale to the checks above.
|
|
123
|
+
if (typeof maxSessionLimitPauses !== "number" || !Number.isFinite(maxSessionLimitPauses) || maxSessionLimitPauses < 0) {
|
|
124
|
+
throw Object.assign(
|
|
125
|
+
new Error(
|
|
126
|
+
`Invalid session-limit config: maxSessionLimitPauses must be a finite number >= 0, got ${typeof maxSessionLimitPauses === "number" ? maxSessionLimitPauses : `${typeof maxSessionLimitPauses} (${JSON.stringify(maxSessionLimitPauses)})`}.`,
|
|
127
|
+
),
|
|
128
|
+
{ code: "INVALID_SESSION_LIMIT_CONFIG" },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Builds the defensive `emitProgress` wrapper around a caller-supplied
|
|
135
|
+
* `onProgress` observer. Extracted from `runIssue()` (forge#2452) — purely a
|
|
136
|
+
* factory, no shared state beyond the `onProgress` closure itself, so this
|
|
137
|
+
* carries no behavioral risk.
|
|
138
|
+
*
|
|
139
|
+
* forge#2240: a caller's onProgress must never be able to crash an otherwise
|
|
140
|
+
* healthy run. A plain try/catch only guards a *synchronous* throw — if
|
|
141
|
+
* onProgress is (or becomes) async and its returned promise rejects, that
|
|
142
|
+
* rejection is never awaited/caught here, producing an unhandled promise
|
|
143
|
+
* rejection that terminates the whole Node process (Node >=15 default
|
|
144
|
+
* behavior) well after runIssue() itself has already resolved. That is
|
|
145
|
+
* strictly worse than the silent-hang bug this issue fixes. Detect a
|
|
146
|
+
* thenable return value and attach a no-op .catch() to it (fire-and-forget —
|
|
147
|
+
* onProgress is not awaited either way, consistent with its documented
|
|
148
|
+
* synchronous-observer contract) so a rejection can never escape as
|
|
149
|
+
* unhandled. (review finding, #2240)
|
|
150
|
+
*
|
|
151
|
+
* @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} onProgress
|
|
152
|
+
* @returns {(event: object) => void}
|
|
153
|
+
*/
|
|
154
|
+
function makeProgressEmitter(onProgress) {
|
|
155
|
+
return (event) => {
|
|
156
|
+
try {
|
|
157
|
+
const result = onProgress(event);
|
|
158
|
+
if (result && typeof result.then === "function") {
|
|
159
|
+
result.catch(() => { /* best-effort observer, never fatal */ });
|
|
160
|
+
}
|
|
161
|
+
} catch { /* best-effort observer, never fatal */ }
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {object} opts
|
|
167
|
+
* @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
|
|
168
|
+
* to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
|
|
169
|
+
* default ("auto" ladder — probes the `claude` CLI, falls back to the API).
|
|
170
|
+
* An invalid value throws synchronously (see below) rather than being forwarded.
|
|
171
|
+
* @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
|
|
172
|
+
* `runner()` call when supplied; only applies on the "api" backend. Omit to keep
|
|
173
|
+
* runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
|
|
174
|
+
* @param {number} [opts.leaseTtlMs] - forge#2239: how long a claimed lease is
|
|
175
|
+
* valid for. Defaults to DEFAULT_LEASE_TTL_MS. Overridable for tests.
|
|
176
|
+
* @param {number} [opts.leaseRenewIntervalMs] - forge#2239: how often the lease
|
|
177
|
+
* is re-written while an unsatisfied phase's runner is executing. Defaults to
|
|
178
|
+
* DEFAULT_LEASE_RENEW_INTERVAL_MS. Overridable for tests.
|
|
179
|
+
* @param {(event: {event: string, phase: string, status?: string, detail?: string, resetAt?: number}) => void} [opts.onProgress] -
|
|
180
|
+
* forge#2240: optional phase-boundary observer. Called with
|
|
181
|
+
* `{event: "phase_enter", phase}` right before a phase's runner is about to
|
|
182
|
+
* execute (i.e. the phase was NOT already satisfied on reconcile), with
|
|
183
|
+
* `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
|
|
184
|
+
* once that phase's outcome is known, and (forge#2524) with
|
|
185
|
+
* `{event: "phase_paused", phase, reason: "session-limit", resetAt, detail}`
|
|
186
|
+
* when a session-limit hit pauses in-process (see the pause/retry loop
|
|
187
|
+
* below). Defaults to a no-op so every existing caller/test is unaffected.
|
|
188
|
+
* Deliberately engine.mjs's ONLY new surface for this issue — no
|
|
189
|
+
* `console.log`/stdout write is added here; printing is the CLI layer's job
|
|
190
|
+
* (bin/engine-cli.mjs), preserving the io-injection/testability convention
|
|
191
|
+
* this module already follows. Invocations are wrapped so a throwing
|
|
192
|
+
* callback can never crash a run.
|
|
193
|
+
* @param {() => number} [opts.now] - clock, overridable for tests.
|
|
194
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep] - forge#2524: the wait
|
|
195
|
+
* primitive used by the session-limit pause loop below. Defaults to a real
|
|
196
|
+
* `setTimeout`-based sleep. Overridable for tests so the suite never
|
|
197
|
+
* actually waits out a real (potentially multi-hour) session-limit reset —
|
|
198
|
+
* a test injects a fast/no-op sleep and asserts the *requested* duration
|
|
199
|
+
* instead of waiting for it. Mirrors the existing `now`/`onProgress`
|
|
200
|
+
* injectability convention.
|
|
201
|
+
* @param {number} [opts.maxSessionLimitPauses] - forge#2524: caps how many
|
|
202
|
+
* consecutive session-limit pauses a single phase attempt will ride out
|
|
203
|
+
* before falling through to the existing engine-error terminate path.
|
|
204
|
+
* Defaults to DEFAULT_MAX_SESSION_LIMIT_PAUSES. Purely a defensive backstop
|
|
205
|
+
* against a persistently-misreported reset time; a genuine reset is
|
|
206
|
+
* expected to need at most one or two pauses in practice.
|
|
207
|
+
*/
|
|
208
|
+
export async function runIssue(opts) {
|
|
209
|
+
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
210
|
+
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
211
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
|
|
212
|
+
// Optional execution-backend override for every phase's `runner()`
|
|
213
|
+
// call (forge#2028 / MAT-3). Left undefined by default so existing
|
|
214
|
+
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
215
|
+
// this is purely additive pass-through, not a new default.
|
|
216
|
+
backend, model,
|
|
217
|
+
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
218
|
+
leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
|
|
219
|
+
// forge#2524: real default is a genuine setTimeout-based wait —
|
|
220
|
+
// tests inject a fast/no-op replacement (see opts.sleep doc above).
|
|
221
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
222
|
+
maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
|
|
223
|
+
onProgress = () => {} } = opts;
|
|
224
|
+
|
|
225
|
+
// forge#2452: extracted into makeProgressEmitter() — see its docstring for
|
|
226
|
+
// the forge#2240 rationale this wrapper implements.
|
|
227
|
+
const emitProgress = makeProgressEmitter(onProgress);
|
|
228
|
+
|
|
229
|
+
// forge#2452: extracted into validateRunIssueOptions() — see its docstring
|
|
230
|
+
// for the forge#2054/#2313/#2329 rationale and required call-order (before
|
|
231
|
+
// any state I/O or timer creation, unchanged from the inline block this
|
|
232
|
+
// replaces).
|
|
233
|
+
validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses });
|
|
234
|
+
|
|
150
235
|
const projector = makeProjector(io);
|
|
151
236
|
|
|
152
237
|
// 1. Load + reconcile (GitHub wins).
|
|
@@ -328,7 +413,56 @@ export async function runIssue(opts) {
|
|
|
328
413
|
const renewTimer = setInterval(renewLease, leaseRenewIntervalMs);
|
|
329
414
|
if (typeof renewTimer.unref === "function") renewTimer.unref();
|
|
330
415
|
try {
|
|
331
|
-
|
|
416
|
+
// forge#2524: bounded pause/retry loop for a genuine, still-future
|
|
417
|
+
// session-limit hit. `runPhaseWithRetry()` itself remains fail-fast
|
|
418
|
+
// for CLI_BACKEND_FAILED (forge#2259 — a deterministic CLI crash
|
|
419
|
+
// reproduces identically on every attempt within one call) — this
|
|
420
|
+
// loop operates one layer up, deciding whether to call it again from
|
|
421
|
+
// scratch (fresh attempt budget) after waiting out a temporary usage
|
|
422
|
+
// cap, rather than treating every CLI_BACKEND_FAILED as equally
|
|
423
|
+
// fatal. `renewTimer` (started above) keeps renewing the lease for
|
|
424
|
+
// the entire duration of this loop, including any `sleep()` below —
|
|
425
|
+
// no separate heartbeat mechanism is needed for the wait itself.
|
|
426
|
+
let pauseCount = 0;
|
|
427
|
+
for (;;) {
|
|
428
|
+
try {
|
|
429
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
|
|
430
|
+
break;
|
|
431
|
+
} catch (e) {
|
|
432
|
+
const resetAtMs = e.resetAtEpochMs;
|
|
433
|
+
const canPause =
|
|
434
|
+
e.code === "CLI_BACKEND_FAILED" &&
|
|
435
|
+
typeof resetAtMs === "number" && Number.isFinite(resetAtMs) &&
|
|
436
|
+
resetAtMs > now() &&
|
|
437
|
+
pauseCount < maxSessionLimitPauses;
|
|
438
|
+
if (!canPause) throw e; // falls through to the unchanged outer catch below
|
|
439
|
+
pauseCount += 1;
|
|
440
|
+
const waitMs = Math.max(0, resetAtMs - now());
|
|
441
|
+
// forge#2524 AC4: append a run-log event so a crash mid-wait
|
|
442
|
+
// leaves a durable record of the pause (deriveState() folds this
|
|
443
|
+
// into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
|
|
444
|
+
// does NOT touch committed/terminal state: the phase genuinely
|
|
445
|
+
// hasn't committed yet, so a resumed run correctly re-enters
|
|
446
|
+
// pickPhase at this same phase regardless of whether this event
|
|
447
|
+
// was ever written.
|
|
448
|
+
appendEvent(dir, issue, {
|
|
449
|
+
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
450
|
+
resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
|
|
451
|
+
});
|
|
452
|
+
// forge#2524 AC6: progress observer sees the pause so a caller
|
|
453
|
+
// (bin/engine-cli.mjs) can render the wait state instead of the
|
|
454
|
+
// process going silent for the duration.
|
|
455
|
+
emitProgress({
|
|
456
|
+
event: "phase_paused", phase: phase.id, reason: "session-limit",
|
|
457
|
+
resetAt: resetAtMs,
|
|
458
|
+
detail: `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`,
|
|
459
|
+
});
|
|
460
|
+
await sleep(waitMs);
|
|
461
|
+
// Loop back and retry the same phase with a fresh attempt budget
|
|
462
|
+
// (a brand-new runPhaseWithRetry() call, not a continuation of
|
|
463
|
+
// the exhausted one) — AC2.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
332
466
|
} catch (e) {
|
|
333
467
|
// forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
|
|
334
468
|
// rethrown by runPhaseWithRetry() (see its own catch below) instead
|
|
@@ -569,5 +703,6 @@ function eventsFromIndex(idx) {
|
|
|
569
703
|
|
|
570
704
|
function freshState(issue, lane) {
|
|
571
705
|
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
572
|
-
branch: null, pr: null, terminal: false, terminalReason: null, lease: null
|
|
706
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
|
|
707
|
+
lastRateLimit: null };
|
|
573
708
|
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -1792,11 +1792,47 @@ async function update() {
|
|
|
1792
1792
|
).trim();
|
|
1793
1793
|
if (porcelain) {
|
|
1794
1794
|
console.log(
|
|
1795
|
-
` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping update.${RESET}`,
|
|
1796
|
-
);
|
|
1797
|
-
console.log(
|
|
1798
|
-
` ${YELLOW}Commit or stash your changes before updating, or run from a clean clone. HEAD was NOT moved.${RESET}`,
|
|
1795
|
+
` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping git-based update.${RESET}`,
|
|
1799
1796
|
);
|
|
1797
|
+
|
|
1798
|
+
// forge#2460 — distinguish "developing on this checkout" from a
|
|
1799
|
+
// dirty git-clone install elsewhere with unrelated changes. The
|
|
1800
|
+
// former (npx-shadowing: FORGE_HOME resolves to the directory the
|
|
1801
|
+
// user is physically running the CLI from) has a git-independent
|
|
1802
|
+
// fallback available — relinkAndHint() only copies commands/hooks
|
|
1803
|
+
// off disk, it never touches git state, so it's safe to run on a
|
|
1804
|
+
// dirty tree. HEAD is still never moved in either case.
|
|
1805
|
+
const cwdResolved = resolve(process.cwd());
|
|
1806
|
+
const forgeHomeResolved = resolve(FORGE_HOME);
|
|
1807
|
+
const isSourceRepoSelf =
|
|
1808
|
+
cwdResolved === forgeHomeResolved ||
|
|
1809
|
+
cwdResolved.startsWith(forgeHomeResolved + sep);
|
|
1810
|
+
|
|
1811
|
+
if (isSourceRepoSelf) {
|
|
1812
|
+
console.log(
|
|
1813
|
+
` ${YELLOW}This looks like the ForgeDock source repo itself — syncing commands/hooks from the working tree instead (HEAD was NOT moved).${RESET}`,
|
|
1814
|
+
);
|
|
1815
|
+
// forge#2493 — relinkAndHint() is wrapped in its own try/catch
|
|
1816
|
+
// here so a failure inside it (forge()/writeInstallReceipt()
|
|
1817
|
+
// disk-write errors) doesn't fall through to the outer catch
|
|
1818
|
+
// below, which prints a fast-forward/merge message that would be
|
|
1819
|
+
// actively wrong for this path — no fast-forward or merge is
|
|
1820
|
+
// ever attempted when isSourceRepoSelf is true.
|
|
1821
|
+
try {
|
|
1822
|
+
await relinkAndHint();
|
|
1823
|
+
} catch (relinkErr) {
|
|
1824
|
+
console.log(
|
|
1825
|
+
` ${YELLOW}Could not sync commands/hooks from the working tree: ${relinkErr.message}${RESET}`,
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
console.log(
|
|
1829
|
+
` To pull the latest published release instead: ${CYAN}npm install -g forgedock@latest && npx forgedock doctor --fix${RESET}`,
|
|
1830
|
+
);
|
|
1831
|
+
} else {
|
|
1832
|
+
console.log(
|
|
1833
|
+
` ${YELLOW}Commit or stash your changes before updating, or run from a clean clone. HEAD was NOT moved.${RESET}`,
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1800
1836
|
return;
|
|
1801
1837
|
}
|
|
1802
1838
|
|
|
@@ -3845,6 +3881,31 @@ switch (command) {
|
|
|
3845
3881
|
const localVersion = getVersion();
|
|
3846
3882
|
console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
|
|
3847
3883
|
|
|
3884
|
+
// forge#2460 — getVersion() always reads FORGE_HOME/package.json. In a
|
|
3885
|
+
// git-clone install (including the npx-shadowing case where FORGE_HOME
|
|
3886
|
+
// resolves to whatever checkout the CLI is being run from), that's this
|
|
3887
|
+
// checkout's own version — not necessarily what a separate global npm
|
|
3888
|
+
// install elsewhere would report. Surface that ambiguity explicitly
|
|
3889
|
+
// rather than letting this read as "the" installed version, mirroring
|
|
3890
|
+
// the disambiguation messaging update() already prints (forge#2220).
|
|
3891
|
+
// WIRE:PROVEN — manual: every existing "version command" test in
|
|
3892
|
+
// bin/tests/router.test.mjs spawns this actual bin/forgedock.mjs file,
|
|
3893
|
+
// whose FORGE_HOME resolves to this repo's own git-clone root (a real
|
|
3894
|
+
// .git directory), so this guard evaluates true on every run of that
|
|
3895
|
+
// suite (`node --test bin/tests/router.test.mjs`, confirmed passing).
|
|
3896
|
+
// findSeparateGlobalInstall() itself fails closed to null on any error
|
|
3897
|
+
// (see its JSDoc) and is exercised by the "update — global npm install
|
|
3898
|
+
// self-update" suite below via the same resolveGlobalNpmForgedockDir()
|
|
3899
|
+
// helper it shares with isGlobalNpmInstall().
|
|
3900
|
+
if (existsSync(join(FORGE_HOME, ".git"))) {
|
|
3901
|
+
const separateGlobalInstall = findSeparateGlobalInstall();
|
|
3902
|
+
if (separateGlobalInstall) {
|
|
3903
|
+
console.log(
|
|
3904
|
+
` ${YELLOW}Note: this is the git clone at ${FORGE_HOME}. Your global npm install at ${separateGlobalInstall} may report a different version.${RESET}`,
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3848
3909
|
const latestVersion = await fetchLatestVersion();
|
|
3849
3910
|
if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
|
|
3850
3911
|
console.log(
|
|
@@ -720,12 +720,34 @@ function checkLabelTransition(command) {
|
|
|
720
720
|
|
|
721
721
|
// Evidence precondition (#2326): reaching workflow:invalid from
|
|
722
722
|
// ready-to-build/building/in-review is structurally allowed above, but
|
|
723
|
-
// still gated against *casual* invalidation —
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
723
|
+
// still gated against *casual* invalidation — see checkInvalidReversalEvidence()
|
|
724
|
+
// below for the full rationale and evidence requirements.
|
|
725
|
+
const evidenceError = checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments);
|
|
726
|
+
if (evidenceError) return evidenceError;
|
|
727
|
+
|
|
728
|
+
return null; // valid transition — allow
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Evidence precondition (#2326): reaching workflow:invalid from
|
|
733
|
+
* ready-to-build/building/in-review is structurally allowed by the label
|
|
734
|
+
* transition state machine, but still gated against *casual* invalidation —
|
|
735
|
+
* a bare relabel with no paper trail is blocked. Require a posted reversal
|
|
736
|
+
* comment (a second FORGE:INVESTIGATOR annotation carrying "**Verdict**:
|
|
737
|
+
* INVALID") already on the issue before allowing the transition through,
|
|
738
|
+
* from a trusted author (issue #2332 — marker text alone is forgeable by
|
|
739
|
+
* anyone who can comment on a public issue; see
|
|
740
|
+
* TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS).
|
|
741
|
+
*
|
|
742
|
+
* Extracted from checkLabelTransition() (issue #2453) as a separable
|
|
743
|
+
* sub-concern — same inputs/outputs, no behavior change.
|
|
744
|
+
*
|
|
745
|
+
* @param {string} newLabel
|
|
746
|
+
* @param {string|null} currentWorkflowLabel
|
|
747
|
+
* @param {Array|null} comments
|
|
748
|
+
* @returns {string|null} Error message to show, or null if allowed.
|
|
749
|
+
*/
|
|
750
|
+
function checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments) {
|
|
729
751
|
if (newLabel === "workflow:invalid" && EVIDENCE_REQUIRED_FOR_INVALID_FROM.has(currentWorkflowLabel)) {
|
|
730
752
|
if (!hasInvalidReversalEvidence(comments)) {
|
|
731
753
|
return [
|
|
@@ -747,7 +769,7 @@ function checkLabelTransition(command) {
|
|
|
747
769
|
}
|
|
748
770
|
}
|
|
749
771
|
|
|
750
|
-
return null; //
|
|
772
|
+
return null; // no evidence required, or evidence present — allow
|
|
751
773
|
}
|
|
752
774
|
|
|
753
775
|
// ---------------------------------------------------------------------------
|