forgedock 1.5.1 → 1.7.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.
@@ -352,14 +352,39 @@ This step is the deterministic counterpart to fix #2 (atomic create-if-absent in
352
352
  Before building agent prompts, run `classify-lane.sh` for every issue in the current dispatch group to compute `{LANE}` and `{PR_BASE}` deterministically. The script output is authoritative — the LLM MUST NOT override or reason around it.
353
353
 
354
354
  ```bash
355
- # Requires classify-lane.sh to be available at ~/.claude/scripts/classify-lane.sh
356
- # (installed by `npx forgedock` — see bin/journey.mjs: forge()'s linkPipelineScripts() step)
357
- # NOTE: the actual invocations below (and in the review-finding sweep) hardcode this
358
- # ~/.claude path directly — there is no $FORGE_HOME-based fallback implemented, so this
359
- # call site is not exposed to the bare-unset-$FORGE_HOME → root-anchored-path footgun
360
- # that affected commands/review-pr.md (see forge#1984, forge#2035 audit). If
361
- # ~/.claude/scripts/ is genuinely unavailable, classify-lane.sh hard-fails per the
362
- # phantom-slug gate above — it does NOT fall through to a filesystem search.
355
+ # Resolve the helper once and reuse it for the initial dispatch and all finding
356
+ # classification loops. Claude keeps its installed path as the default, while
357
+ # OpenCode and Codex can use ForgeDock's repository-local scripts.
358
+ resolve_classify_lane() {
359
+ local candidates=()
360
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
361
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
362
+ [ -n "${OPENCODE_PID:-}" ] ||
363
+ [ -n "${OPENCODE:-}" ]; then
364
+ [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
365
+ [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
366
+ candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
367
+ else
368
+ [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
369
+ candidates+=("$HOME/.claude/scripts/classify-lane.sh")
370
+ [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
371
+ fi
372
+
373
+ local candidate
374
+ for candidate in "${candidates[@]}"; do
375
+ if [ -f "$candidate" ]; then
376
+ printf '%s\n' "$candidate"
377
+ return 0
378
+ fi
379
+ done
380
+ echo "ERROR: classify-lane.sh is not installed in any configured runtime path." >&2
381
+ return 1
382
+ }
383
+
384
+ CLASSIFY_LANE_SCRIPT=$(resolve_classify_lane) || {
385
+ echo "ERROR: cannot classify lanes without classify-lane.sh" >&2
386
+ exit 1
387
+ }
363
388
 
364
389
  declare -A ISSUE_LANE
365
390
  declare -A ISSUE_PR_BASE
@@ -498,7 +523,7 @@ SURFACE_BATCHED_FINDINGS=() # all member issue numbers absorbed into a batch a
498
523
  SURFACE_BATCH_COUNT=0 # count of batch issues created across the run
499
524
 
500
525
  for NUM in {ready_issue_numbers}; do
501
- PR_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$NUM" -R {GH_REPO}) || {
526
+ PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$NUM" -R {GH_REPO}) || {
502
527
  echo "ERROR: classify-lane.sh failed for #$NUM — adding needs-human label and skipping" >&2
503
528
  gh issue edit "$NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
504
529
  continue
@@ -521,13 +546,57 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
521
546
 
522
547
  **Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
523
548
 
549
+ **OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
550
+ an OpenCode runtime marker is present, do not invoke the engine's Claude-backed
551
+ runner and do not use the Claude `Agent(...)` fallback. Dispatch each ready
552
+ issue through OpenCode's native `task` tool, loading the nested
553
+ `commands/work-on.md` pipeline. Use GitHub labels and `FORGE:*` comments as the
554
+ resume state. After each task continuation, re-read the issue workflow label
555
+ and continue until it reaches
556
+ `workflow:merged`, `workflow:invalid`, `needs-human`, or
557
+ `workflow:awaiting-merge`.
558
+
559
+ ```bash
560
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ]; then
561
+ echo "OpenCode native task dispatch selected — load commands/work-on.md for each ready issue."
562
+ echo "Do not call forgedock run-issue or the Claude Agent(...) fallback in this branch."
563
+ echo "If native task dispatch cannot run, post FORGE:OPENCODE_BLOCKED with the missing capability and add needs-human."
564
+ fi
565
+ ```
566
+
567
+ This branch is additive. When OpenCode is not selected, continue through the
568
+ existing engine-first and Claude Agent-spawn paths below without modification.
569
+
524
570
  **CRITICAL — never background via shell `&`/`wait`** (fixed forge#2466): A single `forgedock run-issue` invocation drives an issue through investigate → build → review → close and routinely runs 30+ minutes. Backgrounding the process at the *shell* level (`cmd &` … `wait`) does not escape the Bash tool's own per-invocation ceiling — `wait` is itself the foreground command the tool watches, and it blocks for the combined duration of every process in the chunk. This made engine-first dispatch effectively dead code: any chunk running longer than the ceiling was always killed, so every run silently fell through to the Agent-spawn fallback below. The fix: dispatch each `forgedock run-issue` invocation as its **own `Bash` tool call with `run_in_background=true`** — the harness's native "start it, don't wait, notify me on completion" primitive — never with shell-level `&`/`wait`. This is exactly the same async model the Agent-spawn-fallback path already uses (and Step 4B's notification-driven completion loop already expects), so one monitoring loop now covers both dispatch styles.
525
571
 
526
572
  ```bash
