mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(tui): defer cross-process resume locking
This commit is contained in:
@@ -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
|
||||
2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4
|
||||
2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d
|
||||
2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd
|
||||
2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d
|
||||
|
||||
@@ -6,17 +6,15 @@ English | [中文](2026-07-21-tui-resume-command.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, detect another live owner, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement.
|
||||
The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement.
|
||||
|
||||
## Decision
|
||||
|
||||
`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and another live owner's session remain visible but disabled.
|
||||
`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled.
|
||||
|
||||
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly.
|
||||
`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
|
||||
|
||||
First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race.
|
||||
|
||||
After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
|
||||
After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable.
|
||||
|
||||
@@ -32,10 +30,10 @@ After preflight, the TUI claims the target's exec-stable live lease before flush
|
||||
|
||||
## Consequences
|
||||
|
||||
- Persistence schema and artifact layout include live leases; SQLite advances its unreleased schema version and rejects older databases under the repository's pre-release policy.
|
||||
- Concurrent processes can select or resume the same persisted session because preflight does not serialize them.
|
||||
- `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable.
|
||||
- Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives.
|
||||
|
||||
## Testing
|
||||
|
||||
TUI tests cover keyboard navigation, title/id search, Escape cancellation, running-agent refusal, route absence, occupied and corrupt rows, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Persistence contracts retain valid/corrupt/interrupted behavior, while a real JSONL child process proves another owner is disabled and its crashed lease is reclaimed. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame.
|
||||
TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame.
|
||||
|
||||
@@ -6,17 +6,15 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。
|
||||
原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。
|
||||
|
||||
## Decision
|
||||
|
||||
`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。
|
||||
`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。
|
||||
|
||||
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。
|
||||
`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
|
||||
|
||||
第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。
|
||||
|
||||
预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
|
||||
预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
|
||||
|
||||
`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。
|
||||
|
||||
@@ -32,10 +30,10 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。
|
||||
- 预检不会串行化不同进程;多个进程可以并发选择或恢复同一个持久化会话。
|
||||
- `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。
|
||||
- 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。
|
||||
|
||||
## Testing
|
||||
|
||||
TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。
|
||||
TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。
|
||||
|
||||
@@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and atomically replaces the process with a normalized resume flag so only one runtime owns the terminal; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
@@ -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: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7
|
||||
architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42
|
||||
architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669
|
||||
architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b
|
||||
|
||||
@@ -67,7 +67,7 @@ The shipped loop runs prompt-to-checkpoint work through plugin services and even
|
||||
|
||||
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.
|
||||
|
||||
Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume claims a live lease before load, restores lineage and delegation depth before publication, and releases after quiescence. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent.
|
||||
Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent.
|
||||
|
||||
### Turn Flow
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 `
|
||||
|
||||
**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。
|
||||
|
||||
未提供 id 时会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。
|
||||
未提供 id 时会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程会在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。
|
||||
|
||||
### 轮次流程
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:378`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||
|
||||
@@ -957,7 +957,7 @@ export interface Config {
|
||||
export type JsonlCompression = 'zstd' | 'none'
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-sqlite`
|
||||
|
||||
@@ -996,7 +996,7 @@ export interface Config {
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-query-sqlite`
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
|
||||
|
||||
Types: [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:416`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:398`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -961,29 +961,11 @@ abstract list(): Promise<SessionHeader[]>
|
||||
* @returns one header and opaque revision per materialized session without loading full logs.
|
||||
*/
|
||||
abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>
|
||||
|
||||
/**
|
||||
* Atomically acquire this process's live ownership of a session id.
|
||||
* Reentrant claims share one backend lease. First-party backends override
|
||||
* this process-local fallback to reject another live process and reclaim a
|
||||
* dead owner.
|
||||
* @param id - session identity that is about to become live.
|
||||
* @returns a single-release reference owned by the caller.
|
||||
*/
|
||||
claimLive(id: SessionId): Promise<SessionLiveLease>
|
||||
|
||||
/**
|
||||
* Check whether any process currently owns a live lease for this session.
|
||||
* The base implementation reports only claims on this service instance.
|
||||
* @param id - persisted or prospective session identity.
|
||||
* @returns true while a non-stale lease exists, including this process's lease.
|
||||
*/
|
||||
isLive(id: SessionId): Promise<boolean>
|
||||
```
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md)
|
||||
Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md)
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessionQuery` — `SessionQueryService` (abstract seam)
|
||||
|
||||
|
||||
@@ -34,18 +34,6 @@ interface SessionLocation {
|
||||
}
|
||||
```
|
||||
|
||||
## `SessionLiveLease` — live ownership capability
|
||||
|
||||
`claimLive(id)` returns one idempotent release capability. The base service tracks only its own process; first-party backends additionally reject another live process and reclaim a dead owner's lease. `isLive(id)` reports either local or backend ownership without claiming it.
|
||||
|
||||
```ts type-equiv
|
||||
/** Idempotent capability releasing one acquired live-session lease reference. */
|
||||
interface SessionLiveLease {
|
||||
/** Release this caller's lease reference after its live session reaches quiescence. */
|
||||
release(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## `SessionHeader` — metadata beside the log
|
||||
|
||||
Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`.
|
||||
|
||||
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:371`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `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) |
|
||||
|
||||
@@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
|
||||
dsh --resume <prior-session-id>
|
||||
```
|
||||
|
||||
`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then atomically replaces the process with `dsh --resume <id>`; the terminal never has two owners. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero.
|
||||
`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
|
||||
|
||||
## Code Mode
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
|
||||
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
it('hands /resume to one exec-replaced terminal owner and restores the same session state', async () => {
|
||||
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh in-place resume',
|
||||
tempDirPrefix: 'dsh-in-place-resume-',
|
||||
|
||||
@@ -480,14 +480,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>',
|
||||
jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'claimLive(id: SessionId): Promise<SessionLiveLease>',
|
||||
jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'isLive(id: SessionId): Promise<boolean>',
|
||||
jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1817,10 +1809,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionLineageTrace',
|
||||
declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});',
|
||||
},
|
||||
{
|
||||
name: 'SessionLiveLease',
|
||||
declaration: 'export interface SessionLiveLease {\n release(): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionLocation',
|
||||
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
|
||||
|
||||
@@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import {
|
||||
bindReactLoopAgentContext,
|
||||
prepareReactLoopAgent,
|
||||
@@ -114,7 +114,6 @@ class AgentCreationTransaction {
|
||||
private scope: Scope | undefined
|
||||
private session: Session | undefined
|
||||
private lifecycleDispose: (() => Promise<void> | void) | undefined
|
||||
private liveLease: SessionLiveLease | undefined
|
||||
private detachSession: (() => void) | undefined
|
||||
private detachAgent: (() => void) | undefined
|
||||
private publishing = false
|
||||
@@ -187,12 +186,6 @@ class AgentCreationTransaction {
|
||||
])
|
||||
}
|
||||
|
||||
/** Retain a pre-load persistence lease until this transaction fully tears down. */
|
||||
holdLiveLease(lease: SessionLiveLease): void {
|
||||
this.assertActive()
|
||||
this.liveLease = lease
|
||||
}
|
||||
|
||||
/** Construct the driver and scope, then install their complete ordered lifecycle. */
|
||||
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
|
||||
this.assertActive()
|
||||
@@ -226,11 +219,6 @@ class AgentCreationTransaction {
|
||||
// First yielded, disposed last.
|
||||
yield () => { this.finish() }
|
||||
yield scope.rawDispose
|
||||
yield async () => {
|
||||
const lease = this.liveLease
|
||||
this.liveLease = undefined
|
||||
await lease?.release()
|
||||
}
|
||||
yield () => {
|
||||
this.detachSession?.()
|
||||
this.detachSession = undefined
|
||||
@@ -327,13 +315,7 @@ class AgentCreationTransaction {
|
||||
try {
|
||||
await this.scope?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
const lease = this.liveLease
|
||||
this.liveLease = undefined
|
||||
await lease?.release()
|
||||
} finally {
|
||||
this.finish()
|
||||
}
|
||||
this.finish()
|
||||
}
|
||||
}
|
||||
})())
|
||||
@@ -625,15 +607,6 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
const claiming = persistence.claimLive(options.resumeSessionId)
|
||||
let lease: SessionLiveLease
|
||||
try {
|
||||
lease = await transaction.waitFor(claiming)
|
||||
} catch (error) {
|
||||
void claiming.then(claim => claim.release(), () => {})
|
||||
throw error
|
||||
}
|
||||
transaction.holdLiveLease(lease)
|
||||
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
|
||||
transaction.assertActive()
|
||||
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
|
||||
@@ -391,51 +391,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload during live-lease acquisition releases a late claim', async () => {
|
||||
const sessionId = SessionId('resume-claim-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const claiming = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.claimLive>>>()
|
||||
const claimStarted = Promise.withResolvers<undefined>()
|
||||
const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.claimLive = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
claimStarted.resolve(undefined)
|
||||
return claiming.promise
|
||||
}
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ resumeSessionId: sessionId })
|
||||
}, { inject: ['agents'] }))
|
||||
await claimStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
await rejection
|
||||
|
||||
let releases = 0
|
||||
claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(releases).toBe(1)
|
||||
ctx.sessionPersistence.claimLive = originalClaim
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('propagates a rejected live-lease claim without loading or publishing', async () => {
|
||||
const sessionId = SessionId('resume-claim-rejected')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere'))
|
||||
ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) }
|
||||
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
|
||||
.rejects.toThrow('occupied elsewhere')
|
||||
expect(loads).toBe(0)
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
|
||||
@@ -45,7 +45,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
|
||||
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
|
||||
| `resumeSessionId` | — | Exact persisted session to resume |
|
||||
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff.
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
|
||||
|
||||
## The bin
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
|
||||
```
|
||||
<root>/
|
||||
.live/
|
||||
<encoded-id>.lock # PID + nonce cross-process live lease
|
||||
<encoded-id>.lock.reclaim # ephemeral stale-owner takeover guard
|
||||
cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd)
|
||||
<encoded-id>.jsonl.zstd # default: checksummed header frame + append frames
|
||||
<encoded-id>.jsonl # only with compression: 'none'
|
||||
@@ -46,7 +43,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
|
||||
|
||||
## Write path
|
||||
|
||||
The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/<encoded-id>.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease.
|
||||
The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -69,6 +66,5 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
|
||||
- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration.
|
||||
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
|
||||
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
|
||||
- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals.
|
||||
- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active.
|
||||
- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement.
|
||||
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.
|
||||
|
||||
@@ -14,9 +14,8 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
sessionLeaseOwnerIsLive, shareSessionLiveLease,
|
||||
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
|
||||
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -65,11 +64,6 @@ interface JsonlTornMarker {
|
||||
recoveredEvents: SessionEvent[]
|
||||
}
|
||||
|
||||
interface JsonlLiveLeaseRecord {
|
||||
pid: number
|
||||
nonce: string
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
@@ -141,14 +135,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
return this.coordinator.inspect(id)
|
||||
}
|
||||
|
||||
override claimLive(id: SessionId): Promise<SessionLiveLease> {
|
||||
return this.coordinator.claimLive(id)
|
||||
}
|
||||
|
||||
override isLive(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.isLive(id)
|
||||
}
|
||||
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
@@ -288,110 +274,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
return snapshots
|
||||
}
|
||||
|
||||
/** Atomically publish one process lease, reclaiming a crashed owner's record. */
|
||||
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
|
||||
const path = this.liveLeasePath(id)
|
||||
return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner))
|
||||
}
|
||||
|
||||
private async acquireLiveFile(
|
||||
path: string,
|
||||
id: SessionId,
|
||||
owner: SessionLiveOwner,
|
||||
): Promise<() => Promise<void>> {
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
||||
for (;;) {
|
||||
try {
|
||||
const handle = await open(path, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8')
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
break
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
const current = await this.readLiveLease(path)
|
||||
if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break
|
||||
if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) {
|
||||
throw new Error(`session "${id}" is occupied by another live process`)
|
||||
}
|
||||
const reclaimPath = `${path}.reclaim`
|
||||
let reclaim: Awaited<ReturnType<typeof open>>
|
||||
try {
|
||||
reclaim = await open(reclaimPath, 'wx', 0o600)
|
||||
} catch (reclaimError) {
|
||||
/* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */
|
||||
if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new Error(`session "${id}" live-lease reclamation is already in progress`)
|
||||
}
|
||||
/* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */
|
||||
throw reclaimError
|
||||
}
|
||||
try {
|
||||
/* v8 ignore start -- cross-process revalidation is covered by the two-process race test */
|
||||
const latest = await this.readLiveLease(path)
|
||||
if (latest === undefined) {
|
||||
if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`)
|
||||
} else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) {
|
||||
if (sessionLeaseOwnerIsLive(latest, owner)) {
|
||||
throw new Error(`session "${id}" is occupied by another live process`)
|
||||
}
|
||||
await rm(path, { force: true })
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
} finally {
|
||||
try {
|
||||
await reclaim.close()
|
||||
} finally {
|
||||
await rm(reclaimPath, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return async () => {
|
||||
const current = await this.readLiveLease(path)
|
||||
if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */
|
||||
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
|
||||
const path = this.liveLeasePath(id)
|
||||
const current = await this.readLiveLease(path)
|
||||
if (current === undefined) return await this.exists(path)
|
||||
if (current.pid === owner.pid && current.nonce === owner.nonce) return true
|
||||
if (sessionLeaseOwnerIsLive(current, owner)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private liveLeasePath(id: SessionId): string {
|
||||
return join(this.root, '.live', `${encodeSegment(id)}.lock`)
|
||||
}
|
||||
|
||||
private async readLiveLease(path: string): Promise<JsonlLiveLeaseRecord | undefined> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(path, 'utf8')
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== 'object' || value === null
|
||||
|| !Number.isSafeInteger((value as { pid?: unknown }).pid)
|
||||
|| (value as { pid: number }).pid <= 0
|
||||
|| typeof (value as { nonce?: unknown }).nonce !== 'string'
|
||||
|| (value as { nonce: string }).nonce.length === 0) return undefined
|
||||
return value as JsonlLiveLeaseRecord
|
||||
}
|
||||
|
||||
private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> {
|
||||
await this.ensureRootEncoding()
|
||||
const artifacts: Array<{ header: SessionHeader; path: string }> = []
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Child process that holds one JSONL live-session lease until it is killed. */
|
||||
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
const [root, marker] = process.argv.slice(2)
|
||||
if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts <root> <marker>')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await ctx.sessionPersistence.claimLive(SessionId('leased-session'))
|
||||
await writeFile(marker, 'held')
|
||||
await new Promise<never>(() => { setInterval(() => {}, 60_000) })
|
||||
@@ -1,33 +0,0 @@
|
||||
/** Child process competing to reclaim one stale JSONL live-session lease. */
|
||||
|
||||
import { access, writeFile } from 'node:fs/promises'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
const [root, gate, marker, rawId] = process.argv.slice(2)
|
||||
if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) {
|
||||
throw new Error('usage: live-lease-race-child.ts <root> <gate> <marker> <session-id>')
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await access(gate)
|
||||
break
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
try {
|
||||
await ctx.sessionPersistence.claimLive(SessionId(rawId))
|
||||
await writeFile(marker, 'claimed')
|
||||
await new Promise<never>(() => { setInterval(() => {}, 60_000) })
|
||||
} catch (error) {
|
||||
await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { Context } from 'cordis'
|
||||
import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
let root: string
|
||||
const dirs: string[] = []
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url))
|
||||
const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
@@ -149,186 +142,6 @@ describe('SessionPersistenceJsonl: format helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceJsonl: cross-process live leases', () => {
|
||||
it('reference-counts one physical lease across backend instances in the process', async () => {
|
||||
const dir = await freshRoot()
|
||||
const contexts = [new Context(), new Context()]
|
||||
for (const ctx of contexts) {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
|
||||
}
|
||||
try {
|
||||
const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live'))
|
||||
const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live'))
|
||||
await first.release()
|
||||
await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true)
|
||||
await second.release()
|
||||
await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false)
|
||||
} finally {
|
||||
await Promise.all(contexts.map(ctx => ctx.fiber.dispose()))
|
||||
}
|
||||
})
|
||||
|
||||
it('disables another live owner and reclaims its lease after the process exits', async () => {
|
||||
const dir = await freshRoot()
|
||||
const marker = join(dir, 'lease-held')
|
||||
const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, 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 vi.waitFor(() => access(marker), { timeout: 30_000 })
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
|
||||
try {
|
||||
await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true)
|
||||
await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session')))
|
||||
.rejects.toThrow('occupied by another live process')
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => { resolve() }))
|
||||
child.kill()
|
||||
await closed
|
||||
await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false)
|
||||
const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`)
|
||||
await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`)
|
||||
const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session'))
|
||||
await claim.release()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`live-lease child failed: ${stderr}`, { cause: error })
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill()
|
||||
}
|
||||
}, 40_000)
|
||||
|
||||
it('fails closed on malformed lease records and surfaces lease read errors', async () => {
|
||||
const dir = await freshRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
|
||||
const liveDir = join(dir, '.live')
|
||||
await mkdir(liveDir, { recursive: true })
|
||||
try {
|
||||
const malformed = [
|
||||
'not json',
|
||||
JSON.stringify(null),
|
||||
JSON.stringify({ pid: 1.5, nonce: 'x' }),
|
||||
JSON.stringify({ pid: 0, nonce: 'x' }),
|
||||
JSON.stringify({ pid: process.pid, nonce: 1 }),
|
||||
JSON.stringify({ pid: process.pid, nonce: '' }),
|
||||
]
|
||||
for (const [index, content] of malformed.entries()) {
|
||||
const id = SessionId(`malformed-${index}`)
|
||||
const path = join(liveDir, `${encodeSegment(id)}.lock`)
|
||||
await writeFile(path, content)
|
||||
await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true)
|
||||
await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process')
|
||||
}
|
||||
|
||||
const unreadable = SessionId('unreadable-lease')
|
||||
await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`))
|
||||
await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow()
|
||||
|
||||
const replaced = SessionId('replaced-release')
|
||||
const claim = await ctx.sessionPersistence.claimLive(replaced)
|
||||
const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`)
|
||||
await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' }))
|
||||
await claim.release()
|
||||
expect(await readFile(replacedPath, 'utf8')).toContain('replacement')
|
||||
|
||||
const inherited = SessionId('inherited-owner')
|
||||
const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`)
|
||||
await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner()))
|
||||
await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true)
|
||||
const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited)
|
||||
await inheritedClaim.release()
|
||||
|
||||
const reusedPid = SessionId('reused-pid')
|
||||
const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`)
|
||||
await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' }))
|
||||
await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false)
|
||||
const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid)
|
||||
await reusedPidClaim.release()
|
||||
|
||||
await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300))))
|
||||
.rejects.toThrow()
|
||||
|
||||
const guarded = SessionId('guarded-reclaim')
|
||||
const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`)
|
||||
await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }))
|
||||
await writeFile(`${guardedPath}.reclaim`, 'busy')
|
||||
await expect(ctx.sessionPersistence.claimLive(guarded))
|
||||
.rejects.toThrow('reclamation is already in progress')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows exactly one process to reclaim a stale lease', async () => {
|
||||
const dir = await freshRoot()
|
||||
const liveDir = join(dir, '.live')
|
||||
await mkdir(liveDir, { recursive: true })
|
||||
const sessionId = SessionId('reclaim-race')
|
||||
await writeFile(
|
||||
join(liveDir, `${encodeSegment(sessionId)}.lock`),
|
||||
JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }),
|
||||
)
|
||||
const gate = join(dir, 'race-start')
|
||||
const markers = [join(dir, 'race-a'), join(dir, 'race-b')]
|
||||
const children = markers.map(marker => spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
},
|
||||
))
|
||||
const errors = ['', '']
|
||||
children.forEach((child, index) => {
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk })
|
||||
})
|
||||
try {
|
||||
await writeFile(gate, 'go')
|
||||
await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 })
|
||||
const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8')))
|
||||
expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1)
|
||||
expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1)
|
||||
|
||||
const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]!
|
||||
const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]!
|
||||
if (loser.exitCode === null && loser.signalCode === null) {
|
||||
await new Promise<void>(resolve => loser.once('close', () => { resolve() }))
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
|
||||
try {
|
||||
await expect(ctx.sessionPersistence.claimLive(sessionId))
|
||||
.rejects.toThrow('occupied by another live process')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
const closed = new Promise<void>(resolve => winner.once('close', () => { resolve() }))
|
||||
winner.kill()
|
||||
await closed
|
||||
} catch (error) {
|
||||
throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error })
|
||||
} finally {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill()
|
||||
}
|
||||
}
|
||||
}, 40_000)
|
||||
})
|
||||
|
||||
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
let ctx: Context
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
|
||||
|
||||
## Storage model
|
||||
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
|
||||
|
||||
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Config {
|
||||
|
||||
## Write path
|
||||
|
||||
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database.
|
||||
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -57,4 +57,3 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
|
||||
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
|
||||
- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
|
||||
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
|
||||
- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it.
|
||||
|
||||
@@ -15,9 +15,8 @@ import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
sessionLeaseOwnerIsLive, shareSessionLiveLease,
|
||||
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
|
||||
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -162,14 +161,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.coordinator.inspect(id)
|
||||
}
|
||||
|
||||
override claimLive(id: SessionId): Promise<SessionLiveLease> {
|
||||
return this.coordinator.claimLive(id)
|
||||
}
|
||||
|
||||
override isLive(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.isLive(id)
|
||||
}
|
||||
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
@@ -280,55 +271,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}))
|
||||
}
|
||||
|
||||
/** Atomically acquire one SQLite-backed process lease. */
|
||||
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
|
||||
await this.ready
|
||||
return shareSessionLiveLease(
|
||||
`sqlite:${this.storeIdentity}:${id}`,
|
||||
() => Promise.resolve().then(() => this.acquireLiveRow(id, owner)),
|
||||
)
|
||||
}
|
||||
|
||||
private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const current = this.liveLeaseFor(id)
|
||||
if (current !== undefined
|
||||
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
|
||||
if (sessionLeaseOwnerIsLive(current, owner)) {
|
||||
throw new Error(`session "${id}" is occupied by another live process`)
|
||||
}
|
||||
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
|
||||
}
|
||||
this.db.prepare(`
|
||||
INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce
|
||||
`).run(id, owner.pid, owner.nonce)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await this.ready
|
||||
this.db.prepare(
|
||||
'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?',
|
||||
).run(id, owner.pid, owner.nonce)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a non-stale SQLite lease and remove a crashed owner's row. */
|
||||
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
|
||||
await this.ready
|
||||
const current = this.liveLeaseFor(id)
|
||||
if (current === undefined) return false
|
||||
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|
||||
|| sessionLeaseOwnerIsLive(current, owner)) return true
|
||||
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?')
|
||||
.run(id, current.pid, current.nonce)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Close the database handle (awaited by the coordinator's dispose, post-drain). */
|
||||
async close(): Promise<void> {
|
||||
await this.ready
|
||||
@@ -342,11 +284,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
|
||||
}
|
||||
|
||||
private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined {
|
||||
return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?')
|
||||
.get(id) as { pid: number; nonce: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-replace a session's metadata row. The only caller is the first
|
||||
* materializing `appendBatch`, so writing the row IS the materialization (its
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 9
|
||||
export const SCHEMA_VERSION = 8
|
||||
|
||||
/**
|
||||
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
|
||||
@@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
* rather than being migrated in place.
|
||||
* @param path - the SQLite database file to open (created when absent).
|
||||
* @param journalMode - validated journal pragma.
|
||||
* @returns the open handle with pragmas applied and all tables ensured.
|
||||
* @returns the open handle with pragmas applied and all three tables ensured.
|
||||
*/
|
||||
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
@@ -128,13 +128,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS live_session_leases (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
pid INTEGER NOT NULL,
|
||||
nonce TEXT NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
@@ -7,16 +7,12 @@ import { dirname, join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
|
||||
try {
|
||||
@@ -446,7 +442,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(9)
|
||||
expect(SCHEMA_VERSION).toBe(8)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
@@ -462,47 +458,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => {
|
||||
const path = await freshDbPath()
|
||||
const b = await backend(path)
|
||||
await b.ctx.sessionPersistence.list()
|
||||
const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite
|
||||
const owner = sessionLiveOwner()
|
||||
const occupiedPid = process.pid + 1
|
||||
const originalKill = process.kill.bind(process)
|
||||
vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === occupiedPid) return true
|
||||
return originalKill(pid, signal)
|
||||
})
|
||||
const db = openDatabase(path, 'wal')
|
||||
const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)')
|
||||
insert.run('occupied-lease', occupiedPid, 'another-owner')
|
||||
insert.run('reused-pid', process.pid, 'prior-incarnation')
|
||||
insert.run('stale-claim', 2_147_483_647, 'dead-owner')
|
||||
insert.run('stale-inspect', 2_147_483_647, 'dead-owner')
|
||||
insert.run('owned-inspect', owner.pid, owner.nonce)
|
||||
db.close()
|
||||
|
||||
await expect(concrete.acquireLive(SessionId('occupied-lease'), owner))
|
||||
.rejects.toThrow('occupied by another live process')
|
||||
const reused = await concrete.acquireLive(SessionId('reused-pid'), owner)
|
||||
const claim = await concrete.acquireLive(SessionId('stale-claim'), owner)
|
||||
expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true)
|
||||
expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false)
|
||||
expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false)
|
||||
await claim()
|
||||
await reused()
|
||||
await b.dispose()
|
||||
|
||||
const memory = new Context()
|
||||
await memory.plugin(SessionStore)
|
||||
await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live'))
|
||||
expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true)
|
||||
await memoryClaim.release()
|
||||
await memory.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects and closes a current-schema database with an invalid store identity', async () => {
|
||||
const path = await freshDbPath()
|
||||
const db = openDatabase(path, 'wal')
|
||||
|
||||
@@ -15,10 +15,6 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. |
|
||||
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. |
|
||||
| `claimLive(id): Promise<SessionLiveLease>` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. |
|
||||
| `isLive(id): Promise<boolean>` | Report a current non-stale live lease, including one owned by this process. |
|
||||
|
||||
The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods.
|
||||
|
||||
## Invariants every backend must honor
|
||||
|
||||
@@ -35,7 +31,7 @@ Each `session/event` copies its event into the session controller and starts an
|
||||
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
|
||||
|
||||
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle.
|
||||
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
|
||||
|
||||
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration.
|
||||
|
||||
@@ -48,8 +44,6 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
|
||||
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
|
||||
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
|
||||
| `list()` | List all stored metadata. |
|
||||
| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. |
|
||||
| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. |
|
||||
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
|
||||
|
||||
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
@@ -85,4 +79,3 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov
|
||||
- **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance.
|
||||
- **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale.
|
||||
- **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it.
|
||||
- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease.
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { Context } from 'cordis'
|
||||
import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { sessionLiveOwner } from './lease.ts'
|
||||
import type { SessionLiveLease, SessionLiveOwner } from './lease.ts'
|
||||
|
||||
/**
|
||||
* A stored session's header, valid contiguous event prefix, and optional opaque
|
||||
@@ -65,12 +63,6 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
/** List all stored (materialized) sessions' metadata. */
|
||||
list(): Promise<SessionHeader[]>
|
||||
|
||||
/** Optionally acquire a backend-owned cross-process live-session lease. */
|
||||
acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>>
|
||||
|
||||
/** Optionally inspect and reclaim a backend-owned live-session lease. */
|
||||
inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
|
||||
* coordinator's dispose effect AFTER the quiescence drain. A stateless file
|
||||
@@ -104,7 +96,6 @@ interface LiveSessionState {
|
||||
pending: SessionEvent[]
|
||||
init: Promise<void>
|
||||
flush: Promise<void> | undefined
|
||||
lease?: SessionLiveLease
|
||||
}
|
||||
|
||||
/** Collect the rejection reasons from a set of promises (none-throwing). */
|
||||
@@ -170,12 +161,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
* same id, so writes for one session never interleave. Keyed by session id.
|
||||
*/
|
||||
private chains = new Map<SessionId, Promise<unknown>>()
|
||||
/** One backend lease with process-local reference counting per session id. */
|
||||
private liveClaims = new Map<SessionId, {
|
||||
refs: number
|
||||
releaseBackend: () => Promise<void>
|
||||
}>()
|
||||
private readonly liveOwner = sessionLiveOwner()
|
||||
|
||||
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
|
||||
this.installWritePath()
|
||||
@@ -288,61 +273,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
return this.serialize(id, () => this.inspectCore(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire one process-local reference to the backend's cross-process lease.
|
||||
* @param id - session identity about to become live.
|
||||
* @returns one idempotent release capability.
|
||||
*/
|
||||
async claimLive(id: SessionId): Promise<SessionLiveLease> {
|
||||
const acquireLive = this.backend.acquireLive?.bind(this.backend)
|
||||
if (acquireLive === undefined) return { release: () => Promise.resolve() }
|
||||
await this.serialize(id, async () => {
|
||||
const existing = this.liveClaims.get(id)
|
||||
if (existing !== undefined) {
|
||||
existing.refs += 1
|
||||
return
|
||||
}
|
||||
const releaseBackend = await acquireLive(id, this.liveOwner)
|
||||
this.liveClaims.set(id, { refs: 1, releaseBackend })
|
||||
})
|
||||
let releaseTask: Promise<void> | undefined
|
||||
return {
|
||||
release: () => {
|
||||
if (releaseTask !== undefined) return releaseTask
|
||||
const task = this.serialize(id, async () => {
|
||||
const claim = this.liveClaims.get(id)
|
||||
/* v8 ignore next -- this capability is returned only after its claim enters the serialized map */
|
||||
if (claim === undefined) return
|
||||
claim.refs -= 1
|
||||
if (claim.refs > 0) return
|
||||
try {
|
||||
await claim.releaseBackend()
|
||||
} catch (error) {
|
||||
claim.refs += 1
|
||||
throw error
|
||||
}
|
||||
this.liveClaims.delete(id)
|
||||
})
|
||||
const wrapped = task.catch((error: unknown) => {
|
||||
releaseTask = undefined
|
||||
throw error
|
||||
})
|
||||
releaseTask = wrapped
|
||||
return wrapped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the backend's current cross-process lease state.
|
||||
* @param id - session identity to inspect.
|
||||
* @returns whether this or another live process owns the session.
|
||||
*/
|
||||
isLive(id: SessionId): Promise<boolean> {
|
||||
if (this.liveClaims.has(id)) return Promise.resolve(true)
|
||||
return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false)
|
||||
}
|
||||
|
||||
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const stored = await this.backend.loadStored(id)
|
||||
if (stored === undefined) throw new Error(`session "${id}" not found`)
|
||||
@@ -452,9 +382,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
let disposeError: unknown
|
||||
try {
|
||||
const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session)))
|
||||
errors.push(...await settledErrors(
|
||||
[...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]),
|
||||
))
|
||||
while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()])
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, `${this.backend.name} dispose failed`)
|
||||
@@ -514,8 +441,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
private async retireCore(session: Session): Promise<void> {
|
||||
await this.flush(session)
|
||||
const id = session.header.id
|
||||
const live = this.live.get(session)
|
||||
await live?.lease?.release()
|
||||
await this.serialize(id, () => {
|
||||
this.live.delete(session)
|
||||
if (this.states.get(id)?.owner === session) this.states.delete(id)
|
||||
@@ -529,16 +454,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
const seed = session.events.map(e => structuredClone(e))
|
||||
const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined }
|
||||
this.live.set(session, live)
|
||||
live.init = this.claimLive(session.id).then(async (lease) => {
|
||||
live.lease = lease
|
||||
try {
|
||||
await this.serialize(session.header.id, () => this.onCreated(session, seed))
|
||||
} catch (error) {
|
||||
delete live.lease
|
||||
await lease.release()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
live.init = this.serialize(session.header.id, () => this.onCreated(session, seed))
|
||||
live.init.catch(() => { /* observed by flush/dispose through the controller */ })
|
||||
return live
|
||||
}
|
||||
|
||||
@@ -8,18 +8,10 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistenceRevision } from './revision.ts'
|
||||
import type { SessionLiveLease } from './lease.ts'
|
||||
|
||||
// Re-export the metadata vocabulary so consumers import it from the seam.
|
||||
export type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
export { SessionPersistenceRevision } from './revision.ts'
|
||||
export {
|
||||
sessionLeaseOwnerIsLive,
|
||||
sessionLeaseProcessIsLive,
|
||||
sessionLiveOwner,
|
||||
shareSessionLiveLease,
|
||||
} from './lease.ts'
|
||||
export type { SessionLiveLease, SessionLiveOwner } from './lease.ts'
|
||||
|
||||
/** Lightweight immutable source identity returned without loading a full log. */
|
||||
export interface SessionPersistenceSnapshot {
|
||||
@@ -58,8 +50,6 @@ export interface SessionLocation {
|
||||
* rewriting committed events.
|
||||
*/
|
||||
export abstract class SessionPersistence extends Service {
|
||||
private readonly localLiveClaims = new Map<SessionId, number>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionPersistence')
|
||||
}
|
||||
@@ -133,39 +123,6 @@ export abstract class SessionPersistence extends Service {
|
||||
* @returns one header and opaque revision per materialized session without loading full logs.
|
||||
*/
|
||||
abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>
|
||||
|
||||
/**
|
||||
* Atomically acquire this process's live ownership of a session id.
|
||||
* Reentrant claims share one backend lease. First-party backends override
|
||||
* this process-local fallback to reject another live process and reclaim a
|
||||
* dead owner.
|
||||
* @param id - session identity that is about to become live.
|
||||
* @returns a single-release reference owned by the caller.
|
||||
*/
|
||||
claimLive(id: SessionId): Promise<SessionLiveLease> {
|
||||
this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1)
|
||||
let released = false
|
||||
return Promise.resolve({
|
||||
release: () => {
|
||||
if (released) return Promise.resolve()
|
||||
released = true
|
||||
const refs = this.localLiveClaims.get(id) as number
|
||||
if (refs <= 1) this.localLiveClaims.delete(id)
|
||||
else this.localLiveClaims.set(id, refs - 1)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any process currently owns a live lease for this session.
|
||||
* The base implementation reports only claims on this service instance.
|
||||
* @param id - persisted or prospective session identity.
|
||||
* @returns true while a non-stale lease exists, including this process's lease.
|
||||
*/
|
||||
isLive(id: SessionId): Promise<boolean> {
|
||||
return Promise.resolve(this.localLiveClaims.has(id))
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionPersistence
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/** Process-backed identity helpers for cross-process live-session leases. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER'
|
||||
|
||||
/** Process identity stored in backend-owned cross-process live-session leases. */
|
||||
export interface SessionLiveOwner {
|
||||
/** Operating-system process id; retained across an `execve` handoff. */
|
||||
readonly pid: number
|
||||
/** Exec-stable process-start nonce used when the observer has the same PID. */
|
||||
readonly nonce: string
|
||||
}
|
||||
|
||||
/** Idempotent capability releasing one acquired live-session lease reference. */
|
||||
export interface SessionLiveLease {
|
||||
/** Release this caller's lease reference after its live session reaches quiescence. */
|
||||
release(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable owner inherited only by an exec-replaced process, not inferred from a session id.
|
||||
* @returns this process's PID and exec-stable nonce.
|
||||
*/
|
||||
export function sessionLiveOwner(): SessionLiveOwner {
|
||||
const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID()
|
||||
process.env[LIVE_OWNER_ENV] = nonce
|
||||
return { pid: process.pid, nonce }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a lease pid still names a process; permission denial counts as live.
|
||||
* @param pid - positive operating-system process id from a lease record.
|
||||
* @returns true unless the operating system reports that the process is absent.
|
||||
*/
|
||||
export function sessionLeaseProcessIsLive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a recorded owner still names this process incarnation or another live PID.
|
||||
* A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is
|
||||
* fail-closed because its private nonce is not observable across processes.
|
||||
* @param recorded - owner stored in the backend lease.
|
||||
* @param observer - identity of the process inspecting or claiming the lease.
|
||||
* @returns whether the recorded owner must still be treated as live.
|
||||
*/
|
||||
export function sessionLeaseOwnerIsLive(
|
||||
recorded: SessionLiveOwner,
|
||||
observer: SessionLiveOwner,
|
||||
): boolean {
|
||||
if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce
|
||||
return sessionLeaseProcessIsLive(recorded.pid)
|
||||
}
|
||||
|
||||
interface SharedLeaseEntry {
|
||||
refs: number
|
||||
readonly acquired: Promise<() => Promise<void>>
|
||||
finalizing?: Promise<void>
|
||||
}
|
||||
|
||||
const sharedLeases = new Map<string, SharedLeaseEntry>()
|
||||
|
||||
/**
|
||||
* Reference-count one physical lease across backend instances in this process.
|
||||
* @param key - backend-kind plus canonical storage location and session id.
|
||||
* @param acquire - single physical acquisition performed for the first reference.
|
||||
* @returns an idempotent release for this caller's reference.
|
||||
*/
|
||||
export async function shareSessionLiveLease(
|
||||
key: string,
|
||||
acquire: () => Promise<() => Promise<void>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
for (;;) {
|
||||
let entry = sharedLeases.get(key)
|
||||
if (entry?.finalizing !== undefined) {
|
||||
await entry.finalizing
|
||||
continue
|
||||
}
|
||||
if (entry === undefined) {
|
||||
entry = { refs: 0, acquired: acquire() }
|
||||
sharedLeases.set(key, entry)
|
||||
void entry.acquired.catch(() => {
|
||||
/* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */
|
||||
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
|
||||
})
|
||||
}
|
||||
entry.refs += 1
|
||||
try {
|
||||
await entry.acquired
|
||||
} catch (error) {
|
||||
entry.refs -= 1
|
||||
throw error
|
||||
}
|
||||
let releaseTask: Promise<void> | undefined
|
||||
return () => {
|
||||
if (releaseTask !== undefined) return releaseTask
|
||||
const task = (async () => {
|
||||
entry.refs -= 1
|
||||
if (entry.refs > 0 || sharedLeases.get(key) !== entry) return
|
||||
const release = await entry.acquired
|
||||
await release()
|
||||
/* v8 ignore next -- claims wait for finalization before they can replace this exact entry */
|
||||
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
|
||||
})()
|
||||
const wrapped = task.catch((error: unknown) => {
|
||||
entry.refs += 1
|
||||
/* v8 ignore next -- this closure is the sole writer of its release state until settlement */
|
||||
if (entry.finalizing === wrapped) delete entry.finalizing
|
||||
releaseTask = undefined
|
||||
throw error
|
||||
})
|
||||
if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped
|
||||
releaseTask = wrapped
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
sessionLeaseOwnerIsLive,
|
||||
sessionLeaseProcessIsLive,
|
||||
sessionLiveOwner,
|
||||
shareSessionLiveLease,
|
||||
} from '../src/lease.ts'
|
||||
|
||||
const originalOwner = process.env.DSH_SESSION_LIVE_OWNER
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER
|
||||
else process.env.DSH_SESSION_LIVE_OWNER = originalOwner
|
||||
})
|
||||
|
||||
describe('process live-session lease helpers', () => {
|
||||
it('creates one exec-stable owner identity and classifies process liveness', () => {
|
||||
delete process.env.DSH_SESSION_LIVE_OWNER
|
||||
const first = sessionLiveOwner()
|
||||
expect(first.pid).toBe(process.pid)
|
||||
expect(typeof first.nonce).toBe('string')
|
||||
expect(sessionLiveOwner()).toEqual(first)
|
||||
expect(sessionLeaseOwnerIsLive(first, first)).toBe(true)
|
||||
expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false)
|
||||
expect(sessionLeaseProcessIsLive(process.pid)).toBe(true)
|
||||
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ESRCH' })
|
||||
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing })
|
||||
expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false)
|
||||
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing })
|
||||
expect(sessionLeaseProcessIsLive(999_999)).toBe(false)
|
||||
const denied = Object.assign(new Error('denied'), { code: 'EPERM' })
|
||||
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied })
|
||||
expect(sessionLeaseProcessIsLive(999_998)).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one physical lease until every process-local reference releases', async () => {
|
||||
const releasePhysical = vi.fn<() => Promise<void>>(() => Promise.resolve())
|
||||
const acquire = vi.fn<() => Promise<() => Promise<void>>>(() => Promise.resolve(releasePhysical))
|
||||
const key = `shared-${randomUUID()}`
|
||||
const first = await shareSessionLiveLease(key, acquire)
|
||||
const second = await shareSessionLiveLease(key, acquire)
|
||||
expect(acquire).toHaveBeenCalledTimes(1)
|
||||
await first()
|
||||
expect(releasePhysical).not.toHaveBeenCalled()
|
||||
await second()
|
||||
await second()
|
||||
expect(releasePhysical).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes failed acquisitions and retries a failed physical release', async () => {
|
||||
const key = `retry-${randomUUID()}`
|
||||
await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed'))))
|
||||
.rejects.toThrow('claim failed')
|
||||
|
||||
let releases = 0
|
||||
const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => {
|
||||
releases += 1
|
||||
if (releases === 1) throw new Error('release failed')
|
||||
}))
|
||||
await expect(release()).rejects.toThrow('release failed')
|
||||
await expect(release()).resolves.toBeUndefined()
|
||||
expect(releases).toBe(2)
|
||||
})
|
||||
|
||||
it('waits for a final physical release before reacquiring the same key', async () => {
|
||||
const key = `finalizing-${randomUUID()}`
|
||||
const releaseGate = Promise.withResolvers<undefined>()
|
||||
const firstPhysicalRelease = vi.fn(() => releaseGate.promise)
|
||||
const secondPhysicalRelease = vi.fn(() => Promise.resolve())
|
||||
const releases: Array<() => Promise<void>> = [firstPhysicalRelease, secondPhysicalRelease]
|
||||
let acquisitions = 0
|
||||
const acquire = vi.fn<() => Promise<() => Promise<void>>>((): Promise<() => Promise<void>> => {
|
||||
const release = releases[acquisitions++]
|
||||
if (release === undefined) throw new Error('unexpected physical acquisition')
|
||||
return Promise.resolve(release)
|
||||
})
|
||||
const first = await shareSessionLiveLease(key, acquire)
|
||||
const finalizing = first()
|
||||
const reacquiring = shareSessionLiveLease(key, acquire)
|
||||
await Promise.resolve()
|
||||
expect(acquire).toHaveBeenCalledTimes(1)
|
||||
releaseGate.resolve(undefined)
|
||||
await finalizing
|
||||
const second = await reacquiring
|
||||
expect(acquire).toHaveBeenCalledTimes(2)
|
||||
await second()
|
||||
expect(secondPhysicalRelease).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix,
|
||||
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix,
|
||||
} from '../src/index.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts'
|
||||
@@ -348,46 +348,6 @@ describe('PersistenceCoordinator stored identity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator live leases', () => {
|
||||
it('degrades without backend hooks and retries a failed final release', async () => {
|
||||
const fallbackCtx = new Context()
|
||||
await fallbackCtx.plugin(SessionStore)
|
||||
const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend())
|
||||
const fallbackClaim = await fallback.claimLive(SessionId('fallback-live'))
|
||||
expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false)
|
||||
await fallbackClaim.release()
|
||||
await fallbackCtx.fiber.dispose()
|
||||
|
||||
class LeaseBackend extends ControlledBackend {
|
||||
releaseAttempts = 0
|
||||
async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise<void>> {
|
||||
return async () => {
|
||||
this.releaseAttempts += 1
|
||||
if (this.releaseAttempts === 1) throw new Error('lease release failed')
|
||||
}
|
||||
}
|
||||
inspectLive(): Promise<boolean> {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new LeaseBackend()
|
||||
const coordinator = new PersistenceCoordinator(ctx, backend)
|
||||
const first = await coordinator.claimLive(SessionId('leased'))
|
||||
const second = await coordinator.claimLive(SessionId('leased'))
|
||||
expect(await coordinator.isLive(SessionId('leased'))).toBe(true)
|
||||
await first.release()
|
||||
await expect(second.release()).rejects.toThrow('lease release failed')
|
||||
await expect(second.release()).resolves.toBeUndefined()
|
||||
await expect(second.release()).resolves.toBeUndefined()
|
||||
expect(backend.releaseAttempts).toBe(2)
|
||||
expect(await coordinator.isLive(SessionId('leased'))).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator retirement', () => {
|
||||
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -835,20 +795,4 @@ describe('SessionPersistence service registration', () => {
|
||||
await fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('provides a reference-counted process-local lease fallback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(MemoryPersistence)
|
||||
const id = SessionId('local-live')
|
||||
const first = await ctx.sessionPersistence.claimLive(id)
|
||||
const second = await ctx.sessionPersistence.claimLive(id)
|
||||
expect(await ctx.sessionPersistence.isLive(id)).toBe(true)
|
||||
await first.release()
|
||||
await first.release()
|
||||
expect(await ctx.sessionPersistence.isLive(id)).toBe(true)
|
||||
await second.release()
|
||||
expect(await ctx.sessionPersistence.isLive(id)).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.
|
||||
|
||||
@@ -156,6 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
|
||||
@@ -79,7 +79,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
// Type import also declaration-merges the optional `sessionPersistence`
|
||||
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
|
||||
import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill'
|
||||
import type {
|
||||
FileDiff,
|
||||
@@ -349,7 +349,7 @@ export interface TuiRuntime {
|
||||
formatCwd?: (cwd: string | undefined) => string
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
/** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
/** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
}
|
||||
|
||||
@@ -1283,7 +1283,6 @@ interface ResumeRoute {
|
||||
|
||||
interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
occupied: boolean
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
@@ -1324,7 +1323,6 @@ function summarizeResumeCandidate(
|
||||
snapshot: SessionLogSnapshot,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
occupied: boolean,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
@@ -1332,14 +1330,13 @@ function summarizeResumeCandidate(
|
||||
const foldedGoal = foldGoal(snapshot.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live || occupied) disabledReason = 'occupied by another live agent'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
occupied,
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
@@ -1422,7 +1419,7 @@ class ResumeDialog implements Component, Focusable {
|
||||
const selected = index === this.selectedIndex
|
||||
const status = [
|
||||
candidate.disabledReason === 'current session' ? 'current' : undefined,
|
||||
candidate.record.live || candidate.occupied ? 'live' : undefined,
|
||||
candidate.record.live ? 'live' : undefined,
|
||||
candidate.record.persisted ? 'persisted' : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}`
|
||||
@@ -1841,8 +1838,6 @@ export function createTuiChat(
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let resumeOverlay: TuiOverlaySession | undefined
|
||||
let resumeInFlight = false
|
||||
let resumeReservation: SessionLiveLease | undefined
|
||||
let resumeReservationCommitted = false
|
||||
let resumeScan = 0
|
||||
let tuiServiceFiber: Fiber | undefined
|
||||
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
|
||||
@@ -1855,12 +1850,6 @@ export function createTuiChat(
|
||||
const now = (): number => runtime.now?.() ?? Date.now()
|
||||
const agentStatus = (): AgentStatus => agent.status
|
||||
const isDisposed = (): boolean => disposed
|
||||
const releaseResumeReservation = async (): Promise<void> => {
|
||||
const reservation = resumeReservation
|
||||
if (reservation === undefined) return
|
||||
await reservation.release()
|
||||
resumeReservation = undefined
|
||||
}
|
||||
|
||||
// A configured subtitle renders as a banner line; when absent, the banner has
|
||||
// no subtitle. The banner itself sweeps in on start (see startBannerReveal).
|
||||
@@ -2444,8 +2433,6 @@ export function createTuiChat(
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
overlayManager.beginShutdown()
|
||||
/* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */
|
||||
if (!resumeReservationCommitted) await releaseResumeReservation()
|
||||
contextResolution = undefined
|
||||
clearStatus()
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
@@ -2825,9 +2812,6 @@ export function createTuiChat(
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
try {
|
||||
const occupied = record.live || (record.persisted && persistence !== undefined
|
||||
? await persistence.isLive(record.header.id)
|
||||
: false)
|
||||
let snapshot: SessionLogSnapshot
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) {
|
||||
@@ -2845,13 +2829,11 @@ export function createTuiChat(
|
||||
snapshot,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
occupied,
|
||||
providers,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
record,
|
||||
occupied: record.live,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
@@ -2895,14 +2877,8 @@ export function createTuiChat(
|
||||
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (persistence === undefined) {
|
||||
throw new Error('Resume is unavailable: session persistence is not mounted.')
|
||||
}
|
||||
resumeReservation = await persistence.claimLive(checked.record.header.id)
|
||||
if (disposed) {
|
||||
await releaseResumeReservation()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
if (disposed) return
|
||||
await ctx.sessions.flush(agent.session)
|
||||
// Disposal can run while the flush promise is pending; TypeScript does not model that reentry.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -2916,29 +2892,18 @@ export function createTuiChat(
|
||||
if (disposed) return
|
||||
ui.stop()
|
||||
terminalReleased = true
|
||||
resumeReservationCommitted = true
|
||||
await hostHandoff(checked.record.header.id)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */
|
||||
if (!disposed) {
|
||||
resumeReservationCommitted = false
|
||||
let reported = error
|
||||
try {
|
||||
await releaseResumeReservation()
|
||||
} catch (releaseError: unknown) {
|
||||
reported = new Error(
|
||||
`${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`,
|
||||
)
|
||||
}
|
||||
if (terminalReleased) {
|
||||
ui.start()
|
||||
ui.setFocus(editor)
|
||||
appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error')
|
||||
appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
|
||||
} else {
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
appendNotice(`Resume failed: ${errorChain(reported)}`, 'error')
|
||||
appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -10,7 +10,6 @@ import AgentRegistry, {
|
||||
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -53,8 +52,6 @@ export interface TuiHarnessOptions {
|
||||
sessionPersistence?: {
|
||||
list(): Promise<SessionHeader[]>
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
isLive?(id: ReturnType<typeof SessionId>): Promise<boolean>
|
||||
claimLive?(id: ReturnType<typeof SessionId>): Promise<SessionLiveLease>
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Set false to exercise the optional session-query degradation path. */
|
||||
@@ -140,12 +137,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
inspect: persistence.load === undefined
|
||||
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
|
||||
claimLive: persistence.claimLive === undefined
|
||||
? () => Promise.resolve({ release: () => Promise.resolve() })
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.claimLive!(id),
|
||||
isLive: persistence.isLive === undefined
|
||||
? () => Promise.resolve(false)
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.isLive!(id),
|
||||
} as never)
|
||||
}
|
||||
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
|
||||
|
||||
@@ -396,7 +396,7 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps persisted query records readable when live-lease inspection is unavailable', async () => {
|
||||
it('keeps persisted query records readable without a persistence service', async () => {
|
||||
const target = header('query-only-persisted', 10, '/workspace')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
@@ -503,21 +503,19 @@ describe('resume command and /resume', () => {
|
||||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => {
|
||||
it('preflights route availability and corrupt sessions without losing the current TUI', async () => {
|
||||
const missing = header('missing-route', 10, '/workspace')
|
||||
const occupied = header('occupied', 20, '/workspace')
|
||||
const corrupt = header('corrupt', 30, '/workspace')
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
config: { resumeCommand: RESUME },
|
||||
sessionPersistence: {
|
||||
list: async () => [missing, occupied, corrupt],
|
||||
isLive: async id => id === occupied.id,
|
||||
list: async () => [missing, corrupt],
|
||||
load: async (id) => {
|
||||
if (id === corrupt.id) throw new Error('checksum mismatch')
|
||||
return {
|
||||
meta: id === missing.id ? missing : occupied,
|
||||
events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'),
|
||||
meta: missing,
|
||||
events: resumeEvents('Missing adapter', 'absent-provider'),
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -527,7 +525,6 @@ describe('resume command and /resume', () => {
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('Missing adapter')
|
||||
expect(result.terminal.output).toContain('absent-provider/model-1')
|
||||
expect(result.terminal.output).toContain('Busy session')
|
||||
expect(result.terminal.output).toContain('Unreadable session')
|
||||
result.terminal.send('Missing adapter')
|
||||
result.terminal.send('\r')
|
||||
@@ -537,6 +534,38 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps a session already live in this runtime visible but disabled', async () => {
|
||||
const target = header('live-target', 10, '/workspace')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => Promise.resolve([{
|
||||
header: target,
|
||||
live: true,
|
||||
persisted: true,
|
||||
}]),
|
||||
readSession: () => Promise.resolve({
|
||||
session: target,
|
||||
events: resumeEvents('Live target'),
|
||||
}),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Live target')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('session is already live in this runtime')
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
|
||||
const assistantOnly = header('assistant-route', 20, '/workspace')
|
||||
const empty = header('empty-log', 10, '/workspace')
|
||||
@@ -562,8 +591,6 @@ describe('resume command and /resume', () => {
|
||||
|
||||
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
|
||||
const target = header('target-session', 10, '/workspace')
|
||||
const releaseReservation = vi.fn(() => Promise.resolve())
|
||||
const claimLive = vi.fn(async () => ({ release: releaseReservation }))
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
@@ -571,7 +598,6 @@ describe('resume command and /resume', () => {
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Target session') }),
|
||||
claimLive,
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
@@ -582,8 +608,6 @@ describe('resume command and /resume', () => {
|
||||
await tick(); await tick()
|
||||
expect(handoff).toHaveBeenCalledTimes(1)
|
||||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||||
expect(claimLive).toHaveBeenCalledWith(target.id)
|
||||
expect(releaseReservation).toHaveBeenCalledTimes(1)
|
||||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||||
await dispose(result)
|
||||
@@ -635,39 +659,46 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the current TUI when the target reservation loses the preflight race', async () => {
|
||||
const target = header('reservation-race', 10, '/workspace')
|
||||
it('does not flush or hand off when disposal begins during selected-session preflight', async () => {
|
||||
const target = header('dispose-during-preflight', 10, '/workspace')
|
||||
const secondListing = Promise.withResolvers<SessionRecord[]>()
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const flush = vi.fn()
|
||||
let listings = 0
|
||||
const record: SessionRecord = { header: target, live: false, persisted: true }
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.on('session/flush', flush)
|
||||
},
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Reservation race') }),
|
||||
claimLive: () => Promise.reject(new Error('occupied after preflight')),
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
|
||||
readSession: () => Promise.resolve({
|
||||
session: target,
|
||||
events: resumeEvents('Dispose during preflight'),
|
||||
}),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Reservation race')
|
||||
await tick()
|
||||
result.terminal.send('Dispose during preflight')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('Resume failed: occupied after preflight')
|
||||
await vi.waitFor(() => { expect(listings).toBe(2) })
|
||||
await dispose(result)
|
||||
secondListing.resolve([record])
|
||||
await tick()
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('refuses host handoff when a query backend has no persistence lease service', async () => {
|
||||
it('hands off a validated session exposed by a query backend without a persistence service', async () => {
|
||||
const target = header('query-without-persistence', 10, '/workspace')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
|
||||
() => Promise.reject(new Error('test host retained process')),
|
||||
)
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
@@ -692,42 +723,14 @@ describe('resume command and /resume', () => {
|
||||
result.terminal.send('Query without persistence')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('session persistence is not mounted')
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases a reservation that resolves after TUI disposal', async () => {
|
||||
const target = header('late-reservation', 10, '/workspace')
|
||||
const claiming = Promise.withResolvers<{ release(): Promise<void> }>()
|
||||
const release = vi.fn(() => Promise.resolve())
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Late reservation') }),
|
||||
claimLive: () => claiming.promise,
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Late reservation')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
claiming.resolve({ release })
|
||||
await tick()
|
||||
expect(release).toHaveBeenCalledTimes(1)
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hand off after disposal begins during the current-session flush', async () => {
|
||||
const target = header('dispose-during-flush', 10, '/workspace')
|
||||
const flushing = Promise.withResolvers<undefined>()
|
||||
const release = vi.fn(() => Promise.resolve())
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
@@ -739,7 +742,6 @@ describe('resume command and /resume', () => {
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }),
|
||||
claimLive: async () => ({ release }),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
@@ -752,14 +754,12 @@ describe('resume command and /resume', () => {
|
||||
await tick()
|
||||
flushing.resolve(undefined)
|
||||
await disposing
|
||||
expect(release).toHaveBeenCalledTimes(1)
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hand off after disposal begins while terminal input drains', async () => {
|
||||
const target = header('dispose-during-drain', 10, '/workspace')
|
||||
const draining = Promise.withResolvers<undefined>()
|
||||
const release = vi.fn(() => Promise.resolve())
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
@@ -767,7 +767,6 @@ describe('resume command and /resume', () => {
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }),
|
||||
claimLive: async () => ({ release }),
|
||||
},
|
||||
})
|
||||
result.terminal.drainInput.mockImplementationOnce(() => draining.promise)
|
||||
@@ -780,36 +779,33 @@ describe('resume command and /resume', () => {
|
||||
await dispose(result)
|
||||
draining.resolve(undefined)
|
||||
await tick()
|
||||
expect(release).toHaveBeenCalledTimes(1)
|
||||
expect(handoff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a target reservation release failure after a recoverable host rejection', async () => {
|
||||
const target = header('release-failure', 10, '/workspace')
|
||||
let releases = 0
|
||||
it('does not restart the terminal when a pending host rejects during disposal', async () => {
|
||||
const target = header('host-rejects-during-disposal', 10, '/workspace')
|
||||
const host = Promise.withResolvers<never>()
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => host.promise)
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: () => Promise.reject(new Error('host rejected')),
|
||||
handoffResume: handoff,
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents('Release failure') }),
|
||||
claimLive: async () => ({
|
||||
release: () => ++releases === 1
|
||||
? Promise.reject(new Error('lock unavailable'))
|
||||
: Promise.resolve(),
|
||||
}),
|
||||
load: async () => ({ meta: target, events: resumeEvents('Host disposal') }),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Release failure')
|
||||
result.terminal.send('Host disposal')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('target reservation release failed')
|
||||
expect(result.terminal.output).toContain('release failed: lock')
|
||||
await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() })
|
||||
const startsBeforeDispose = result.terminal.started
|
||||
await dispose(result)
|
||||
expect(releases).toBe(2)
|
||||
host.reject(new Error('host rejected after disposal'))
|
||||
await tick()
|
||||
expect(result.terminal.started).toBe(startsBeforeDispose)
|
||||
expect(result.terminal.output).not.toContain('host rejected after disposal')
|
||||
})
|
||||
|
||||
it('rejects a candidate whose cwd changes between listing and preflight', async () => {
|
||||
|
||||
@@ -95,7 +95,6 @@ export const LINK_MAP: Record<string, string> = {
|
||||
CreateSessionOptions: 'persistence.md',
|
||||
SessionHeader: 'persistence.md',
|
||||
SessionLocation: 'persistence.md',
|
||||
SessionLiveLease: 'persistence.md',
|
||||
SessionPersistenceSnapshot: 'persistence.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxExecutionPolicy: 'sandbox.md',
|
||||
|
||||
@@ -379,11 +379,6 @@
|
||||
"symbol": "SessionLocation",
|
||||
"source": "packages/session-persistence/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionLiveLease",
|
||||
"source": "packages/session-persistence/session-persistence/src/lease.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventSurface",
|
||||
|
||||
Reference in New Issue
Block a user