sendPrewarm() issues the prewarm with signal: AbortSignal.timeout(...). On a timeout or network error fetch throws rather than returning a result, and nothing between that throw and start()'s .catch() handles it:
sendPrewarm() — await fetchImpl(...), no try/catch
prewarm() — await this.sendPrewarm(target), no try/catch
tick() — await this.prewarm(target, now) inside for (const target of this.targets.values()), no try/catch
start() — .catch() logs cachekeep tick failed
So one throwing target aborts the entire tick. Reproduced with an injected fetchImpl that throws for target A only, with A, B, C all tracked and due:
tick THREW: TimeoutError: The operation timed out.
prewarm attempts: ["A"] <- B and C never attempted
tick +1m attempts: ["A"]
tick +2m attempts: ["A"]
tick +3m attempts: ["A"]
It never recovers. The failing target's backoff assignment lives in the !result.ok branch, which a throw skips, so cacheExpiresAt is never advanced — A stays first-due and re-aborts the tick on every subsequent tick. B and C are starved until they age out as stale roughly two hours later, at which point cachekeep is silently doing nothing for them. publishTrackedSessions() at the end of tick() is also skipped, so the cross-process lease record stops being refreshed.
This is live, not theoretical — TimeoutError: The operation timed out. is what AbortSignal.timeout produces under Bun 1.3.14, and that exact string appears as cachekeep tick failed in my plugin log.
Worth noting the other two callers of this same throwing path both defend against it, which is what makes the tick loop look like an oversight rather than a decision:
warmFableAfterOpus wraps prewarmNow() in try/catch — packages/opencode/src/index.ts:1197-1225
PrimeManager.fire wraps its send in try/catch
One trap for whoever fixes it: prewarm() currently uses result.status == null to mean "unbuildable body, delete the tracked session". Mapping a thrown fetch onto that shape would make a transient network blip permanently drop a live session — worse than the current bug. The thrown case needs its own discriminator so it takes the backoff branch, not the delete branch.
I have a fix with tests ready and will open a PR shortly.
sendPrewarm()issues the prewarm withsignal: AbortSignal.timeout(...). On a timeout or network errorfetchthrows rather than returning a result, and nothing between that throw andstart()'s.catch()handles it:sendPrewarm()—await fetchImpl(...), no try/catchprewarm()—await this.sendPrewarm(target), no try/catchtick()—await this.prewarm(target, now)insidefor (const target of this.targets.values()), no try/catchstart()—.catch()logscachekeep tick failedSo one throwing target aborts the entire tick. Reproduced with an injected
fetchImplthat throws for target A only, with A, B, C all tracked and due:It never recovers. The failing target's backoff assignment lives in the
!result.okbranch, which a throw skips, socacheExpiresAtis never advanced — A stays first-due and re-aborts the tick on every subsequent tick. B and C are starved until they age out as stale roughly two hours later, at which point cachekeep is silently doing nothing for them.publishTrackedSessions()at the end oftick()is also skipped, so the cross-process lease record stops being refreshed.This is live, not theoretical —
TimeoutError: The operation timed out.is whatAbortSignal.timeoutproduces under Bun 1.3.14, and that exact string appears ascachekeep tick failedin my plugin log.Worth noting the other two callers of this same throwing path both defend against it, which is what makes the tick loop look like an oversight rather than a decision:
warmFableAfterOpuswrapsprewarmNow()in try/catch —packages/opencode/src/index.ts:1197-1225PrimeManager.firewraps its send in try/catchOne trap for whoever fixes it:
prewarm()currently usesresult.status == nullto mean "unbuildable body, delete the tracked session". Mapping a thrown fetch onto that shape would make a transient network blip permanently drop a live session — worse than the current bug. The thrown case needs its own discriminator so it takes the backoff branch, not the delete branch.I have a fix with tests ready and will open a PR shortly.