573
+ if [ "${FORGE_RUNTIME:-}" != "opencode" ] &&
574
+ [ -z "${OPENCODE_SESSION_ID:-}" ] &&
575
+ [ -z "${OPENCODE_PID:-}" ] &&
576
+ [ -z "${OPENCODE:-}" ]; then
527
577
  # Engine-first dispatch: check CLI availability, then dispatch ready issues in score order
528
578
  # Uses SORTED_READY_SET[] from Step 3E.5 (descending value/cost) and budget gate from Step 4A-pre.0
529
579
  FORGEDOCK_AVAILABLE=$(command -v forgedock >/dev/null 2>&1 && echo "true" || echo "false")
530
580
 
581
+ # Backend preflight canary (forge#2743): `command -v forgedock` only proves the orchestrator CLI
582
+ # binary is on PATH — it says nothing about whether the engine's execution backend (the `claude`
583
+ # CLI spawn, or a working `ANTHROPIC_API_KEY`) can actually run a phase. Without this probe, an
584
+ # environmental failure (e.g. forge#2741 — `spawnSync claude` ENOENT despite a shell `command -v`
585
+ # probe reporting the binary present) commits the ENTIRE ready set to engine-first dispatch, every
586
+ # `forgedock run-issue` call fails within seconds, and nothing re-routes the batch — a human has
587
+ # to notice and manually re-dispatch via the Agent-spawn path below.
588
+ #
589
+ # Use the runner's tested backend policy rather than duplicating it as shell/Node prose. This
590
+ # reuses the production resolved-CLI path check (including transient-shim refresh), honors an
591
+ # explicit FORGEDOCK_BACKEND, and accepts API fallback only when ANTHROPIC_API_KEY is configured.
592
+ # It performs no paid model request.
593
+ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
594
+ if ! forgedock backend-check --quiet; then
595
+ echo "WARNING: forgedock CLI is on PATH, but no configured execution backend is locally usable. Downgrading to the Agent-spawn fallback path for this entire run — see forge#2743."
596
+ FORGEDOCK_AVAILABLE="false"
597
+ fi
598
+ fi
599
+
531
600
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
532
601
  # Build the budget-gated dispatch queue first (forge#1743's should_dispatch() still applies
533
602
  # per-issue, independent of the concurrency cap below).
@@ -598,6 +667,7 @@ else
598
667
  # Fall through to Agent-spawn template below. The SubagentStop hook (bin/hooks/interactive-engine.mjs)
599
668
  # bridges these runs to the engine run-log for state persistence even on the fallback path.
600
669
  fi
670
+ fi
601
671
  ```
602
672
 
603
673
  **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Issue one `Bash(...)` call per issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
@@ -849,7 +919,7 @@ If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle
849
919
 
850
920
  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
921
 
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 — a resume re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's stall-resume mechanism is `Agent(resume=...)`-specific and is unchanged by this fix — an engine-dispatched issue that stalls is not currently auto-resumed by Step 4B.5; it surfaces the same way any other stalled issue does, via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path outside this file's scope.)
922
+ **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
923
 
854
924
  **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
925
 
@@ -888,6 +958,10 @@ classify_predecessor_state() {
888
958
  # predecessor were still mid-pipeline — which is accurate, since the
889
959
  # stall-detection step below (item 2) auto-resumes/retries a completed
890
960
  # agent run that ends in workflow:engine-error, same as any other stall.
961
+ # Fixed forge#2743: item 2's sub-case 2b is what actually fulfills this for
962
+ # ENGINE_DISPATCH_MAP-tracked issues (fresh Agent-spawn fallback on empty
963
+ # committed state) — 2a's Agent(resume=...) alone cannot reach them, since
964
+ # they were never in AGENT_ISSUE_MAP.
891
965
  echo "IN_PROGRESS"
892
966
  elif [ "$PRED_STATE" = "CLOSED" ]; then
893
967
  # Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
@@ -1086,7 +1160,11 @@ done
1086
1160
  echo "#{NUM}: $FINAL_STATE"
1087
1161
  ```
