Repository navigation
ds-bot: a part switch that hits a disposed fiber fails silently and loses the handoff #280
Description
What happened? / 发生了什么?
When the plugin fiber is disposed while a part switch is in flight, every service lookup inside the switch throws:
cannot get required service "sessionController" in inactive context
cannot get required service "llm" in inactive context
cannot get required service "profileContext" in inactive context
The switch never completes, the handoff note is never written, and the input waiting in the old part's inbox is never handed on — so the Bot silently stays on the old, over-budget part. The user-visible effect is "the Bot never moves on to a fresh session", and the handoff content is gone.
The failure is logged at warn by a bare .catch(), indistinguishable from an ordinary provider hiccup:
// src/host/parts.js:680-681 (main)
void runSwitch(agent, job)
.catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error))Nothing distinguishes "the model call failed, retry later" from "this context is gone, there is nothing left to retry against".
src/host/delivery.js:10-15 — the single lookup helper, no context guard:
const resolveAgent = async (sessionId) => {
const resolved = await ctx.sessionController.resolveAgent(sessionId)
if ('error' in resolved) throw Object.assign(new Error(resolved.error?.message ?? 'Session unavailable'), { code: resolved.error?.code })
rt.hideBotUnusedTools(resolved.agent)
return resolved.agent
}Reached from five call sites on the switch path (line numbers are main):
| file | line | function |
|---|---|---|
parts.js |
660 | forward |
parts.js |
821 | moveOn |
parts.js |
932 | followFolder |
parts.js |
1068 | earlierPage |
chat-index.js |
54 | eventsBackwards (via handoffLines → partLines) |
Cordis resolves a service by walking up from the caller's own fiber and throwing as soon as a requested property appears in that fiber's inject list but has no instance in its store:
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) {
error.message = `cannot get required service "${prop}" in inactive context`
throw error
}Since ds-bot declares inject = ['llm', 'tools', 'systemPrompt', 'sessionController', ...], all of these throw identically the moment the ds-bot fiber itself is dead — so the llm failure alone does not prove the whole app went down, and this issue does not claim it did.
What does point past ds-bot is a second signature appearing in the same milliseconds, from a different plugin's fiber:
the default model was not restored: cannot get required service "profileContext" in inactive context
at get documentPath (app.asar/.../dsh-config-editor/lib/index.js:25)
at app.asar/.../cordis/lib/index.js:136
Different plugins' fibers failing within the same millisecond means the plugin tree was torn down or reloaded as a unit — not one plugin misbehaving.
Suggested fix
-
Classify the failure instead of swallowing it. The
inactive contexterror is a plainError—reflect.jscreates it asnew Error('cannot get property "X" without inject')and only rewrites.messagewhen it hits the inject list. It carries nocode, so classification is by message match, not a code switch. Whatever the guard decides, route the abort somewhere other than the.catch()that handles provider 502s and log it atinfo, so a disposed tree stops looking like a network error inwarn. -
Guard before touching state — with an API that exists.
FiberStateis aconst enumin cordis and is erased at compile time; it is not among the package's runtime exports (the publishedlib/index.jsexportsContext, CordisError, DisposableList, EventsService, Fiber, Inject, Logger, LoggerService, RegistryService, Service, ValidationError, buildOuterStack, c16, c256, composeError, …— noFiberState), and the compiled code compares bare numbers (impl.fiber.state !== 2). Sostate !== FiberState.ACTIVEwould be aReferenceError. The same goes for checkingimpl.fiber.stateyourself: when a provider dies, the consumer fiber falls back to state 0 (PENDING), not ACTIVE, so a number check cannot tell "I am dead" from "my provider is dead".Two guards that do work (both verified on cordis 4.0.4 against three disposal scenarios — only-self-disposed, whole-tree-disposed, provider-only-disposed):
// A: check the public fiber uid — null once disposed (fiber.ts: "0 for the root fiber, null once disposed") if (ctx.fiber?.uid === null) throw /* SWITCH_ABORTED_DISPOSED */
This distinguishes "the ds-bot fiber itself is gone" from "a provider died", which is the distinction this report needs.
// B: or just touch the service and classify by message (the error carries no code) try { ctx.sessionController } catch (e) { if (/in inactive context/.test(e.message)) throw /* SWITCH_ABORTED_DISPOSED */ throw e }
A plain property read throws in all three scenarios, so this one also covers the provider-only case.
-
Never leave the switch half-moved. At
parts.js:719-723the archive step is already forgiving, but the input is not: the carry buffer is spliced out atparts.js:694and handed toforward(defined at 656) atparts.js:702, whose failure is only warned about. Ifforwardthrows because its first hop is a deadresolveAgent, those messages are already gone fromjob.carry. Restore them, or defer removing them until the move is committed.
Caveat on line numbers
Stacks quoted below come from the build installed on 10-14, which is not line-identical to main: writeNote was at parts.js:363 (main: 368), moveOn at 717 (main: 757 / 821), runSwitch at 688 (main: 706). The stack text is left verbatim as evidence; every reference in the prose above uses main.
Representative stacks (as logged):
at Object.resolveAgent (ds-bot/src/host/delivery.js:10:32)
at Object.eventsBackwards (ds-bot/src/host/chat-index.js:54:28)
at partLines (ds-bot/src/host/parts.js:259:22)
at handoffLines (ds-bot/src/host/parts.js:287:24)
at moveOn (ds-bot/src/host/parts.js:738:97)
at Object.resolveAgent (ds-bot/src/host/delivery.js:10:32)
at moveOn (ds-bot/src/host/parts.js:799:28)
at async runSwitch (ds-bot/src/host/parts.js:709:9)
Screenshot (strongly recommended) / 截图(强烈建议)
No response
Steps to reproduce / 复现步骤
- Have several Bot sessions run past the model window (or hit a provider 502), so part switches are triggered.
- While switches are in flight, dispose or reload the plugin tree (quit the app, or add/remove a plugin in the plugin manager).
- Re-open the app: the Bot is still on the old, over-budget part, and the handoff never happened.
Source evidence below.
Diagnostics / 诊断信息
Environment
- Windows 11, DeepSeek Harness desktop
- ds-bot 0.1.7 —
parts.jssha2566d25c7cf22e9ad30…,delivery.jsa678c6b650243135…,chat-index.js6ad7efefa31d7aaa…(identical to upstreammain) - @deepseek-ai/cordis 4.0.4 installed locally; the runtime-export and disposal-scenario findings were also verified against 4.0.5 (identical conclusions)
Frequency observed
21 occurrences on one machine, inside the 10-14 12:37:47 – 12:54:23 window: 7 distinct seconds, 8 millisecond clusters (12:53:18 holds two). Every cluster carries sessionController ×2, plus one of llm or profileContext:
| local time | sessionController | llm | profileContext |
|---|---|---|---|
| 12:37:47 | 2 | 1 | 0 |
| 12:39:15 | 2 | 0 | 1 |
| 12:42:21 | 2 | 1 | 0 |
| 12:43:04 | 2 | 1 | 0 |
| 12:50:55 | 2 | 0 | 1 |
| 12:53:18 | 1 + 2 | 0 | 1 |
| 12:54:23 | 2 | 0 | 1 |
When it triggers
Whenever the plugin tree is disposed or reloaded while a switch is in flight — an app quit, or the plugin manager installing/removing plugins. On this machine the nearest event was a pnpm Packages: +1 -27 run at 12:37:33, 14 seconds before the first cluster, with 8 more pnpm runs between 12:46:11 and 12:46:49; the renderer's code cache recompiled the same module set at 12:38:14, 12:40:35, 12:42:29, 12:43:13, 12:45:29, 12:50:59, 12:53:27 and 12:54:42. The app itself did not crash: diagnostics.jsonl kept appending (192 lines across the window) and bot session files were written at 12:38:15/16, 12:40:36, 12:42:30, 12:43:14 and 12:45:30.
The same window also shows many switches in flight at once, triggered by sessions over the model window and a provider 502 (Gemini Web: Bad gateway: failed to reach backend service) — which is presumably why so many switches were running when the tree was disposed. The over-window signal is preflight_compress_failed, 61 occurrences in that window (77 across the whole file): 38,241 to 46,431 tokens against a fixed window of 24576, always with model=gemini-web/gemini-4-argon.
Representative diagnostics.jsonl lines (all within 1 ms):
{"time":1791952667308,"level":"warn","source":"host","text":"the context steward failed for session-9ad54a92-...; moving on to a fresh part: 502 Gemini Web: Bad gateway: failed to reach backend service"}
{"time":1791952667309,"level":"warn","source":"host","text":"DS Bot did not move on to a fresh part: cannot get required service \"sessionController\" in inactive context"}
{"time":1791952667309,"level":"warn","source":"host","text":"handoff note for DS Bot failed past the hard line; moving on with the earlier note: cannot get required service \"llm\" in inactive context"}
{"time":1791952667309,"level":"warn","source":"host","text":"input for session-9ad54a92-... was not handed on: cannot get required service \"sessionController\" in inactive context"}
The report contains no API keys, tokens, or personal data.
Privacy / 隐私
- This report has no API keys, tokens or personal data. / 报告里没有 API key、token 或个人信息。
Activity