@jayyuen66/dsh-session-rescue
中文
它做什么
- 回合非正常收尾时代除人工干预,三类自动注入:瞬时失败续跑、输出截断续写、待办未闭合补跑。
- 另有请求级 429 兜底:监听
agent/request-error(waterfall),命中限流即返回{ kind: "retry" }让宿主在同一回合重发该请求。- 退避阶梯默认 2s/5s/10s/20s/30s、同一会话最多 5 次(阶梯与次数都是非 volatile 的部署值,见设置项末组),回合收口(
agent/status→idle)即重置计数。 - provider 的
Retry-After优先于阶梯,超过 30s 封顶(requestRetryBackoffCapMs)就不等、委托next()交给官方 llm-retry。
- 退避阶梯默认 2s/5s/10s/20s/30s、同一会话最多 5 次(阶梯与次数都是非 volatile 的部署值,见设置项末组),回合收口(
- client 半(构建产物
client.js)在输入框下挂一块 dock:- 编辑重发(本会话)/ Fork 重发(新分支)/ 排队消息的撤回与编辑 / 停止并重问 / 手动重试与继续输出 / 续跑倒计时横幅 / 每会话开关。
/state轮询是自排循环 + 两档:有 pending 时 1s(横幅要按秒走倒计时),无 pending 时 5s 兜底;上一跳落地后才排下一跳,在飞期间不叠并发也不停摆。- 无订阅者或页签隐藏即停表,回到可见补一跳;快照内容没变就不通知订阅者(旧实现每 tick 无条件遍历一遍订阅者)。
- 喂给
useSyncExternalStore的subscribe/getSnapshot是模块级稳定引用:写成内联函数会让 React 每次渲染重跑订阅 effect(官方实现useEffect(bind(...),[subscribe])依赖数组只收subscribe),退订→再订阅会把一次渲染放大成一发/state。 - 另有一张设置卡与按平台的 429 重试档位。
- 读的宿主面:
agent/error、agent/status、agent/request-error、agent/disposed(清理)。 - 回合事实(
turn/start、turn/end、user/message、tool/call、todo/write)经ctx.sessionProjections的投影单元增量折叠,判定只读stateOf()的同步水位。- 注册表缺席的 profile,或折叠不可采信的形状(
turn/end对不上它的turn/start、opener 被窗口淘汰),回退session.snapshotEvents()全量扫描,判定逐项相同。
- 注册表缺席的 profile,或折叠不可采信的形状(
三种自动动作
resume续跑:agent/error的error.failure被lib/failure-classify.ts判为瞬时,等resumeDelayMs后注入。- 瞬时形状:
RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT/EMPTY_RESPONSE、HTTP 429、5xx、带限流措辞的QUOTA、PI_AI_ERROR加无法归类的finish_reason或上游空响应措辞。
- 瞬时形状:
continue续写:agent/status转idle且最后一条turn/end的reason.kind === "max-tokens",等continueDelayMs后注入「从截断处继续」。unfinished补跑:同一条turn/end是completed,但该回合自己写的todo/write快照里仍有非completed项(lib/turn-review.ts,纯结构化信号、零文本猜测),等unfinishedDelayMs后注入。- 三类各有独立的延迟/冷却/次数闸门(
lib/resume-scheduler.ts,冷却按 kind 分别记录)。- 注入一律是
role: "user"、source: { kind: "plugin:session-rescue" }的消息:模型会接着跑,token 与配额继续消耗。
- 注入一律是
什么时候不会动
- 判为永久失败:
CONTEXT_WINDOW_EXCEEDED/AUTH/INVALID_CREDENTIAL/MISSING_CREDENTIAL/INVALID_REQUEST/INVALID_ARGS/NO_ADAPTER/INVALID_MODEL_CONTEXT/INVALID_PREPARED_CALL、HTTP 401/403。- 余额耗尽措辞(
insufficient quota|balance|credits等)与一切未知形状;error没有.failure(普通 Error)同此论。 - 分类是内置安全逻辑,设置卡不提供改它的入口。
- 余额耗尽措辞(
- 非根会话(子代理);
providerExcludes命中的那条 provider 的resume——它不挡continue/unfinished,也不挡请求级 429 重试。 - 你按了停止:最后一条
turn/end的reason.kind为aborted/interrupted时既不注入、也不消耗配额。 - 它在等你:该回合内调用过
ask_user_question→ 补跑不注入;该回合首条user/message的source.kind === "goal"(goal 轮次驱动)→ 三类一律让路。 - 到点前二次校验(
preFireCheck)不过:agent 已消失、状态非idle、inbox 有排队消息、失败轮之后已有新轮 → 静默作废,不计数、不进冷却。- 闸门拦下则是同类已有待办、同类冷却内、或同类次数已满。
- 次数是「连续」语义——一次
completed回合把resume/continue清零,unfinished只在清单闭合时才恢复。
安装
dsh plugin --profile web add @jayyuen66/dsh-session-rescue
- 需要 dsh
>=0.2.0-rc.2:真源是package.json里peerDependencies下的@deepseek-ai/dsh(宿主自 0.1.7-rc 起在装插件时校验它;alpha.1 还没有这道门)。engines.dsh同值但无人读。 - 包在公共 npm 上,安装不需要凭据。
- 卸载:
dsh plugin --profile web remove @jayyuen66/dsh-session-rescue。许可 MIT,源码仓见package.json的repository.url。
在 dsh 里启用
- 组合包形态:包内
cordis.patch.yml带- id: session-rescue+name: "@jayyuen66/dsh-session-rescue",由package.json的dsh.bundle.patch指向,dsh plugin add自动登记。- 发布态入口是
prepack重建的host.js与client.js。
- 发布态入口是
- host 半硬依赖
timer与settings(inject: ["timer", "settings"]);webServer走子 fiber 依赖,所以没有 webServer 的宿主(TUI)自动续跑照常、只是那六条路由不存在。 - 设置卡在插件管理页的
plugins.bundle.config(该槽按 bundle 包名 keyed,key =@jayyuen66/dsh-session-rescue,即~/.dsh/profiles/web/package.json里dsh.profile.bundles的那一行;configForms.get()与 settings 命名空间仍是裸条目 idsession-rescue):改动点「保存」才写 settings、「撤销」丢弃。 - 不想开 UI 时部署默认值写在注册行
config:上,优先级 = 设置卡运行时值 > 行config> 内置默认,配置非法则插件加载失败(响亮报错)。
设置项
命名空间 session-rescue(0.1.7 起隐式注册:命名空间 = cordis.patch.yml 里的条目 id,本包不再调 settings.register):设置项与行 config 共用 host.ts 里同一份 Config schema(单源防漂移),内置默认逐字段落在 .default() 上,标了 .volatile() 的十三项即设置卡的可编辑面(另有三枚非 volatile 的部署值,见下面末组),时间单位 ms。取值范围:延迟 1000–300000(continueDelayMs 500–300000)、冷却 5000–3600000、次数 0–20。
- 全局:
enabledtrue、providerExcludes[]。 resume:resumeDelayMs10000、resumeCooldownMs120000、maxResumes3、chainResumeDelayMs60000(续跑消息自己开出的回合再失败时旁路冷却、按此延迟重排)。continue:continueDelayMs3000、continueCooldownMs60000、maxContinues3。unfinished:resumeOnOpenTodostrue(这一类自己的开关)、unfinishedDelayMs5000、unfinishedCooldownMs120000、maxUnfinished2。- 请求级 429 重试的三枚部署值:刻意不标
.volatile()⇒ 设置卡没有它们的行,只在注册行的config:上给(cordis 交进 apply 的是值而非引用,改值随重启生效)。- 默认:
requestRetryMax5(0–20)、requestRetryBackoffMs[2000, 5000, 10000, 20000, 30000](数组至少一项)、requestRetryBackoffCapMs30000(min 1000)。
- 默认:
对外接口
- 六条
webServer路由(kind: "exact"):GET /_dsh/session-rescue/state(各会话计数、待办剩余时间、开关态,外加本次 apply 的写操作令牌)POST /_dsh/session-rescue/cancel?sessionId=POST /_dsh/session-rescue/toggle?sessionId=(会话级开关,关掉顺带解除待办)POST /_dsh/session-rescue/resume(client 在connection/reset时通知恢复挂起待办)- 另外两条:
GET /_dsh/session-rescue/retry-providers、POST /_dsh/session-rescue/retry-policy
- 写操作的信任闸门:六条路由 handler 体的第一条语句都是
shared/lib/trust的guardTrust(req, res, { servingNonLoopback }),判据依次为 Host 权威 →sec-fetch-site白名单 →Origin逐字比对;servingNonLoopback只从webServer.host === "0.0.0.0"取。- 任一不成立 →
403+ JSON{ ok: false, error: "untrusted host authority" | "cross-origin request rejected" }(lib/http的isCrossOrigin支因此不可达:白名单更严且文案相同)。 - 方法不对 → 405 +
Allow头 +{ ok: false, error: "GET only" }(两条 GET 路由)或"POST only"(四条 POST 路由),不再是空体。
- 任一不成立 →
- CSRF 与体积:POST 须以
x-rescue-csrf回灌state下发的 token(缺或错 → 403invalid csrf token);retry-policy的 body 上限 64 KiB(超限 413、坏流 400)。 retry-policy写的是官方llm-pi-ai命名空间的providers.<name>.retryPolicy(档位default/enhanced/always/off,经settings.mutate落盘),本包不自建第二套重试配置。- 模型可见的唯一面:
agent.followup()发出的一条role: "user"消息,id形如session-rescue-<时间戳>-<序号>,正文是lib/messages.ts里的固定中英模板,不插值任何会话内容。 - 可选读总线
ctx.get("lessonLoop"):装了才沉淀transient-failure、unclassified-failure、max-tokens、unfinished-turn四类事实,并在同 provider 之后真跑完一个completed回合时补一条pass。- 总线缺席或抛错只 warn,主流程不依赖它。
数据与隐私
- host 半不写文件、不发外部请求(无
node:fs、无网络调用)。- 运行态全在内存且都有界:调度器最多 200 条会话记录,挂起待办与待兑 pass 台账各 64 条,超限裁最旧,
agent/disposed与插件卸载时释放定时器。
- 运行态全在内存且都有界:调度器最多 200 条会话记录,挂起待办与待兑 pass 台账各 64 条,超限裁最旧,
- 持久化只有两处、都经官方 settings:本包命名空间
session-rescue与llm-pi-ai的重试档位,落在<dsh 数据目录>($DSH_HOME)里,重启不丢。 - 每会话开关只存活在进程内存,重启即回到全局
enabled。注入正文的语言取官方 locale 偏好(settings.describe()里locale那一条的value.preference),该条目没被投影时默认中文。 - 装了
lesson-loop才有内容外流:交给它的记录含失败的 code/status/message 原文、session id 与该会话的cwd,落盘位置由 lesson-loop 决定;没装则一条都不产生。
常见问题
- 它会不会偷偷花钱:会。注入的是用户角色消息,发出后模型继续跑、继续消耗 token 与配额。
- 一键关:设置卡「启用自动续跑」(
enabled = false,三类注入与请求级 429 重试同时停,手动 UI 保留)。 - 范围更窄的出口:dock 的「自动续跑(本会话)」、
resumeOnOpenTodos(只关补跑)、providerExcludes(只排除某条 provider 的续跑)。 - 为什么有时模型停了却没自动续跑:日志找
[session-rescue] <sid>: auto-<kind> skipped (<reason>)(pending/cooldown/max-resumes)或vetoed at fire time (<reason>);配额是连续语义,跑完一回合就恢复。 - 会不会打断「它在问我」:不会,
ask_user_question之后不注入;goal 轮次驱动的回合三类全部让路。 - 补跑从不触发:它要求该回合自己写过
todo/write且清单里仍有非completed项——从不用待办工具的会话结构性不会触发(openTodos为null)。 - 装不上:404 多半是该版本还没发到 npmjs(先看
dist-tags.latest)。- 404 通常是同组库包
@jayyuen66/dsh-plugin-shared还没上 registry——本包值 import 它的lib/locale与lib/http,缺了就是ERR_MODULE_NOT_FOUND。
- 404 通常是同组库包
- 判定有没有测试兜着:
test/覆盖瞬时失败续跑、成功回合重置配额、待办未闭合补跑、等用户回答不注入四条主链。- 其中
test/integration/loader-boot.test.ts用真实 cordis loader 装载发布产物端到端验。
- 其中
English
What it does
- It removes the manual chore when a turn ends badly, via three automatic injections: resume after a transient failure, continue after output truncation, re-run after an open todo list.
- There is also a request-level 429 backstop: it listens on
agent/request-error(waterfall) and returns{ kind: "retry" }on rate limiting so the host re-sends that request inside the same turn.- The backoff ladder defaults to 2s/5s/10s/20s/30s with at most 5 retries per session (the ladder and the counter are both non-volatile deployment values, see Settings), and the counter resets when the turn closes (
agent/status→idle). - A provider
Retry-Afterwins over the ladder, and anything above the 30s cap (requestRetryBackoffCapMs) is not waited out - the plugin delegates tonext()and lets the official llm-retry take over.
- The backoff ladder defaults to 2s/5s/10s/20s/30s with at most 5 retries per session (the ladder and the counter are both non-volatile deployment values, see Settings), and the counter resets when the turn closes (
- The client half (built artifact
client.js) docks below the input box:- edit-resend (this session) / fork-resend (new branch) / withdraw and edit queued messages / stop-and-re-ask / manual retry and continue-output / a countdown banner / a per-session switch
- The
/statepoll is a self-scheduling two-tier loop: 1s while a pending exists (the banner counts down by the second), 5s as a fallback when nothing is pending. - The next hop is armed only after the current one lands, so an in-flight request neither overlaps with it nor strands the loop.
- It stops with no subscribers or while the tab is hidden, and catches up one tick on return. Subscribers are only notified when the snapshot actually changed.
- The
subscribe/getSnapshotpair handed touseSyncExternalStoreis made of module-level stable references, so a re-render never re-subscribes. - Inline closures make React re-run the subscribe effect on every render (the shipped React 18.3.1 is
useEffect(bind(...),[subscribe])): unsubscribe then re-subscribe turns one render into an extra/state. - There is also a settings card and per-platform 429 retry presets.
- Host surface read:
agent/error,agent/status,agent/request-error,agent/disposed(cleanup). - Turn facts (
turn/start,turn/end,user/message,tool/call,todo/write) fold incrementally into actx.sessionProjectionsunit; every verdict reads the synchronousstateOf()watermark.- On profiles without that registry, or on shapes the fold cannot vouch for (a
turn/endwhoseturn/startis outside the window, an evicted opener), it falls back to a fullsession.snapshotEvents()scan with identical verdicts.
- On profiles without that registry, or on shapes the fold cannot vouch for (a
The three automatic actions
resume:error.failureonagent/erroris judged transient bylib/failure-classify.ts; the message lands afterresumeDelayMs.- Transient shapes:
RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT/EMPTY_RESPONSE, HTTP 429, 5xx,QUOTAcarrying rate-limit wording,PI_AI_ERRORcarrying an unrecognizedfinish_reasonor empty-response wording.
- Transient shapes:
continue:agent/statusgoes toidleand the lastturn/endcarriesreason.kind === "max-tokens"; the "continue from the truncation" message lands aftercontinueDelayMs.unfinished: that sameturn/endiscompleted, but thetodo/writesnapshot that turn wrote itself still holds non-completeditems (lib/turn-review.ts, a purely structured signal, no text guessing); the message lands afterunfinishedDelayMs.- Each kind has its own delay / cooldown / quota gates (
lib/resume-scheduler.ts, cooldowns tracked per kind).- Every injection is a
role: "user"message withsource: { kind: "plugin:session-rescue" }: the model picks the conversation back up, so tokens and quota keep being spent.
- Every injection is a
When it does nothing
- Permanent failures:
CONTEXT_WINDOW_EXCEEDED/AUTH/INVALID_CREDENTIAL/MISSING_CREDENTIAL/INVALID_REQUEST/INVALID_ARGS/NO_ADAPTER/INVALID_MODEL_CONTEXT/INVALID_PREPARED_CALL, HTTP 401/403.- Exhausted-balance wording (
insufficient quota|balance|creditsand friends) and anything unrecognized; anerrorwithout.failure(a plain Error) counts the same. - Classification is built-in safety logic and the card deliberately exposes no way to change it.
- Exhausted-balance wording (
- Non-root sessions (sub-agents);
resumeon a provider listed inproviderExcludes- which does not gatecontinue,unfinished, or the request-level 429 retry. - You stopped it: when the last
turn/endcarriesreason.kindabortedorinterrupted, nothing is injected and no quota is spent. - It is waiting for you: the turn called
ask_user_question→ no re-run message; the turn's firstuser/messagecarriessource.kind === "goal"(goal-round driven) → all three kinds stand down. - The pre-fire check (
preFireCheck) fails: the agent is gone, the status is notidle, the inbox holds queued messages, or a newer turn already started → the pending injection is silently voided, without counting or entering cooldown.- The gates block it when that kind already has a pending record, is inside its cooldown, or has spent its quota.
- Quota is a consecutive notion - one
completedturn zeroesresume/continue, whileunfinishedonly refills once the list closes.
Install
dsh plugin --profile web add @jayyuen66/dsh-session-rescue
- Requires dsh
>=0.2.0-rc.2: the source of truth is the@deepseek-ai/dshentry underpeerDependencies(the host checks it on plugin install from 0.1.7-rc; alpha.1 has no such gate yet).engines.dshcarries the same value but nothing reads it. - The packages are on the public npm registry, so installation needs no credentials.
- Remove with
dsh plugin --profile web remove @jayyuen66/dsh-session-rescue. MIT licensed; the source repository is inrepository.urlofpackage.json.
Enabling it in dsh
- Bundle form: the package's own
cordis.patch.ymlcarries- id: session-rescue+name: "@jayyuen66/dsh-session-rescue"and is pointed at bydsh.bundle.patchinpackage.json, sodsh plugin addregisters it.- The published entry points are the
prepack-rebuilthost.jsandclient.js.
- The published entry points are the
- The host half hard-depends on
timerandsettings(inject: ["timer", "settings"]);webServeris required through a child fiber, so a host without webServer (TUI) keeps auto-resume working and simply never gets the six routes. - The settings card lives in
plugins.bundle.configon the plugin page, keyed by the bundle package name@jayyuen66/dsh-session-rescue(that is thedsh.profile.bundlesrow in~/.dsh/profiles/web/package.json).configForms.get()and the settings namespace stay the bare entry idsession-rescue; edits are staged, Save writes them into settings, Revert discards them.
- Without UI, deployment defaults go on the registration line's
config:; precedence = card runtime value > lineconfig> schema.default(), and an invalidconfigfails the plugin load loudly.
Settings
Namespace session-rescue: since 0.1.7 the namespace is registered implicitly (it IS the entry id in cordis.patch.yml — the package no longer calls settings.register). The settings form and the line config share one Config schema in host.ts (single source, drift-proof); built-in defaults sit on each field's .default(), and the thirteen .volatile() fields are what the card exposes (three further non-volatile deployment values sit at the end of the list below). Times in ms. Ranges: delays 1000-300000 (continueDelayMs 500-300000), cooldowns 5000-3600000, counters 0-20.
- Global:
enabledtrue,providerExcludes[]. resume:resumeDelayMs10000,resumeCooldownMs120000,maxResumes3,chainResumeDelayMs60000(when the turn opened by a resume message fails again, the cooldown is bypassed and it re-schedules at this delay).continue:continueDelayMs3000,continueCooldownMs60000,maxContinues3.unfinished:resumeOnOpenTodostrue(this kind's own switch),unfinishedDelayMs5000,unfinishedCooldownMs120000,maxUnfinished2.- The three deployment values of the request-level 429 retry: not
.volatile(), so the card has no row for them - they go on the registration line'sconfig:only (plain values, so a change applies on restart).- Defaults:
requestRetryMax5(0-20),requestRetryBackoffMs[2000, 5000, 10000, 20000, 30000](at least one entry),requestRetryBackoffCapMs30000(min 1000).
- Defaults:
Public surface
- Six
webServerroutes (kind: "exact"):GET /_dsh/session-rescue/state(per-session counters, remaining pending time, switch state, plus this apply's write token)POST /_dsh/session-rescue/cancel?sessionId=POST /_dsh/session-rescue/toggle?sessionId=(per-session switch; turning it off also disarms the pending record)POST /_dsh/session-rescue/resume(the client signalsconnection/resetso suspended pendings re-arm)- The remaining two:
GET /_dsh/session-rescue/retry-providers,POST /_dsh/session-rescue/retry-policy
- Trust gate on writes: all six handlers open with
guardTrust(req, res, { servingNonLoopback })fromshared/lib/trust, judged as Host authority ->sec-fetch-siteallowlist -> verbatimOrigincomparison.- Any failure ->
403plus JSON{ ok: false, error: "untrusted host authority" | "cross-origin request rejected" }(theisCrossOriginleg inlib/httpbecomes unreachable - stricter allowlist, same text);servingNonLoopbackcomes only fromwebServer.host === "0.0.0.0". - Wrong method -> 405 with
Allowplus{ ok: false, error: "GET only" }(the two GET routes) or"POST only"(the four POST routes) - no longer an empty body.
- Any failure ->
- CSRF and size: POSTs must echo in
x-rescue-csrfthe tokenstatehanded out (missing or wrong -> 403invalid csrf token); theretry-policybody is capped at 64 KiB (oversized 413, unreadable stream 400). retry-policywrites into the officialllm-pi-ainamespace atproviders.<name>.retryPolicy(presetsdefault/enhanced/always/off, persisted viasettings.mutate); the package keeps no second retry configuration of its own.- The only model-visible artifact: one message through
agent.followup()withrole: "user", anidlikesession-rescue-<timestamp>-<seq>, and text taken from the fixed bilingual templates inlib/messages.ts- no session content is interpolated. - Optional read of
ctx.get("lessonLoop"): when present it receives thetransient-failure,unclassified-failure,max-tokensandunfinished-turnfacts, and onepassonce the same provider genuinely finishes acompletedturn.- An absent or throwing bus only warns, the main path never depends on it.
Data and privacy
- The host half writes no files and makes no outbound requests (no
node:fs, no network calls).- All runtime state is in memory and bounded: at most 200 session records in the scheduler, 64 suspended pendings and 64 pending pass entries, oldest trimmed first, with timers released on
agent/disposedand on plugin unload.
- All runtime state is in memory and bounded: at most 200 session records in the scheduler, 64 suspended pendings and 64 pending pass entries, oldest trimmed first, with timers released on
- Persistence happens in exactly two places, both through the official settings service: this package's namespace
session-rescueand thellm-pi-airetry presets, stored under<dsh data dir>($DSH_HOME) and surviving restarts. - The per-session switch lives only in process memory; a restart falls back to the global
enabled. - The injected text follows the official locale preference (
value.preferenceon thelocalerow ofsettings.describe()) and defaults to Chinese when that entry is not projected. - Content only leaves towards
lesson-loopif that plugin is installed: it receives the failure code/status/message verbatim, the session id and the session'scwd, and lesson-loop alone decides where that is written. Without it, nothing is recorded.
FAQ
- Does it silently spend money: yes. The injections are user-role messages, so the model continues and keeps consuming tokens and quota.
- One switch off: the card's "Enable auto-resume" (
enabled = false) stops all three injections and the request-level 429 retry while keeping the manual UI. - Narrower exits: the dock's per-session switch,
resumeOnOpenTodos(re-run only),providerExcludes(resume on one provider only). - Why did it stop without resuming: look for
[session-rescue] <sid>: auto-<kind> skipped (<reason>)(pending/cooldown/max-resumes) orvetoed at fire time (<reason>)in the log; the budget is consecutive-semantics and one finished turn refills it. - Will it interrupt a question aimed at me: no. Nothing is injected after
ask_user_question, and goal-round driven turns are skipped by all three kinds. - The re-run never fires: it requires that turn to have written a
todo/writelist that still holds non-completeditems - sessions that never use the todo tool structurally cannot trigger it (openTodosstaysnull). - Install fails with 404: that version was never published to npmjs (check
dist-tags.latest).- 404 usually means the sibling library package
@jayyuen66/dsh-plugin-sharedis not on the registry yet - this package value-imports itslib/localeandlib/http, so a missing one isERR_MODULE_NOT_FOUND.
- 404 usually means the sibling library package
- What backs these decisions:
test/covers the four main chains - transient failure resumes, a successful turn refills the quota, an open todo list re-runs, and a waiting-for-user turn stays untouched.test/integration/loader-boot.test.tsboots the published artifact through the real cordis loader.
No comments yet. Be the first to write one.