Skip to content

ds-bot: a part switch that hits a disposed fiber fails silently and loses the handoff #280

Closed

Description

@Gruu6y

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

  1. Classify the failure instead of swallowing it. The inactive context error is a plain Error — reflect.js creates it as new Error('cannot get property "X" without inject') and only rewrites .message when it hits the inject list. It carries no code, 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 at info, so a disposed tree stops looking like a network error in warn.

  2. Guard before touching state — with an API that exists. FiberState is a const enum in cordis and is erased at compile time; it is not among the package's runtime exports (the published lib/index.js exports Context, CordisError, DisposableList, EventsService, Fiber, Inject, Logger, LoggerService, RegistryService, Service, ValidationError, buildOuterStack, c16, c256, composeError, … — no FiberState), and the compiled code compares bare numbers (impl.fiber.state !== 2). So state !== FiberState.ACTIVE would be a ReferenceError. The same goes for checking impl.fiber.state yourself: 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.

  3. Never leave the switch half-moved. At parts.js:719-723 the archive step is already forgiving, but the input is not: the carry buffer is spliced out at parts.js:694 and handed to forward (defined at 656) at parts.js:702, whose failure is only warned about. If forward throws because its first hop is a dead resolveAgent, those messages are already gone from job.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 / 复现步骤

  1. Have several Bot sessions run past the model window (or hit a provider 502), so part switches are triggered.
  2. While switches are in flight, dispose or reload the plugin tree (quit the app, or add/remove a plugin in the plugin manager).
  3. 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.js sha256 6d25c7cf22e9ad30…, delivery.js a678c6b650243135…, chat-index.js 6ad7efefa31d7aaa… (identical to upstream main)
  • @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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions