@jayyuen66/dsh-session-rescue
v0.1.1
Published
Session rescue for dsh web: auto-resume on transient model failures, max-tokens continuation and unclosed-todo catch-up (host), plus edit-resend / retry / queue-withdraw / stop-and-reask / settings card (client).
Maintainers
Readme
@jayyuen66/dsh-session-rescue
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, and it only takes over when the host resolved no
retryPolicyfor that provider: it listens onagent/request-error(waterfall) and returns{ kind: "retry" }on rate limiting so the host re-sends that request inside the same turn.- Both chains share one waterfall and the first
{kind:'retry'}wins, so when a policy exists the officialllm-retryowns the retry (retryableCodesfiltering,providerRetryAfterMs, jitter, durablellm/retryevents) and this plugin steps aside. Only the settings card'sdefaultpreset (unset) leaves the ladder as the fallback. - 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 back tonext().
- Both chains share one waterfall and the first
- 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/messagecarries asource.kindof"goal"(goal-round driven) or"schedule"(the official Schedule reminder) → 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.1-alpha.2: the source of truth is the@deepseek-ai/dshentry underpeerDependencies(the host validates it on plugin install from 0.1.7-rc, and it checks the@deepseek-ai/dsh-*peers the same way).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: registered implicitly (it IS the entry id in cordis.patch.yml — the package does not call 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 from shared'sisNonLoopbackBindHost(webServer.host), because the Host binds a real interface address - the wildcard addresses are rejected when the host loads. - 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.