1088
1162
 
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:
1163
+ 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.
1164
+
1165
+ **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:
1166
+
1167
+ **2a. Agent-spawn-dispatched issue (`AGENT_ISSUE_MAP[{NUMBER}]` set)** — unchanged from prior behavior. Resume the same agent in place:
1090
1168
  ```
1091
1169
  Agent(
1092
1170
  resume=AGENT_ISSUE_MAP[{NUMBER}],
@@ -1095,7 +1173,109 @@ done
1095
1173
  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
1174
  )
1097
1175
  ```
1098
- **Resume ALL stalled agents in a single message** (parallel resume). Do not wait between resumes. Each resume re-occupies a worker slot — increment `ACTIVE_DISPATCH_COUNT` by 1 per agent resumed here (it was already decremented by the "Concurrency slot release" rule above when the stall was first observed as a completion). <!-- Added: forge#1912 -->
1176
+
1177
+ **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:
1178
+ ```bash
1179
+ # Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
1180
+ # committed phases. Primary source: the `FORGE:STATE` HTML-comment block the engine already
1181
+ # mirrors onto the issue BODY on every phase transition (`bin/engine/state.mjs`/`projector.mjs`
1182
+ # — see phase-3-dependency.md "Engine mode (default)"), which carries `committed`/`branch`/`pr`
1183
+ # as real JSON — read it directly rather than screen-scraping terminal text:
1184
+ # NOTE (forge#2750 review fix): `bin/engine/state.mjs`'s `serializeState()` writes the block
1185
+ # as THREE separate lines — `<!-- FORGE:STATE`, the JSON payload, and `-->` — never all on one
1186
+ # 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 -->
1187
+ # (GNU `grep -P` without `-z` operates per-line, not across newlines, regardless of `[\s\S]`)
1188
+ # — it always returns empty. Extract the block with a `sed` line-range instead, which handles
1189
+ # the real multi-line shape correctly:
1190
+ STATE_BLOCK=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
1191
+ | sed -n '/<!-- FORGE:STATE/,/-->/p')
1192
+ STATE_JSON=$(echo "$STATE_BLOCK" | sed '1d;$d')
1193
+ if [ -n "$STATE_JSON" ]; then
1194
+ # Missing or schema-invalid fields are unverifiable, not empty. In particular, jq's
1195
+ # `length` is also zero for null, {}, and "", so require the committed array type first.
1196
+ # The persisted engine run ID and state version are also required: together they give every
1197
+ # orchestrator handling this same failure one stable scope while allowing a later failure
1198
+ # for the same issue/run name to elect a new claimant.
1199
+ if echo "$STATE_JSON" | jq -e \
1200
+ '(.run | type == "string" and test("^[A-Za-z0-9._:/-]+$")) and (.v | type == "number" and . >= 0 and floor == .) and (.committed | type == "array") and (.committed | length == 0) and (.branch == null) and (.pr == null)' \
1201
+ >/dev/null 2>&1; then
1202
+ STATE_RUN=$(echo "$STATE_JSON" | jq -r '.run')
1203
+ STATE_VERSION=$(echo "$STATE_JSON" | jq -r '.v')
1204
+ EMPTY_COMMITTED_STATE="true"
1205
+ else
1206
+ EMPTY_COMMITTED_STATE="false"
1207
+ fi
1208
+ else
1209
+ # No FORGE:STATE block was ever written to the issue body (e.g. the run failed before
1210
+ # phase 1 committed anything). There is no reliable structured signal available in this
1211
+ # case — fail SAFE: do NOT auto-fallback on an unverifiable state. This surfaces via the
1212
+ # existing stall-detection alert / needs-human path instead, exactly as before this fix.
1213
+ # (An earlier draft of this fix attempted a second fallback source — a
1214
+ # `committed=[] branch=null pr=null` diagnostic line assumed to be present in the
1215
+ # completed background Bash call's captured stdout — but no code in this file or
1216
+ # phase-3-dependency.md actually captures/populates that stdout into a named variable,
1217
+ # so that fallback was dead code reading an unset variable. Removed rather than wired up:
1218
+ # the FORGE:STATE block above is the engine's own durable, already-documented mirror of
1219
+ # this exact data and should always be present by the time a phase transition occurs;
1220
+ # treating its absence as "cannot verify, don't risk it" is the correct fail-safe.)
1221
+ EMPTY_COMMITTED_STATE="false"
1222
+ fi
1223
+ # A non-empty committed/branch/pr means real per-issue work exists that a fresh Agent-spawn
1224
+ # dispatch could collide with or duplicate — that case is NOT auto-fallen-back; it surfaces
1225
+ # via the existing stall-detection alert / needs-human path instead, exactly as before this fix.
1226
+
1227
+ if [ "$EMPTY_COMMITTED_STATE" = "true" ]; then
1228
+ # Atomic-enough cross-session claim: post first, then elect the lowest server-assigned
1229
+ # comment ID for this batch. Concurrent claimants may both post, but exactly one can win.
1230
+ # Pagination is mandatory; any POST/list/parse failure fails closed and dispatches nothing.
1231
+ CLAIM_SCOPE="${STATE_RUN}:${STATE_VERSION}"
1232
+ CLAIM_TOKEN="${CLAIM_SCOPE}-{NUMBER}-$$-${RANDOM}"
1233
+ ENGINE_FALLBACK_BODY="<!-- FORGE:ENGINE_FALLBACK -->
1234
+ ## Engine-First Dispatch Failed — Falling Back to Agent-Spawn
1235
+
1236
+ **Batch**: ${CLAIM_SCOPE}
1237
+ **Claim**: ${CLAIM_TOKEN}
1238
+
1239
+ \`forgedock run-issue\` ended at \`workflow:engine-error\` with no committed phases, branch, or PR
1240
+ (\`committed=[] branch=null pr=null\`) — this is an environmental/tool failure (forge#2261), not a
1241
+ per-issue content failure, and nothing was committed that a fresh dispatch could collide with.
1242
+ Auto-falling back to the Agent-spawn \`/work-on\` path for this issue. See forge#2743."
1243
+ CLAIM_ID=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --method POST \
1244
+ --raw-field body="$ENGINE_FALLBACK_BODY" --jq '.id' 2>/dev/null) || CLAIM_ID=""
1245
+ CLAIM_LIST_OK="true"
1246
+ CLAIM_IDS=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
1247
+ --jq ".[] | select(.body | split(\"\\n\") | (any(. == \"<!-- FORGE:ENGINE_FALLBACK -->\") and any(. == \" **Batch**: ${CLAIM_SCOPE}\"))) | .id" \
1248
+ 2>/dev/null) || CLAIM_LIST_OK="false"
1249
+ WINNER_CLAIM_ID=$(printf '%s\n' "$CLAIM_IDS" | grep -E '^[0-9]+$' | sort -n | head -1)
1250
+ CLAIM_WON="false"
1251
+ [ "$CLAIM_LIST_OK" = "true" ] && [ -n "$CLAIM_ID" ] && \
1252
+ [ "$CLAIM_ID" = "$WINNER_CLAIM_ID" ] && CLAIM_WON="true"
1253
+
1254
+ # Reuse the SAME Agent-spawn template as Step 4A's "Agent-spawn path (fallback when forgedock
1255
+ # CLI unavailable)" section above (the `Agent(subagent_type="general-purpose", ...)` block
1256
+ # under "Copy this template. Fill in variables. Do not modify the structure:") verbatim —
1257
+ # HARD RULE 1 preserved. This is a fresh dispatch, not a resume: capture its returned agent
1258
+ # ID into AGENT_ISSUE_MAP so subsequent completions for this issue route through 2a above.
1259
+ if [ "$CLAIM_WON" = "true" ]; then
1260
+ Agent(
1261
+ subagent_type="general-purpose",
1262
+ model="{SUBAGENT_MODEL}",
1263
+ description="Work on {PROJECT_PREFIX}#{NUMBER} (engine-error fallback)",
1264
+ run_in_background=true,
1265
+ 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>"
1266
+ )
1267
+ AGENT_ISSUE_MAP[{NUMBER}] = <agent_id returned by the Agent() call above>
1268
+ # Re-occupies the worker slot this completion just freed (see "Concurrency slot release" above).
1269
+ ACTIVE_DISPATCH_COUNT=$((ACTIVE_DISPATCH_COUNT + 1))
1270
+ else
1271
+ echo "#{NUMBER}: engine fallback claim lost or could not be verified — no duplicate Agent dispatch."
1272
+ fi
1273
+ else
1274
+ 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."
1275
+ fi
1276
+ ```
1277
+
1278
+ **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
1279
 
