Replies: 2 comments
|
Checked every piece of this against current source on upstream master, and it holds exactly, right down to the type assertion that erases the guard. packages/core/agent-loop/src/agent.ts:324 still has: That "as" is doing real work here. It is a compile-time-only cast, nothing checks at runtime that signal.reason actually matches AgentCancelCause's shape before it gets appended. On the append side, packages/core/session/src/surface.ts's validateSessionEventData only branches on request/header and tool/result. There is no turn/end case at all, so nothing catches a malformed abort cause before it lands in the durable log. And packages/session/session-format-v0-to-v1/src/migration.ts confirms the read side is exactly as strict as you describe: assertReleasedV0Keys(reason, ['kind'], ...) for the abort cause, so any extra member throws and the whole session becomes unreadable on that frozen edge. So the gap is exactly where you located it. A write-time type assertion with no runtime check, feeding a read-time validator that must not soften. Your suggestion (A), normalizing signal.reason at the point turn/end is appended, looks like the right fix, since it closes the hole at its actual source rather than only at agent.cancel()'s current callers, which is where a plugin or a future internal change could still slip past. Solid writeup, and a genuinely useful repair tool if the maintainers want it attached. |
|
核实结论:机制完全属实,master(c291e79)逐点命中,与 #6045/#6151/#6455 属同一「冻结代际严格校验」问题族.
|
Uh oh!
There was an error while loading. Please reload this page.
Summary
A single extra member inside a released-v0 turn/end abort cause turns an entire session into an unreadable artifact. The frozen v0→v1 migration edge correctly refuses the Session (it must not accept unknown payload members), the source artifact is deliberately left untouched, and nothing in the product can repair or skip the offending event. The user-visible result is a conversation that can never be opened again:
复制
failed to observe session "session-9b5ba54f-f4ae-4a69-abf7-1af5f6e949de":
@deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session:
turn/end 7585 reason abort cause has unexpected member "stack";
source v0 artifact remains unchanged
The damaged row (stack abbreviated; whitespace added here for readability) is:
json
复制
{"type":"turn/end","seq":7585,"time":1788510095834,
"data":{"turn":4,
"reason":{"kind":"aborted",
"reason":{"kind":"user","stack":"Error\n at ...undici...\n at async HttpProvider.requestOnce (.../@deepseek-ai/dsh-web-fetch-http/...)\n at async ToolRuntime.dispatchToolBody (.../@deepseek-ai/dsh-tools/...)\n at async Object. (.../@deepseek-ai/dsh-tool-call-timeout-policy/...)"}}}}
Context (inference, not verified against the writing build): the turn was aborted by the user while a web_fetch tool call was in flight, and the in-flight tool error's stack ended up attached to the abort cause.
Environment
DSH Desktop 2.0.9 (win32 x64), dshVersion 0.1.5-rc.1 (from health-snapshots/*/manifest.json).
Migration edge involved: @deepseek-ai/dsh-session-format-v0-to-v1 ^0.1.5-rc.1.
The damaged artifact was written 2026-08-31 by an earlier build: its stack references resources/app.asar.unpacked/node_modules/@deepseek-ai/..., while the 2.0.9 install unpacks only native modules (@img, @VScode, node-pty, pnpm, …). So this is not a 2.0.9 write regression — but it is reproducible by 2.0.9's write path (see "Why 2.0.9 can still produce this").
Impact
One malformed member ⇒ the whole Session is unreadable. There is no partial loading, no skip path, and no repair path.
The failure is discovered long after the fact (the next time the conversation is opened), and the trigger is a normal user action: pressing stop while a tool call is in flight.
The artifact that would need editing is the one the product deliberately does not touch ("source v0 artifact remains unchanged"), so users cannot self-recover through any supported interface.
Root cause
@deepseek-ai/dsh-agent-loop persists the raw AbortSignal reason into the durable log without any vocabulary check:
js
复制
cancel(cause, options = {}) {
if (!options.keepInbox) { this.inbox.clear(); /* ... / }
if (this.phase.kind !== "idle") this.phase.abort.abort(cause);
}
js
复制
} catch (error) {
if (signal.aborted) {
turnEnds = { kind: "aborted", reason: signal.reason }; // verbatim
throw error;
}
// ...
} finally {
try { this.session.append("turn/end", { turn, reason: turnEnds }); / ... */ }
}
@deepseek-ai/dsh-session's Session.append() validates the new event through validateSessionEventData(), which inspects only request/header and tool/result. turn/end payload members are never validated, even though the function's own documentation states the intent that "a bad event fails at the append site rather than later during a backend flush".
@deepseek-ai/dsh-session-format-v0-to-v1 then refuses the Session on read: assertReleasedEventPayload() → turnEndReasonValue() requires assertReleasedV0Keys(cause, ["kind"]) for user|parent|disposed|legacy (and ["kind","reason"] for hook). One extra member throws SessionFormatError("... abort cause has unexpected member ..."). The package's own documentation states the policy: "It also rejects unexpected payload members." Recoverable decoding only drops physically torn/corrupt rows; a semantic payload violation has no recovery path.
Net: the write side does not enforce the vocabulary it declares, and the read side cannot tolerate a violation it must not accept.
Why 2.0.9 can still produce this
The declared types are already correct:
ts
复制
type AgentCancelCause = { kind: 'user' } | { kind: 'parent' }
| { kind: 'hook'; reason: string } | { kind: 'disposed' };
type TurnEndCancelCause = AgentCancelCause | { kind: 'legacy' };
and every shipped caller complies: the agent loop / ACP / pause paths pass { kind: "user" }, subagent paths { kind: "parent" }, teardown { kind: "disposed" }, and the host gateway's session.cancel handler hardcodes agent.cancel({ kind: "user" }, { keepInbox: true }), so no remote caller can inject a cause.
But the guard is the type system only, which is erased at runtime, and the append seam does not check this field. Any in-process JS caller — a plugin calling the public agent.cancel() API with a richer object, or a future internal regression — reproduces an artifact that is fine when written and unreadable when reopened.
Suggested fix
(A) Write-site normalization (defensive). In @deepseek-ai/dsh-agent-loop, normalize signal.reason to TurnEndCancelCause before appending turn/end (coerce anything outside the vocabulary to a known kind and log a warning). A caller mistake then cannot corrupt the durable log.
(B) Append-site validation (structural). Extend validateSessionEventData() in @deepseek-ai/dsh-session to cover turn/end (ideally the whole released event vocabulary), so an invalid cause fails where it is created — which is what that function is for.
(C) Product decision (optional). Please consider making this class of failure recoverable or at least actionable:
the read error could name the artifact path and the offending member, not only the seq;
a documented repair path (e.g. a dsh command) would let users rescue a session instead of losing it permanently.
I am not asking to loosen the frozen released-format validator silently: it is right that a released generation is immutable and strict. If any tolerance is ever added for unknown cause members, it should be explicit and logged.
Workaround used here (verified)
The damage is repairable at the physical layer without touching the frozen format semantics, because @deepseek-ai/dsh-session-persistence-jsonl stores the log as concatenated checksummed Zstandard frames — one independently decodable batch per frame:
decode every frame; find the row carrying the extra member;
delete exactly that member from the row's raw JSON text;
re-encode only the frame that contains it (checksum flag kept, as the writer does), and copy every other byte through unchanged.
Verified outcome for the session above:
2735 frames / 3638 rows before and after; exactly one row changed; 2734 frames byte-identical; the repaired cause is {"kind":"user"}, a first-class v0 value.
After the repair the host observed the session again and completed the full migration to the current generation (session.v3.jsonl.zstd, version: 3, 8 turns / 91 tool calls, turn 4 → {"kind":"aborted","reason":{"kind":"user"}}), which confirms the release path accepts the repaired artifact.
A scan of every session artifact under $DSH_HOME (all generations) found this single occurrence.
A repair tool + verifier exist for this case and can be attached if useful.
Request
Confirm whether the intended contract is "the durable log may only contain TurnEndCancelCause", and if so add the write/append guard (A/B). The read side is behaving as documented; the durability hole is on the write side.
All reactions