Skip to content

Releases: chimneyQ/dsh-model-rotator

v0.2.14 — unresolvable pinned head reported as ignored

Choose a tag to compare

@chimneyQ chimneyQ released this 18 Oct 16:01

0.2.14

An unresolvable pinned head is now reported as ignored.

Ordering only promotes the pinned pair when it is actually among the candidates and not cooling down, but the status line printed the raw string either way — so pinned head: nonexistent/nope read as if it were in effect. It now says (not among the N candidates - ignored) or (cooling down - ignored for now).

Docs (added after release, commit 28a5953). The README carried hardcoded assertion counts that had drifted from 34/44 to 35/102. Removed rather than refreshed: a number in prose rots silently, and the suite itself is the authority.

Suites: offline 194, client 102, settings 35, pin-cooling, release gate 23.

v0.2.13 — the benchmark no longer crashes when a probed model fails

Choose a tag to compare

@chimneyQ chimneyQ released this 18 Oct 13:07

0.2.13 - the benchmark no longer crashes when a probed model fails

model_rotator_benchmark probes candidates and reports how each one behaved. Its failure branch read a variable named failedBecause - the name the failover walk uses for its failure text - while the benchmark's own text lives in failure. So probing any unhealthy candidate threw:

ReferenceError: failedBecause is not defined

...instead of reporting the failure. That is exactly the case the tool exists to measure: you reach for the benchmark because something is failing.

Why it survived 192 green assertions. The suite asserted the tool was registered and never once called it. Registration is not execution. 6b/6c now call it for real against a failing candidate and require both the summary and the recorded FAIL; reverting the fix fails them immediately (verified by restoring the bug and watching test-offline.mjs throw at the benchmark call).

Also verified in this release (no code change, now confirmed on the live host):

  • The window guard acts on real traffic: 61 recorded switches since it went live, all routed to a 1M-window route, zero to the 262k route that caused the original deadlock.
  • model_rotator_status reports the live pressure and held-back count; the settings page shows windowGuard as page-owned.

Tests. 194 offline · 102 client · 35 settings · 23 release gate.

Full detail: CHANGELOG.md

v0.2.12 — a route too small for this conversation is not a failover candidate

Choose a tag to compare

@chimneyQ chimneyQ released this 18 Oct 09:49

0.2.12 - a failover no longer moves a conversation onto a route too small to hold it

A model can be perfectly healthy and still be the wrong place to continue this conversation: the request overflows that route's context window, the turn dies, and the compaction that could have shrunk the conversation cannot run either — the summary is requested on the same route.

Live case. A conversation of roughly 450k tokens was running on a 1M-window route and was switched to a route whose window is 262k. From then on every turn ended on MAX_TOKENS with a one-token reply, and eight consecutive compactions failed with "summarization truncated at the token cap (incomplete checkpoint)". The conversation was unrecoverable.

The fix. The ranking now drops candidates that cannot hold the conversation as it stands:

  • Pressure comes from the host's token meter, measured on the conversation the turn is actually serving.
  • Window comes from the adapter that owns the route (ctx.llm.resolveModelInfo()). A profile declaration is not a dispatch — the profile on this machine declares 262,144 for that route, and the provider accepted a 448k request, which is exactly why the buyer is the authority and a stated catalog value is not.
  • A candidate is held back only when it is smaller than pressure + 32,768.

Nothing is rejected on a number the plugin does not have: no pressure reading means no guard, an unpublished window means no opinion, and a route on the same window as the current one is exempt — the guard decides about moving, not about staying.

What you see

  • The model list marks a held-back candidate with reason window (settings page, both languages): "context window too small for this conversation (usable in a fresh one)" — a verdict about this conversation, not about the model.
  • model_rotator_status prints the guard, the pressure it acted on, and how many routes it is holding back.
  • New setting windowGuard (default on, toggle on the settings page). Turn it off and every discovered model is a candidate again, whatever its size.

Tests. 192 offline assertions (21a–21g are the new guard cases, including the "guard off", "no pressure reading" and "unpublished window" controls), 35 settings assertions, 102 client assertions, 23 release-gate assertions. Three negative controls each failed exactly the assertion that describes them.

Full detail: CHANGELOG.md

v0.2.11 - the switch tab reads the conversation the Host injects

Choose a tag to compare