1100
1280
  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
1281
 
@@ -1391,17 +1571,9 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1391
1571
  RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1392
1572
  RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1393
1573
 
1394
- node -e '
1395
- import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ shouldReResolve }) => {
1396
- const classified = { kind: process.argv[1], pattern: process.argv[2], args: [] };
1397
- const config = {
1398
- enabled: process.argv[3] === "false" ? false : process.argv[3],
1399
- maxRounds: process.argv[4] === "unbounded" ? undefined : Number(process.argv[4]),
1400
- };
1401
- const rounds = Number(process.argv[5]);
1402
- console.log(JSON.stringify(shouldReResolve(classified, config, rounds)));
1403
- }, () => process.exit(0));
1404
- ' "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1574
+ node "{REPO_PATH}/bin/engine/orchestrate-canary.mjs" \
1575
+ "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
1576
+ "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1405
1577
  ```
1406
1578
 
1407
1579
  If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR` has reached `max_rounds` — bounded termination per the issue's acceptance criteria), skip this step for the current cycle and log the reason. `RERESOLVE_ROUNDS_SO_FAR` starts at 0 for the batch and increments once per cycle this step actually runs (declare it alongside the other Step 4A.pre batch-scope accumulators — do not re-initialize per completion).
@@ -1995,7 +2167,7 @@ Cascade-dispatched findings inherit NO lane assumption from the parent batch. St
1995
2167
 
