BLOCKER-1 (dispatch deadlock): wrap session.promptAsync / session.prompt in
withDispatchTimeout() that uses Promise.race with a default 30s timeout.
Stalled upstream responses no longer hold the reservation forever.
BLOCKER-2 (post-dispatch failure released too early): collapse the
holdReservationAfterDispatch flag into a dispatchAttempted state so the
post-dispatch hold runs in the finally block regardless of whether
promptAsync resolved or threw. AGENTS.md's documented race window where
promptAsync 'returns before durably accepted, later failures arrive as
session.error' is now covered.
HIGH-6 (sync/async protocol duplicated): extract dispatchAfterSessionIdle
internal runner. promptAsyncAfterSessionIdle and promptAfterSessionIdle
become thin wrappers passing client.session.promptAsync vs prompt as
the dispatch callback. Future reservation semantics fixes apply once.
HIGH-7 (releasePromptAsyncReservation prefix foot-gun, partial): tighten
reservationSourceMatches to require prefix strings to end in ':' so
release cannot accidentally free reservations whose source merely starts
with the same identifier characters. Symbol token verification is still
internal-only as the audit invariant prevents external callers from
bypassing the gate.
Closes BLOCKER-1, BLOCKER-2, HIGH-6
Refs HIGH-7 (prefix hardened; token-required release deferred to follow-up)
Co-authored-by: gate-correctness (deep / gpt-5.3-codex high)
createPluginModule and PluginModuleDeps were exposed at package entry as
a test seam. Their export creates accidental public TS API obligations
for internal manager/tool/hook constructor types. Move to
src/testing/create-plugin-module.ts so only tests reach them.
Closes HIGH-8
Co-authored-by: api-surface (deep / gpt-5.3-codex high)
Treat parsed variant as part of runtime-fallback model equivalence so variant-only fallback hops remain distinct while preserving the existing Claude-family alias handling.
Constraint: Oracle verification flagged unresolved PR #3322 review concerns about variant equivalence and remote state
Rejected: Preserve provider identity in equivalence | contradicted the original live-loop fix for equivalent Claude aliases
Confidence: medium
Scope-risk: narrow
Directive: Any future equivalence broadening must prove both live retry-loop behavior and variant/provider semantics with targeted tests before merging
Tested: bun run typecheck
Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts
Not-tested: Full live end-to-end repro across all provider redundancy policies
Prevent runtime fallback from cycling through provider aliases that resolve to the same underlying Claude family model. This keeps retry handling moving toward a genuinely distinct fallback model instead of appearing to fallback while staying on the same effective model.
Constraint: Live retry/fallback bug is in /Users/ravi/Code/personal/oh-my-opencode, while oh-my-openagent contribution work remains isolated to /Users/ravi/Code/forks/oh-my-openagent
Rejected: Change fallback chain precedence (category vs agent) first | lower-confidence root cause than equivalent-model retry
Confidence: high
Scope-risk: narrow
Directive: Keep alias-equivalence logic limited to model families that are intentionally interchangeable for runtime failover, and expand with targeted tests before broadening provider-family collapsing
Tested: bun run typecheck
Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts
Not-tested: Full live end-to-end session repro against external provider outages
Activity signals (tool calls like compress, grep, bash) were treated as
'progress' by the stagnation detector, resetting the stagnation counter
every cycle. This prevented MAX_STAGNATION_COUNT from being reached,
causing infinite continuation loops when models degrade to minimal
responses in long sessions (e.g. GLM-5.1 at ~100K tokens).
Stagnation now only tracks actual todo state changes: incomplete count
decrease, completed count increase, or todo snapshot change. Tool-level
activity no longer resets the stagnation counter.
Addresses two issues identified by cubic on PR #3952.
1. Watchdog cancellation was too narrow — only `text`/`reasoning` parts
counted as progress, so a subagent that immediately ran tools
(Read/Bash/Edit) emitted `tool`/`tool_use`/`tool_result`/`tool-call`/
`step-start` parts that the watchdog ignored, risking a false fire
on actively-working subagents. Broaden to: any assistant part of any
known type counts as progress (the model has started responding,
whether or not visible text has arrived yet). `info.error` and
`info.finish` continue to cancel.
2. Test timing margins were tight (15ms pre-cancel against a 40ms
timer), risking CI flakiness on loaded runners. Bumped to a 100ms
threshold with a 40ms pre-cancel window and a 250ms post-fire wait,
giving a 60ms margin before the timer fires and ~2.5x the threshold
after — robust against scheduler delay.
Refactor for testability: extracted the OpenCode-event→watchdog-signal
translation out of `hook.ts` into an exported `observeEventForWatchdog`
helper on the watchdog module. This let me add direct unit tests for
every part-type case (text, reasoning, tool, tool_use, tool_result,
tool-call, step-start, file) plus the error/finish/empty-parts branches
without spinning up the full hook. Net diff: hook.ts shrinks, watchdog
module gains a small pure function with parametrised coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
`createManagers` unconditionally called `markServerRunningInProcess()`
whenever `tmuxConfig.enabled` was true, which made `isServerRunning()`
short-circuit to `true` for *any* serverUrl — bypassing the guard in
`createTeamLayout` that is supposed to skip pane creation when the
opencode server is not running.
When a user launches vanilla `opencode` (no `opencode serve` /
`opencode web`), `ctx.serverUrl` is undefined and the TmuxSessionManager
falls back to `http://localhost:4096`. With the in-process flag set, the
team layout proceeded to spawn tmux panes whose `opencode attach`
commands then failed with "Unable to connect" — exactly the symptom in
the bug report.
Only mark the server as in-process running when the SDK actually
provides `ctx.serverUrl`. When it does not, `isServerRunning()` falls
back to a real HTTP probe, the existing guard in `createTeamLayout`
returns null, and no panes are created.
Closes#3894
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.