@chimneyQ chimneyQ released this 18 Oct 09:24

0.2.11

  • Fixed: the Model switches tab could not tell which conversation it was in. Both 0.2.9 and 0.2.10 read the id as sessions.list.getSnapshot().current - a field that store never carried (it exposes ids / byId / phase / projectionsBySession; current belongs to the uiSession service instead). The tab now declares the Host's own inject contract, inject: (sessionId) => ({ sessionId }), and reads props.sessionId - the same thing the built-in 对话 and 轨迹 tabs do; the service read is kept only as a best-effort fallback for a Host that injects nothing. Until this, the tab always degraded to the unfiltered log, which is what the reader saw: an empty-state line next to the "conversation not identified" banner.
  • 185 offline assertions, 102 client assertions (6j-6l pin the inject contract and prove an injected id outranks a service-reported one).

v0.2.10 - a switch record is filed under the conversation that caused it

Choose a tag to compare

@chimneyQ chimneyQ released this 18 Oct 09:14

0.2.10

  • Fixed: a switch was recorded against the wrong conversation. The 0.2.9 tab files records per conversation, but the owning session was read at the turn boundary (agent/turn-stopping) while the pending queue is process-wide. A conversation whose turn never stops therefore left its switch in that queue, and the next conversation to stop a turn drained the batch and stamped it with its own name. Reproduced live: conversation A's switch was filed under conversation B, so A's own tab read "no model switches in this conversation" - history that looks empty. The record is now written where the switch happens: the llm/stream candidate branch resolves the initiating agent's session through ctx.get('agents').currentInitiator() (the same seam adoptServedModel already trusts) and appends at/from/to/purpose/reason with it. The turn boundary now only decides whether to also post the in-conversation row.
  • Fixed: a switch with no resolvable session is no longer lost. A plugin route, or a host without agents, has no live turn: the record is still written with an empty session (visible in the every-session view only), without throwing or warning.
  • Fixed: the tab no longer shows "nothing here" over a non-empty log. When the requested conversation returns zero rows but the log total is not zero, the tab falls back to the full history and says so (new switches.fallback copy) instead of claiming there were no switches.
  • Deliberately unchanged: the log is still in-memory (lost on restart), subagent turns still write no root record, and the every-session view still does no per-conversation isolation.
  • 185 offline assertions (scenario 20 rewritten as 20a-20m, adding the cross-talk regression and the no-live-turn case); 99 client assertions.

v0.2.9 — the switch record moves to its own tab; a dead end stops asking

Choose a tag to compare

@chimneyQ chimneyQ released this 17 Oct 19:40

Two complaints, one cause.

The switch record existed only as a notice row steered into the conversation: it spent context on a plumbing fact the reader may not have been looking at, and turning the row off left no record at all. And a quota wall still raised the "switch model?" card first, although the balance was gone for the day — the only possible answer was to switch, so the card bought a modal and a 10 second wait and nothing else.

The record moved to its own tab

A new 模型切换 / Model switches page, beside 对话 / 轨迹 / 上下文, lists one row per switch: when, from which model to which, and why. It reads GET /_dsh/model-rotator/switches?session=<id>, so nothing is added to the conversation.

Records are filed per conversation. The host hands no session to a plugin route, so the session is bound at agent/turn-stopping — the one moment that both sees the switch and knows whose conversation it was.

  • The in-conversation row is now opt-in (noticeSwitch defaults to off).
  • The queue is consumed by the root conversation only; consuming it for a subagent is how a root conversation's switch went unannounced.

A deterministic dead end stops asking

quota and unsupported are settled before a retry could matter — and now before a question could either: declining cannot keep the model working. They switch on their own.

Auth deliberately keeps its question: a 403 is often a burst-rate proxy that clears, so "keep my model and let me retry" is a real choice there.

On the tests

The consent-gate suite had to be rebuilt around the new rule, and that is the interesting part: every one of those assertions used a quota message to trigger the gate, so all fourteen would have passed for the wrong reason once quota stopped asking. They now fail transiently (ECONNRESET), which isolates the purpose exclusion from the dead-end exclusion — and auth is the negative control: remove the gate outright and the auth assertion stops asking too.

The client half found a defect on the way worth naming: react/jsx-runtime reads only props.children, so the jsx(type, props, children) third argument it first used would have rendered an empty tab in the real host, silently. Only driving the component caught it.