1996
2168
  ```bash
1997
2169
  for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1998
- PR_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
2170
+ PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$FINDING_NUM" -R {GH_REPO}) || {
1999
2171
  echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human label and removing from QUEUED_FINDINGS" >&2
2000
2172
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
2001
2173
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
@@ -2345,7 +2517,7 @@ declare -A SWEEP_LANE
2345
2517
  declare -A SWEEP_PR_BASE
2346
2518
 
2347
2519
  for FINDING_NUM in "${SWEEP_EXECUTE[@]}"; do
2348
- SWEEP_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
2520
+ SWEEP_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$FINDING_NUM" -R {GH_REPO}) || {
2349
2521
  echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human and skipping" >&2
2350
2522
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
2351
2523
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
@@ -2526,4 +2698,3 @@ fi
2526
2698
  ```
2527
2699
 
2528
2700
  ---
2529
-
@@ -125,7 +125,16 @@ Append `-{NUMBER}` to ensure uniqueness: e.g. `fix/work-on-build-landing-file-85
125
125
  ### B1C: Create worktree
126
126
 
127
127
  ```bash
128
- WORKTREE_PATH="/path/to/repo/.claude/worktrees/{BRANCH_SLUG}"
128
+ WORKTREE_ROOT="/path/to/repo/.claude/worktrees"
129
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
130
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
131
+ [ -n "${OPENCODE_PID:-}" ] ||
132
+ [ -n "${OPENCODE:-}" ]; then
133
+ WORKTREE_ROOT="/path/to/repo/.opencode/worktrees"
134
+ elif [ "${FORGE_RUNTIME:-}" = "codex" ]; then
135
+ WORKTREE_ROOT="/path/to/repo/.codex/worktrees"
136
+ fi
137
+ WORKTREE_PATH="${WORKTREE_ROOT}/{BRANCH_SLUG}"
129
138
  git worktree add {WORKTREE_PATH} -b {BRANCH} origin/{SOURCE_BRANCH}
130
139
  ```
131
140
 
@@ -1411,7 +1411,16 @@ esac
1411
1411
  cd {REPO_PATH}
1412
1412
  git fetch origin
1413
1413
  BRANCH="fix/{slug}-{NUMBER}"
1414
- WORKTREE_PATH="{REPO_PATH}/.claude/worktrees/{BRANCH_SLUG}"
1414
+ WORKTREE_ROOT="{REPO_PATH}/.claude/worktrees"
1415
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
1416
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
1417
+ [ -n "${OPENCODE_PID:-}" ] ||
1418
+ [ -n "${OPENCODE:-}" ]; then
1419
+ WORKTREE_ROOT="{REPO_PATH}/.opencode/worktrees"
1420
+ elif [ "${FORGE_RUNTIME:-}" = "codex" ]; then
1421
+ WORKTREE_ROOT="{REPO_PATH}/.codex/worktrees"
1422
+ fi
1423
+ WORKTREE_PATH="${WORKTREE_ROOT}/{BRANCH_SLUG}"
1415
1424
  git worktree add {WORKTREE_PATH} -b {BRANCH} origin/{PR_BASE}
1416
1425
  ```
1417
1426