When a subagent is dispatched to a provider and the underlying SDK
enters a silent internal retry loop on a 429/quota error, no error
event is ever emitted back to OpenCode. The runtime-fallback hook —
which is fully reactive (listens to message.updated/session.error/
session.status) — has nothing to react to and never dispatches the
configured fallback. The subagent sits in `retry` status until the
parent's 30-minute poll timeout (DEFAULT_POLL_TIMEOUT_MS) gives up,
during which the parent's pending task tool call shows "waiting for
subagent" with no indication of failure.
This change adds a first-prompt watchdog that synthesises the missing
error-event trigger:
- Armed when a user message lands in a subagent session
(membership check via `subagentSessions`).
- Cancelled on the first sign of progress: any assistant message
with text/reasoning content, finish field, or an error field (any
of which is something the existing handlers will deal with).
- Cancelled on session terminal events (idle/stop/deleted/error).
- On fire (90s default): aborts the in-flight request and routes
into the existing dispatchFallbackRetry path — the same code that
runs when a session.error arrives. No new fallback mechanism.
Design choices:
- Dispatch fallback, do not abort the subagent outright. Network
loss looks identical to a stuck retry from the hook's vantage
point; with fallback-dispatch behaviour, network loss degrades
to today's baseline (both attempts fail, 30-min outer timeout
still ends things) rather than destructively aborting work.
- Scope strictly to subagents. Parent/user sessions can legitimately
take 90s+ to produce the first token; subagent dispatches in
practice produce first content much faster, so a 90s ceiling is
safe.
- Threshold is tunable via the third arg to createFirstPromptWatchdog;
DEFAULT_FIRST_PROMPT_WATCHDOG_MS = 90_000 in constants.ts.
Also adds a diagnostic log in session-status-handler when a
`session.status: retry` event arrives whose message does not match
RETRYABLE_ERROR_PATTERNS. This is the hook's other silent-return
spot for retry events; logging the raw retry message will let us
extend the patterns next time we hit a provider whose phrasing
we don't yet match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SessionStart hooks fire on every user prompt instead of only at session
start. The root cause is clearSessionHookState(), called on every
session.idle event, which clears sessionFirstMessageProcessed. This
resets the isFirstMessage guard, making it always return true, so
SessionStart hooks execute on every prompt.
sessionFirstMessageProcessed is session-level state (tracks whether the
first message has been processed) and should only be cleared in
clearAllSessionHookState() on session deletion/disposal, not on idle.
sessionErrorState and sessionInterruptState remain cleared on idle since
they are per-response transient state.
Both injectors call resolveRegisteredAgentName, which returns the
registered alias verbatim. OpenCode TUI registers agent names with
leading zero-width characters (U+200B) for sort ordering, so that
alias can be e.g. "\u200B\u200BAtlas - Plan Executor". Passing it
directly to promptAsync produces "Agent not found" because the
OpenCode SDK does an exact match against its canonical display
name registry.
Strip the ZWSP sort prefix on the resolved name before sending it
to promptAsync in:
- src/hooks/atlas/boulder-continuation-injector.ts
- src/hooks/todo-continuation-enforcer/continuation-injection.ts
Add regression tests asserting promptAsync receives the canonical
display name (no \u200B) even when the registered alias carries
a ZWSP sort prefix. Same root cause class as #3494 / #3547. Tests
were RED on dev before the fix and GREEN after.
processingErrors was emptied in a finally block, so a second
session.error fired for the same assistant message id after the
first recovery resolved would re-run abort, history fetch, the
recovery toast, and any auto-resume promptAsync (resumeSession),
producing duplicate internal prompt injections during stale event
re-emission or polling-driven retries.
Drop the in-flight delete and keep the dedupe permanent for the
plugin lifetime. A genuinely new failure starts a new assistant
message with a different id, so this never blocks future legitimate
errors. Same-id duplicates collapse into a single recovery attempt.
Add hook.test.ts asserting that two sequential handleSessionRecovery
calls for the same recoverable info trigger session.abort, the
recovery toast, and any internal promptAsync at most once.
Continuation injector previously called normalizeAgentForPromptKey,
which collapsed agent inputs like "Sisyphus - Ultraworker" or
"sisyphus" down to the lowercase config key (e.g. "sisyphus").
OpenCode's promptAsync rejects that with "Agent not found", so the
ralph-loop continue prompt silently failed to dispatch on parent
sessions whose inherited message used a known display name.
Switch to normalizeAgentForPrompt and add a small wrapper that:
- preserves any agent string already in canonical " - " display form
verbatim (covers ZWSP-prefixed inheritance and user-defined custom
agents)
- otherwise normalizes config keys / legacy parenthesized names to the
registered display name OpenCode expects.
Update the existing regression tests so that ZWSP-prefixed and clean
inherited agents both assert the registered display name reaches
promptAsync.
Keep prompt reservations briefly after successful dispatch so rapid idle/message/error transitions cannot inject the same follow-up twice.
Route all production session prompt calls through the shared gate, restore skipped background resume state, release holds after abort/recovery paths, and preserve Ralph/ULW loop state when a dispatch is deferred.
Add regression coverage for session routing, static prompt route auditing, team-mode live messaging, model suggestion retries, call-omo-agent reuse, background parent wakes, runtime fallback, compaction recovery, Atlas, and Ralph/ULW loops.
Guard the tool-pair validator when transformed assistant messages belong to a tracked subagent session, while keeping normal main-session orphaned tool_use repair intact.
Constraint: code-yeongyu/oh-my-openagent#3996 reports background Oracle sessions hanging after synthetic placeholder tool_result insertion.
Rejected: disable tool-pair-validator globally | would regress main-session compaction/orphaned tool_use repair.
Confidence: high
Scope-risk: narrow
Directive: Keep subagent skip coverage and normal repair coverage together when changing tool-pair validation.
Tested: bun test src/hooks/tool-pair-validator/hook.test.ts src/plugin/messages-transform.test.ts
Tested: bun run typecheck
Not-tested: live Oracle background task e2e; no remote push or PR comment performed.
When runtime-fallback aborts an in-flight request to swap in a fallback
model, opencode emits session.error{isAbort:true} as a consequence. The
existing event handler treated that as a user cancellation and called
resetRetryState — wiping attemptCount. Every subsequent provider
auto-retry signal then started over at attempt:1, never reaching
max_fallback_attempts, producing an infinite retry loop firing a new
fallback every ~2 seconds.
The bug only surfaces when the configured fallback target itself
silently fails (e.g. github-copilot quota exhausted): the original
model keeps re-emitting retry signals, our handler keeps "fixing"
them, the counter never advances. Reproducible on upstream/dev HEAD
(5ffbe0e24e).
Fix:
- New `internallyAbortedSessions: Set<string>` on HookDeps tracks
sessions whose abort we triggered ourselves.
- abortSessionRequest in auto-retry.ts adds the session to the set
when called with one of our internal sources:
"session.status.retry-signal", "message.updated.retry-signal",
"session.timeout". The "session.stop" source (user-initiated) is
intentionally NOT marked — that path must still wipe state.
- handleSessionError in event-handler.ts checks the set before the
cancellation branch. If the session is marked, consume the flag
(delete it so a later user-abort still gets the reset) and skip
resetRetryState. The state's attemptCount is preserved, so the
next iteration progresses 1→2→3→... until max_fallback_attempts.
- dispose() clears the new set alongside the other per-session maps.
Tests: 3 new event-handler integration tests cover the fix
(internal-abort preserves state, external-abort still resets,
consecutive internal-abort cycles advance attemptCount). Existing
tests pass: 7/7 on event-handler. Pre-existing 2 dispose-test flakes
on the full runtime-fallback suite were verified to exist on
upstream/dev without this patch — unrelated.
bun run build: pass. bun run typecheck: pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevent delayed loop-start message counts from overwriting active Ralph Loop state after the loop has already advanced, so ULW completion can still enter Oracle verification instead of iterating forever.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>