fix(cowork): keep DSH startup warmup alive when the pinned session model is stale - #36
Merged
newfish merged 1 commit intoSep 15, 2026
Conversation
…del is stale
runDshRuntimeWarmup() warmed the route of listSessions()[0] — and per
coworkStore's `ORDER BY s.pinned DESC, activity_at DESC, ...` that row is
always a PINNED session. resolveSessionDshRoute() re-resolves that row's
STORED model/model_provider through resolveDshProviderRoute(...,
{ requireProviderDisambiguation: true }), which throws a
ModelProviderSelectionError once the provider has dropped that model id
(or the provider itself is disabled). The throw escaped before the
`route = sessionRoute ?? defaultRoute` fallback, so the whole warmup
degraded to WARN "Warmup failed; first turn will cold-start" even with a
healthy app-global default route — every startup cold-started.
Resolve the pinned session route inside its own try/catch: a dead session
route now degrades to the default route and is logged, while a resolvable
session route still wins precedence exactly as before.
Adds tests/coworkDshWarmupStaleSessionRoute.test.mjs: red on the baseline
(0 prewarm calls), green after the fix; a positive-control test pins the
session-route precedence, and a no-session test pins the default route.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
On every app start, the DSH runtime warmup was voided and the first cowork turn
paid the full cold start (process boot + plugin load + session/ensure), because
prewarmDshRuntimebailed out at the very first step and logged:Observed in the app's
cowork.logat2026-09-13T02:44:45.521Z(exactly the0.9.0 startup moment); the same model-id error was logged on 09-11 / 09-12 /
09-13 as 1 / 11 / 1 occurrences (13 total
Warmup failed; first turn will cold-startentries in the log). It is not a one-off: any user who has everpinned a session, whose provider later removed the model, hits this on every
start.
Root cause
runDshRuntimeWarmup()(v0.9.0 tree, commit62577cb1):src/main/libs/coworkRunner.ts:7460—const recent = this.store.listSessions?.()?.[0];src/main/libs/coworkRunner.ts:7461—const sessionRoute = recent?.id ? this.resolveSessionDshRoute(recent.id) : null;← throws heresrc/main/libs/coworkRunner.ts:7462-7463— the default-route fallback that was never reachedsrc/main/libs/coworkRunner.ts:7493— the outer catch that turns any throw into the WARN aboveThree facts compose the bug:
The warmed row is always a PINNED session.
coworkStore.listSessions()(
src/main/coworkStore.ts:3444) orders its result withORDER BY s.pinned DESC, activity_at DESC, s.updated_at DESC, s.created_at DESC, s.id DESC(
src/main/coworkStore.ts:3516), so index[0]is the highest-activitypinned session, not the most recent one.
That row's STORED model/provider is re-resolved, and resolution can
throw.
resolveSessionDshRoute()readssession.model/session.modelProvideroff the row and callsresolveDshProviderRoute(model, providerHint, { requireProviderDisambiguation: true }).In
resolveMatchedProviderthe provider-hint branch returns theProvider '<p>' does not offer enabled model '<m>'; provider selection is required.error (
src/main/libs/claudeSettings.ts:237) when the hint provider isenabled but no longer offers that model id;
resolveDshProviderRouterethrows it as
ModelProviderSelectionError(
src/main/libs/claudeSettings.ts:568, class at:120).Nothing catches it inside the warmup. The throw from step 2 escapes
straight to the outer
try/catch, sodefaultRouteis never consulted —even though the app-global default route (
resolveDshProviderRoute()withno override) was perfectly healthy.
Net effect: one stale session row nullifies the warmup for every app start.
The data side is real and reproducible on this machine: the pinned row
cowork_sessions.id=349112f7-75f8-463d-82c6-be5721dc082bcarriedmodel='deepseek-v4-flash-vision-exp'+model_provider='deepseek', and a fulllocal census on the reporting host found 43 more sessions holding removed/unknown model ids (machine-local measurement; reviewers cannot recompute it from the log alone)
(
deepseek-v4-flash,deepseek-v4.1-flash-expires-on-0910,deepseek/deepseek-v4-flash-vision-exp, models of the now-gonescnetprovider, …).
Note: the runtime itself is not at fault here.
hub.prewarm()only ever saw afully-materialized route; the
... provider selection is required.text isproduced exclusively in
claudeSettings.resolveMatchedProviderand thrown atroute-resolution time —
git grep -n "does not offer enabled model" 62577cb1over the whole tree returns exactly one hit,
src/main/libs/claudeSettings.ts:237.Fix
Isolate the pinned-session resolution in
runDshRuntimeWarmup()so a deadsession route degrades to the default route instead of voiding the warmup:
Behaviour is unchanged whenever the session route resolves — the session route
still takes precedence (
sessionRouteis preferred overdefaultRoute), so theexisting "session routing wins" intent is preserved. Only the failure mode
changes: previously the whole warmup died, now it logs and warms the default
route. The turn path (
resolveSessionDshRoutecallers inrunDshSessionLocal) is deliberately untouched.Verification
New regression test:
tests/coworkDshWarmupStaleSessionRoute.test.mjs(whitelisted in.gitignoreper repo convention). It drives the real
CoworkRunner.runDshRuntimeWarmupwith a fake store (a pinned session whose stored model was removed from its
provider catalog) and a fake DSH hub, and installs the provider catalog through
the real
claudeSettings.setStoreGetterhook — no dsh-runtime spawn needed.Red on the baseline (
src/main/libs/coworkRunner.tsreverted to62577cb1):with the matching cowork.log entry proving the mechanism:
Green after the fix:
with:
The test also carries a positive control (
prewarm still prefers the pinned session route when that route resolves→gw-sess / sess-model-1, which passesboth before and after the fix, guarding session-route precedence) and a
default-route case for the no-session path.
Existing tests (all on the same build):
node --test tests/coworkDshWarmup.test.mjsnode --test tests/coworkDshFallbackRoute.test.mjs tests/claudeSettingsModelResolution.test.mjs tests/coworkKernelRouting.test.mjs tests/coworkMetabotLlmRouting.test.mjsnode --test tests/runtimeDependencyContract.test.mjs tests/coworkDshModelSwitch.test.mjsnpm run compile:electron(tsc --project electron-tsconfig.json)Compiler self-control: the same
tscrejects a deliberate type error withTS2322and exit code 2, so the type-check gate is not idling.Follow-up (not in this patch)
There is no migration for session-level stored model ids.
cowork_sessions.model/
model_providerare written when a user picks a model in the composer and arenever reconciled against the provider catalog afterwards;
src/main/services/llmBrainMigration.tsmigrates only bot brain ids(
metabots.llm_id), not session rows. This patch makes the warmup resilient tothat, but the stale session rows themselves remain — a session turn started from
such a row still resolves through the same throwing path. A proper follow-up is
either a one-shot migration that clears/repoints session-level model ids whose
provider no longer offers them, or dropping the stale override at the session
read boundary. Data repair is intentionally out of scope here.