test-offline 169 → 179, test-client 82 → 95.

v0.2.8 — the switch notice named the wrong failure reason

Choose a tag to compare

@chimneyQ chimneyQ released this 17 Oct 17:13

The switch notice says "A → B", so its reason has to explain why A went away. It was read from the last failure the walk saw — which, by the time a candidate answers, belongs to whichever candidate failed immediately before the winner.

With one dead model those are the same string, so the bug stayed invisible. With two they differ, and the notice blames the wrong route.

Observed live on 2026-10-18: 魔塔 was out of balance (429 insufficient balance), the channel tried after it had a socket reset, nvidia answered — and the notice told the reader the switch was due to (network). A balance wall presented as a network blip is the kind of wrong that sends the next hour in the wrong direction.

The reason now comes from the starting model's own failure, recorded once. The fallback to the last failure stays for the case where the starting model never reported one (a host refusal, an aborted turn), so the notice cannot go empty.

The fixture fails twice before the third channel succeeds, with the two dead reasons deliberately different — a one-dead-model fixture cannot tell the two sources apart. The negative control reverts to the old source and fails exactly that assertion, reproducing the (network) wording from the real incident.

Offline assertions 168 → 169.

v0.2.7 — a compaction out of quota no longer ends the turn

Choose a tag to compare

@chimneyQ chimneyQ released this 17 Oct 17:02

A long conversation starts by compacting its own context, so when the current model's balance ran out the summary call was the first casualty — and compaction was on the do-not-fail-over list. The refusal went straight through and the turn died before the answer existed. The switch machinery was working the whole time; it was never asked.

Compaction now walks the candidate list like any other dead route.

  • Session titles still stay put: a caption nobody waits on is not worth a second provider.
  • No consent modal before a compaction switch — it would park the turn on plumbing the reader is not watching and switch on timeout anyway. The ordinary switch notice still arrives at the turn boundary.
  • No model adoption from a compaction switch: a summary that fails on context size says nothing about fitness for the conversation.

Three negative controls prove each exclusion is what makes its assertion pass. Offline assertions 165 -> 168.

v0.2.6

Choose a tag to compare

@chimneyQ chimneyQ released this 17 Oct 16:10
  • Fixed: other channels of the same model were deduplicated out of the walk. This is the real problem 0.2.5 uncovered once the classification was right: when WorkBuddy met its quota wall, buddy and trae — the same model on other channels — were healthy, but "one channel per model family" kept them out of the candidate list entirely, so the turn ended on the quota error with a working route sitting one slot away. A family now keeps up to two channels: the ranked winner plus one proven-usable backup. A cooling backup is dropped, since dispatching it would walk into the same wall. The switch notice is already deduplicated by model id, so arriving at a second channel of the same model is not announced as "switched to the same model again".
  • Measured: before the fix the walk held one channel of deepseek-v4.1-flash and buddy (ok=1538, healthy) was absent; after it holds three and buddy is back. [fed9508]

v0.2.5

Choose a tag to compare

@chimneyQ chimneyQ released this 17 Oct 14:55
  • Fixed: an exhausted quota read as a rate limit, so the switch never happened. WorkBuddy answers "可用账号均在限流中,最近一个将于 … 解禁" — every account exhausted. Classified as rate-limit it cooled for 60 seconds and retried the same wall; one evening accumulated 702 failures. An account pool that is entirely unavailable is now recognised as quota and rests like any other balance wall.
  • New: the release time is parsed out of the message. When the provider states when it comes back, that beats any calendar policy. parseReleaseTime() accepts 2026/10/18 00:29:52, 2026-10-18 00:29:52 and ISO forms; a parsed time becomes the exact cooldown (measured here: 60 seconds became 1.7 hours), and only an unparseable message falls back to the next-day rule.
  • Fixed: a switch was not persisted. The switch notice was queued and then returned, so scheduleStatsSave() never ran — a restart showed the old model still in use. The save happens with the enqueue now.
  • Improved: a pin defers while it cools. A model pinned with model_rotator_select took the head on every turn even while cooling, so every turn opened by walking into the same wall. The pin now applies only while the model is healthy; the ranking decides during cooldown and the pin returns by itself afterwards. [a2d5a0c]