forgedock 1.5.1 → 1.6.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/forgedock.mjs +21 -20
- package/bin/hooks/session-start.mjs +22 -1
- package/bin/registry.mjs +86 -0
- package/bin/runner.mjs +197 -34
- package/commands/orchestrate/phase-3-dependency.md +6 -1
- package/commands/orchestrate/phase-4-execution.md +113 -3
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
package/bin/forgedock.mjs
CHANGED
|
@@ -59,8 +59,8 @@ import {
|
|
|
59
59
|
detectClaudeVersion,
|
|
60
60
|
loadBreakpoints,
|
|
61
61
|
hasFeature,
|
|
62
|
-
getPersistedHomeState,
|
|
63
62
|
setPersistedHomeState,
|
|
63
|
+
getInstalledCommandsDriftStatus,
|
|
64
64
|
} from "./registry.mjs";
|
|
65
65
|
import { renderMark, ember } from "./cinema.mjs";
|
|
66
66
|
import {
|
|
@@ -2740,27 +2740,16 @@ async function doctor(fix = false) {
|
|
|
2740
2740
|
`incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
|
|
2741
2741
|
);
|
|
2742
2742
|
} else {
|
|
2743
|
-
//
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
const recorded = getPersistedHomeState();
|
|
2751
|
-
let persistedVersion = recorded?.version || "";
|
|
2752
|
-
if (!persistedVersion) {
|
|
2753
|
-
try {
|
|
2754
|
-
persistedVersion = readFileSync(versionPath, "utf-8").trim();
|
|
2755
|
-
} catch {
|
|
2756
|
-
// version file missing/unreadable — reported below as "unknown"
|
|
2757
|
-
}
|
|
2758
|
-
}
|
|
2759
|
-
const sourceVersion = getVersion();
|
|
2760
|
-
if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
|
|
2743
|
+
// Package-vs-installed-commands drift comparison is factored into
|
|
2744
|
+
// a single shared helper (bin/registry.mjs's
|
|
2745
|
+
// getInstalledCommandsDriftStatus, forge#2719) so doctor, the
|
|
2746
|
+
// `version` command, and the SessionStart hook all agree on one
|
|
2747
|
+
// source of truth instead of re-deriving this logic independently.
|
|
2748
|
+
const { persistedVersion, isStale, unknown } = getInstalledCommandsDriftStatus(FORGE_HOME);
|
|
2749
|
+
if (!unknown && isStale) {
|
|
2761
2750
|
warn(
|
|
2762
2751
|
"Persisted toolset home (~/.forge)",
|
|
2763
|
-
`v${persistedVersion} — source is v${
|
|
2752
|
+
`v${persistedVersion} — source is v${getVersion()}. Run: npx forgedock update`,
|
|
2764
2753
|
);
|
|
2765
2754
|
} else {
|
|
2766
2755
|
pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
|
|
@@ -3959,6 +3948,18 @@ switch (command) {
|
|
|
3959
3948
|
`Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
|
|
3960
3949
|
);
|
|
3961
3950
|
}
|
|
3951
|
+
|
|
3952
|
+
// forge#2719 — surface package-vs-installed-commands drift (previously
|
|
3953
|
+
// only visible via `doctor` Check 5d). Uses the same shared helper doctor
|
|
3954
|
+
// calls, so both surfaces agree. Silent when versions match or when
|
|
3955
|
+
// either version is unknown (e.g. a git-clone dev install, which never
|
|
3956
|
+
// persists to ~/.forge — see registry.mjs's getInstalledCommandsDriftStatus).
|
|
3957
|
+
const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
|
|
3958
|
+
if (!driftStatus.unknown && driftStatus.isStale) {
|
|
3959
|
+
console.log(
|
|
3960
|
+
` ${YELLOW}Installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion}${RESET} — run: ${CYAN}npx forgedock update${RESET}`,
|
|
3961
|
+
);
|
|
3962
|
+
}
|
|
3962
3963
|
break;
|
|
3963
3964
|
}
|
|
3964
3965
|
case "help":
|
|
@@ -51,6 +51,8 @@ const FORGE_HOME = resolve(__dirname, "..", "..");
|
|
|
51
51
|
let resolveState;
|
|
52
52
|
let nudgeSeen;
|
|
53
53
|
let markNudgeSeen;
|
|
54
|
+
// Assigned alongside the other registry.mjs helpers below — see forge#2719.
|
|
55
|
+
let getInstalledCommandsDriftStatus;
|
|
54
56
|
|
|
55
57
|
// ---------------------------------------------------------------------------
|
|
56
58
|
// forge-utils helpers — populated inside the try block to honour fail-open
|
|
@@ -88,7 +90,7 @@ try {
|
|
|
88
90
|
// that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
89
91
|
/** @type {import('../registry.mjs')} */
|
|
90
92
|
(
|
|
91
|
-
{ resolveState, nudgeSeen, markNudgeSeen } = await import(
|
|
93
|
+
{ resolveState, nudgeSeen, markNudgeSeen, getInstalledCommandsDriftStatus } = await import(
|
|
92
94
|
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
93
95
|
)
|
|
94
96
|
);
|
|
@@ -126,6 +128,25 @@ try {
|
|
|
126
128
|
const cwd = process.cwd();
|
|
127
129
|
const state = resolveState(cwd);
|
|
128
130
|
|
|
131
|
+
// Surface package-vs-installed-commands drift as a single non-fatal
|
|
132
|
+
// advisory line (forge#2719). Uses the same shared helper doctor's
|
|
133
|
+
// Check 5d and the `version` command call, so all three surfaces agree.
|
|
134
|
+
// Best-effort: any failure here must never block the session, and
|
|
135
|
+
// managed-optedout directories stay completely silent per this hook's
|
|
136
|
+
// existing contract for that state (see the switch below).
|
|
137
|
+
if (state !== "managed-optedout") {
|
|
138
|
+
try {
|
|
139
|
+
const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
|
|
140
|
+
if (!driftStatus.unknown && driftStatus.isStale) {
|
|
141
|
+
console.log(
|
|
142
|
+
`ForgeDock: installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion} — run: npx forgedock update`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Fail open — never let a drift-check error affect session start.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
switch (state) {
|
|
130
151
|
case "managed-active":
|
|
131
152
|
await handleManagedActive(cwd);
|
package/bin/registry.mjs
CHANGED
|
@@ -465,6 +465,92 @@ export async function setPersistedHomeState({ path, version, updatedAt } = {}) {
|
|
|
465
465
|
});
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Package-vs-installed-commands drift detection (issue #2719)
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
//
|
|
472
|
+
// Single shared source of truth for "is the persisted ~/.forge/ toolset
|
|
473
|
+
// (commands/skills the SessionStart hook and ~/.claude/commands actually
|
|
474
|
+
// load) older than the forgedock package currently running?" — previously
|
|
475
|
+
// this comparison existed only inline inside doctor's Check 5d
|
|
476
|
+
// (bin/forgedock.mjs), so a user who bumped the npm package but never ran
|
|
477
|
+
// `forgedock update` had no way to learn about the drift outside of
|
|
478
|
+
// proactively running `doctor`. This helper is now the one place the
|
|
479
|
+
// comparison lives; doctor, the `version` command, and the SessionStart hook
|
|
480
|
+
// all call it instead of re-deriving the logic (forge#1589 split-brain
|
|
481
|
+
// precedent — see the persistHome() comment above for why that matters).
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Compare the running forgedock package's version against the version last
|
|
485
|
+
* persisted to `~/.forge/` (the copy the SessionStart hook and
|
|
486
|
+
* `~/.claude/commands` actually load from — see persistHome() in
|
|
487
|
+
* bin/journey.mjs).
|
|
488
|
+
*
|
|
489
|
+
* Fail-open: never throws. Any failure to read either version (missing/
|
|
490
|
+
* corrupt package.json, missing/corrupt registry, missing/unreadable
|
|
491
|
+
* `~/.forge/version` file) resolves to `unknown: true` with empty version
|
|
492
|
+
* strings, rather than propagating an error to the caller. This mirrors the
|
|
493
|
+
* fail-open contract required of both doctor (best-effort diagnostics) and
|
|
494
|
+
* the SessionStart hook (must never block a Claude Code session — see
|
|
495
|
+
* forge#383/forge#489).
|
|
496
|
+
*
|
|
497
|
+
* Version comparison delegates to the already-fixed compareVersions() above
|
|
498
|
+
* (forge#1932 — do not reimplement comparison logic here).
|
|
499
|
+
*
|
|
500
|
+
* @param {string} forgeHome - Absolute path to the ForgeDock installation
|
|
501
|
+
* root whose package.json holds the "source" version (the package
|
|
502
|
+
* currently running). Callers pass their own FORGE_HOME.
|
|
503
|
+
* @returns {{
|
|
504
|
+
* persistedVersion: string,
|
|
505
|
+
* sourceVersion: string,
|
|
506
|
+
* isStale: boolean,
|
|
507
|
+
* unknown: boolean
|
|
508
|
+
* }}
|
|
509
|
+
*/
|
|
510
|
+
export function getInstalledCommandsDriftStatus(forgeHome) {
|
|
511
|
+
const notFound = { persistedVersion: "", sourceVersion: "", isStale: false, unknown: true };
|
|
512
|
+
|
|
513
|
+
let sourceVersion = "";
|
|
514
|
+
try {
|
|
515
|
+
const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
|
|
516
|
+
sourceVersion = pkg.version || "";
|
|
517
|
+
} catch {
|
|
518
|
+
// Missing/unreadable/corrupt package.json — cannot determine the source
|
|
519
|
+
// version at all; degrade to unknown rather than guessing.
|
|
520
|
+
return notFound;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Prefer the version registry.mjs last recorded for persistHome()'s write
|
|
524
|
+
// (the single source of truth update()/install also write through),
|
|
525
|
+
// falling back to reading ~/.forge/version directly on disk when the
|
|
526
|
+
// registry has no entry (e.g. an older ForgeDock version wrote ~/.forge/
|
|
527
|
+
// before forge#1943 added registry tracking). Mirrors doctor's Check 5d.
|
|
528
|
+
let persistedVersion = "";
|
|
529
|
+
try {
|
|
530
|
+
const recorded = getPersistedHomeState();
|
|
531
|
+
persistedVersion = recorded?.version || "";
|
|
532
|
+
} catch {
|
|
533
|
+
// getPersistedHomeState() already fails open to null — this catch is
|
|
534
|
+
// defensive only.
|
|
535
|
+
}
|
|
536
|
+
if (!persistedVersion) {
|
|
537
|
+
try {
|
|
538
|
+
const versionPath = join(HOME, ".forge", "version");
|
|
539
|
+
persistedVersion = readFileSync(versionPath, "utf-8").trim();
|
|
540
|
+
} catch {
|
|
541
|
+
// version file missing/unreadable — no persisted-home version is
|
|
542
|
+
// knowable; degrade to unknown.
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!persistedVersion) {
|
|
547
|
+
return notFound;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const isStale = compareVersions(persistedVersion, sourceVersion) < 0;
|
|
551
|
+
return { persistedVersion, sourceVersion, isStale, unknown: false };
|
|
552
|
+
}
|
|
553
|
+
|
|
468
554
|
// ---------------------------------------------------------------------------
|
|
469
555
|
// Claude Code version detection and breakpoints resolution (issue #1252)
|
|
470
556
|
// ---------------------------------------------------------------------------
|
package/bin/runner.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
rmSync,
|
|
48
48
|
realpathSync,
|
|
49
49
|
} from "fs";
|
|
50
|
-
import { join, dirname, basename, relative, isAbsolute } from "path";
|
|
50
|
+
import { join, dirname, basename, relative, isAbsolute, extname } from "path";
|
|
51
51
|
import os from "os";
|
|
52
52
|
import { execSync, spawnSync } from "child_process";
|
|
53
53
|
import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
|
|
@@ -143,11 +143,23 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
|
143
143
|
// evict the oldest (insertion-order, via `Map`'s iteration order and a
|
|
144
144
|
// delete+re-set on refresh) so a process that probes an unbounded
|
|
145
145
|
// number of distinct `cwd` values cannot grow this cache without limit.
|
|
146
|
-
// Each entry stores `{ available, cachedAt }` instead of a bare
|
|
147
|
-
// `isClaudeCliAvailable()` can evaluate the TTL
|
|
146
|
+
// Each entry stores `{ available, cliPath, cachedAt }` instead of a bare
|
|
147
|
+
// boolean so `isClaudeCliAvailable()` can evaluate the TTL, and so the
|
|
148
|
+
// *same* resolved absolute executable path the probe validated can be
|
|
149
|
+
// reused by `runCliBackend()`'s production invocation (issue #2741) — see
|
|
150
|
+
// `resolveClaudeCliBinary()` below. `cliPath` is `null` when unavailable or
|
|
151
|
+
// when the probe succeeded but no path-like line could be parsed out of it
|
|
152
|
+
// (e.g. an injected test `execImpl` that returns a bare version string).
|
|
148
153
|
const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
|
|
149
154
|
const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
|
|
150
155
|
const cliAvailabilityCache = new Map();
|
|
156
|
+
// Windows file extensions `spawnSync(shell:false)` can actually launch:
|
|
157
|
+
// native executables (`.exe`, `.com`) directly, and `.cmd`/`.bat` via Node's
|
|
158
|
+
// own built-in special-cased re-exec through cmd.exe (see the comment on
|
|
159
|
+
// `runCliBackend()` below). An extensionless file — e.g. the POSIX shell
|
|
160
|
+
// shim `npm`/`npx` also drop alongside these on Windows — cannot be
|
|
161
|
+
// launched by `CreateProcess` at all, with or without a full absolute path.
|
|
162
|
+
const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
|
|
151
163
|
// Cap tool-result payloads so a large file read or verbose command does not
|
|
152
164
|
// blow the context window in a single turn.
|
|
153
165
|
const MAX_TOOL_RESULT_CHARS = 100_000;
|
|
@@ -203,21 +215,110 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
203
215
|
// Backend selection (issue #2003) — CLI vs API execution backend
|
|
204
216
|
// ---------------------------------------------------------------------------
|
|
205
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Split the combined probe command's stdout (issue #2741 — see
|
|
220
|
+
* `isClaudeCliAvailable()`) into the leading "path-like" lines (from the
|
|
221
|
+
* `where`/`command -v` half of the command) versus everything after them
|
|
222
|
+
* (the `claude --version` output). A path-like line is one containing a
|
|
223
|
+
* path separator (`/` or `\`); `claude --version` output (e.g.
|
|
224
|
+
* `"1.2.3 (Claude Code)"`) never does, so the first non-path-like line
|
|
225
|
+
* marks the boundary.
|
|
226
|
+
*
|
|
227
|
+
* Pure/side-effect-free so it can be unit tested directly against
|
|
228
|
+
* synthetic multi-line `where` output without spawning anything.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} raw
|
|
231
|
+
* @returns {string[]} Leading path-like lines, in original order.
|
|
232
|
+
*/
|
|
233
|
+
function parseProbeOutput(raw) {
|
|
234
|
+
const lines = String(raw ?? "")
|
|
235
|
+
.split(/\r?\n/)
|
|
236
|
+
.map((line) => line.trim())
|
|
237
|
+
.filter(Boolean);
|
|
238
|
+
const pathLines = [];
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
if (line.includes("/") || line.includes("\\")) {
|
|
241
|
+
pathLines.push(line);
|
|
242
|
+
} else {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return pathLines;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Pick the best resolved `claude` executable path out of the candidate
|
|
251
|
+
* path-like lines produced by `parseProbeOutput()` (issue #2741).
|
|
252
|
+
*
|
|
253
|
+
* On Windows, prefers a candidate with a recognized spawnable extension
|
|
254
|
+
* (`WINDOWS_SPAWNABLE_EXTENSIONS`) over an extensionless one — `where`
|
|
255
|
+
* matches bare filenames as well as PATHEXT-suffixed ones, and an
|
|
256
|
+
* npx-transient shim directory can contain both an extensionless POSIX
|
|
257
|
+
* shell script and a `.cmd`/`.ps1` sibling generated by npm's cross-platform
|
|
258
|
+
* shim generator. If every candidate is extensionless, checks the same
|
|
259
|
+
* directory for a `.cmd`/`.exe`/`.bat`/`.com` sibling with the same base
|
|
260
|
+
* name before giving up and returning the bare match as-is.
|
|
261
|
+
*
|
|
262
|
+
* On POSIX, `command -v` returns a single resolvable path directly usable
|
|
263
|
+
* by `spawnSync(shell:false)` (POSIX `execve` honors the shebang line), so
|
|
264
|
+
* no extension preference is needed.
|
|
265
|
+
*
|
|
266
|
+
* @param {string[]} pathLines
|
|
267
|
+
* @returns {string|null}
|
|
268
|
+
*/
|
|
269
|
+
function selectResolvedCliPath(pathLines) {
|
|
270
|
+
if (pathLines.length === 0) return null;
|
|
271
|
+
if (process.platform !== "win32") return pathLines[0];
|
|
272
|
+
|
|
273
|
+
const withExt = pathLines.find((line) =>
|
|
274
|
+
WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
|
|
275
|
+
);
|
|
276
|
+
if (withExt) return withExt;
|
|
277
|
+
|
|
278
|
+
const bare = pathLines[0];
|
|
279
|
+
const dir = dirname(bare);
|
|
280
|
+
const base = basename(bare);
|
|
281
|
+
for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
|
|
282
|
+
const candidate = join(dir, `${base}${ext}`);
|
|
283
|
+
if (existsSync(candidate)) return candidate;
|
|
284
|
+
}
|
|
285
|
+
return bare;
|
|
286
|
+
}
|
|
287
|
+
|
|
206
288
|
/**
|
|
207
289
|
* Probe whether the Claude Code CLI (`claude`) is present and responds on
|
|
208
290
|
* this machine, so `forgedock run` can drive it directly instead of
|
|
209
291
|
* requiring a separate, billable ANTHROPIC_API_KEY.
|
|
210
292
|
*
|
|
211
|
-
* Deliberately
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
293
|
+
* Deliberately runs a single command *string* through a shell (via
|
|
294
|
+
* `execSync`) rather than `execFileSync("claude", [...])` or a no-shell
|
|
295
|
+
* `spawnSync`. On Windows, `claude` may be installed as a `claude.cmd`
|
|
296
|
+
* shim, or present only as an npx-transient shim (an extensionless script
|
|
297
|
+
* in a `_npx` cache directory, reachable only via the shell-augmented
|
|
298
|
+
* PATH); `execFileSync`/no-shell `spawnSync` do not resolve either case the
|
|
299
|
+
* same way a shell does and throw ENOENT even though the CLI is genuinely
|
|
300
|
+
* runnable (see issue #382 for the `.cmd`-shim regression this originally
|
|
301
|
+
* guarded against, and issue #2741 for the npx-shim case). `execSync`
|
|
302
|
+
* always runs its command through a shell, which resolves both cases
|
|
303
|
+
* correctly on Windows and behaves identically on POSIX.
|
|
304
|
+
*
|
|
305
|
+
* issue #2741 — probe/invocation asymmetry fix: this probe now does double
|
|
306
|
+
* duty. In a single `execImpl` call (so per-cwd caching below still pays
|
|
307
|
+
* the probe cost exactly once — existing tests assert this), it both
|
|
308
|
+
* verifies `claude --version` responds AND captures the shell-resolved
|
|
309
|
+
* absolute path (`where claude` on Windows / `command -v claude` on POSIX)
|
|
310
|
+
* that made that resolution possible. That resolved path is cached
|
|
311
|
+
* alongside `available` and exposed via `resolveClaudeCliBinary()` below,
|
|
312
|
+
* so `runCliBackend()`'s production call site can spawn the *exact* binary
|
|
313
|
+
* this probe validated — with `shell:false` — instead of a bare `"claude"`
|
|
314
|
+
* name that the no-shell OS exec-path search may fail to resolve the same
|
|
315
|
+
* way. The two commands are chained with `&&` (path-resolution failure
|
|
316
|
+
* short-circuits the whole probe as "unavailable") rather than run
|
|
317
|
+
* independently: an environment where `claude` only resolves as an
|
|
318
|
+
* unlocatable shell alias/function (no path lookup, `--version` succeeds
|
|
319
|
+
* anyway) was never actually spawnable via `spawnSync` in the first place,
|
|
320
|
+
* so treating that as "unavailable" here is consistent with what
|
|
321
|
+
* `runCliBackend()` can actually do with it.
|
|
221
322
|
*
|
|
222
323
|
* Bounded by CLI_PROBE_TIMEOUT_MS so a hung or misbehaving `claude` binary
|
|
223
324
|
* can never make backend resolution (and therefore --dry-run, or the start
|
|
@@ -249,15 +350,18 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
249
350
|
if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
|
|
250
351
|
return cached.available;
|
|
251
352
|
}
|
|
353
|
+
const resolveCmd = process.platform === "win32" ? "where claude" : "command -v claude";
|
|
252
354
|
let available;
|
|
355
|
+
let cliPath = null;
|
|
253
356
|
try {
|
|
254
|
-
execImpl(
|
|
357
|
+
const raw = execImpl(`${resolveCmd} && claude --version`, {
|
|
255
358
|
cwd,
|
|
256
359
|
stdio: ["ignore", "pipe", "ignore"],
|
|
257
360
|
encoding: "utf-8",
|
|
258
361
|
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
259
362
|
});
|
|
260
363
|
available = true;
|
|
364
|
+
cliPath = selectResolvedCliPath(parseProbeOutput(raw));
|
|
261
365
|
} catch {
|
|
262
366
|
available = false;
|
|
263
367
|
}
|
|
@@ -266,7 +370,7 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
266
370
|
// entry — whether "oldest" means "never refreshed" or "not refreshed in
|
|
267
371
|
// the longest time."
|
|
268
372
|
cliAvailabilityCache.delete(cwd);
|
|
269
|
-
cliAvailabilityCache.set(cwd, { available, cachedAt: Date.now() });
|
|
373
|
+
cliAvailabilityCache.set(cwd, { available, cliPath, cachedAt: Date.now() });
|
|
270
374
|
if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
|
|
271
375
|
const oldestKey = cliAvailabilityCache.keys().next().value;
|
|
272
376
|
cliAvailabilityCache.delete(oldestKey);
|
|
@@ -274,6 +378,28 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
274
378
|
return available;
|
|
275
379
|
}
|
|
276
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Return the exact absolute `claude` executable path that
|
|
383
|
+
* `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
|
|
384
|
+
* probe/cache is populated or refreshed first. This is what closes the
|
|
385
|
+
* probe/invocation asymmetry: `runCommand()`'s production call site passes
|
|
386
|
+
* this resolved path as `runCliBackend()`'s `bin` argument instead of the
|
|
387
|
+
* bare `"claude"` default, so the invocation spawns the identical binary
|
|
388
|
+
* the probe already confirmed responds — with `shell:false` preserved
|
|
389
|
+
* throughout (see the SECURITY note on `runCliBackend()` below).
|
|
390
|
+
*
|
|
391
|
+
* @param {string} [cwd] - Working directory for the probe (default cwd).
|
|
392
|
+
* @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (e.g.
|
|
393
|
+
* `execImpl` test seam).
|
|
394
|
+
* @returns {string|null} Absolute resolved path, or `null` when the CLI is
|
|
395
|
+
* unavailable or no path-like line could be parsed from the probe output
|
|
396
|
+
* (falls back to the bare `"claude"` name at the call site in that case).
|
|
397
|
+
*/
|
|
398
|
+
export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
|
|
399
|
+
isClaudeCliAvailable(cwd, opts);
|
|
400
|
+
return cliAvailabilityCache.get(cwd)?.cliPath ?? null;
|
|
401
|
+
}
|
|
402
|
+
|
|
277
403
|
/**
|
|
278
404
|
* Shared backend-resolution primitive (issue #2026) used by both
|
|
279
405
|
* `resolveBackend()` (below, `forgedock run`'s engine ladder — issue #2003)
|
|
@@ -386,19 +512,32 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
386
512
|
* arbitrary shell commands with this process's privileges). Do not
|
|
387
513
|
* reintroduce `shell: true` or string-command interpolation here.
|
|
388
514
|
*
|
|
389
|
-
* Windows `.cmd` shim resolution works WITHOUT `shell: true
|
|
390
|
-
*
|
|
391
|
-
* safely re-invoke through cmd.exe internally when the
|
|
515
|
+
* Windows `.cmd`/`.bat` shim resolution works WITHOUT `shell: true` — BUT
|
|
516
|
+
* only when `bin` is already a resolvable target: Node's spawn/spawnSync
|
|
517
|
+
* safely re-invoke through cmd.exe internally when the *given* target is a
|
|
392
518
|
* `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
|
|
393
519
|
* are never parsed as shell syntax (verified empirically on Windows: a
|
|
394
520
|
* malicious-looking argv element is delivered to the child process
|
|
395
521
|
* byte-for-byte, not executed). This is the correct fix for the
|
|
396
522
|
* `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
|
|
397
|
-
* #382
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
523
|
+
* #382 for a `claude.cmd` sitting directly on a real PATH entry.
|
|
524
|
+
*
|
|
525
|
+
* CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
|
|
526
|
+
* via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
|
|
527
|
+
* false for an npx-transient shim (an extensionless script living in a
|
|
528
|
+
* `_npx` cache directory rather than a stable PATH entry with a registered
|
|
529
|
+
* PATHEXT extension) — `spawnSync(shell:false)` resolves a bare command
|
|
530
|
+
* name via the OS's own exec-path search, which does not carry the same
|
|
531
|
+
* shell/PATHEXT semantics `isClaudeCliAvailable()`'s shell-based probe
|
|
532
|
+
* uses, and ENOENTs on that shim even though the probe reports the CLI
|
|
533
|
+
* available. The fix is NOT `shell: true` (would reopen the injection hole
|
|
534
|
+
* above); it is resolving the *exact* absolute path the probe validated
|
|
535
|
+
* once, via `resolveClaudeCliBinary()`, and passing that resolved path as
|
|
536
|
+
* `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
|
|
537
|
+
* absolute path (native `.exe`, or `.cmd`/`.bat` via the special-cased
|
|
538
|
+
* re-exec described above) without needing any further PATH/PATHEXT
|
|
539
|
+
* resolution of its own. See `isClaudeCliAvailable()`/
|
|
540
|
+
* `resolveClaudeCliBinary()` above for how that resolution happens.
|
|
402
541
|
*
|
|
403
542
|
* `--dangerously-skip-permissions` mirrors the established headless-CI
|
|
404
543
|
* invocation pattern already documented in docs/CI.md and
|
|
@@ -454,14 +593,19 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
454
593
|
* @param {string[]} [opts.args]
|
|
455
594
|
* @param {string} opts.cwd
|
|
456
595
|
* @param {{log: Function, error?: Function}} [opts.logger]
|
|
457
|
-
* @param {string} [opts.bin] - Executable to invoke (default "claude").
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
596
|
+
* @param {string} [opts.bin] - Executable to invoke (default "claude").
|
|
597
|
+
* UPDATED (issue #2741): the real production caller (`runCommand()`
|
|
598
|
+
* below) now deliberately passes `resolveClaudeCliBinary(cwd) ?? "claude"`
|
|
599
|
+
* here — a pre-resolved absolute path, not the bare default — to close
|
|
600
|
+
* the probe/invocation asymmetry described on `isClaudeCliAvailable()`
|
|
601
|
+
* above. The bare `"claude"` default remains purely a fallback for that
|
|
602
|
+
* production call site (when resolution genuinely finds nothing) and a
|
|
603
|
+
* test seam: tests still point this at a controlled fake binary instead
|
|
604
|
+
* of either invoking the real `claude` CLI (slow, non-deterministic, real
|
|
605
|
+
* side effects) or fighting platform-specific PATH-resolution semantics
|
|
606
|
+
* to shim "claude" itself (Windows resolves a bare executable name via
|
|
607
|
+
* the *calling* process's search path at the OS level, which is not
|
|
608
|
+
* reliably overridable per-call from within the same process).
|
|
465
609
|
* @param {Function} [opts.spawnFn] - Injectable replacement for
|
|
466
610
|
* `child_process.spawnSync` (default `spawnSync`). Test seam only —
|
|
467
611
|
* production callers must never override this. Mirrors the identical
|
|
@@ -825,8 +969,12 @@ export function runCliBackend({
|
|
|
825
969
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
826
970
|
|
|
827
971
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
828
|
-
// unparsed elements — see the SECURITY note above.
|
|
829
|
-
//
|
|
972
|
+
// unparsed elements — see the SECURITY note above. `bin` is expected to
|
|
973
|
+
// already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
|
|
974
|
+
// and the `opts.bin` doc comment above, issue #2741) — spawnSync launches
|
|
975
|
+
// an already-resolved `.exe`/`.cmd`/`.bat` path directly (with Node's
|
|
976
|
+
// built-in special-cased `.cmd`/`.bat` re-exec) without needing any
|
|
977
|
+
// further shell/PATHEXT resolution of its own.
|
|
830
978
|
const result = spawnFn(bin, cliArgs, {
|
|
831
979
|
cwd,
|
|
832
980
|
encoding: "utf-8",
|
|
@@ -1896,7 +2044,22 @@ export async function runCommand(opts = {}) {
|
|
|
1896
2044
|
}.`,
|
|
1897
2045
|
);
|
|
1898
2046
|
}
|
|
1899
|
-
|
|
2047
|
+
// Pass the exact absolute path isClaudeCliAvailable() already validated
|
|
2048
|
+
// for this cwd (issue #2741) — resolveClaudeCliBinary() reuses that same
|
|
2049
|
+
// per-cwd cache, so this does not trigger a second probe. Falls back to
|
|
2050
|
+
// the bare "claude" name only if resolution genuinely found nothing
|
|
2051
|
+
// (e.g. an injected-execImpl test scenario with no path-like output),
|
|
2052
|
+
// preserving prior behavior in that edge case.
|
|
2053
|
+
const resolvedBin = resolveClaudeCliBinary(cwd) ?? "claude";
|
|
2054
|
+
return runCliBackend({
|
|
2055
|
+
spec,
|
|
2056
|
+
userMessage,
|
|
2057
|
+
systemPrompt: cliSystemPrompt,
|
|
2058
|
+
args,
|
|
2059
|
+
cwd,
|
|
2060
|
+
logger,
|
|
2061
|
+
bin: resolvedBin,
|
|
2062
|
+
});
|
|
1900
2063
|
}
|
|
1901
2064
|
|
|
1902
2065
|
if (!apiKey) {
|
|
@@ -1061,7 +1061,12 @@ The engine drives every phase transition deterministically, mirrors state to the
|
|
|
1061
1061
|
|
|
1062
1062
|
**Why engine-first**: The engine's phase table enforces gate semantics in code — not via LLM interpretation of markdown specs. This eliminates the class of bug where the LLM assumes a review approved when the FORGE:REVIEW comment is absent or unreadable (issue #1714). Interactive sessions that run `/work-on` via Skill invocations additionally bridge to the engine run-log via the SubagentStop hook (`bin/hooks/interactive-engine.mjs`) — state is durable across compaction and context resets.
|
|
1063
1063
|
|
|
1064
|
-
**Fallback
|
|
1064
|
+
**Fallback — best-effort, not all-or-nothing (fixed forge#2743)**: Engine-first is guaranteed to have an Agent-spawn fallback under two distinct trigger conditions, not just CLI absence:
|
|
1065
|
+
|
|
1066
|
+
1. **CLI absent at dispatch time**: If `forgedock` is not in PATH, `phase-4-execution.md` Step 4A falls back to spawning Agent sub-agents that run `Skill("work-on", ...)` per issue for the whole batch, before any dispatch happens.
|
|
1067
|
+
2. **Backend unavailable despite CLI presence, or a runtime engine-error with empty committed state**: `command -v forgedock` only proves the orchestrator CLI is installed — it says nothing about whether the engine's execution backend (the `claude` CLI spawn) can actually run a phase (forge#2741 is a concrete case: `spawnSync claude` ENOENTs even though a shell `command -v` probe reports the binary present). Step 4A now runs a cheap backend preflight canary before committing the whole ready set to engine-first, downgrading the entire run to Agent-spawn on failure. And per-issue, Step 4B's completion handler auto-falls-back any individual engine-dispatched issue that completes at `workflow:engine-error` with an empty committed state (`committed=[] branch=null pr=null` — see `bin/engine-cli.mjs`'s `formatTerminalDiagnostics()`) to the same Agent-spawn template, rather than leaving it stuck waiting on a resume mechanism that only exists for Agent-spawn-dispatched issues. An engine-error with partial committed state (branch/PR non-null) is NOT auto-fallen-back, to avoid double-work — it surfaces via the existing stall-detection alert instead.
|
|
1068
|
+
|
|
1069
|
+
In both cases the SubagentStop hook still bridges the fallback Agent-spawn runs to the engine run-log for state persistence. Engine-first is therefore best-effort: it is always backed by a working Agent-spawn path, whether the gap is discovered before dispatch (canary) or after (per-issue fallback) — a whole ready set is never permanently lost to an environmental engine failure.
|
|
1065
1070
|
|
|
1066
1071
|
### Concurrency model: in-process worker pool + worktree-per-issue
|
|
1067
1072
|
|
|
@@ -528,6 +528,32 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
|
|
|
528
528
|
# Uses SORTED_READY_SET[] from Step 3E.5 (descending value/cost) and budget gate from Step 4A-pre.0
|
|
529
529
|
FORGEDOCK_AVAILABLE=$(command -v forgedock >/dev/null 2>&1 && echo "true" || echo "false")
|
|
530
530
|
|
|
531
|
+
# Backend preflight canary (forge#2743): `command -v forgedock` only proves the orchestrator CLI
|
|
532
|
+
# binary is on PATH — it says nothing about whether the engine's execution backend (the `claude`
|
|
533
|
+
# CLI spawn, or a working `ANTHROPIC_API_KEY`) can actually run a phase. Without this probe, an
|
|
534
|
+
# environmental failure (e.g. forge#2741 — `spawnSync claude` ENOENT despite a shell `command -v`
|
|
535
|
+
# probe reporting the binary present) commits the ENTIRE ready set to engine-first dispatch, every
|
|
536
|
+
# `forgedock run-issue` call fails within seconds, and nothing re-routes the batch — a human has
|
|
537
|
+
# to notice and manually re-dispatch via the Agent-spawn path below.
|
|
538
|
+
#
|
|
539
|
+
# CRITICAL — do NOT use a shell-resolved probe here (`command -v claude`, `claude --version` run
|
|
540
|
+
# directly by bash, `which claude`, etc.). forge#2741's entire root cause is that a shell-resolved
|
|
541
|
+
# probe and the engine's actual invocation resolve the `claude` binary differently: bash's builtin
|
|
542
|
+
# PATH lookup (and `execSync("claude --version", ...)`, used deliberately by
|
|
543
|
+
# `bin/runner.mjs`'s `isClaudeCliAvailable()` for this exact reason) can find and run an
|
|
544
|
+
# npx-transient/extensionless shim that `bin/runner.mjs`'s actual backend call —
|
|
545
|
+
# `runCliBackend()`'s `spawnFn(bin, cliArgs, { shell: false, ... })` with bare `bin = "claude"`
|
|
546
|
+
# (no shell resolution) — cannot. A canary that shell-resolves `claude` reproduces the exact
|
|
547
|
+
# blind spot it exists to catch and reports the backend healthy in precisely forge#2741's failure
|
|
548
|
+
# scenario. The canary MUST reproduce `runCliBackend()`'s exact invocation shape — bare binary
|
|
549
|
+
# name, `shell: false` — via a one-off Node `spawnSync` call, not a bash-native probe:
|
|
550
|
+
if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
551
|
+
if ! node -e "const r = require('node:child_process').spawnSync('claude', ['--version'], { shell: false }); process.exit(r.error ? 1 : 0);" 2>/dev/null; then
|
|
552
|
+
echo "WARNING: forgedock CLI is on PATH, but its execution backend is not invocable via the engine's own spawn shape (bare 'claude', shell:false) in this environment. Downgrading to the Agent-spawn fallback path for this entire run — see forge#2743. (If forge#2741's ENOENT root cause is fixed, this canary will pass again on the next /orchestrate invocation.)"
|
|
553
|
+
FORGEDOCK_AVAILABLE="false"
|
|
554
|
+
fi
|
|
555
|
+
fi
|
|
556
|
+
|
|
531
557
|
if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
|
|
532
558
|
# Build the budget-gated dispatch queue first (forge#1743's should_dispatch() still applies
|
|
533
559
|
# per-issue, independent of the concurrency cap below).
|
|
@@ -849,7 +875,7 @@ If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle
|
|
|
849
875
|
|
|
850
876
|
You will be automatically notified when each background agent completes — or, for an engine-first dispatch (Step 4A, `FORGEDOCK_AVAILABLE=true`), when a backgrounded `Bash(run_in_background=true, command="forgedock run-issue ...")` call completes. Both are the same underlying "background task finished" notification the harness delivers; treat them identically for the purposes of this step. **Do NOT use `sleep` loops to poll for completion.** Instead, wait for the automatic notification. When one arrives, look up which issue it belongs to — `AGENT_ISSUE_MAP` for an `agent_completed` notification, `ENGINE_DISPATCH_MAP` (fixed forge#2466) for a background-Bash notification from the engine-first path — and immediately process it exactly the same way regardless of which map resolved it; every check below (`classify_predecessor_state()`, dependent dispatch, stall/staging checks) keys off GitHub labels/state, not which dispatch mechanism produced them. **Fallback**: if a runtime ever fails to deliver a background-Bash completion notification, poll `gh issue view --json labels` for the in-flight engine-dispatched issue's workflow label instead of blocking — the same degrade-gracefully behavior already used when `BACKGROUND_DISPATCH_ENABLED=false` for the Agent-spawn path (see `phase-3-dependency.md` → Background Dispatch Mode).
|
|
851
877
|
|
|
852
|
-
**Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, or a background-Bash completion for an engine-dispatched issue — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` call is issued —
|
|
878
|
+
**Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, or a background-Bash completion for an engine-dispatched issue — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed or fallback-dispatched (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` (or, per item 2b below, the fresh `Agent(...)` fallback dispatch) call is issued — either re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's TIME-BASED hang detector — an agent that never completes at all, as opposed to one that completes at `workflow:engine-error` — remains `Agent(resume=...)`-specific and out of scope for this fix: an engine-dispatched issue that silently hangs still surfaces only via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path for that case. The completion-triggered case — an engine-dispatched issue that DOES complete, at `workflow:engine-error` with empty committed state — is now auto-fallen-back to Agent-spawn by item 2b below, fixed forge#2743.)
|
|
853
879
|
|
|
854
880
|
**Ordering requirement (MANDATORY — prevents transient over-cap)**: For every agent that completed in this notification batch, finish its terminal-state check and, if applicable, its resume re-increment (items 1-2 below) BEFORE computing `dispatch_headroom` for item 5's newly-ready-issue dispatch. Computing headroom before a to-be-resumed agent's re-increment would let a genuinely-still-running agent's freed slot be double-booked — once by a fresh dispatch, once by the resume itself — transiently exceeding `MAX_CONCURRENT`. Process every completed agent's items 1-2 to a decision (terminal vs. resumed) first; only then compute headroom once for the batch's item 5 dispatch.
|
|
855
881
|
|
|
@@ -888,6 +914,10 @@ classify_predecessor_state() {
|
|
|
888
914
|
# predecessor were still mid-pipeline — which is accurate, since the
|
|
889
915
|
# stall-detection step below (item 2) auto-resumes/retries a completed
|
|
890
916
|
# agent run that ends in workflow:engine-error, same as any other stall.
|
|
917
|
+
# Fixed forge#2743: item 2's sub-case 2b is what actually fulfills this for
|
|
918
|
+
# ENGINE_DISPATCH_MAP-tracked issues (fresh Agent-spawn fallback on empty
|
|
919
|
+
# committed state) — 2a's Agent(resume=...) alone cannot reach them, since
|
|
920
|
+
# they were never in AGENT_ISSUE_MAP.
|
|
891
921
|
echo "IN_PROGRESS"
|
|
892
922
|
elif [ "$PRED_STATE" = "CLOSED" ]; then
|
|
893
923
|
# Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
|
|
@@ -1086,7 +1116,11 @@ done
|
|
|
1086
1116
|
echo "#{NUM}: $FINAL_STATE"
|
|
1087
1117
|
```
|
|
1088
1118
|
|
|
1089
|
-
2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on
|
|
1119
|
+
2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on.
|
|
1120
|
+
|
|
1121
|
+
**Two distinct resume mechanisms, dispatched by which map resolved the completion (fixed forge#2743)** — `AGENT_ISSUE_MAP`-resolved completions (Agent-spawn path) and `ENGINE_DISPATCH_MAP`-resolved completions (engine-first path) hit `workflow:engine-error` for structurally different reasons and need different recovery:
|
|
1122
|
+
|
|
1123
|
+
**2a. Agent-spawn-dispatched issue (`AGENT_ISSUE_MAP[{NUMBER}]` set)** — unchanged from prior behavior. Resume the same agent in place:
|
|
1090
1124
|
```
|
|
1091
1125
|
Agent(
|
|
1092
1126
|
resume=AGENT_ISSUE_MAP[{NUMBER}],
|
|
@@ -1095,7 +1129,83 @@ done
|
|
|
1095
1129
|
prompt="The previous /work-on invocation stopped before completing the full pipeline. The issue is currently at {CURRENT_WORKFLOW_STATE}. Continue — invoke Skill(skill='work-on', args='{NUMBER} --under-orchestration') to resume the routing loop from the current state. /work-on will re-read GitHub state and pick up where it left off."
|
|
1096
1130
|
)
|
|
1097
1131
|
```
|
|
1098
|
-
|
|
1132
|
+
|
|
1133
|
+
**2b. Engine-first-dispatched issue (`ENGINE_DISPATCH_MAP[{NUMBER}]` set, no `AGENT_ISSUE_MAP` entry)** — `Agent(resume=...)` has nothing to resume here; the completed task was a backgrounded `Bash(command="forgedock run-issue ...")` call, not an `Agent()`. Re-issuing the identical `forgedock run-issue` command would just reproduce the same environmental failure. Instead, fall back to the Agent-spawn path for this ONE issue — but only when it is safe to do so:
|
|
1134
|
+
```bash
|
|
1135
|
+
# Idempotency guard: only fall back once per issue per engine-error occurrence.
|
|
1136
|
+
ALREADY_FALLEN_BACK=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
|
|
1137
|
+
--jq '[.[] | select(.body | contains("FORGE:ENGINE_FALLBACK"))] | length' 2>/dev/null || echo "0")
|
|
1138
|
+
|
|
1139
|
+
# Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
|
|
1140
|
+
# committed phases. Primary source: the `FORGE:STATE` HTML-comment block the engine already
|
|
1141
|
+
# mirrors onto the issue BODY on every phase transition (`bin/engine/state.mjs`/`projector.mjs`
|
|
1142
|
+
# — see phase-3-dependency.md "Engine mode (default)"), which carries `committed`/`branch`/`pr`
|
|
1143
|
+
# as real JSON — read it directly rather than screen-scraping terminal text:
|
|
1144
|
+
# NOTE (forge#2750 review fix): `bin/engine/state.mjs`'s `serializeState()` writes the block
|
|
1145
|
+
# as THREE separate lines — `<!-- FORGE:STATE`, the JSON payload, and `-->` — never all on one
|
|
1146
|
+
# line. A single-line `grep -oP '(?<=<!-- FORGE:STATE)[\s\S]*?(?=-->)'` NEVER matches this <!-- allowlist:portability — cited non-portable anti-pattern being replaced, not a command this spec runs -->
|
|
1147
|
+
# (GNU `grep -P` without `-z` operates per-line, not across newlines, regardless of `[\s\S]`)
|
|
1148
|
+
# — it always returns empty. Extract the block with a `sed` line-range instead, which handles
|
|
1149
|
+
# the real multi-line shape correctly:
|
|
1150
|
+
STATE_BLOCK=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
|
|
1151
|
+
| sed -n '/<!-- FORGE:STATE/,/-->/p')
|
|
1152
|
+
STATE_JSON=$(echo "$STATE_BLOCK" | sed '1d;$d')
|
|
1153
|
+
if [ -n "$STATE_JSON" ]; then
|
|
1154
|
+
COMMITTED_COUNT=$(echo "$STATE_JSON" | jq -r '.committed | length' 2>/dev/null)
|
|
1155
|
+
STATE_BRANCH=$(echo "$STATE_JSON" | jq -r '.branch' 2>/dev/null)
|
|
1156
|
+
STATE_PR=$(echo "$STATE_JSON" | jq -r '.pr' 2>/dev/null)
|
|
1157
|
+
[ "$COMMITTED_COUNT" = "0" ] && [ "$STATE_BRANCH" = "null" ] && [ "$STATE_PR" = "null" ] \
|
|
1158
|
+
&& EMPTY_COMMITTED_STATE="true" || EMPTY_COMMITTED_STATE="false"
|
|
1159
|
+
else
|
|
1160
|
+
# No FORGE:STATE block was ever written to the issue body (e.g. the run failed before
|
|
1161
|
+
# phase 1 committed anything). There is no reliable structured signal available in this
|
|
1162
|
+
# case — fail SAFE: do NOT auto-fallback on an unverifiable state. This surfaces via the
|
|
1163
|
+
# existing stall-detection alert / needs-human path instead, exactly as before this fix.
|
|
1164
|
+
# (An earlier draft of this fix attempted a second fallback source — a
|
|
1165
|
+
# `committed=[] branch=null pr=null` diagnostic line assumed to be present in the
|
|
1166
|
+
# completed background Bash call's captured stdout — but no code in this file or
|
|
1167
|
+
# phase-3-dependency.md actually captures/populates that stdout into a named variable,
|
|
1168
|
+
# so that fallback was dead code reading an unset variable. Removed rather than wired up:
|
|
1169
|
+
# the FORGE:STATE block above is the engine's own durable, already-documented mirror of
|
|
1170
|
+
# this exact data and should always be present by the time a phase transition occurs;
|
|
1171
|
+
# treating its absence as "cannot verify, don't risk it" is the correct fail-safe.)
|
|
1172
|
+
EMPTY_COMMITTED_STATE="false"
|
|
1173
|
+
fi
|
|
1174
|
+
# A non-empty committed/branch/pr means real per-issue work exists that a fresh Agent-spawn
|
|
1175
|
+
# dispatch could collide with or duplicate — that case is NOT auto-fallen-back; it surfaces
|
|
1176
|
+
# via the existing stall-detection alert / needs-human path instead, exactly as before this fix.
|
|
1177
|
+
|
|
1178
|
+
if [ "$ALREADY_FALLEN_BACK" -eq 0 ] && [ "$EMPTY_COMMITTED_STATE" = "true" ]; then
|
|
1179
|
+
ENGINE_FALLBACK_BODY="<!-- FORGE:ENGINE_FALLBACK -->
|
|
1180
|
+
## Engine-First Dispatch Failed — Falling Back to Agent-Spawn
|
|
1181
|
+
|
|
1182
|
+
\`forgedock run-issue\` ended at \`workflow:engine-error\` with no committed phases, branch, or PR
|
|
1183
|
+
(\`committed=[] branch=null pr=null\`) — this is an environmental/tool failure (forge#2261), not a
|
|
1184
|
+
per-issue content failure, and nothing was committed that a fresh dispatch could collide with.
|
|
1185
|
+
Auto-falling back to the Agent-spawn \`/work-on\` path for this issue. See forge#2743."
|
|
1186
|
+
gh issue comment {NUMBER} {GH_FLAG} --body "$ENGINE_FALLBACK_BODY" # <!-- allowlist:check-command-side-effects — intentional pipeline status annotation, mirrors work-on/build/architect.md:700 -->
|
|
1187
|
+
|
|
1188
|
+
# Reuse the SAME Agent-spawn template as Step 4A's "Agent-spawn path (fallback when forgedock
|
|
1189
|
+
# CLI unavailable)" section above (the `Agent(subagent_type="general-purpose", ...)` block
|
|
1190
|
+
# under "Copy this template. Fill in variables. Do not modify the structure:") verbatim —
|
|
1191
|
+
# HARD RULE 1 preserved. This is a fresh dispatch, not a resume: capture its returned agent
|
|
1192
|
+
# ID into AGENT_ISSUE_MAP so subsequent completions for this issue route through 2a above.
|
|
1193
|
+
Agent(
|
|
1194
|
+
subagent_type="general-purpose",
|
|
1195
|
+
model="{SUBAGENT_MODEL}",
|
|
1196
|
+
description="Work on {PROJECT_PREFIX}#{NUMBER} (engine-error fallback)",
|
|
1197
|
+
run_in_background=true,
|
|
1198
|
+
prompt="<same template body as Step 4A's Agent-spawn path — see the 'Agent-spawn path (fallback when forgedock CLI unavailable)' section above; fill {NUMBER}/{GH_REPO}/{REPO_PATH}/{LANE}/{PR_BASE}/{ISSUE_TITLE} exactly as that template does>"
|
|
1199
|
+
)
|
|
1200
|
+
AGENT_ISSUE_MAP[{NUMBER}] = <agent_id returned by the Agent() call above>
|
|
1201
|
+
# Re-occupies the worker slot this completion just freed (see "Concurrency slot release" above).
|
|
1202
|
+
ACTIVE_DISPATCH_COUNT=$((ACTIVE_DISPATCH_COUNT + 1))
|
|
1203
|
+
elif [ "$ALREADY_FALLEN_BACK" -eq 0 ]; then
|
|
1204
|
+
echo "#{NUMBER}: engine-error with non-empty committed state (partial work exists) — NOT auto-falling back. Surfaces via standard stall-detection alert; forgedock resume-stalled remains available for manual/scripted recovery."
|
|
1205
|
+
fi
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
**Resume ALL stalled agents in a single message** (parallel resume/fallback across 2a and 2b together). Do not wait between resumes. Each resume or fallback dispatch re-occupies a worker slot — increment `ACTIVE_DISPATCH_COUNT` by 1 per agent resumed/dispatched here (it was already decremented by the "Concurrency slot release" rule above when the stall was first observed as a completion). <!-- Added: forge#1912 -->
|
|
1099
1209
|
|
|
1100
1210
|
3. **Track resume cycles per agent.** If an agent has been resumed 2+ times and still hasn't reached a terminal state, report it as a failure — do not resume again.
|
|
1101
1211
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|