diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index ffb3afa51a..4e105ef598 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml new file mode 100644 index 0000000000..5556ed5fa1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.i18n.yaml @@ -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: 4bca02fe3893ac39621ed79a000ca8f86db4ff67 +2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md new file mode 100644 index 0000000000..4bca02fe38 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md @@ -0,0 +1,29 @@ +# 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. + +Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event. + +Checkpoint failure and cancellation are 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. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. 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. + +The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence. + +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` prevents omission for that loop but hides checkpoint policy inside the mechanism and removes Cordis-level replacement and ordering. 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, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, 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. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries. diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md new file mode 100644 index 0000000000..1f187eb644 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.zh.md @@ -0,0 +1,29 @@ +# 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` 检查点仍是轮次的收尾边界。 + +持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。 + +检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。 + +ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。 + +崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。 + +## 考虑过的替代方案 + +刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop`,虽能防止该循环漏装,却会将检查点策略隐藏在机制中,并失去 Cordis 层的替换与排序能力。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。 + +## 后果 + +发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7f7a827b4a..bc6af5c350 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # 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 -architecture.md: aee53adb1f675a061878c5668ffc1c8ce476bd60 -architecture.zh.md: a8d68144295f7898a51d7b2bd2bc8c1b4cccdb81 +architecture.md: 2d9cd725313c083b30f31f7d55e2300e44795caf +architecture.zh.md: ea02d4b367fba1bb6cb9865fa87d40a8c33ae381 diff --git a/docs/architecture.md b/docs/architecture.md index aee53adb1f..2d9cd72531 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,7 +62,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. +The shipped loop runs prompt-to-checkpoint work through plugin services and events. A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. @@ -90,7 +90,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) @@ -102,10 +102,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) @@ -126,7 +126,7 @@ Adapter failures close the step before `agent/request-error` with exact `Error`, Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. ### Agent Handles @@ -144,7 +144,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([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)). `ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index a8d6814429..ea02d4b367 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -62,7 +62,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件可见的服务和事件,持续处理从提示词到检查点的工作。 +已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 @@ -90,7 +90,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) @@ -102,10 +102,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) @@ -126,7 +126,7 @@ forever: 其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -每个会话事件都包围在轮次内。重新加载会保留中断的日志尾部,并用合成的 `interrupted` 轮次结束事件将其闭合。持久轮次关闭后的故障只通过 `agent/error` 报告,因为此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 ### Agent 句柄 @@ -144,7 +144,7 @@ forever: **模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知;循环等待轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约。 +持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0ffe871ae1..87f31cb5ce 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -81,7 +81,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) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:40`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:43`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -272,7 +272,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` @@ -1598,7 +1598,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) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:37`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:38`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1818,6 +1818,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/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)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 42d92bae29..dbbd057fbf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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:179`](../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:188`](../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:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../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:315`](../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:246`](../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:262`](../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), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -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:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:87`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:99`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`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:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../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:93`](../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:105`](../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:82`](../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:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 49e4b67c39..5b6b0b1ca3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -98,6 +98,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"] @@ -526,6 +527,12 @@ 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_invariants + 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_invariants pkg_agent_loop_testkit --> pkg_llm @@ -683,6 +690,7 @@ flowchart TD pkg_acp_demo --> pkg_command_goal pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_invariants + pkg_acp_demo --> pkg_session_checkpoint_policy pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_session_query pkg_acp_demo --> pkg_session_reference @@ -695,6 +703,7 @@ flowchart TD pkg_cli_demo --> pkg_invariants 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 @@ -707,6 +716,7 @@ flowchart TD pkg_tui_demo --> pkg_invariants 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_session_query pkg_tui_demo --> pkg_session_reference @@ -818,6 +828,7 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -838,6 +849,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`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), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`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), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`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), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`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), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`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) | diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl new file mode 100644 index 0000000000..16d72490a8 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"semantic-checkpoint-replay","createdAt":1,"delegationDepth":0} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json new file mode 100644 index 0000000000..192212820e --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/replay.override.json @@ -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" } } + ] + } +] diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl new file mode 100644 index 0000000000..fa003415b7 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl @@ -0,0 +1,21 @@ +{"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":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"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":15,"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":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"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":[13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl new file mode 100644 index 0000000000..3075390cf5 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/stdout.expected.jsonl @@ -0,0 +1,9 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"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":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":2,"result":{"modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"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":"session_info_update","title":"Perform one side-effecting remote mutati","updatedAt":"{{updatedAt}}"}}} +{"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"}} diff --git a/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts new file mode 100644 index 0000000000..e43d2ca4d0 --- /dev/null +++ b/examples/acp-agent/tests/semantic-checkpoint.snapshot.ts @@ -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 { + 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 | 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 }), + ]) + } + }) +}) diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json index 0f40e9d8b6..7024820966 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -6,7 +6,9 @@ "op": "promptAndCancel", "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", "afterUpdate": "tool_call", + "waitForFile": { "path": "started.txt" }, "waitForToolCallUpdate": "call_skipped" - } + }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json index c0aa7730d7..ec47a5cd1d 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, { "type": "block-start", "index": 1, "blockType": "tool-call" }, { "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" }, { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } }, diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 25a6c1aabf..16d218f778 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -5,15 +5,15 @@ {"type":"step/start","seq":3,"time":1784437195076,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} {"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} {"type":"tool/result","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"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":17,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[16],"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 749c9f7d5f..a2b46185d3 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -2,7 +2,7 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"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":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Run two shell commands: wait","updatedAt":"{{updatedAt}}"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/input.json b/examples/acp-agent/tests/snapshots/cancel/input.json index 0bc989ed10..a2e2fdc5f0 100644 --- a/examples/acp-agent/tests/snapshots/cancel/input.json +++ b/examples/acp-agent/tests/snapshots/cancel/input.json @@ -2,6 +2,7 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." } + { "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." }, + { "op": "waitForTurnEnd" } ] } diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 6cfa31a750..e1edc1dadd 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService 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 } diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2ff464de7b..f0eb7ea6d8 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -39,6 +39,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' diff --git a/examples/package.json b/examples/package.json index 1ceeb0930a..3444cc7d45 100644 --- a/examples/package.json +++ b/examples/package.json @@ -35,6 +35,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:*", diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c5d6fa36f3..624041854e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -99,7 +99,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan 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: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the diff --git a/knip.json b/knip.json index 0cc6c792b8..dbc1e585de 100644 --- a/knip.json +++ b/knip.json @@ -317,6 +317,10 @@ "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" diff --git a/packages/core/session/README.md b/packages/core/session/README.md index e338e05fe9..e39ea1b23c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -107,11 +107,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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b560408916..2b1c6b197f 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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' diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index aa8aa91531..52ac9247c1 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -10,6 +10,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import type { CallId } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { TOOL_NOT_STARTED } from './repair.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-session' @@ -133,8 +134,8 @@ function validateEvent( break } requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail) - 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) { fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } pendingCalls = { kind: 'delete', callId: event.data.callId } diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index efbb3d2004..6d2de49c75 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -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] } : {}, }) } diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index bc0a79759d..7b1b671737 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -256,7 +256,7 @@ describe('session-log invariants', () => { })).toThrow(/outside any open turn/) }) - it('allows interrupted repair results and unresolved calls at step end', async () => { + it('allows not-started repair results and unresolved calls at step end', async () => { const repaired = (await setup()).ctx.sessions.create() expect(() => { repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -267,7 +267,7 @@ describe('session-log invariants', () => { callId: CallId('crashed'), content: [], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED }, }, { surfaceOp: 'append' }) repaired.append('step/end', { turn: 1, step: 1 }) repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 765502b8ce..1edda36cfb 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -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', () => { diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index c21eed0b81..51497884cf 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -16,13 +16,14 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | exact current-surface reads and bounded `dsh-session:` snapshots | +| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | | ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer | | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | -Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. +The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. ## Config @@ -44,6 +45,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | +| `sessionReferences` | service defaults | cross-session candidate and snapshot limits routed to `dsh-session-reference` | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy. diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 1668e0967e..6f3dd21ebd 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 55b2547e7a..b1f3833317 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,7 +1,9 @@ /** * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), * human-command registry, JSONL session persistence, and the - * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout. + * {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one + * ordered lifecycle so ACP sessions quiesce before persistence detaches. It + * writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see @@ -21,6 +23,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 SessionQueryService from '@deepseek-ai/dsh-session-query' import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' @@ -106,23 +109,24 @@ export const Config: z = z.object({ * NO agents (its `agents` list defaults to `[]`) and carries the deployment * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP * bridge owns stdout for JSON-RPC and creates one agent per `session/new` - * from the provider/model pair. No logger, no `hmr` — stdout stays pure. + * from the provider/model pair. The composite effect unloads in reverse order, + * keeping checkpoint and persistence listeners attached until ACP agents have + * flushed their closing events. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { const goals = config.goals ?? {} - ctx.plugin(CommandService) - if (goals !== false) ctx.plugin(commandGoal) - ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) - // This front door owns the same persistence/reference cluster as the TUI; - // extracting these few calls would introduce a shared app-composition facade. - /* jscpd:ignore-start */ - ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { - root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, - ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), - }) - ctx.plugin(SessionQueryService) - ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) - /* jscpd:ignore-end */ - ctx.plugin(acp, { provider: config.provider, model: config.model }) + ctx.effect(function* () { + yield ctx.plugin(CommandService).dispose + if (goals !== false) yield ctx.plugin(commandGoal).dispose + yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose + yield ctx.plugin(UserInteractionService).dispose + yield ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }).dispose + yield ctx.plugin(sessionCheckpointPolicy).dispose + yield ctx.plugin(SessionQueryService).dispose + yield ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}).dispose + yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose + }, 'acp-demo.composition') } diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index c476a491c2..29791b497f 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -36,8 +36,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', 'session-query/session-query', - 'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths', + 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', + 'session-query/session-query', 'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index f943643ebe..cdc104e987 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../ui/tool-ask-user" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index 1c00a32891..f977bc5986 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-invariants": "^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", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "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:^", diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index 82884a2a6e..f7d543c3fe 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -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' @@ -94,4 +95,5 @@ export function apply(ctx: Context, config: Config): void { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), }) + ctx.plugin(sessionCheckpointPolicy) } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index b6227d9702..e002be43e0 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -24,7 +24,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', 'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention', ] diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index c7e3aed914..df5758b7b8 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../agent-spine-demo" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 40185a1bb4..146e6503bf 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -12,6 +12,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and | `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins | | `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | | `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | +| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | | `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | Exact current-surface reads and bounded `@session` snapshots consumed by the TUI | | `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | | `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | @@ -38,6 +39,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `workspaceContext` | required | Workspace-instruction config, or `false` | | `persistenceRoot` | `./.sessions` | JSONL persistence root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | +| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | | `resumeSessionId` | — | Exact persisted session to resume | diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 9de5b112e8..ad2e92be5d 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "^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-query": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -69,6 +70,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index a8b3dc7654..69b6a3a291 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -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 SessionQueryService from '@deepseek-ai/dsh-session-query' import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' @@ -124,6 +125,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(SessionQueryService) ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) ctx.plugin(UserInteractionService) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 815be70ff6..8c05abfbab 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -50,6 +50,7 @@ describe('dsh-tui-demo app', () => { 'CommandService', 'command-goal', 'SessionPersistenceJsonl', + 'session-checkpoint-policy', 'SessionQueryService', 'SessionReferenceService', 'UserInteractionService', @@ -59,12 +60,12 @@ describe('dsh-tui-demo app', () => { ]) expect(calls[0]?.config).toBeUndefined() expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ + expect(calls[5]?.config).toEqual({ maxReferences: 2, candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[6]?.config as { sessionId: string } + const tuiConfig = calls[7]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', @@ -72,7 +73,7 @@ describe('dsh-tui-demo app', () => { maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[7]?.config as { + const spineConfig = calls[8]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -106,10 +107,10 @@ describe('dsh-tui-demo app', () => { }) expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[4]?.config).toEqual({}) + expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[6]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[7]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[7]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[8]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -125,12 +126,12 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[5]?.config as { sessionId: string } + const tuiConfig = calls[6]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[6]?.config as { agents: Array> }).agents[0]) + expect((calls[7]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[6]?.config).toMatchObject({ goals: false }) + expect(calls[7]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index fd4876039d..cb219721a5 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -53,6 +53,9 @@ { "path": "../../ui/tool-ask-user" }, + { + "path": "../../session-persistence/session-checkpoint-policy" + }, { "path": "../../session-persistence/session-persistence-jsonl" }, diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 6435a4bea7..d1e4b2286e 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -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`) | diff --git a/packages/session-persistence/session-checkpoint-policy/README.md b/packages/session-persistence/session-checkpoint-policy/README.md new file mode 100644 index 0000000000..004c49c5cb --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/README.md @@ -0,0 +1,45 @@ +# 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' +``` + +Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the 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. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work. + +The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions. + +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. diff --git a/packages/session-persistence/session-checkpoint-policy/package.json b/packages/session-persistence/session-checkpoint-policy/package.json new file mode 100644 index 0000000000..5d0fe5f465 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/package.json @@ -0,0 +1,52 @@ +{ + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.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-invariants": "^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-invariants": "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" + } +} diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts new file mode 100644 index 0000000000..138e45db2d --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -0,0 +1,75 @@ +/** + * 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 { TOOL_ABORTED_BEFORE_DISPATCH, 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, +): AsyncIterable { + return (async function* (): AsyncIterable { + await ctx.sessions.flush(session) + yield* next() + })() +} + +/** Materialize the canonical result for a call cancelled before tool dispatch. */ +function abortedBeforeDispatchResult(): ToolExecutionResult { + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + } +} + +/** + * 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 => { + 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 => { + if (exec.agent === undefined || exec.parent !== undefined) return next() + await ctx.sessions.flush(exec.agent.session) + if (exec.signal.aborted) return abortedBeforeDispatchResult() + return next() + }) + + ctx.on('agent/post-step', (agent): Promise => ctx.sessions.flush(agent.session)) +} diff --git a/packages/session-persistence/session-checkpoint-policy/src/invariant.ts b/packages/session-persistence/session-checkpoint-policy/src/invariant.ts new file mode 100644 index 0000000000..f6baece911 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`. + * @module @deepseek-ai/dsh-session-checkpoint-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy' + +/** Cordis companion plugin name. */ +export const name = 'session-checkpoint-policy-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and + * persistence seams; this stateless policy owns no independent mutable relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts new file mode 100644 index 0000000000..411e374833 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -0,0 +1,106 @@ +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[] = [] +const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS + for (;;) { + try { + await access(path) + return + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +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 { + 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.') + }) +}) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts new file mode 100644 index 0000000000..17a9aec997 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -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 { + 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 ') +} +const persistenceRoot = root +const failpoint = marker + +class CrashAdapter extends LlmAdapter { + async * stream(_options: GenerateOptions): AsyncIterable { + 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() diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts new file mode 100644 index 0000000000..2056d30729 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -0,0 +1,249 @@ +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, { TOOL_ABORTED_BEFORE_DISPATCH } 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 { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return Promise.reject(new Error('not used')) + } + list(): Promise { return Promise.resolve([]) } +} + +class RecordingAdapter extends LlmAdapter { + constructor(private readonly order: string[]) { super() } + async * stream(_options: GenerateOptions): AsyncIterable { + this.order.push('adapter') + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function setup(): Promise { + 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): Promise { + 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() + 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() + 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, + signal: new AbortController().signal, + }) + 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('does not dispatch when cancellation lands during the tool checkpoint', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel')) + const agent = { session } as Agent + const controller = new AbortController() + const gate = Promise.withResolvers() + 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-cancelled'), name: 'write', arguments: {}, agent, + signal: controller.signal, + }) + await Promise.resolve() + expect(order).toEqual(['flush:start']) + controller.abort('cancelled during checkpoint') + gate.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(order).toEqual(['flush:start', 'flush:end']) + }) + + 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, + signal: new AbortController().signal, + }) + 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, + signal: new AbortController().signal, + }) + 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 + 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') + }) +}) diff --git a/packages/session-persistence/session-checkpoint-policy/tsconfig.json b/packages/session-persistence/session-checkpoint-policy/tsconfig.json new file mode 100644 index 0000000000..1b81e05951 --- /dev/null +++ b/packages/session-persistence/session-checkpoint-policy/tsconfig.json @@ -0,0 +1,36 @@ +{ + "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": "../../support/invariants" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index cf766d2057..0362d49a96 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -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 the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. 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 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 4c346390a9..3c676bb47d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -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] diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 8099aaf9cc..39619ff60a 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -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 diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 4a7f12e759..da29c73b5d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -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 } diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 6d9e1392fe..045686ba5b 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -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. @@ -25,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l `PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator implements durability at checkpoints but does not select their schedule. Persisted deployments explicitly compose [`dsh-session-checkpoint-policy`](../session-checkpoint-policy) when they want request-, tool-dispatch-, and completed-step recovery boundaries; omitting it leaves the loop's coarser checkpoints intact. + When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle. The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. @@ -59,7 +61,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 diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 84386c016b..aaea0ddfff 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -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 { + 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 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 { + 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 { diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index be4573aeb0..2821969457 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,6 +21,7 @@ import { existsSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' import { ClientSideConnection, PROTOCOL_VERSION, @@ -34,6 +35,9 @@ import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } fr export type { AgentUnderTest } from './launcher.ts' +const DEFAULT_WAIT_TIMEOUT_MS = 10_000 +const WAIT_POLL_INTERVAL_MS = 10 + /** * One step of a scenario's deterministic input script (`input.json`). The * harness interprets these in order. `newSession` captures the server-issued @@ -42,10 +46,13 @@ export type { AgentUnderTest } from './launcher.ts' * * `promptAndCancel` starts a prompt without awaiting completion, waits until * the client observes the selected update (`agent_message_chunk` by default), - * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the - * step open for a terminal tool update that may follow the prompt response. + * then cancels and awaits completion. An optional `waitForFile` first observes + * a cwd-relative readiness marker, and a named `waitForToolCallUpdate` keeps + * the step open for a terminal tool update that may follow the prompt response. * `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending * the prompt, then keeps the application live until that later update arrives. + * `waitForTurnEnd` holds the subprocess open until the selected session's latest + * complete raw-JSONL turn boundary is `turn/end`; its timeout defaults to 10s. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -58,8 +65,10 @@ export type InputStep = op: 'promptAndCancel' text: string afterUpdate?: 'agent_message_chunk' | 'tool_call' + waitForFile?: { path: string; timeoutMs?: number } waitForToolCallUpdate?: string } + | { op: 'waitForTurnEnd'; timeoutMs?: number } | { op: 'cancel' } | { op: 'setMode'; modeId: string } | { op: 'setModeExpectError'; modeId: string } @@ -295,7 +304,15 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) + await runStep( + client, + step, + cwd, + match => active.waitForUpdate(match), + () => sessionId, + (id) => { sessionId = id }, + (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), + ) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -365,6 +382,7 @@ async function runStep( waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, + waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, ): Promise { switch (step.op) { case 'initialize': @@ -429,6 +447,9 @@ async function runStep( const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' await waitForUpdate(u => u.sessionUpdate === afterUpdate) + if (step.waitForFile !== undefined) { + await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs) + } // Arm this before cancellation so a fast tool drain cannot outrun the waiter. const toolCallUpdateDone = step.waitForToolCallUpdate === undefined ? undefined @@ -438,6 +459,12 @@ async function runStep( if (toolCallUpdateDone !== undefined) await toolCallUpdateDone return } + case 'waitForTurnEnd': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnEnd before newSession') + await waitForTurnEnd(sessionId, step.timeoutMs) + return + } case 'cancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: cancel before newSession') @@ -485,6 +512,51 @@ async function runStep( } } +/** + * Wait until the raw JSONL backend exposes one complete closing turn boundary. + * The ACP cancel notification settles its prompt before the agent necessarily + * reaches quiescence, so cancellation snapshots use this external boundary to + * keep subprocess disposal from changing an `aborted` turn into `disposed`. + */ +async function waitForPersistedTurnEnd( + root: string, + sessionId: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs + while (true) { + const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) + if (log !== undefined && latestTurnIsClosed(log.content)) return + if (Date.now() >= deadline) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) + } + await delay(WAIT_POLL_INTERVAL_MS) + } +} + +/** Wait for a cwd-relative marker proving an external action reached readiness. */ +async function waitForWorkspaceFile( + cwd: string, + path: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + const target = join(cwd, path) + const deadline = Date.now() + timeoutMs + while (!existsSync(target)) { + if (Date.now() >= deadline) { + throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) + } + await delay(WAIT_POLL_INTERVAL_MS) + } +} + +/** Return whether the last complete raw-JSONL turn boundary closes its turn. */ +function latestTurnIsClosed(content: string): boolean { + const complete = content.slice(0, content.lastIndexOf('\n') + 1) + return complete.lastIndexOf('\n{"type":"turn/end",') + > complete.lastIndexOf('\n{"type":"turn/start",') +} + /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 26d2c41638..df3bb0b970 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -49,6 +49,8 @@ interface Behavior { cancelAtToolCall?: boolean /** Emit the parked tool call's terminal update after answering cancellation. */ cancelToolCallUpdate?: boolean + /** Persist the scripted logs while handling cancellation, before stdin EOF. */ + persistLogsOnCancel?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */ @@ -63,7 +65,7 @@ interface Behavior { stderrNote?: string /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ lateInheritedOutput?: boolean - /** Session logs to persist on stdin EOF. */ + /** Session logs to persist on stdin EOF and, when selected, on cancellation. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ strayRootFile?: boolean @@ -304,6 +306,7 @@ function handleFrame(frame: Record): void { }, }) } + if (behavior.persistLogsOnCancel === true) writeLogs() } return default: @@ -313,12 +316,16 @@ function handleFrame(frame: Record): void { } } -function flushLogsAndExit(): void { +function writeLogs(): void { for (const log of behavior.logs ?? []) { const target = join(sessionsRoot, log.file) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, log.lines.map(l => JSON.stringify(instantiate(l))).join('\n') + '\n') } +} + +function flushLogsAndExit(): void { + writeLogs() if (behavior.strayRootFile === true) writeFileSync(join(sessionsRoot, 'stray.txt'), 'not a bucket\n') if (behavior.strayBucketFile === true) { mkdirSync(join(sessionsRoot, 'bucket-noise'), { recursive: true }) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ca88c51b46..a87e72d3d4 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -493,6 +493,37 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) + it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' }) + const workspaceDir = join(dir, 'workspace') + const { mkdir } = await import('node:fs/promises') + await mkdir(workspaceDir, { recursive: true }) + await writeFile(join(workspaceDir, 'started.txt'), 'started') + const result = await runScenario( + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + waitForFile: { path: 'started.txt' }, + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir }, + ) + expect(result.rawStdout).toContain('"stopReason":"cancelled"') + + const missing = await scenario({ prompt: 'hang-until-cancel' }) + await expect(runScenario( + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + waitForFile: { path: 'never.txt', timeoutMs: 20 }, + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, + )).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/) + }) + it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'respond' }) const result = await runScenario( @@ -530,6 +561,55 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"')) }) + it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'bucket/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + ], + }], + }) + const result = await runScenario( + { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForTurnEnd' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"') + }) + + it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => { + const missing = await scenario({}) + await expect(runScenario( + { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] }, + { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, + )).rejects.toThrow(/did not persist turn\/end within 20ms/) + + const open = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'bucket/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ], + }], + }) + await expect(runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTurnEnd', timeoutMs: 20 }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile }, + )).rejects.toThrow(/did not persist turn\/end within 20ms/) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( @@ -620,6 +700,7 @@ describe('runScenario', () => { [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], + [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/], [{ op: 'cancel' }, /cancel before newSession/], [{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/], [{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd4d80b45f..ac1cfe96b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,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 @@ -1217,6 +1220,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@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 @@ -1371,6 +1377,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 @@ -1438,6 +1447,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 @@ -2530,6 +2542,45 @@ 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-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@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-invariants': @@ -4094,6 +4145,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 00f0fe0035..90433c4f5f 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 -README.md: cdf38d4474a0e0148a4804e14c12971c76b38e27 -README.zh.md: 99d57c6f900371a46b94c5ea80b2a1665fd5e8d1 +README.md: f2ccd8939e497d10359aafe8b1bd8b364875ed98 +README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index cdf38d4474..f2ccd8939e 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -26,4 +26,4 @@ Each wheel contains exactly one executable. The fixed tags are `py3-none-manylin ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, local bash, and a local filesystem provider for bounded workspace-instruction loading. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 99d57c6f90..30bdf46fee 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 41d06a2a4e..dbafb21881 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index a0eccdf483..824aa03e7b 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -27,6 +27,11 @@ config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' +# Persistence owns durable storage; this separate policy explicitly selects +# the request, tool-dispatch, and completed-step durability checkpoints. +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + # Local bash executor; $DSH_CWD wins over the process cwd. - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 181df4986e..956d6f8ff8 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 -README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14 -README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211 +README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b +README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534 diff --git a/python/sdk/README.md b/python/sdk/README.md index 5fd1bc7cd8..23d15d617b 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -19,7 +19,7 @@ with DeepSeekHarness() as harness: `DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. -By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. +By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 247a2ca5ea..4f6aef1383 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -15,7 +15,7 @@ with DeepSeekHarness() as harness: `DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。 -默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 +默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、配有显式组合语义检查点策略的 JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index bc1da849b2..6ff14fa266 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -28,6 +28,8 @@ _CORDIS_YML = """\ name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: './sessions' +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 4858686191..400394ae4e 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -15,7 +15,10 @@ from deepseek_harness_runtime import ( def test_default_config_is_shipped_with_the_package() -> None: path = bundled_default_config_path() assert path == bundled_package_dir() / "runtime" / "cordis.yml" - assert "@deepseek-ai/dsh-agent-spine-demo" in path.read_text() + config = path.read_text() + assert "@deepseek-ai/dsh-agent-spine-demo" in config + assert "@deepseek-ai/dsh-session-persistence-jsonl" in config + assert "@deepseek-ai/dsh-session-checkpoint-policy" in config def test_unknown_explicit_mode_fails_loud() -> None: diff --git a/tsconfig.build.json b/tsconfig.build.json index 2c1c30cfd0..f5217903a8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,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" }, diff --git a/tsconfig.json b/tsconfig.json index bb102e01c2..348c75f6c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,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" },