Repository navigation
Replies: 5 comments
补充:宿主侧异常栈 + 实装代码的隔离复现;还涉及投影清理竞态和残留 hook同为 Desktop 0.2.1-rc.2,实装 build 1. 真实故障现场取得的调用链在一次 profile/plugin 重载附近,记录到 shell executor 重新 ACTIVE、PermissionPresetService 五次进入 FAILED、Auto-review 留在 PENDING。约一分钟后,三个全新的工具请求仍进入旧的 prepended callback: 这些调用没有进入具体工具正文。现场 trace 没有捕获到更早的第一个 cleanup/startup 异常,因此下面的完整因果链是隔离复现所得,不冒称每个历史内部步骤都已现场抓到。也不据此断定全局 2. 不加载任何第三方插件,也无需人为注入一个会 throw 的业务服务使用从实装版本复制的原始代码:
仅适配 schema/remote decorators、内存 session persistence、惰性 shell 和 LLM IO。不是完整桌面 E2E。关键步骤:
稳定得到: 3. 为什么旧 hook 能一直留下这里的关键不只是加载顺序:
非 Auto 对照也有意义:原代码下选 4. 部分修补的对照结果
新增死锁的有界对照:P 提供一个 更窄的试验方向是:让 5. 安全验收不能只断言“工具恢复”实装 Auto-review teardown 会把 Auto 会话切到 所以不能用“下游默认 deny”掩盖 reviewer 根本没运行,也不能为了恢复构造而直接跳过 Auto 的可用性校验、默认放行或关闭审批。应独立断言:reload 前后的 preset/sandbox/approval、review 调用次数、下游触达次数、旧 hook 撤销,以及 Auto unavailable 的明确 fail-closed 行为。 本地另试验了显式的受限人工审批回退:仅 Auto 改为经过验证的 验证边界
希望上游把 Auto 恢复 bootstrap、特定资源的销毁时序、异常后的完整 effect 清理、审批边界 一并纳入回归,而不只处理最后出现的 inactive-context 文案。 |
本地候选审查更新:投影租约组合也不可部署,不能把清理完成等同于安全恢复补充上一条实装代码复现。已进一步做独立只读审查和有界隔离测试,没有修改实际安装或用户权限。 目前共 35 项断言通过,但其中明确包含“成功复现候选缺陷”的见证测试,不是 35 项安全验收通过。前文的 scoped lease 只是试验方向,现已确认有下列阻断点,不能直接落地。 A. 迁移失败后,清掉唯一 gate 并不等于 fail closed在有效配置下,
这是 source-exact 业务类 + 模拟 session/IO 的准入链路见证,不是对运行中 Host 执行危险工具的测试。当前实装 当前实装 Session.append 明确区分提交前拒绝和提交后观察者失败:后者会 contained。因此测试模拟的是提交前失败,不是让普通 session/event 监听器 throw,也不是声称磁盘 I/O 同步抛错就是线上触发源。 必要条件是:把“Auto 集成不可用/权限迁移未完整提交”的拒绝状态接入真实工具准入,且覆盖旧 gate 撤销与新 gate 就绪之间的空隙。只修 cleanup、只预校验 fallback,或只放过 bootstrap throw 都不足。多会话迁移还应逐会话结算,不能首个异常跳过后续会话。 B. 引用计数相同,不代表投影 definition 属于同一代已把独立审查提出的并发窗口做成有界测试:
结果:新 service.current == 原因是显式 dispose 的 fiber 在清理结束前已移出 registry;新 provider 可在旧 lease 仍持有 key 时 register。ProjectionRegistry 对同 key 只检查 stateVersion、增加 refs,不替换 existing.def;新引用因此保住了旧 需要 definition/owner-generation 身份约束,或消除投影对短生命周期 owner 的闭包依赖。不能仅凭 refs 最终正确,就判定代次正确。 结论与版本边界
|
后续验证:Cordis 分组清理源码小修,以及完整准入方案的架构问题补充一份可独立审查的 Cordis grouped cleanup 源码差异。它已完成本地构建、类型检查和回归,但不是本讨论中 Auto 故障的完整修复,也不建议单独部署来解决这个故障。根据当前贡献政策,先附在本讨论中征求维护者意见,不创建 PR。 本次重新核对的上游 HEAD 仍为 1. 这份小修做什么、不做什么
仅 5 个文件:运行时实现/JSDoc、14 项新回归、vendor-local Vitest 配置、两处文档。完整差异 322 增行/9 删行,其中运行时文件 25 增行/9 删行。根测试 inventory 不包含 vendor tests,因此此处显式提供独立测试入口,没有把它冒充默认套件覆盖。 2. 源码小修的验证
环境为 Windows、Harness 随附的 Node 24 执行器(本轮核对为 24.21.0)、pnpm 11.28.5。Windows 的 native host-addon-only gate 按现有脚本退出,不将其解释为新验证了原生组件。没有执行发布、签名或安装。回滚验证只针对新建的 LF-canonical 源码副本,不代表生产 Desktop 回滚已经验收。 复现测试入口: pnpm exec vitest run --config vendor/cordis/vitest.config.ts
pnpm exec vitest run packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts --maxWorkers 1
pnpm run build:artifacts
pnpm run build:check3. 另一个实验候选的真实审核验证:不与本补丁混算此前为验证失效关闭机制,另外制作了包含 Tools / permission-presets / projection / reviewer / Cordis 的隔离编译候选。该候选不是下面的源码小修,也没有部署:
**范围限制:**规范 request/tool 事件由 maintenance 驱动,不是 AgentLoop 自主选工具的端到端验收;人工审批回答端是 deny-only test responder,不是真实 Web 人工点击。未证明长期生产稳定、所有模型故障路径或 OS 越界沙箱行为。 该实验还发现并修正了最终检查后再次读取 4. 请求维护者确认的架构边界完整候选不能机械翻译为 TypeScript 合入,主要有三个问题:
想请确认:
以下仅附源码小修,不附未确认架构的五模块实验补丁。补丁 SHA-256: 展开完整源码差异(5 文件,含 14 项回归)以下 diff 以所列基线为准,不是生产部署步骤。 diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts
index 6bb1caa7..8610e3ae 100644
--- a/vendor/cordis/src/fiber.ts
+++ b/vendor/cordis/src/fiber.ts
@@ -404,8 +404,11 @@ export class Fiber {
*
* `execute` runs immediately; the disposers it produces are collected and
* run (in reverse order) either when the returned disposer is called or
- * when the fiber unloads, whichever comes first. Calling the disposer twice
- * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is
+ * when the fiber unloads, whichever comes first. Every grouped disposer runs,
+ * even if another fails. Disposal then throws the original single error or
+ * an `AggregateError` in cleanup order; an entirely synchronous group still
+ * completes or throws synchronously. Calling the disposer twice is a no-op.
+ * Throws `CordisError('INACTIVE_EFFECT')` if the fiber is
* already disposed, and `TypeError` if `execute` returns an invalid shape.
*
* @param execute — the effect body; see {@link Effect} for accepted shapes.
@@ -427,18 +430,31 @@ export class Fiber {
const dispose = () => {
if (disposing) return disposalTask
disposing = true
- let task!: void | Promise<void>
- for (const disposable of disposables.splice(0).reverse()) {
- if (task) {
- task = task.then(() => runDisposable(disposable))
- } else {
+ let task!: void | Promise<unknown>
+ const errors: unknown[] = []
+ const run = (disposable: Disposable) => {
+ try {
const result = runDisposable(disposable)
if (isObject(result) && 'then' in result) {
- task = result as any
+ return Promise.resolve(result).catch((error) => { errors.push(error) })
}
+ } catch (error) {
+ errors.push(error)
}
}
- return disposalTask = task
+ for (const disposable of disposables.splice(0).reverse()) {
+ if (task) {
+ task = task.then(() => run(disposable))
+ } else {
+ task = run(disposable)
+ }
+ }
+ const finish = () => {
+ if (errors.length === 1) throw errors[0]
+ if (errors.length > 1) throw new AggregateError(errors, 'multiple effect disposers failed')
+ }
+ if (task) return disposalTask = task.then(finish)
+ return finish()
}
const meta: EffectMeta = { label, children: [] }
diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md
index d0181dbf..b1f8dc32 100644
--- a/vendor/cordis/README.md
+++ b/vendor/cordis/README.md
@@ -100,6 +100,18 @@ The monorepo uses Yakumo to build and test all packages. Most examples in the
docs use public APIs from `cordis`; loader examples additionally use
`@cordisjs/plugin-loader` and `@cordisjs/plugin-include`.
+## Grouped effect disposal
+
+An effect runs its collected disposers in last-in, first-out order and drains the entire group even when a disposer throws or rejects. Direct effect disposal rethrows the original value when exactly one cleanup fails, or an `AggregateError` with the failures in cleanup order when more than one fails. An entirely synchronous group still completes or throws synchronously; asynchronous cleanup is awaited before the next disposer runs. Repeated public disposer calls remain single-shot no-ops, while internal effect owners join cleanup that is already in flight.
+
+This ordering applies to explicitly grouped effects, not globally across sibling fibers or service consumers. Fiber unloading retains its existing error logging and containment behavior.
+
+Run the local source-plane regression suite from the Harness repository root:
+
+```sh
+pnpm exec vitest run --config vendor/cordis/vitest.config.ts
+```
+
## Volatile configuration
Schemas may return `Volatile<T>` references, whose values are read through `.get()`. Cordis exports the consumer types; Loader commits volatile-only changes into the running references without restarting and notifies the owning fiber through `loader/volatile-update`. Direct `fiber.update()` retains its existing update waterfall and default restart.
diff --git a/vendor/README.md b/vendor/README.md
index 65427620..761400c0 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -62,6 +62,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
24. **`timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources.
+25. **`cordis/src/fiber.ts` grouped disposal error drain**: every disposer in an explicit effect group runs in LIFO order even after synchronous throws or asynchronous rejections. Direct disposal rethrows a single original failure or an `AggregateError` containing all failures in cleanup order, after the group drains; successful synchronous disposal stays synchronous. Existing in-flight ownership joining, public single-shot disposal, and sibling-fiber unload behavior are unchanged; no global consumer-first wait is added. The effect JSDoc and `cordis/README.md` document the contract. `cordis/tests/fiber.spec.ts` pins error identity, complete LIFO draining, asynchronous settlement, reentrant ownership, and pending setup; `cordis/vitest.config.ts` runs these tests against the root TypeScript source aliases because the Harness root test inventory excludes vendor tests.
+
## Sync procedure
To update a vendored package from upstream:
diff --git a/vendor/cordis/tests/fiber.spec.ts b/vendor/cordis/tests/fiber.spec.ts
new file mode 100644
index 00000000..e61c2f43
--- /dev/null
+++ b/vendor/cordis/tests/fiber.spec.ts
@@ -0,0 +1,270 @@
+import { Context, FiberState } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
+
+function thrownBy(callback: () => unknown): unknown {
+ try {
+ callback()
+ } catch (error) {
+ return error
+ }
+ throw new Error('expected synchronous disposal to throw')
+}
+
+describe('grouped effect disposal', () => {
+ it('keeps successful synchronous cleanup LIFO and single-shot', () => {
+ const ctx = new Context()
+ const calls: number[] = []
+ const dispose = ctx.effect(function* () {
+ for (const index of [0, 1, 2]) yield () => { calls.push(index) }
+ })
+
+ expect(dispose()).toBeUndefined()
+ expect(calls).toEqual([2, 1, 0])
+ expect(dispose()).toBeUndefined()
+ expect(calls).toEqual([2, 1, 0])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('waits for successful async cleanup before running earlier disposers', async () => {
+ const ctx = new Context()
+ const release = Promise.withResolvers<void>()
+ const calls: string[] = []
+ const dispose = ctx.effect(function* () {
+ yield () => { calls.push('earlier') }
+ yield async () => {
+ calls.push('later:start')
+ await release.promise
+ calls.push('later:end')
+ }
+ }, 'async-success')
+
+ const pending = dispose()
+ expect(calls).toEqual(['later:start'])
+ expect(dispose()).toBeUndefined()
+ expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(['async-success'])
+ release.resolve()
+ await pending
+ expect(calls).toEqual(['later:start', 'later:end', 'earlier'])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it.each([0, 1, 2])('drains after a sync failure at registration %s and rethrows its identity', (failedIndex) => {
+ const ctx = new Context()
+ const failure = new Error(`cleanup ${failedIndex}`)
+ const calls: number[] = []
+ const dispose = ctx.effect(function* () {
+ for (const index of [0, 1, 2]) yield () => {
+ calls.push(index)
+ if (index === failedIndex) throw failure
+ }
+ })
+
+ expect(thrownBy(dispose)).toBe(failure)
+ expect(calls).toEqual([2, 1, 0])
+ expect(dispose()).toBeUndefined()
+ expect(calls).toEqual([2, 1, 0])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('synchronously aggregates every failure in LIFO cleanup order', () => {
+ const ctx = new Context()
+ const failures = [new Error('first'), new Error('second'), new Error('third')]
+ const calls: number[] = []
+ const dispose = ctx.effect(function* () {
+ for (const [index, failure] of failures.entries()) yield () => {
+ calls.push(index)
+ throw failure
+ }
+ })
+
+ const error = thrownBy(dispose)
+ expect(error).toBeInstanceOf(AggregateError)
+ if (!(error instanceof AggregateError)) throw error
+ expect(error.message).toBe('multiple effect disposers failed')
+ expect(error.errors).toHaveLength(3)
+ for (const [index, failure] of failures.toReversed().entries()) {
+ expect(error.errors[index]).toBe(failure)
+ }
+ expect(calls).toEqual([2, 1, 0])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it.each([new Error('async cleanup'), 'non-Error rejection', undefined])('preserves a single async rejection value: %s', async (failure) => {
+ const ctx = new Context()
+ const calls: number[] = []
+ const dispose = ctx.effect(function* () {
+ yield () => { calls.push(0) }
+ yield () => {
+ calls.push(1)
+ return Promise.reject(failure)
+ }
+ yield () => { calls.push(2) }
+ })
+
+ await expect(dispose()).rejects.toBe(failure)
+ expect(calls).toEqual([2, 1, 0])
+ expect(dispose()).toBeUndefined()
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('drains mixed sync throws and async rejections before reporting all errors', async () => {
+ const ctx = new Context()
+ const release = Promise.withResolvers<void>()
+ const failures = [new Error('oldest'), new Error('async'), new Error('newest')]
+ const calls: string[] = []
+ const dispose = ctx.effect(function* () {
+ yield () => { calls.push('oldest'); throw failures[0] }
+ yield async () => {
+ calls.push('async:start')
+ await release.promise
+ calls.push('async:end')
+ throw failures[1]
+ }
+ yield () => { calls.push('middle') }
+ yield () => { calls.push('newest'); throw failures[2] }
+ })
+
+ const pending = dispose()
+ const observed = pending.catch((error: unknown) => error)
+ expect(calls).toEqual(['newest', 'middle', 'async:start'])
+ release.resolve()
+ const error = await observed
+ expect(error).toBeInstanceOf(AggregateError)
+ if (!(error instanceof AggregateError)) throw error
+ expect(error.errors).toHaveLength(3)
+ for (const [index, failure] of failures.toReversed().entries()) {
+ expect(error.errors[index]).toBe(failure)
+ }
+ expect(calls).toEqual(['newest', 'middle', 'async:start', 'async:end', 'oldest'])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('joins reentrantly started nested cleanup and preserves its aggregate identity', async () => {
+ const ctx = new Context()
+ const release = Promise.withResolvers<void>()
+ const earlierFailure = new Error('inner earlier')
+ const laterFailure = new Error('inner later')
+ const calls: string[] = []
+ const inner = ctx.effect(function* () {
+ yield () => { calls.push('inner:earlier'); throw earlierFailure }
+ yield async () => {
+ calls.push('inner:later:start')
+ await release.promise
+ calls.push('inner:later:end')
+ throw laterFailure
+ }
+ })
+ let innerResult: Promise<unknown> | undefined
+ const outer = ctx.effect(function* () {
+ yield () => { calls.push('outer:earlier') }
+ yield inner
+ yield () => {
+ calls.push('outer:later')
+ innerResult = inner().catch((error: unknown) => error)
+ }
+ })
+
+ const outerResult = outer().catch((error: unknown) => error)
+ expect(calls).toEqual(['outer:later', 'inner:later:start'])
+ expect(inner()).toBeUndefined()
+ expect(outer()).toBeUndefined()
+ release.resolve()
+ const error = await outerResult
+ expect(error).toBe(await innerResult)
+ expect(error).toBeInstanceOf(AggregateError)
+ if (!(error instanceof AggregateError)) throw error
+ expect(error.errors).toHaveLength(2)
+ expect(error.errors[0]).toBe(laterFailure)
+ expect(error.errors[1]).toBe(earlierFailure)
+ expect(calls).toEqual(['outer:later', 'inner:later:start', 'inner:later:end', 'inner:earlier', 'outer:earlier'])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('awaits pending setup and drains every collected cleanup before rejecting', async () => {
+ const ctx = new Context()
+ const setupBlocked = Promise.withResolvers<void>()
+ const finishSetup = Promise.withResolvers<void>()
+ const cleanupStarted = Promise.withResolvers<void>()
+ const finishCleanup = Promise.withResolvers<void>()
+ const earlierFailure = new Error('earlier cleanup')
+ const laterFailure = new Error('later cleanup')
+ const calls: string[] = []
+ const dispose = ctx.effect(async function* () {
+ yield () => { calls.push('earlier'); throw earlierFailure }
+ yield async () => {
+ calls.push('later:start')
+ cleanupStarted.resolve()
+ await finishCleanup.promise
+ calls.push('later:end')
+ throw laterFailure
+ }
+ setupBlocked.resolve()
+ await finishSetup.promise
+ calls.push('setup:end')
+ yield () => { calls.push('last') }
+ })
+ await setupBlocked.promise
+
+ const pending = dispose().catch((error: unknown) => error)
+ expect(calls).toEqual([])
+ finishSetup.resolve()
+ await cleanupStarted.promise
+ expect(calls).toEqual(['setup:end', 'last', 'later:start'])
+ finishCleanup.resolve()
+ const error = await pending
+ expect(error).toBeInstanceOf(AggregateError)
+ if (!(error instanceof AggregateError)) throw error
+ expect(error.errors).toHaveLength(2)
+ expect(error.errors[0]).toBe(laterFailure)
+ expect(error.errors[1]).toBe(earlierFailure)
+ expect(calls).toEqual(['setup:end', 'last', 'later:start', 'later:end', 'earlier'])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('drains collected cleanup after pending setup rejects and retains the setup error', async () => {
+ const ctx = new Context()
+ const setupBlocked = Promise.withResolvers<void>()
+ const finishSetup = Promise.withResolvers<void>()
+ const failure = new Error('setup failed')
+ const calls: number[] = []
+ const dispose = ctx.effect(async function* () {
+ yield () => { calls.push(0) }
+ yield () => { calls.push(1) }
+ setupBlocked.resolve()
+ await finishSetup.promise
+ throw failure
+ })
+ await setupBlocked.promise
+
+ const pending = dispose()
+ const observed = expect(pending).rejects.toBe(failure)
+ finishSetup.resolve()
+ await observed
+ expect(calls).toEqual([1, 0])
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+
+ it('keeps owner unload error containment and drains the failed group', async () => {
+ const ctx = new Context()
+ const earlierFailure = new Error('earlier cleanup')
+ const laterFailure = new Error('later cleanup')
+ const errors: unknown[] = []
+ const calls: number[] = []
+ ctx.logger.error = (error: unknown) => { errors.push(error) }
+ ctx.effect(function* () {
+ yield () => { calls.push(0); throw earlierFailure }
+ yield async () => { calls.push(1); throw laterFailure }
+ })
+
+ await ctx.fiber.restart()
+ expect(calls).toEqual([1, 0])
+ expect(errors).toHaveLength(1)
+ const error = errors[0]
+ expect(error).toBeInstanceOf(AggregateError)
+ if (!(error instanceof AggregateError)) throw error
+ expect(error.errors[0]).toBe(laterFailure)
+ expect(error.errors[1]).toBe(earlierFailure)
+ expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
+ expect(ctx.fiber.getEffects()).toEqual([])
+ })
+})
diff --git a/vendor/cordis/vitest.config.ts b/vendor/cordis/vitest.config.ts
new file mode 100644
index 00000000..a0330c31
--- /dev/null
+++ b/vendor/cordis/vitest.config.ts
@@ -0,0 +1,13 @@
+import { fileURLToPath } from 'node:url'
+import tsconfigPaths from 'vite-tsconfig-paths'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ root: fileURLToPath(new URL('../../', import.meta.url)),
+ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
+ test: {
+ include: ['vendor/cordis/tests/**/*.spec.ts'],
+ pool: 'forks',
+ maxWorkers: 1,
+ },
+}) |
|
I reproduced a related case with an unofficial third-party review plugin on DSH 0.2.1-rc.2 / Cordis 4.0.5. The plugin reviews calls through The test used a fresh isolated DSH home with the installed Loader, permission service, session store, AgentLoop and tool dispatch:
A volatile timeout-setting reload also kept review working. No migration failure was injected. Source inspection shows that The agent and reviewer used local mocks. The tool only wrote a private marker and had no extra denial guard. No external API calls or production changes were made. Desktop filesystem HMR and process restart were not tested. Could the proposed host check also cover third-party review plugins and static custom presets, blocking calls while the required reviewer is absent? Or is that guarantee outside the current custom-preset contract? |
|
One follow-up to my earlier test: I also exercised the actual source-file watcher on DSH 0.2.1-rc.2 with Cordis 4.0.5, using an isolated harness and mocked tool/reviewer transport. No external model calls were made. The test deliberately held disposal open to widen the gap between removing the old plugin and mounting its replacement. During that gap, a tool body executed once with zero reviewer calls. The selected preset also changed to This extends my earlier Loader test to the source-watcher path. I have not rerun it against a newer upstream revision. Would a host-owned requirement linking a custom preset to its active review integration be in scope, so calls are blocked while that integration is unavailable, including during replacement? |
Uh oh!
There was an error while loading. Please reload this page.
[Bug] 运行时重载导致权限选择器消失与工具永久失效:permission-presets 与 auto 预设的依赖死锁(0.2.1-rc.2 仍复现)
@deepseek-ai/dsh-permission-presets/@deepseek-ai/dsh-experimental-auto-review概述
在进行中的会话里,一旦发生插件重载(例如用户在设置面板修改了模型、思维强度或权限,或者触发了热重载),就会引发权限选择器消失 + 工具调用永久报废的链式故障:
/permission斜杠命令同时消失;cannot get required service "sessionProjections" in inactive context;核心因果链路
环境信息
0.2.1-rc.2(Windows 11 x64, Build 26300)desktop(启用@deepseek-ai/dsh-experimental-auto-review,默认权限预设为auto)@deepseek-ai/dsh-permission-presets0.2.1-rc.2@deepseek-ai/dsh-client-ui-permission-presets0.2.1-rc.2@deepseek-ai/dsh-experimental-auto-review0.2.1-rc.2故障现象
/permission时,命令列表里不再出现该选项。tools.*PTC 调用直接挂死报错:turn/end: {"reason":{"kind":"completed"}},界面看似一切正常,实际上会话已丧失任何工具调用能力。稳定复现步骤
fiber.update());cordis.patch.yml(触发 [BUG] Changing cordis.patch.yml while sessions are running kills every running session mid-turn #8857 路径)。/permission命令无法打出;inactive context错误。快速确诊指标
DSH 会在
~/.dsh/storages/session_projcache/sessions/<sessionId>.json缓存各会话的已注册投影。permissions);permissions。在 PowerShell 中执行以下单行命令,即可快速查看最近会话的状态:
实测输出对比:
现场实录与时间线证据
1. 故障发生时间线(2026-10-17,UTC+8)
permissions)auto预设cordis.yml修改时间变更为此刻,首次分发被中止(插件图触发重建)inactive context失败,会话沦为僵尸permissions)2. 原始日志证据(截取自
session.v4.jsonl.zstd)崩溃时的标准堆栈:
深入根因与代码定位
1. 构造期依赖环形死锁,导致必定抛出未捕获异常
packages/interaction/permission-presets/src/index.ts:428-436(pinInitialPermission)permission-presets在其构造函数中(第 248–250 行),会立即遍历当前所有会话ctx.sessions.list(),并调用pinInitialPermission()尝试恢复权限;this.autoAdmit唯二不是 undefined 的时机,是外部调用其registerAuto()方法(第 307–316 行);registerAuto()的是@deepseek-ai/dsh-experimental-auto-review(见其index.ts:127, 721);auto-review本身又声明了对permissionPresets的强依赖,在permissionPresets尚未构造完毕前,它根本不会启动,更不可能调用registerAuto()。死锁闭环:在
permission-presets执行构造函数期间,this.autoAdmit百分之百是 undefined。只要此时内存里有一个活跃会话的预设是auto,这里就必定抛异常。冷启动时能幸免,仅仅是因为冷启动时
ctx.sessions.list()是空的;而运行时重载时必死无疑。2. Fiber 回滚清理机制,让投影永久丢失
ctx.sessionProjections.register()是声明式注册(绑定调用方 Fiber 的生命周期)。构造函数一抛异常,Cordis 的事务回滚直接注销了刚刚注册的permissions投影;auto-review也在等待permissionPresets,系统直接卡死;snapshot()时,只会遍历当前有效注册表,导致下发的全量投影中直接漏掉了permissions。3. 前端 UI 判空规则,导致静默退场
dsh-client-ui-permission-presets/lib/client.js:327/permission的available回调同样直接判断selectionOf(...) !== undefined。permissions的 baseline 后,同步清空本地投影,使得按钮与命令双双进入静默隐藏逻辑,给用户造成“莫名其妙失踪”的困惑。与已有相关 Issue 的关系
此前社区已有数个相关的 Issue 反馈,但都仅记录了局部片段:
auto会话时构造会抛出异常,分析了死锁机制inactive context等表象cordis.patch.yml导致正在执行的会话被杀建议修复方案
按修复性价比与优先级排序:
pinInitialPermission中,遇到preset === 'auto'且autoAdmit === undefined时,不要直接 throw;registerAuto()真正被调用时再完成 pin;或者仅记录告警。这样构造函数能够顺利返回,死锁立即解开。auto-review对permissionPresets的依赖改为可选依赖(optional),或者在服务就绪事件里异步注入registerAuto,打破循环等待。permissions投影缺失,界面按钮可退化为展示默认占位图或置灰状态,/permission命令也可提示“当前服务不可用”,而不是直接消失。inactive context,在派发工具前若检测到上下文失效,应主动尝试获取最新的活动会话上下文,或在日志中给出更清晰的致命拦截提示。All reactions