Skip to content

fix(cowork): keep DSH startup warmup alive when the pinned session model is stale - #36

Merged
newfish merged 1 commit into
metaid-developers:mainfrom
WuFenG-Hub:fix/dsh-prewarm-stale-session-route
Sep 15, 2026
Merged

newfish merged 1 commit into
metaid-developers:mainfrom
WuFenG-Hub:fix/dsh-prewarm-stale-session-route

Conversation

@WuFenG-Hub

@WuFenG-Hub WuFenG-Hub commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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
prewarmDshRuntime bailed out at the very first step and logged:

[WARN] [prewarmDshRuntime] Warmup failed; first turn will cold-start
  error: Provider 'deepseek' does not offer enabled model 'deepseek-v4-flash-vision-exp'; provider selection is required.

Observed in the app's cowork.log at 2026-09-13T02:44:45.521Z (exactly the
0.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-start entries in the log). It is not a one-off: any user who has ever
pinned a session, whose provider later removed the model, hits this on every
start.

Root cause

runDshRuntimeWarmup() (v0.9.0 tree, commit 62577cb1):

  • src/main/libs/coworkRunner.ts:7460const recent = this.store.listSessions?.()?.[0];
  • src/main/libs/coworkRunner.ts:7461const sessionRoute = recent?.id ? this.resolveSessionDshRoute(recent.id) : null; ← throws here
  • src/main/libs/coworkRunner.ts:7462-7463 — the default-route fallback that was never reached
  • src/main/libs/coworkRunner.ts:7493 — the outer catch that turns any throw into the WARN above

Three facts compose the bug:

  1. The warmed row is always a PINNED session. coworkStore.listSessions()
    (src/main/coworkStore.ts:3444) orders its result with
    ORDER 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-activity
    pinned session, not the most recent one.

  2. That row's STORED model/provider is re-resolved, and resolution can
    throw.
    resolveSessionDshRoute() reads session.model /
    session.modelProvider off the row and calls
    resolveDshProviderRoute(model, providerHint, { requireProviderDisambiguation: true }).
    In resolveMatchedProvider the provider-hint branch returns the
    Provider '<p>' does not offer enabled model '<m>'; provider selection is required.
    error (src/main/libs/claudeSettings.ts:237) when the hint provider is
    enabled but no longer offers that model id; resolveDshProviderRoute
    rethrows it as ModelProviderSelectionError
    (src/main/libs/claudeSettings.ts:568, class at :120).

  3. Nothing catches it inside the warmup. The throw from step 2 escapes
    straight to the outer try/catch, so defaultRoute is never consulted —
    even though the app-global default route (resolveDshProviderRoute() with
    no 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-be5721dc082b carried
model='deepseek-v4-flash-vision-exp' + model_provider='deepseek', and a full
local 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-gone scnet
provider, …).

Note: the runtime itself is not at fault here. hub.prewarm() only ever saw a
fully-materialized route; the ... provider selection is required. text is
produced exclusively in claudeSettings.resolveMatchedProvider and thrown at
route-resolution time — git grep -n "does not offer enabled model" 62577cb1
over the whole tree returns exactly one hit,
src/main/libs/claudeSettings.ts:237.

Fix

Isolate the pinned-session resolution in runDshRuntimeWarmup() so a dead
session route degrades to the default route instead of voiding the warmup:

let sessionRoute: ReturnType<typeof resolveDshProviderRoute> = null;
if (recent?.id) {
  try {
    sessionRoute = this.resolveSessionDshRoute(recent.id);
  } catch (error) {
    coworkLog('WARN', 'prewarmDshRuntime', 'Pinned session route did not resolve; warming the default route', {
      sessionId: recent.id,
      error: error instanceof Error ? error.message : String(error),
    });
  }
}
const defaultRoute = resolveDshProviderRoute();
const route = (sessionRoute?.baseUrl && sessionRoute.apiKey) ? sessionRoute : defaultRoute;

Behaviour is unchanged whenever the session route resolves — the session route
still takes precedence (sessionRoute is preferred over defaultRoute), so the
existing "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 (resolveSessionDshRoute callers in
runDshSessionLocal) is deliberately untouched.

Verification

New regression test:
tests/coworkDshWarmupStaleSessionRoute.test.mjs (whitelisted in .gitignore
per repo convention). It drives the real CoworkRunner.runDshRuntimeWarmup
with 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.setStoreGetter hook — no dsh-runtime spawn needed.

Red on the baseline (src/main/libs/coworkRunner.ts reverted to 62577cb1):

$ node --test tests/coworkDshWarmupStaleSessionRoute.test.mjs
✖ prewarm falls back to the default route when the pinned session model was removed by its provider
  AssertionError [ERR_ASSERTION]: the warmup must still spawn the runtime
  0 !== 1
ℹ tests 3 / pass 2 / fail 1
EXIT=1

with the matching cowork.log entry proving the mechanism:

[WARN] [prewarmDshRuntime] Warmup failed; first turn will cold-start
  error: Provider 'gw-stale' does not offer enabled model 'stale-model-1'; provider selection is required.

Green after the fix:

$ node --test tests/coworkDshWarmupStaleSessionRoute.test.mjs
✔ prewarm falls back to the default route when the pinned session model was removed by its provider
✔ prewarm still prefers the pinned session route when that route resolves
✔ prewarm uses the default route when there is no session to warm
ℹ tests 3 / pass 3 / fail 0
EXIT=0

with:

[WARN] [prewarmDshRuntime] Pinned session route did not resolve; warming the default route
  sessionId: prewarm-stale-pinned-session
  error: Provider 'gw-stale' does not offer enabled model 'stale-model-1'; provider selection is required.
[INFO] [prewarmDshRuntime] DSH runtime ready
  provider: gw-default

The test also carries a positive control (prewarm still prefers the pinned session route when that route resolvesgw-sess / sess-model-1, which passes
both 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):

command result
node --test tests/coworkDshWarmup.test.mjs 3 pass / 0 fail, exit 0
node --test tests/coworkDshFallbackRoute.test.mjs tests/claudeSettingsModelResolution.test.mjs tests/coworkKernelRouting.test.mjs tests/coworkMetabotLlmRouting.test.mjs 33 pass / 0 fail, exit 0
node --test tests/runtimeDependencyContract.test.mjs tests/coworkDshModelSwitch.test.mjs 23 pass / 0 fail, exit 0
npm run compile:electron (tsc --project electron-tsconfig.json) exit 0

Compiler self-control: the same tsc rejects a deliberate type error with
TS2322 and 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_provider are written when a user picks a model in the composer and are
never reconciled against the provider catalog afterwards;
src/main/services/llmBrainMigration.ts migrates only bot brain ids
(metabots.llm_id), not session rows. This patch makes the warmup resilient to
that, 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.

…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.
@newfish
newfish merged commit a0af845 into metaid-developers:main Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants