fix(session): add semantic crash checkpoints

This commit is contained in:
Yichen Jiang
2026-07-21 14:50:06 +08:00
parent 9a5c81f9e5
commit 6d12e3ab41
56 changed files with 1016 additions and 61 deletions
@@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca
Key choices recorded here because they are durable, contested, and surprising:
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)``append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-semantic-session-checkpoints.md: e444e493bd0feface12088b7dba9227703fa147f
2026-07-21-semantic-session-checkpoints.zh.md: 503c768b5653e22c464c5892bd7f4336113d1e38
@@ -0,0 +1,25 @@
# Agent Note: Semantic session checkpoints
Status: implemented
English | [中文](2026-07-21-semantic-session-checkpoints.zh.md)
## Problem
Persistence buffered every synchronous `session/event` until the loop's final turn checkpoint. A turn is the correct conversational transaction, but it is too coarse as the only crash-recovery point: a hard crash during a long model request or tool call could discard the whole in-flight turn, including the request envelope needed to identify what had been attempted. A tool call with no result was also repaired with one undifferentiated interruption error, so the resumed model could not tell whether execution had started and could retry a side effect blindly.
## Decision
`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary.
Checkpoint failure is fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body; a rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences.
Crash repair distinguishes durable evidence. An assistant tool request without a `tool/call` becomes `TOOL_NOT_STARTED` and may be retried if still needed. A durable `tool/call` without a result becomes `TOOL_OUTCOME_UNKNOWN`; its model-visible result permits retry only for read-only or idempotent operations and directs the model to verify external state or ask the user before deciding about side-effecting work. A provider that supports idempotency keys can receive the stable `callId`, but the Harness does not claim generic exactly-once effects.
## Alternatives considered
Flushing every event or streaming chunk minimizes loss but turns local append and `fsync` latency into the hot path and destabilizes streaming throughput. Moving the barriers into `agent-loop` centralizes the policy but makes one persistence strategy mandatory in the mechanism layer. Keeping turn-only flush preserves throughput but loses the request and execution intent needed for safe recovery. Automatically retrying every unmatched call is safe only for a subset of tools and can duplicate irreversible effects.
## Consequences
Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. A keyless ACP snapshot loads a seeded unknown-outcome session through the shipped ACP example and proves that the retry-risk guidance reaches both resumed history and the next model turn.
@@ -0,0 +1,25 @@
# Agent Note: 语义会话检查点
Status: implemented
[English](2026-07-21-semantic-session-checkpoints.md) | 中文
## 问题
持久化机制会缓冲所有同步 `session/event`,直到 agent loop(智能体循环)执行最后的轮次检查点才写入。一个轮次是正确的对话事务,但作为唯一的崩溃恢复点过于粗粒度:如果在耗时的模型请求或工具调用期间发生硬崩溃,整个进行中的轮次都可能丢失,其中包括识别已尝试操作所需的请求封套。系统还会使用同一种不作区分的中断错误,修复没有结果的工具调用,因此恢复运行的模型无法判断调用是否已经开始,可能会盲目重试带有副作用的操作。
## 决策
`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。
检查点失败在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体;步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。
崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。
## 考虑过的替代方案
刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop` 虽能集中管理策略,却会让某种持久化策略成为机制层的强制选项。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。
## 后果
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACPAgent Client Protocol)、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。一项无密钥 ACP 快照通过已交付的 ACP 示例加载预置的结果未知会话,并证明重试风险指引会同时出现在恢复后的历史记录与下一个模型轮次中。
+4 -4
View File
@@ -84,7 +84,7 @@ forever:
agent/pre-step
snapshot the derived messages (the reconstruction boundary)
'step/start'
agent/request (config only) -> log request/header -> llm/stream (frozen)
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
on final adapter-path or terminal in-band failure:
'step/end'
agent/request-error(original error, failure facts, immutable prior failures, signal)
@@ -96,10 +96,10 @@ forever:
schedule tool calls by ctx.tools.executionMode:
exclusive -> one-call barrier
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute
each model-order result -> ordered tools/post-execute -> 'tool/result'
append accepted tool-batch context after all recorded results, then steering
agent/post-step
agent/post-step -> checkpoint complete response/results
'step/end'
agent/turn-continuation
agent/turn-stop (terminal policy)
@@ -138,7 +138,7 @@ The session log is the source of truth. `deriveMessages()` projects session even
**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract.
Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
### Model Content
+4 -3
View File
@@ -77,7 +77,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/examples/acp-demo/src/index.ts:38`](../packages/examples/acp-demo/src/index.ts)
Source: [`packages/examples/acp-demo/src/index.ts:39`](../packages/examples/acp-demo/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -261,7 +261,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/examples/cli-demo/src/index.ts:25`](../packages/examples/cli-demo/src/index.ts)
Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts)
## `@deepseek-ai/dsh-code-runtime-worker`
@@ -1385,7 +1385,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts)
Source: [`packages/examples/tui-demo/src/index.ts:34`](../packages/examples/tui-demo/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
@@ -1589,6 +1589,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
+3 -3
View File
@@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:153`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:162`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:220`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
@@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) |
@@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
+13 -3
View File
@@ -96,6 +96,7 @@ flowchart TD
pkg_hooks_codex["hooks-codex"]
end
subgraph group_session_persistence["packages/session-persistence"]
pkg_session_checkpoint_policy["session-checkpoint-policy"]
pkg_session_persistence["session-persistence"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"]
@@ -361,6 +362,11 @@ flowchart TD
pkg_hooks_codex --> pkg_session
pkg_hooks_codex --> pkg_session_persistence
pkg_hooks_codex --> pkg_tools
pkg_session_checkpoint_policy --> pkg_agent
pkg_session_checkpoint_policy --> pkg_llm
pkg_session_checkpoint_policy --> pkg_session
pkg_session_checkpoint_policy --> pkg_session_persistence
pkg_session_checkpoint_policy --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
@@ -481,6 +487,7 @@ flowchart TD
pkg_acp_demo --> pkg_app_boot
pkg_acp_demo --> pkg_command_goal
pkg_acp_demo --> pkg_commands
pkg_acp_demo --> pkg_session_checkpoint_policy
pkg_acp_demo --> pkg_session_persistence_jsonl
pkg_acp_demo --> pkg_tools
pkg_acp_demo --> pkg_user_interaction
@@ -490,6 +497,7 @@ flowchart TD
pkg_cli_demo --> pkg_app_boot
pkg_cli_demo --> pkg_llm
pkg_cli_demo --> pkg_session
pkg_cli_demo --> pkg_session_checkpoint_policy
pkg_cli_demo --> pkg_session_persistence_jsonl
pkg_cli_demo --> pkg_tools
pkg_cli_demo --> pkg_workspace_context
@@ -501,6 +509,7 @@ flowchart TD
pkg_tui_demo --> pkg_commands
pkg_tui_demo --> pkg_llm
pkg_tui_demo --> pkg_session
pkg_tui_demo --> pkg_session_checkpoint_policy
pkg_tui_demo --> pkg_session_persistence_jsonl
pkg_tui_demo --> pkg_tool_ask_user
pkg_tui_demo --> pkg_tools
@@ -588,6 +597,7 @@ flowchart TD
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
@@ -607,6 +617,6 @@ flowchart TD
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
@@ -0,0 +1 @@
{"type":"session","version":0,"id":"semantic-checkpoint-replay","createdAt":1,"delegationDepth":0}
@@ -0,0 +1,11 @@
[
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "text" },
{ "type": "text-delta", "index": 0, "text": "I will verify the external state before deciding whether to retry the side-effecting operation." },
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "I will verify the external state before deciding whether to retry the side-effecting operation." } },
{ "type": "finish", "reason": { "kind": "stop" } }
]
}
]
@@ -0,0 +1,20 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"assistant/message","seq":3,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"surfaceOp":"append"}
{"type":"tool/call","seq":4,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}}
{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}],"isError":true,"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}}
{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":10,"time":0,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":16,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -0,0 +1,8 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Perform one side-effecting remote mutation."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"unknown-outcome-call","title":"write_remote","kind":"other","status":"in_progress","rawInput":{"value":1}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"unknown-outcome-call","status":"failed","content":[{"type":"content","content":{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}}]}}}
{"jsonrpc":"2.0","id":2,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,129 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
launchAcpTestAgent,
normalizeSessionLog,
normalizeStdout,
scrubRequestHeaders,
type AgentUnderTest,
type NormalizeContext,
} from '@deepseek-ai/dsh-acp-snapshot'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { describe, expect, it } from 'vitest'
const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'semantic-checkpoint-snapshots/tool-outcome-unknown')
const replayFixture = join(fixtureDir, 'replay.jsonl')
const replayOverride = join(fixtureDir, 'replay.override.json')
const stdoutExpected = join(fixtureDir, 'stdout.expected.jsonl')
const sessionExpected = join(fixtureDir, 'session.expected.jsonl')
const sessionId = SessionId('semantic-checkpoint-unknown-outcome')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const agent: AgentUnderTest = {
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
}
async function seedInterruptedSession(root: string, cwd: string): Promise<string> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const meta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
cwd,
delegationDepth: 0,
}
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: 12, data: { turn: 1, step: 1 } },
{
type: 'assistant/message',
seq: 3,
time: 13,
data: {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash' },
},
surfaceOp: 'append',
},
{
type: 'tool/call',
seq: 4,
time: 14,
data: {
turn: 1,
step: 1,
callId: CallId('unknown-outcome-call'),
name: 'write_remote',
arguments: '{"value":1}',
},
},
]
try {
await ctx.sessionPersistence.create(meta)
await ctx.sessionPersistence.append(sessionId, events)
const location = ctx.sessionPersistence.locate(meta)
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
return location.path
} finally {
await ctx.fiber.dispose()
}
}
describe('semantic checkpoint recovery snapshot', () => {
it('loads an unknown tool outcome and carries retry-risk guidance into the next model turn', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-cwd-'))
const sessionsRoot = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-sessions-'))
let launched: ReturnType<typeof launchAcpTestAgent> | undefined
try {
const sessionPath = await seedInterruptedSession(sessionsRoot, cwd)
launched = launchAcpTestAgent({
agent,
cwd,
env: {
DSH_SNAPSHOT: 'replay',
DSH_SNAPSHOT_FILE: replayFixture,
DSH_SNAPSHOT_OVERRIDE: replayOverride,
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
},
})
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await launched.client.loadSession({ sessionId, cwd, mcpServers: [] })
await launched.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'Continue safely from the interrupted operation.' }],
})
await launched.close()
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
const stdout = normalizeStdout(launched.rawStdout(), normalization)
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
if (refreshing) {
await writeFile(stdoutExpected, stdout)
await writeFile(sessionExpected, session)
}
expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
expect(session).toContain('TOOL_OUTCOME_UNKNOWN')
expect(session).toContain('Do not retry blindly.')
} finally {
await launched?.close('SIGKILL').catch(() => undefined)
await Promise.all([
rm(cwd, { recursive: true, force: true }),
rm(sessionsRoot, { recursive: true, force: true }),
])
}
})
})
@@ -16,5 +16,3 @@
{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}
{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}}
+5 -1
View File
@@ -11,6 +11,7 @@ import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
@@ -70,7 +71,10 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
// Durable JSONL persistence is opt-in: only the resume e2e needs it, and the
// other suites stay file-free. Loaded last so a resume's deferred
// `ctx.inject(['sessionPersistence'])` resolves once this is present.
if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
if (options.persistenceRoot !== undefined) {
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
await ctx.plugin(SessionCheckpointPolicy)
}
return ctx
}
+3
View File
@@ -34,6 +34,9 @@
config:
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
+1
View File
@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-tui-demo": "workspace:*",
@@ -33,7 +33,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
tsconfigPath,
actions: [
{ waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
// The question text first appears in the streamed tool-call card. Wait
// for the dialog's input legend so Enter cannot arrive before it owns
// terminal input when pre-dispatch policy yields.
{ waitFor: '↑↓ navigate • Enter select', send: '\r' },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],
})
+4
View File
@@ -106,6 +106,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/session-persistence/session-checkpoint-policy": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/util/paths": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
+4
View File
@@ -440,6 +440,10 @@ export function apply(ctx: Context, config: Config = {}): void {
})
return [{ type: 'text', text: `started background task ${id}` }]
}
// A durability or policy wrapper may yield before dispatch. Normalize a
// cancellation that arrived during that boundary before the executor can
// expose its backend-specific pre-spawn error.
if (exec.signal?.aborted) throw new Error('command aborted')
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
...exec.signal ? { signal: exec.signal } : {},
@@ -286,6 +286,20 @@ describe('bash tool', () => {
expect(text(result)).toMatch(/aborted/)
})
it('normalizes foreground cancellation that arrives before spawn', async () => {
const ctx = await setup()
const controller = new AbortController()
controller.abort('session/cancel')
const result = await ctx.tools.execute({
callId: CallId('call-pre-spawn-abort'),
name: 'bash',
arguments: { command: 'printf should-not-run', description: 'test command' },
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(text(result)).toBe('Error: command aborted')
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
it.each([
+2 -2
View File
@@ -101,11 +101,11 @@ Appended surface entries preserve reusable prefixes. A `replace` operation inval
#### What the model sees
If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.`
If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`
#### Token effect
Zero tokens in an intact session. Each repaired call adds this retained error text on resume.
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume.
#### KV Cache effect
+1 -1
View File
@@ -22,7 +22,7 @@ import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
+17 -3
View File
@@ -8,6 +8,12 @@
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
/**
* Return deterministic synthetic events that close an open tail turn. Unmatched
* calls receive error results first, followed by an open `step/end` and an
@@ -82,6 +88,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
// Close calls before their step: providers reject dangling assistant calls,
// and Map insertion order preserves their transcript order.
for (const [callId, { step, callSeq }] of pendingCalls) {
const started = callSeq !== undefined
closers.push({
type: 'tool/result',
seq: seq++,
@@ -90,12 +97,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
turn: openTurn,
step,
callId,
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
error: started
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
},
surfaceOp: 'append',
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},
...started ? { sourceEventSeqs: [callSeq] } : {},
})
}
+14 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers } from '../src/index.ts'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -47,9 +47,7 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([2, 3])
})
it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => {
// A step issued one tool call (in the assistant message) but crashed before
// the tool/result was logged — the classic mid-tool crash.
it('marks an assistant tool request with no recorded call as not started', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
@@ -64,8 +62,11 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED },
})
expect(result.type === 'tool/result' && result.data.content).toEqual([{
type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}])
})
it('does NOT synthesize a result for a tool-call that already has one', () => {
@@ -152,6 +153,14 @@ describe('interruptedTurnClosers', () => {
const result = closers[0]!
expect((result as SurfaceEvent).surfaceOp).toBe('append')
expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.content[0].text).toContain('first verify external state or ask the user')
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
+2
View File
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -56,6 +57,7 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7",
+2
View File
@@ -21,6 +21,7 @@ import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-demo'
@@ -110,5 +111,6 @@ export function apply(ctx: Context, config: Config): void {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(acp, { provider: config.provider, model: config.model })
}
@@ -35,7 +35,8 @@ const dshPackages = [
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
'ui/acp', 'examples/acp-demo', 'util/paths',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
+3
View File
@@ -44,6 +44,9 @@
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}
+2
View File
@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
@@ -51,6 +52,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+2
View File
@@ -15,6 +15,7 @@ import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
@@ -91,4 +92,5 @@ export function apply(ctx: Context, config: Config): void {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
}
@@ -15,7 +15,8 @@ const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
'session-persistence/session-persistence-jsonl',
'context/workspace-context',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
+1
View File
@@ -16,6 +16,7 @@
{ "path": "../../core/system-prompt" },
{ "path": "../../core/tools" },
{ "path": "../agent-spine-demo" },
{ "path": "../../session-persistence/session-checkpoint-policy" },
{ "path": "../../session-persistence/session-persistence-jsonl" },
{ "path": "../../ui/app-boot" }
]
+2
View File
@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tui": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
@@ -62,6 +63,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
+2
View File
@@ -21,6 +21,7 @@ import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiTui from '@deepseek-ai/dsh-tui'
@@ -109,6 +110,7 @@ export function composeTuiApp(ctx: Context, config: Config): void {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui, {
...config.ui,
@@ -44,6 +44,7 @@ describe('dsh-tui-demo app', () => {
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'session-checkpoint-policy',
'UserInteractionService',
'ui-tui',
'agent-spine-demo',
@@ -51,10 +52,10 @@ describe('dsh-tui-demo app', () => {
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
const tuiConfig = calls[5]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[5]?.config as {
const spineConfig = calls[6]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
readonly maxParallelToolCalls: number
@@ -88,8 +89,8 @@ describe('dsh-tui-demo app', () => {
})
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
expect(calls[5]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[6]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',
})
@@ -105,12 +106,12 @@ describe('dsh-tui-demo app', () => {
workspaceContext: false,
})
const tuiConfig = calls[3]?.config as { sessionId: string }
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[4]?.config).toMatchObject({ goals: false })
expect(calls[5]?.config).toMatchObject({ goals: false })
})
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
+3
View File
@@ -47,6 +47,9 @@
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}
+1
View File
@@ -5,6 +5,7 @@ The durable session-persistence seam and its storage backends. The interface pac
| Package | Role | ctx key |
|---|---|---|
| `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` |
| `session-checkpoint-policy/` | Semantic durability barriers for agent requests and tool execution | (wraps `ctx.llm` / `ctx.tools`, listens on agent events) |
| `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) |
| `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
@@ -0,0 +1,41 @@
# dsh-session-checkpoint-policy
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`.
## Plugin (namespace: `session-checkpoint-policy`)
This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`, and the presence of `ctx.sessionPersistence`. Load it beside one persistence backend:
```yaml
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A post-step rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
## Model Experience
### Interrupted calls
#### What the model sees
The plugin adds no prompt or tool schema. A hard crash after a tool checkpoint but before its result leaves a durable unmatched call; session recovery supplies the model-visible `TOOL_OUTCOME_UNKNOWN` result owned by `dsh-session`. The message permits retry for read-only or idempotent work and requires state verification or user confirmation for calls that may have side effects.
#### Token effect
Successful checkpoints add no tokens and do not change the request. Recovery adds one short tool-result message to balance the interrupted transcript.
#### KV Cache effect
The repair result is appended after the reusable prefix, so it does not invalidate earlier cache entries.
## Known Limitations and Deferred Work
- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
- Streaming `assistant/chunk` events have no per-chunk checkpoint. They reach storage with the next semantic checkpoint, so a hard crash may lose the current partial response.
- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically.
@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-session-checkpoint-policy",
"description": "Semantic session durability checkpoints before model requests and tool side effects",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,65 @@
/**
* Semantic durability checkpoints for model requests, top-level tool dispatch,
* and completed agent steps.
* @module @deepseek-ai/dsh-session-checkpoint-policy
*/
import type { Context } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
/** Cordis plugin name used by Loader diagnostics. */
export const name = 'session-checkpoint-policy'
/** Services whose request, tool, session, and persistence boundaries this policy joins. */
export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools']
/**
* Delay construction of the downstream model stream until the complete logged
* request prefix is durable. A checkpoint rejection prevents adapter dispatch.
*
* @param ctx - plugin context that owns the session store.
* @param session - live session named by the model request.
* @param next - downstream `llm/stream` chain.
* @returns a stream that checkpoints before requesting its first chunk.
*/
function afterCheckpoint(
ctx: Context,
session: Session,
next: () => AsyncIterable<StreamChunk>,
): AsyncIterable<StreamChunk> {
return (async function* (): AsyncIterable<StreamChunk> {
await ctx.sessions.flush(session)
yield* next()
})()
}
/**
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
* logged request before adapter dispatch; top-level tool calls checkpoint their
* recorded call before the tool body; post-step checkpoints retain the complete
* response/result batch. Nested tool dispatches reuse the durable outer call.
*
* Checkpoint failures are fail-closed at the model and tool side-effect
* boundaries: the downstream adapter or tool body is not invoked.
*
* @param ctx - plugin context that owns the listeners.
*/
export function apply(ctx: Context): void {
ctx.on('llm/stream', (options, next): AsyncIterable<StreamChunk> => {
if (options.sessionId === undefined) return next()
const session = ctx.sessions.get(options.sessionId)
return session === undefined ? next() : afterCheckpoint(ctx, session, next)
})
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.agent === undefined || exec.parent !== undefined) return next()
await ctx.sessions.flush(exec.agent.session)
return next()
})
ctx.on('agent/post-step', (agent): Promise<void> => ctx.sessions.flush(agent.session))
}
@@ -0,0 +1,104 @@
import { spawn } from 'node:child_process'
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import SessionStore, {
SessionId, TOOL_OUTCOME_UNKNOWN,
type SessionEvent,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const sessionId = SessionId('semantic-checkpoint-crash')
const roots: string[] = []
async function waitForFile(path: string): Promise<void> {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
await access(path)
return
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await new Promise(resolve => setTimeout(resolve, 10))
}
throw new Error(`crash child did not reach failpoint ${path}`)
}
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
roots.push(root)
const marker = join(root, 'failpoint')
const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
cwd: repoRoot,
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdio: ['ignore', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
try {
await waitForFile(marker)
const markerText = await readFile(marker, 'utf8')
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once('close', (code, signal) => { resolve({ code, signal }) })
})
child.kill('SIGKILL')
const exit = await closed
expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
return { root, markerText }
} catch (error: unknown) {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
throw new Error(`crash child failed: ${stderr}`, { cause: error })
}
}
async function load(root: string): Promise<SessionEvent[]> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
try {
return (await ctx.sessionPersistence.load(sessionId)).events
} finally {
await ctx.fiber.dispose()
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => {
it('persists the complete request before model dispatch', async () => {
const crashed = await crashAt('request')
expect(crashed.markerText).toBe('request-dispatched')
const events = await load(crashed.root)
expect(events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'request/header', 'step/end', 'turn/end',
])
expect(events.at(-1)).toMatchObject({
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
})
})
it('persists tool intent before a side effect and repairs its missing result as unknown', async () => {
const crashed = await crashAt('tool')
expect(crashed.markerText).toBe('tool-side-effect')
const events = await load(crashed.root)
expect(events.some(event => event.type === 'assistant/message')).toBe(true)
expect(events.some(event => event.type === 'tool/call')).toBe(true)
const result = events.find(event => event.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result?.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.content[0].text).toContain('Do not retry blindly.')
})
})
@@ -0,0 +1,59 @@
import { writeFile } from 'node:fs/promises'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as checkpointPolicy from '../../src/index.ts'
function waitForCrash(): Promise<never> {
return new Promise(() => { setInterval(() => {}, 60_000) })
}
const [mode, root, marker] = process.argv.slice(2)
if ((mode !== 'request' && mode !== 'tool') || root === undefined || marker === undefined) {
throw new Error('usage: crash-child.ts <request|tool> <persistence-root> <marker>')
}
const persistenceRoot = root
const failpoint = marker
class CrashAdapter extends LlmAdapter {
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
if (mode === 'request') {
await writeFile(failpoint, 'request-dispatched')
await waitForCrash()
return
}
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield {
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: CallId('crash-call'), name: 'crash_tool', arguments: '{}' },
}
yield { type: 'finish', reason: { kind: 'tool-calls' } }
}
}
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(checkpointPolicy)
ctx.llm.registerAdapter(['crash'], new CrashAdapter())
ctx.tools.register({
name: 'crash_tool',
description: 'records an external effect and never returns',
parameters: {},
async execute() {
await writeFile(failpoint, 'tool-side-effect')
return waitForCrash()
},
})
const handle = await ctx.agents.create({
sessionId: SessionId('semantic-checkpoint-crash'),
agentOptions: { provider: 'crash', model: 'crash' },
})
handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }])
await waitForCrash()
@@ -0,0 +1,212 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as checkpointPolicy from '../src/index.ts'
const contexts: Context[] = []
class TestPersistence extends SessionPersistence {
locate(_meta: SessionHeader): undefined { return undefined }
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return Promise.reject(new Error('not used'))
}
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
}
class RecordingAdapter extends LlmAdapter {
constructor(private readonly order: string[]) { super() }
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
this.order.push('adapter')
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
async function setup(): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TestPersistence)
await ctx.plugin(checkpointPolicy)
return ctx
}
async function drain(stream: AsyncIterable<StreamChunk>): Promise<void> {
for await (const _chunk of stream) { /* drain */ }
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('session-checkpoint-policy request boundary', () => {
it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('request-checkpoint'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const gate = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/flush', async () => {
order.push('flush:start')
await gate.promise
order.push('flush:end')
})
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
const pending = drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
}))
await Promise.resolve()
expect(order).toEqual(['flush:start'])
gate.resolve(undefined)
await pending
expect(order).toEqual(['flush:start', 'flush:end', 'adapter'])
})
it('delegates a request without a live session without checkpointing', async () => {
const ctx = await setup()
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] }))
expect(order).toEqual(['adapter'])
})
it('delegates an already-detached session id without checkpointing', async () => {
const ctx = await setup()
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'),
}))
expect(order).toEqual(['adapter'])
})
it('does not dispatch the adapter when the checkpoint rejects', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('request-failure'))
const order: string[] = []
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
await expect(drain(ctx.llm.stream({
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
}))).rejects.toThrow('disk unavailable')
expect(order).toEqual([])
})
})
describe('session-checkpoint-policy tool and step boundaries', () => {
it('awaits the checkpoint before a top-level tool body', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('tool-checkpoint'))
const agent = { session } as Agent
const gate = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/flush', async () => {
order.push('flush:start')
await gate.promise
order.push('flush:end')
})
ctx.tools.register({
name: 'write', description: 'side effect', parameters: {},
execute: async () => { order.push('tool'); return [] },
})
const pending = ctx.tools.execute({
callId: CallId('write-1'), name: 'write', arguments: {}, agent,
})
await Promise.resolve()
expect(order).toEqual(['flush:start'])
gate.resolve(undefined)
await expect(pending).resolves.toMatchObject({ isError: false })
expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
})
it('turns a rejected checkpoint into an error result without running the tool body', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('tool-failure'))
const agent = { session } as Agent
let ran = false
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
ctx.tools.register({
name: 'write', description: 'side effect', parameters: {},
execute: async () => { ran = true; return [] },
})
const result = await ctx.tools.execute({
callId: CallId('write-2'), name: 'write', arguments: {}, agent,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }])
expect(ran).toBe(false)
})
it('reuses the outer checkpoint for a nested tool dispatch', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('nested-tool'))
const agent = { session } as Agent
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
ctx.tools.register({ name: 'nested', description: 'nested', parameters: {}, execute: async () => [] })
await ctx.tools.execute({
callId: CallId('nested-1'), name: 'nested', arguments: {}, agent,
parent: Symbol('outer') as never,
})
expect(flushes).toBe(0)
})
it('checkpoints the complete recorded step at agent/post-step', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('post-step'))
const agent = { session } as Agent
const flushed: string[] = []
ctx.on('session/flush', (current) => { flushed.push(current.id) })
await agentEvents(ctx, agent).serial(
'agent/post-step', 1, 1, new AbortController().signal,
)
expect(flushed).toEqual([session.id])
})
})
describe('session-checkpoint-policy lifecycle', () => {
it('removes its wrappers when the owning fiber is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TestPersistence)
const session = ctx.sessions.create(SessionId('disposed-policy'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
ctx.llm.registerAdapter(['mock'], new RecordingAdapter([]))
const fiber = await ctx.plugin(checkpointPolicy)
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
expect(flushes).toBe(1)
await fiber.dispose()
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
expect(flushes).toBe(1)
})
it('keeps the Loader-safe namespace plugin shape', () => {
expect('default' in checkpointPolicy).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(checkpointPolicy) as Record<string, unknown>
expect(unwrapped).toBe(checkpointPolicy)
expect(unwrapped.name).toBe('session-checkpoint-policy')
expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools'])
expect(typeof unwrapped.apply).toBe('function')
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../core/tools"
}
]
}
@@ -32,7 +32,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
- **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
@@ -46,7 +46,7 @@ The plugin buffers frozen session events and drains them on flush or disposal. A
#### What the model sees
JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages.
JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Raw `assistant/chunk` records do not duplicate messages.
#### Token effect
@@ -211,8 +211,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
}
})
// The last index (into eventEntries) that is a valid `turn/end` — the last
// fully-committed boundary (the loop flushes only at turn/end).
// The last index (into eventEntries) that is a valid `turn/end` — holes
// through a closed turn are always committed corruption.
let lastTurnEnd = -1
for (let i = parsed.length - 1; i >= 0; i--) {
const p = parsed[i]
@@ -39,7 +39,7 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer
#### What the model sees
SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages.
SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages.
#### Token effect
@@ -166,8 +166,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
}
})
// The last index that is a valid `turn/end` — the last fully-committed
// boundary (the loop flushes only at turn/end).
// The last index that is a valid `turn/end` — holes through a closed turn
// are always committed corruption.
let lastTurnEnd = -1
for (let i = parsed.length - 1; i >= 0; i--) {
if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
@@ -16,7 +16,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
## Invariants every backend must honor
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (a risk-classified error `tool/result` per unanswered assistant call, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
- **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
- **Durability.** `append` returns only once the batch is durable.
@@ -59,7 +59,7 @@ Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `ve
#### What the model sees
This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call.
This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
#### Token effect
@@ -9,8 +9,8 @@
*/
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionPersistence } from '../src/index.ts'
@@ -122,7 +122,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('crash recovery: an interrupted tool call gets a synthetic error result so resume is a valid transcript', async () => {
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('interrupted-toolcall')
@@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.
@@ -162,6 +162,40 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('unknown-tool-outcome')
await persistence.create(m)
await persistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
])
const loaded = await persistence.load(m.id)
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (synthetic?.type !== 'tool/result' || synthetic.data.content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(synthetic.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(synthetic.data.content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
const resumed = new Session(m.id, loaded.events, loaded.meta)
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
expect(resumedResult?.content[0]).toMatchObject({
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
})
} finally {
await dispose()
}
})
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
const { persistence, dispose } = await make()
try {
+7 -8
View File
@@ -13,7 +13,7 @@ import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import { Session, SessionId, TOOL_NOT_STARTED, foldRequestHeader } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
@@ -165,8 +165,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tool-execution
// pipeline step ends the turn with no tool/result, which is legal.)
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) {
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
@@ -174,11 +174,10 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
}
// Turn-enclosure (the turn-enclosure Agent Note): EVERY session event not handled by a boundary
// case above must sit inside an open turn. The durable session log uses the
// turn as its commit/replay boundary (the JSONL backend treats anything
// after the last turn/end as a crash tail), so a bare event between turns is
// silently dropped on reload. The loop records queued user messages after
// turn/start, and an idle agent.inject() wraps its context/message in a
// one-shot turn. A `default`
// turn as its replay enclosure; recovery closes an interrupted open tail,
// so a bare event between turns has no valid resumed position. The loop
// records queued user messages after turn/start, and an idle agent.inject()
// wraps its context/message in a one-shot turn. A `default`
// (not an enumerated list) is deliberate: SessionEventMap is
// merge-extensible, so a PLUGIN-added event type appended while idle must
// also fail here rather than fall through and be dropped on resume.
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { InvariantError } from '@deepseek-ai/dsh-invariants'
@@ -217,7 +217,7 @@ describe('session-log invariants', () => {
callId: CallId('crashed'),
content: [{ type: 'text', text: 'interrupted' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
+48
View File
@@ -170,6 +170,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../packages/sandbox/sandbox-policy
'@deepseek-ai/dsh-session-checkpoint-policy':
specifier: workspace:*
version: link:../packages/session-persistence/session-checkpoint-policy
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:*
version: link:../packages/session-persistence/session-persistence-jsonl
@@ -731,6 +734,9 @@ importers:
'@deepseek-ai/dsh-commands':
specifier: workspace:^
version: link:../../ui/commands
'@deepseek-ai/dsh-session-checkpoint-policy':
specifier: workspace:^
version: link:../../session-persistence/session-checkpoint-policy
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
@@ -849,6 +855,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-checkpoint-policy':
specifier: workspace:^
version: link:../../session-persistence/session-checkpoint-policy
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
@@ -910,6 +919,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-checkpoint-policy':
specifier: workspace:^
version: link:../../session-persistence/session-checkpoint-policy
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
@@ -1608,6 +1620,42 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/session-persistence/session-checkpoint-policy:
devDependencies:
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-agent-loop-testkit':
specifier: workspace:^
version: link:../../support/agent-loop-testkit
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../session-persistence
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../session-persistence-jsonl
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
packages/session-persistence/session-persistence:
devDependencies:
'@deepseek-ai/dsh-session':
+1
View File
@@ -20,6 +20,7 @@
{ "path": "./packages/core/session" },
{ "path": "./packages/core/scope" },
{ "path": "./packages/session-persistence/session-persistence" },
{ "path": "./packages/session-persistence/session-checkpoint-policy" },
{ "path": "./packages/session-persistence/session-persistence-jsonl" },
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
{ "path": "./packages/session-query/session-query" },
+1
View File
@@ -33,6 +33,7 @@
{ "path": "./packages/core/session" },
{ "path": "./packages/core/scope" },
{ "path": "./packages/session-persistence/session-persistence" },
{ "path": "./packages/session-persistence/session-checkpoint-policy" },
{ "path": "./packages/session-persistence/session-persistence-jsonl" },
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
{ "path": "./packages/session-query/session-query" },