fix(runtime-fallback): add first-prompt watchdog for stuck subagents
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>
This commit is contained in:
committed by
YeonGyu-Kim
parent
bda0452b2a
commit
a130fa70d1
@@ -0,0 +1,132 @@
|
||||
import type { HookDeps, RuntimeFallbackTimeout } from "./types"
|
||||
import type { AutoRetryHelpers } from "./auto-retry"
|
||||
import { HOOK_NAME, DEFAULT_FIRST_PROMPT_WATCHDOG_MS } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { createFallbackState } from "./fallback-state"
|
||||
import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
|
||||
const SOURCE = "first-prompt-watchdog"
|
||||
|
||||
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
|
||||
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
|
||||
|
||||
export interface FirstPromptWatchdog {
|
||||
onUserMessage(sessionID: string, model?: string, agent?: string): void
|
||||
onAssistantProgress(sessionID: string): void
|
||||
onSessionTerminal(sessionID: string): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createFirstPromptWatchdog(
|
||||
deps: HookDeps,
|
||||
helpers: AutoRetryHelpers,
|
||||
watchdogMs: number = DEFAULT_FIRST_PROMPT_WATCHDOG_MS,
|
||||
): FirstPromptWatchdog {
|
||||
const timers = new Map<string, RuntimeFallbackTimeout>()
|
||||
const armed = new Set<string>()
|
||||
|
||||
const cancel = (sessionID: string): void => {
|
||||
const timer = timers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(sessionID)
|
||||
}
|
||||
armed.delete(sessionID)
|
||||
}
|
||||
|
||||
const fire = async (sessionID: string, model: string | undefined, agent: string | undefined): Promise<void> => {
|
||||
timers.delete(sessionID)
|
||||
armed.delete(sessionID)
|
||||
|
||||
if (!subagentSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: session no longer a subagent at fire time, skipping`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, deps.pluginConfig)
|
||||
|
||||
if (fallbackModels.length === 0) {
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: subagent silent past ${watchdogMs}ms with no fallback configured`, {
|
||||
sessionID,
|
||||
model,
|
||||
agent: resolvedAgent,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let state = deps.sessionStates.get(sessionID)
|
||||
if (!state) {
|
||||
const initialModel = resolveFallbackBootstrapModel({
|
||||
sessionID,
|
||||
source: SOURCE,
|
||||
eventModel: model,
|
||||
resolvedAgent,
|
||||
pluginConfig: deps.pluginConfig,
|
||||
})
|
||||
if (!initialModel) {
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: no model info available, cannot dispatch fallback`, { sessionID })
|
||||
return
|
||||
}
|
||||
state = createFallbackState(initialModel)
|
||||
deps.sessionStates.set(sessionID, state)
|
||||
deps.sessionLastAccess.set(sessionID, Date.now())
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: subagent silent past ${watchdogMs}ms, dispatching fallback`, {
|
||||
sessionID,
|
||||
model: state.currentModel,
|
||||
fallbackCount: fallbackModels.length,
|
||||
})
|
||||
|
||||
// Unlike the error-event path, the original request is still pending from
|
||||
// OpenCode's perspective when the watchdog fires. Forcefully end it so the
|
||||
// fallback prompt can take over cleanly. Network errors from abort are
|
||||
// logged inside abortSessionRequest and do not block fallback dispatch.
|
||||
await helpers.abortSessionRequest(sessionID, SOURCE)
|
||||
|
||||
await dispatchFallbackRetry(deps, helpers, {
|
||||
sessionID,
|
||||
state,
|
||||
fallbackModels,
|
||||
resolvedAgent,
|
||||
source: SOURCE,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
onUserMessage(sessionID, model, agent) {
|
||||
if (!sessionID) return
|
||||
if (!subagentSessions.has(sessionID)) return
|
||||
if (armed.has(sessionID)) return
|
||||
|
||||
armed.add(sessionID)
|
||||
const timer = setTimeout(async () => {
|
||||
await fire(sessionID, model, agent)
|
||||
}, watchdogMs)
|
||||
timers.set(sessionID, timer)
|
||||
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: armed for subagent`, { sessionID, model, agent, watchdogMs })
|
||||
},
|
||||
onAssistantProgress(sessionID) {
|
||||
if (!sessionID || !armed.has(sessionID)) return
|
||||
cancel(sessionID)
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: cancelled (assistant progress observed)`, { sessionID })
|
||||
},
|
||||
onSessionTerminal(sessionID) {
|
||||
if (!sessionID || !armed.has(sessionID)) return
|
||||
cancel(sessionID)
|
||||
log(`[${HOOK_NAME}] ${SOURCE}: cancelled (session terminal)`, { sessionID })
|
||||
},
|
||||
dispose() {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
armed.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user