From 2ae9f4fdf3a0087d4dc90b14a71486af676a8a0e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 12:31:26 +0800 Subject: [PATCH 1/7] feat(tui): add safe session resume flow --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 34 +- .../2026-07-21-tui-resume-command.zh.md | 34 +- apps/cli/README.md | 2 +- apps/cli/package.json | 4 +- apps/cli/src/tui.ts | 37 +- apps/cli/tsconfig.json | 3 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/config-catalog.md | 25 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 38 +- docs/core-data-structures/persistence.md | 12 + docs/core-data-structures/session-query.md | 12 +- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tests/fixtures/tui-scripted.cordis.yml | 2 + .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 50 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 + packages/core/agent-loop/src/index.ts | 31 +- packages/core/agent-loop/tests/resume.spec.ts | 45 ++ packages/examples/tui-demo/README.md | 3 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +++- .../tests/fixtures/live-lease-child.ts | 16 + .../tests/fixtures/live-lease-race-child.ts | 33 + .../tests/jsonl.spec.ts | 182 ++++- .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 67 +- .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 35 +- .../session-persistence/README.md | 8 +- .../session-persistence/src/coordinator.ts | 86 ++- .../session-persistence/src/index.ts | 38 ++ .../session-persistence/src/lease.ts | 98 +++ .../session-persistence/tests/lease.spec.ts | 62 ++ .../tests/persistence.spec.ts | 58 +- .../session-query/session-query/README.md | 1 + .../session-query/session-query/src/index.ts | 18 +- .../session-query/session-query/src/types.ts | 8 + .../session-query/tests/session-query.spec.ts | 19 + packages/ui/app-boot/README.md | 3 +- packages/ui/app-boot/src/index.ts | 19 +- packages/ui/app-boot/tests/app-boot.spec.ts | 24 +- packages/ui/tui/README.md | 9 +- packages/ui/tui/package.json | 9 + packages/ui/tui/src/index.ts | 404 ++++++++++- packages/ui/tui/tests/harness.ts | 32 +- packages/ui/tui/tests/plugin-shape.spec.ts | 1 + .../snapshots/resume-sessions.expected.txt | 69 +- packages/ui/tui/tests/tui.snapshot.ts | 25 +- packages/ui/tui/tests/tui.spec.ts | 634 ++++++++++++++++-- packages/ui/tui/tsconfig.json | 6 + pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 2 + scripts/type-equiv.manifest.json | 10 + 57 files changed, 2312 insertions(+), 192 deletions(-) create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts create mode 100644 packages/session-persistence/session-persistence/src/lease.ts create mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 210215eb3d..42370c5dad 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303 -2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343 +2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 +2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 2282eaa9bf..23755696a9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: Product-level TUI session resume Status: implemented @@ -6,36 +6,36 @@ English | [中文](2026-07-21-tui-resume-command.zh.md) ## Problem -The TUI can resume a session by launch (`RESUME_SESSION_ID= dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable. +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. ## Decision -A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears. +`/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. -- **Exit hint.** Process-exiting shutdown prints `To resume this session: ` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load. -- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit. -- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`. +`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. -`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume ` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh. +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, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. + +After preflight, the TUI flushes the current session and 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 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`. + +`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. ## Alternatives considered -**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know. +**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. -**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing. +**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. -**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command. +**Treat a missing adapter as a missing session.** Rejected: storage validity and current route availability are independent facts. The selector keeps the row and names the unavailable provider/model. -**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited. - -**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer. +**Persist goal activation across resume.** Rejected: durable intent is not authorization to continue after a human or process boundary. Goal phase survives; automatic continuation does not. ## Consequences -- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted. -- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing. -- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume ` flag to run it. +- 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. +- `/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 -`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume ` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake. +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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index f9d989a5b4..cd2e19a2ef 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: 产品级 TUI 会话恢复 Status: implemented @@ -6,36 +6,36 @@ Status: implemented ## Problem -TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID= dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。 +原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 ## Decision -`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。 +`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。 -- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: `(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。 -- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。 -- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume ` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 + +预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 + +`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 ## Alternatives considered -**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。 +**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 -**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。 +**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 -**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。 +**把适配器缺失视为会话缺失。** 否决:存储有效性和当前路由可用性是相互独立的事实。选择器会保留该行,并指出不可用的提供方/模型。 -**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。 - -**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。 +**恢复会话时延续目标激活状态。** 否决:持久意图并不代表跨越用户或进程边界后仍获授权继续执行。目标阶段会保留,但不会自动续跑。 ## Consequences -- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。 -- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。 -- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume ` 标志来运行它。 +- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。 +- `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。 +- 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume ` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。 +TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index f830b4647d..86bda3fbf5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 ` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero; +- resumes a persisted session with `dsh --resume ` 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; - 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. diff --git a/apps/cli/package.json b/apps/cli/package.json index d7942c1e2d..8557965d34 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,8 @@ "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", + "cordis": "^4.0.0-rc.7" } } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6f97a68ad3..4a4ca8d7fc 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -19,9 +19,12 @@ import { loadEnv, loadPersonalPatches, parseResumeArg, + replaceResumeArg, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { Context } from 'cordis' +import type { TuiResumeHost } from '@deepseek-ai/dsh-tui' const NAME = 'dsh' @@ -65,7 +68,39 @@ export async function runTui(argv: string[]): Promise { // after loadEnv and before boot reads it through the config's `!!js`. const { resumeSessionId, rest } = parseResumeArg(argv) if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId - const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + const entry = process.argv[1] + const execve = process.execve?.bind(process) + const app: { current?: Context } = {} + const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { + async handoff(sessionId): Promise { + const current = app.current + if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) + const nextArgv = [ + process.execPath, + ...process.execArgv, + entry, + ...replaceResumeArg(process.argv.slice(2), sessionId), + ] + process.env[RESUME_SESSION_ID_ENV] = sessionId + try { + await current.fiber.dispose() + execve(process.execPath, nextArgv, process.env) + throw new Error('process replacement returned unexpectedly') + } catch (error) { + process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`) + process.exit(1) + } + }, + } + const ctx = await boot( + NAME, + resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), + loadPersonalPatches(NAME), + (hostCtx) => { + if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + }, + ) + app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index cbc786d4c6..b33280943a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/ui/tui" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..ea83179477 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 +architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..f0ce115d0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -Without an id, creation mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `-session-`; `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. ### Turn Flow @@ -147,7 +147,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` joins log-only events to an open turn or creates a flushed zero-step turn. `session/title` folds latest-wins with source seqs/provenance; fallback and its optional provider never delay responses. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..eef66e3b9d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时,创建流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 +未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 @@ -147,7 +147,7 @@ forever: 持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把纯日志事件加入开放轮次,或创建一个已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq/来源信息;回退标题及其可选提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..6b841214c5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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:360`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:378`](../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:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../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:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query-sqlite` @@ -1571,7 +1571,7 @@ Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1581,11 +1581,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -1600,6 +1599,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -1608,6 +1609,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1630,7 +1635,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:248`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..9bda12f6b0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..5b3f7c4afb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise + +/** + * 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 + +/** + * 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 ``` -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) +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) -Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) @@ -996,6 +1014,14 @@ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchE */ listSessions(): Promise +/** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ +async readSession(sessionId: SessionId): Promise + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. @@ -1057,9 +1083,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` @@ -1701,7 +1727,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:132`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..ffd4304376 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,6 +34,18 @@ 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 +} +``` + ## `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`. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 0fe1596aaf..25315e3166 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -25,7 +25,17 @@ interface SessionRecord { } ``` -`SessionSurfaceSnapshot` is one exact-read observation rather than a retained subscription. Its raw-log boundary and folded events come from the same live-preferred load. +`SessionLogSnapshot` is the complete detached, replay-validated raw log used by resume preflight. `SessionSurfaceSnapshot` is one exact-read surface observation rather than a retained subscription. + +```ts type-equiv +/** One validated detached observation of a logical session's complete raw log. */ +interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} +``` ```ts type-equiv /** One atomic live-preferred observation of a session's current model surface. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..7f7ecc4f2d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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:353`](../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:371`](../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) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 9522fb4979..032a82bdaf 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. 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= 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 atomically replaces the process with `dsh --resume `; 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= 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. ## Code Mode diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index e4548da79e..4668747077 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -29,6 +29,8 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none + resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 66697673ba..9c198870ad 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,8 +1,10 @@ -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) @@ -44,6 +46,31 @@ function seedWorkspace( } } +/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ +async function seedResumeSession(cwd: string): Promise { + const sessionCwd = await realpath(cwd) + const id = SessionId('resume-target') + const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: { content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'persisted answer' }], provenance: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } }, + { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } }, + { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none') + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, [ + JSON.stringify(toHeaderLine(meta)), + ...events.map(event => JSON.stringify(event)), + '', + ].join('\n')) +} + /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ async function readLoggedSystemPrompt(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') @@ -233,6 +260,27 @@ 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 () => { + const output = await smoke({ + label: 'dsh in-place resume', + tempDirPrefix: 'dsh-in-place-resume-', + binScript: dshBinScript, + configArgs: [scriptedConfigPath], + prepare: seedResumeSession, + actions: [ + { waitFor: 'scripted TUI ready.', send: '/resume\r' }, + { waitFor: 'Resume selector design', send: 'Resume selector design' }, + { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: 'Preserve restored state', send: '/exit\r' }, + ], + }) + const released = output.indexOf('\u001B[?2004l') + const restored = output.indexOf('Resume selector design — DeepSeek Harness') + expect(released).toBeGreaterThanOrEqual(0) + expect(restored).toBeGreaterThan(released) + expect(output).toContain('Preserve restored state') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots the shipped default config with no arguments and no personal overlay', async () => { const output = await smoke({ label: 'dsh default boot', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..f49cc75b1c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,6 +480,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise', 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', + 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', + 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 */', + }, ], }, { @@ -498,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listSessions(): Promise', jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', }, + { + signature: 'async readSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */', + }, { signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise', jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */', @@ -1805,10 +1817,18 @@ 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;\n}', + }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', }, + { + name: 'SessionLogSnapshot', + declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', + }, { name: 'SessionPersistenceRevision', declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bdaa3f2401..15ece492e3 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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 { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { bindReactLoopAgentContext, prepareReactLoopAgent, @@ -114,6 +114,7 @@ class AgentCreationTransaction { private scope: Scope | undefined private session: Session | undefined private lifecycleDispose: (() => Promise | void) | undefined + private liveLease: SessionLiveLease | undefined private detachSession: (() => void) | undefined private detachAgent: (() => void) | undefined private publishing = false @@ -186,6 +187,12 @@ 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() @@ -219,6 +226,11 @@ 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 @@ -315,7 +327,13 @@ class AgentCreationTransaction { try { await this.scope?.dispose() } finally { - this.finish() + try { + const lease = this.liveLease + this.liveLease = undefined + await lease?.release() + } finally { + this.finish() + } } } })()) @@ -607,6 +625,15 @@ 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, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index fdf5c39514..2d19db7a0d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -391,6 +391,51 @@ 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>>() + const claimStarted = Promise.withResolvers() + 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 + 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) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index a20a0fae9d..bba7ae7f7e 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -41,10 +41,11 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | +| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | | `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-` 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. +Fresh runs mint a `main-session-` 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. ## The bin diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..4aaf30f45a 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,6 +6,9 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / + .live/ + .lock # PID + nonce cross-process live lease + .lock.reclaim # ephemeral stale-owner takeover guard cwd-/ # per-project bucket (or _no-cwd/ when no cwd) .jsonl.zstd # default: checksummed header frame + append frames .jsonl # only with compression: 'none' @@ -43,7 +46,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. 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. +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/.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. ## Model Experience @@ -66,5 +69,6 @@ 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). -- **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. +- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it 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. - **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. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..a3ed611a4c 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,8 +14,9 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -64,6 +65,11 @@ 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' @@ -135,6 +141,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise { + 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. @@ -274,6 +288,110 @@ 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> { + 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> { + 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 || sessionLeaseProcessIsLive(current.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + const reclaimPath = `${path}.reclaim` + let reclaim: Awaited> + 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 (sessionLeaseProcessIsLive(latest.pid)) { + 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 and clean up a crashed owner's record. */ + async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { + 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 (sessionLeaseProcessIsLive(current.pid)) return true + return false + } + + private liveLeasePath(id: SessionId): string { + return join(this.root, '.live', `${encodeSegment(id)}.lock`) + } + + private async readLiveLease(path: string): Promise { + 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> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts new file mode 100644 index 0000000000..2a42b8c402 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts @@ -0,0 +1,16 @@ +/** 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 ') + +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(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts new file mode 100644 index 0000000000..b4ef3b6e59 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts @@ -0,0 +1,33 @@ +/** 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 ') +} + +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(() => { setInterval(() => {}, 60_000) }) +} catch (error) { + await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) + await ctx.fiber.dispose() +} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..517cf7bb04 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,17 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { access, 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] } @@ -142,6 +149,179 @@ 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(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() + + 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(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(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 () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index f1f4bc1f7b..374da93faa 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -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. 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, 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). 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. 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. 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. ## Model Experience diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 5804c18282..4399ee9c90 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise { + 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. @@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers })) } + /** Atomically acquire one SQLite-backed process lease. */ + async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> { + await this.ready + return shareSessionLiveLease( + `sqlite:${this.storeIdentity}:${id}`, + () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), + ) + } + + private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise { + this.db.exec('BEGIN IMMEDIATE') + try { + const current = this.liveLeaseFor(id) + if (current !== undefined + && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { + if (sessionLeaseProcessIsLive(current.pid)) { + 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 { + await this.ready + const current = this.liveLeaseFor(id) + if (current === undefined) return false + if ((current.pid === owner.pid && current.nonce === owner.nonce) + || sessionLeaseProcessIsLive(current.pid)) 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 { await this.ready @@ -284,6 +342,11 @@ 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 diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8b8dcd78e0..6a5be76eb9 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -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 = 8 +export const SCHEMA_VERSION = 9 /** * 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 three tables ensured. + * @returns the open handle with pragmas applied and all tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -128,6 +128,13 @@ 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 + `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3976e71549..a3aba22323 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -7,6 +7,7 @@ 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' @@ -442,7 +443,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(8) + expect(SCHEMA_VERSION).toBe(9) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -458,6 +459,38 @@ 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 db = openDatabase(path, 'wal') + const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') + insert.run('occupied-lease', process.pid, 'another-owner') + 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 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 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') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 25429bd720..2bfa3a31b1 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,6 +15,10 @@ 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` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | 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` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | +| `isLive(id): Promise` | 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 @@ -31,7 +35,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 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. +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. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. @@ -44,6 +48,8 @@ The `PersistenceBackend` 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). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index fb46aa4877..8ea1790b3e 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,6 +8,8 @@ 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 @@ -63,6 +65,12 @@ export interface PersistenceBackend { /** List all stored (materialized) sessions' metadata. */ list(): Promise + /** Optionally acquire a backend-owned cross-process live-session lease. */ + acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> + + /** Optionally inspect and reclaim a backend-owned live-session lease. */ + inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -96,6 +104,7 @@ interface LiveSessionState { pending: SessionEvent[] init: Promise flush: Promise | undefined + lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -161,6 +170,12 @@ export class PersistenceCoordinator { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map>() + /** One backend lease with process-local reference counting per session id. */ + private liveClaims = new Map Promise + }>() + private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() @@ -273,6 +288,61 @@ export class PersistenceCoordinator { 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 { + 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 | 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 { + 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`) @@ -382,6 +452,9 @@ export class PersistenceCoordinator { 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`) @@ -441,6 +514,8 @@ export class PersistenceCoordinator { private async retireCore(session: Session): Promise { 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) @@ -454,7 +529,16 @@ export class PersistenceCoordinator { 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.serialize(session.header.id, () => this.onCreated(session, seed)) + 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.catch(() => { /* observed by flush/dispose through the controller */ }) return live } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index c785c9354c..3aa602c8c0 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -8,10 +8,13 @@ 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 { 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 { @@ -50,6 +53,8 @@ export interface SessionLocation { * rewriting committed events. */ export abstract class SessionPersistence extends Service { + private readonly localLiveClaims = new Map() + constructor(ctx: Context) { super(ctx, 'sessionPersistence') } @@ -123,6 +128,39 @@ export abstract class SessionPersistence extends Service { * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise + + /** + * 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 { + 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 { + return Promise.resolve(this.localLiveClaims.has(id)) + } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts new file mode 100644 index 0000000000..5cc51117ab --- /dev/null +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -0,0 +1,98 @@ +/** 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 + /** Per-process-start nonce that distinguishes PID reuse. */ + 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 +} + +/** + * 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' + } +} + +interface SharedLeaseEntry { + refs: number + readonly acquired: Promise<() => Promise> +} + +const sharedLeases = new Map() + +/** + * 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>, +): Promise<() => Promise> { + let entry = sharedLeases.get(key) + 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 | 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 -- the entry remains installed until this exact final release succeeds */ + 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 releaseTask until settlement */ + if (releaseTask === wrapped) releaseTask = undefined + throw error + }) + releaseTask = wrapped + return wrapped + } +} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts new file mode 100644 index 0000000000..e35bba9311 --- /dev/null +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { + 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(sessionLeaseProcessIsLive(process.pid)).toBe(true) + + const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) + 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>(() => Promise.resolve()) + const acquire = vi.fn<() => Promise<() => Promise>>(() => 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) + }) +}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 6b31d0843b..36192c37d7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -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 SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -348,6 +348,46 @@ 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> { + return async () => { + this.releaseAttempts += 1 + if (this.releaseAttempts === 1) throw new Error('lease release failed') + } + } + inspectLive(): Promise { + 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() @@ -795,4 +835,20 @@ 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() + }) }) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index a83317ecf8..019eced081 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -5,6 +5,7 @@ ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title. diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 2028f908c1..826802e2b4 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -5,7 +5,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import { Session, type SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { @@ -19,6 +19,7 @@ import type { SessionEventTraceRequest, SessionEventWindow, SessionLineageTrace, + SessionLogSnapshot, SessionRecord, SessionResultFilter, SessionSearchExecContext, @@ -118,6 +119,21 @@ export abstract class SessionQueryService extends Service { return this._corpus.listSessions() } + /** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ + async readSession(sessionId: SessionId): Promise { + const loaded = await this._corpus.load(sessionId) + new Session(sessionId, loaded.events, loaded.header) + return { + session: structuredClone(loaded.header), + events: loaded.events.map(event => structuredClone(event)), + } + } + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index b231bd9f78..0f89de8156 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -39,6 +39,14 @@ export interface SessionSurfaceSnapshot { events: SurfaceEvent[] } +/** One validated detached observation of a logical session's complete raw log. */ +export interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} + /** Lightweight metadata for one event within a logical session. */ export interface SessionEventRecord { /** Session that owns the event. */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index a2ea051dd0..a69c3bcb93 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -105,6 +105,25 @@ function rejectUnknown(reason: unknown): Promise { } describe('session-query exact reads', () => { + it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { + const valid = header('valid-log', 2) + const corrupt = header('corrupt-log', 1) + const validEvents = eventLog('valid') + const corruptEvents = [{ ...eventLog('bad')[0]!, seq: 1 }] + TestPersistence.reset([ + { meta: valid, events: validEvents }, + { meta: corrupt, events: corruptEvents }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + const snapshot = await ctx.sessionQuery.readSession(valid.id) + expect(snapshot).toEqual({ session: valid, events: validEvents }) + Object.assign(snapshot.events[0]!, { time: 999 }) + expect(TestPersistence.entries.get(valid.id)?.events[0]?.time).toBe(10) + await expect(ctx.sessionQuery.readSession(corrupt.id)).rejects.toThrow('seed event at index 0 has seq 1') + }) + it('prefers a live owner that attaches while its persisted prefix is inspected', async () => { const shared = header('attach-during-inspect', 2) TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }]) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index abd8feec20..c37b2d5fb3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `parseResumeArg(argv)` | Split the `--resume ` / `--resume=` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | +| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume ` pair while preserving positional arguments | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index c303ac1e71..195f0bdb11 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -80,6 +80,18 @@ export function parseResumeArg( return { resumeSessionId, rest } } +/** + * Replace any existing resume flag with one canonical trailing `--resume ` pair. + * @param argv - current arguments after command dispatch. + * @param sessionId - selected session id. + * @returns flag-normalized arguments for a process replacement. + */ +export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] { + if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`) + const { rest } = parseResumeArg(argv) + return [...rest, RESUME_FLAG, sessionId] +} + /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. @@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree * (see {@link loadPersonalPatches}); an empty list mounts none. + * @param prepare - optional host setup run against the root context before any Loader entry mounts. * @returns the root context once every entry has started. */ export async function boot( - binName: string, absoluteConfigPath: string, patches?: PatchOptions[], + binName: string, + absoluteConfigPath: string, + patches?: PatchOptions[], + prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() + await prepare?.(ctx) ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 76e5238db7..6ea9c4e66e 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -55,6 +55,15 @@ describe('parseResumeArg', () => { }) }) +describe('replaceResumeArg', () => { + it('keeps positional arguments and replaces either existing flag form', () => { + expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(() => replaceResumeArg([], '')).toThrow('non-empty session id') + }) +}) + describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() @@ -196,6 +205,19 @@ describe('boot', () => { } }) + it('runs host preparation before the Loader tree mounts', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const prepared: Context[] = [] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) }) + try { + expect(prepared).toEqual([ctx]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 21601fa280..ee3e787c3f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,9 @@ The footer sums the session's reported usage as `↑ `/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. -When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place. +`/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, flushes it, 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, 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`. + +`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. ## Config @@ -42,17 +44,20 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | +| `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `resumeDialogWidth` | `88` | Resume-selector width in columns | +| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | -| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend | +| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id | ```yaml - id: terminal diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 4d668ba697..76e9c9c7aa 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -33,9 +33,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -48,6 +50,12 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, + "@deepseek-ai/dsh-session-query": { + "optional": true + }, + "@deepseek-ai/dsh-goal": { + "optional": true + }, "@deepseek-ai/dsh-skill": { "optional": true } @@ -60,6 +68,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index dfe420f1f6..7317f3000b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -66,12 +66,17 @@ import { type SessionHeader, type TodoItem, } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' // Side-effect type import: declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' @@ -120,9 +125,22 @@ declare module 'cordis' { interface Context { /** Terminal-only interaction service, available only while a TUI is mounted. */ tui: TuiExtensionService + /** Optional process host that can replace this TUI with a resumed session. */ + tuiResumeHost: TuiResumeHost } } +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise +} + /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -162,7 +180,7 @@ export { } from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' @@ -177,6 +195,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -185,6 +205,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -210,10 +234,13 @@ const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) +const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -228,10 +255,13 @@ const tuiConfigSchemaFields = { maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + resumeDialogWidth: resumeDialogWidthSchema, + resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -251,11 +281,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -268,10 +297,13 @@ export const Config: z = z.object({ maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, + resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -287,10 +319,13 @@ export interface ResolvedTuiConfig { maxToolOutputLines: number maxQuestionOptions: number maxModelOptions: number + maxResumeOptions: number questionDialogWidth: number questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + resumeDialogWidth: number + resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -314,6 +349,8 @@ 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. */ + handoffResume?: TuiResumeHost['handoff'] } /** @@ -328,10 +365,13 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + resumeDialogWidth: config?.resumeDialogWidth ?? 88, + resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1236,6 +1276,173 @@ class ModelDialog implements Component { } } +interface ResumeRoute { + provider: string + model: string +} + +interface ResumeCandidate { + record: SessionRecord + occupied: boolean + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'rejected': return `turn ${event.data.turn}: rejected` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + occupied: boolean, + availableProviders: ReadonlySet, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + 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.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), + ...route === undefined ? {} : { route }, + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Searchable keyboard selector over detached, preflighted resume summaries. */ +class ResumeDialog implements Component, Focusable { + private query = '' + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void {} + + private filtered(): ResumeCandidate[] { + const query = this.query.trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + handleInput(data: string): void { + this.invalidate() + const filtered = this.filtered() + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else if (data === '\x7f' || data === '\b') { + this.query = Array.from(this.query).slice(0, -1).join('') + this.selectedIndex = 0 + this.error = '' + } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { + this.query += data + this.selectedIndex = 0 + this.error = '' + } + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + filtered.length - this.maxVisible, + )) + const end = Math.min(filtered.length, start + this.maxVisible) + const body: string[] = [ + this.query === '' + ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` + : this.palette.text(`Search: ${displayText(this.query)}`), + '', + ] + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const selected = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live || candidate.occupied ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` + body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) + if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) + body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) + if (this.error !== '') body.push(this.palette.error(displayText(this.error))) + return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() @@ -1585,6 +1792,7 @@ export function createTuiChat( const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) const persistence = ctx.get('sessionPersistence') + const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.color) const mdTheme = markdownTheme(palette) @@ -1631,6 +1839,9 @@ export function createTuiChat( const referenceControllers = new Set() let activeQuestion: PendingQuestion | undefined let modelOverlay: TuiOverlaySession | undefined + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } let contextWindow: number | undefined @@ -1640,6 +1851,8 @@ export function createTuiChat( > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() + const agentStatus = (): AgentStatus => agent.status + const isDisposed = (): boolean => disposed // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2204,7 +2417,6 @@ export function createTuiChat( } return all .filter(header => header.cwd === agent.session.header.cwd) - .sort((a, b) => b.createdAt - a.createdAt) } /** @@ -2597,37 +2809,153 @@ export function createTuiChat( }) } - /** - * List this workspace's resumable sessions, newest first, each with its - * resume command and a marker on the current one. Warns when resume is not - * configured or no persistence backend is mounted; notes when nothing is - * persisted yet. The listing is asynchronous (a persistence scan), so the - * transcript updates once it resolves. - */ - const showResume = (): void => { - const template = config.resumeCommand - if (template === undefined) { - appendNotice('Resume is not configured for this app.', 'warning') - return + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet, + ): Promise => { + 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) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + 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', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } } - if (persistence === undefined) { - appendNotice('Resume is not available: no persistence backend is mounted.', 'warning') - return - } - void listWorkspaceSessions().then((sessions) => { - if (sessions.length === 0) { - appendNotice('No resumable sessions found for this workspace yet.', 'info') + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { + if (resumeInFlight) return + resumeInFlight = true + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0)) - const lines = sessions.map((header) => { - const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ') - const marker = header.id === agent.session.id ? palette.success(' (current)') : '' - return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}` + await ctx.sessions.flush(agent.session) + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + ui.stop() + try { + 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; pre-commit rejection keeps it live */ + if (!disposed) { + ui.start() + ui.setFocus(editor) + appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } + } + } catch (error: unknown) { + /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ + if (!disposed) { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } finally { + resumeInFlight = false + } + } + + /** Open the current-workspace searchable session selector. */ + const showResume = (): void => { + if (agent.status !== 'idle') { + appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: () => new ResumeDialog( + candidates, + resolved.maxResumeOptions, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: resolved.resumeDialogWidth, + maxHeight: resolved.resumeDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined }) - chat.addChild(new Text(lines.join('\n'), 1, 0)) requestRender() + }, (error: unknown) => { + if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) } @@ -2828,6 +3156,14 @@ export function createTuiChat( } rebuildTranscript(true) + const restoredGoal = foldGoal(agent.session.events).goal + if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { + appendNotice( + `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` + + 'Human confirmation is required; send “继续” or run /goal resume.', + 'warning', + ) + } setStatus(agent.status) try { ui.start() @@ -2915,9 +3251,11 @@ export function apply(ctx: Context, config: Config): void { // Truecolor is a terminal capability, so detect it here at the process // boundary from COLORTERM; an explicit `truecolor` config value still wins. const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + const resumeHost = ctx.get('tuiResumeHost') mountTui(ctx, Object.assign({}, config, { truecolor }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), + ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, }) } /* v8 ignore stop */ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c6da283236..6b9bd143d4 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -14,6 +14,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' +import { TestSessionQueryService } from './session-query.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -48,7 +49,14 @@ export interface TuiHarnessOptions { resolveModelContext?: (provider: string, model: string) => Promise } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ - sessionPersistence?: { list(): Promise } + sessionPersistence?: { + list(): Promise + load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> + isLive?(id: ReturnType): Promise + } + handoffResume?: TuiRuntime['handoffResume'] + /** Set false to exercise the optional session-query degradation path. */ + mountSessionQuery?: boolean } export interface TuiHarness void> { @@ -118,7 +126,26 @@ export async function createTuiTestHarness undefined, + create: () => Promise.resolve(), + append: () => Promise.resolve(), + load: persistence.load === undefined + ? (id: ReturnType) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType) => persistence.load!(id), + inspect: persistence.load === undefined + ? (id: ReturnType) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType) => persistence.load!(id), + claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + isLive: persistence.isLive === undefined + ? () => Promise.resolve(false) + : (id: ReturnType) => persistence.isLive!(id), + } as never) + } + if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { + await ctx.plugin(TestSessionQueryService) } const sessionId = SessionId('main-session') const session = ctx.sessions.create( @@ -178,6 +205,7 @@ export async function createTuiTestHarness { expect(unwrapped.name).toBe('ui-tui') expect(unwrapped.inject).toEqual([ 'agents', + 'sessions', 'commands', 'userInteraction', 'tools', diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 7711b71636..d78aa6d31f 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=10 bufferRow=10 +cursor hidden column=0 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -10,23 +10,60 @@ buffer style 1-21 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim -3| -4| " Resumable sessions " - style 1-18 fg=bright-blue bold -5| " 2024-01-02 03:04 (current) " - style 1-16 fg=bright-black - style 17-26 fg=green -6| " RESUME_SESSION_ID=main-session dsh " -7| " 2024-01-01 00:00 " - style 1-16 fg=bright-black -8| " RESUME_SESSION_ID=earlier-session dsh " -9| "────────────────────────────────────────────────────────────────────────────────────────────" +3| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -10| " " +4| " " style 1-1 inverse -11| "────────────────────────────────────────────────────────────────────────────────────────────" +5| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim style 65-91 dim -13-31| +7-8| +9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " + style 2-89 fg=bright-blue +10| " │ Search: title or session id │ " + style 2-2 fg=bright-blue + style 4-10 fg=bright-black + style 12-30 dim + style 89-89 fg=bright-blue +11| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +12| " │ › Untitled session │ " + style 2-2 fg=bright-blue + style 4-21 fg=bright-blue bold + style 89-89 fg=bright-blue +13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " + style 2-2 fg=bright-blue + style 4-69 fg=bright-black + style 89-89 fg=bright-blue +14| " │ current · live · main-session │ " + style 2-2 fg=bright-blue + style 4-34 dim + style 89-89 fg=bright-blue +15| " │ unavailable: current session │ " + style 2-2 fg=bright-blue + style 4-33 fg=yellow + style 89-89 fg=bright-blue +16| " │ Resume selector design │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " + style 2-2 fg=bright-blue + style 4-76 fg=bright-black + style 89-89 fg=bright-blue +18| " │ persisted · earlier-session │ " + style 2-2 fg=bright-blue + style 4-32 dim + style 89-89 fg=bright-blue +19| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " + style 2-2 fg=bright-blue + style 4-60 dim + style 89-89 fg=bright-blue +21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 fg=bright-blue +22-31| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 182f9aae14..16d6794002 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -646,13 +646,27 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('lists this workspace\'s resumable sessions with their commands', async () => { + it('opens the searchable resume selector with log-backed session summaries', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z')) + const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' } const harness = await setupSnapshot({ config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' }, - sessionPersistence: { list: async () => [ - { version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' }, - { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }, - ] }, + sessionPersistence: { + list: async () => [earlier], + load: async () => ({ + meta: earlier, + events: [ + { type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + ], + }), + }, }, { columns: 92, rows: 32 }) harness.terminal.send('/resume') harness.terminal.send('\r') @@ -662,6 +676,7 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.flush() await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + dateNow.mockRestore() }) it('pins the detailed session diagnostics card', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 724ef9697c..a6f8d30341 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -6,8 +6,10 @@ import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' -import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -152,10 +154,13 @@ describe('TUI config', () => { maxToolOutputLines: 6, maxQuestionOptions: 8, maxModelOptions: 8, + maxResumeOptions: 8, questionDialogWidth: 200, questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, + resumeDialogWidth: 88, + resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -169,10 +174,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -185,10 +193,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -204,6 +215,21 @@ describe('resume command and /resume', () => { const RESUME = 'RESUME_SESSION_ID={session} dsh' const header = (id: string, createdAt: number, cwd: string): SessionHeader => ({ version: 0, id: SessionId(id), createdAt, cwd }) + const resumeEvents = ( + title: string, + provider = 'deepseek', + time = 100, + reason: TurnEndReason = { kind: 'completed' }, + ): SessionEvent[] => [ + { type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, + { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, + ] it('prints the resume command on exit once the session is persisted', async () => { const result = await setup({ @@ -243,73 +269,589 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('lists this workspace\'s sessions newest-first and marks the current one', async () => { + it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + const older = header('older-session', 500, '/workspace') + const newer = header('newer-session', 2000, '/workspace') + const handoff = vi.fn>() const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME }, + handoffResume: handoff, sessionPersistence: { - list: async () => [ - header('main-session', 1000, '/workspace'), - header('older-session', 500, '/workspace'), - header('newer-session', 2000, '/workspace'), - header('foreign-session', 3000, '/elsewhere'), - ], + list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')], + load: async id => id === newer.id + ? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) } + : { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + const output = result.terminal.output + expect(output).toContain('Resume session') + expect(output).toContain('Newer product work') + expect(output).toContain('Older investigation') + expect(output).toContain('current · live') + expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation')) + expect(output).not.toContain('foreign-session') + result.terminal.send('Older') + await tick() + expect(result.terminal.output).toContain('Search: Older') + result.terminal.send('\x1b') + await tick() + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('handles selector navigation, empty matches, and backspace search edits', async () => { + const target = header('keyboard-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + result.terminal.send('zz') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('No session matches this search') + result.terminal.send('\x7f') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('Search: title or session id') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('current session') + result.terminal.send('\x1b') + await dispose(result) + }) + + it('clips candidate count through the configured visible-session limit', async () => { + const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] + const result = await setup({ + cwd: '/workspace', + config: { maxResumeOptions: 1 }, + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Limited ${id}`), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('1/3') + await dispose(result) + }) + + it.each([ + [{ kind: 'aborted' }, 'cancelled'], + [{ kind: 'error', step: 1, message: 'failed' }, 'error'], + [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'max-tokens' }, 'max tokens'], + [{ kind: 'rejected', reason: 'policy' }, 'rejected'], + [{ kind: 'interrupted' }, 'interrupted'], + [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'], + ] as const)('renders the last turn result %s', async (reason, label) => { + const target = header(`turn-${label}`, 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain(`turn 1: ${label}`) + await dispose(result) + }) + + it('refuses while running instead of cancelling or switching', async () => { + const result = await setup({ cwd: '/workspace', status: 'running' }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('finish or be cancelled first') + expect(result.agent.cancelled).toEqual([]) + await dispose(result) + }) + + it('warns when the optional session-query service is absent', async () => { + const result = await setup({ cwd: '/workspace', mountSessionQuery: false }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session query is not mounted') + await dispose(result) + }) + + it('keeps persisted query records readable when live-lease inspection is unavailable', async () => { + const target = header('query-only-persisted', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query-only persisted session'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Query-only persisted session') + expect(result.terminal.output).toContain('persisted') + expect(result.terminal.output).not.toContain('session cannot be loaded') + await dispose(result) + }) + + it('contains a session-query scan failure in the current TUI', async () => { + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.reject(new Error('index unavailable')), + } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - const output = result.terminal.output - expect(output).toContain('Resumable sessions') - expect(output).toContain('RESUME_SESSION_ID=main-session dsh') - expect(output).toContain('(current)') - expect(output).toContain('RESUME_SESSION_ID=newer-session dsh') - expect(output).not.toContain('foreign-session') - // Newest-first: the newer session's command precedes the current session's. - // Match the full resume command, not the bare id: the banner detail line - // echoes the current session id (`main-session`) above the listing. - expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=main-session'), - ) - expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=older-session'), - ) + expect(result.terminal.output).toContain('Resume session scan failed: index unavailable') + expect(result.terminal.stopped).toBe(0) await dispose(result) }) - it('warns from /resume when resume is not configured', async () => { - const result = await setup({ cwd: '/workspace' }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Resume is not configured') - await dispose(result) - }) - - it('warns from /resume when no persistence backend is mounted', async () => { - const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('no persistence backend is mounted') - await dispose(result) - }) - - it('notes from /resume when no workspace sessions are persisted yet', async () => { + it('supersedes a slower prior selector scan', async () => { + const first = Promise.withResolvers() + let calls = 0 const result = await setup({ - cwd: '/workspace', - config: { resumeCommand: RESUME }, - sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] }, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + first.reject(new Error('superseded scan failed')) + await tick() + expect(calls).toBe(2) + expect(result.terminal.output).toContain('No matching sessions') + expect(result.terminal.output).not.toContain('superseded scan failed') + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[B') + await dispose(result) + }) + + it('drops a selector scan that resolves after TUI disposal', async () => { + const listing = Promise.withResolvers() + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never) + }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('No resumable sessions found') + await dispose(result) + listing.resolve([]) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('drops loaded selector summaries when the TUI disposed during log reads', async () => { + const target = header('dispose-during-load', 10, '/workspace') + const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => loading.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + await dispose(result) + loading.resolve({ meta: target, events: resumeEvents('Disposed load') }) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('preflights route availability and occupied or 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, + 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'), + } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + 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') + await tick() + expect(result.terminal.output).toContain('route is currently unavailable') + expect(result.terminal.stopped).toBe(0) + 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') + const events = resumeEvents('Assistant route', 'deepseek') + .filter(event => event.type !== 'request/header') + .map((event, seq) => ({ ...event, seq })) as SessionEvent[] + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [assistantOnly, empty], + load: async id => id === assistantOnly.id + ? { meta: assistantOnly, events } + : { meta: empty, events: [] }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('deepseek/model-1') + expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString()) + await dispose(result) + }) + + it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { + const target = header('target-session', 10, '/workspace') + const handoff = vi.fn>(() => Promise.reject(new Error('test host retained process'))) + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Target session') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Target session') + result.terminal.send('\r') + await tick(); await tick() + expect(handoff).toHaveBeenCalledTimes(1) + expect(handoff).toHaveBeenCalledWith(target.id) + expect(result.terminal.stopped).toBeGreaterThan(0) + expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') + await dispose(result) + }) + + it('restores the UI when a host returns instead of replacing the process', async () => { + const target = header('returning-host', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: async () => undefined as never, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Returning host') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Returning host') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('resume host returned without replacing the process') + await dispose(result) + }) + + it('keeps the current TUI when the selected log fails its second preflight load', async () => { + const target = header('racing-corruption', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + if (++loads > 1) throw new Error('log changed during selection') + return { meta: target, events: resumeEvents('Racing corruption') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Racing corruption') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session') + expect(result.terminal.output).toContain('log changed during selection') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('rejects a candidate whose cwd changes between listing and preflight', async () => { + const target = header('moving-workspace', 10, '/workspace') + let listings = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')], + load: async () => ({ + meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'), + events: resumeEvents('Moving workspace'), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Moving workspace') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('different workspace') + await dispose(result) + }) + + it('admits only one handoff while the selected preflight is pending', async () => { + const target = header('single-handoff', 10, '/workspace') + const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + let loads = 0 + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => ++loads === 1 + ? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') }) + : preflight.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Single handoff') + result.terminal.send('\r') + result.terminal.send('\r') + await tick() + preflight.resolve({ meta: target, events: resumeEvents('Single handoff') }) + await tick(); await tick() + expect(loads).toBe(2) + await dispose(result) + }) + + it('rechecks running state and candidate existence before loading the selected log', async () => { + const target = header('preflight-races', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Preflight races') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.agent.status = 'running' + result.terminal.send('Preflight races') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + + let disappearingLists = 0 + const disappearing = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => ++disappearingLists <= 2 ? [target] : [], + load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }), + }, + }) + disappearing.terminal.send('/resume') + disappearing.terminal.send('\r') + await tick(); await tick() + disappearing.terminal.send('Disappearing target') + disappearing.terminal.send('\r') + await tick() + expect(disappearing.terminal.output).toContain('is no longer available') + await dispose(disappearing) + }) + + it('rechecks idleness after the selected log finishes loading', async () => { + const target = header('load-turns-running', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + loads += 1 + if (loads === 2) result.agent.status = 'running' + return { meta: target, events: resumeEvents('Load turns running') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Load turns running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + }) + + it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => { + const target = header('fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') + expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('keeps the selector independent from an absent command fallback', async () => { + const target = header('no-fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('No fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('No fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place') + await dispose(result) + }) + + it('rechecks idleness after the current-session flush', async () => { + const target = header('post-flush-running', 10, '/workspace') + const control: { setRunning?: () => void } = {} + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => { control.setRunning?.() }) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }), + }, + }) + control.setRunning = () => { result.agent.status = 'running' } + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Post-flush running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + expect(handoff).not.toHaveBeenCalled() + result.agent.status = 'idle' await dispose(result) }) }) describe('pi-tui chat lifecycle and transcript', () => { + it('restores durable goal phase without implying automatic continuation', async () => { + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'create', + goal: { + id: GoalId('restored-goal'), + revision: 1, + objective: 'Resume only with human confirmation', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 10, + updatedAt: 10, + } + const result = await setup({ + beforeMount(session) { + session.append('context/message', { + content: renderGoalChange(change), + source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, + meta: change as unknown as JsonValue, + }, { surfaceOp: 'append' }) + }, + }) + expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') + expect(result.terminal.output).toContain('/goal resume') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('goal active') + await dispose(result) + }) + it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ // A fixed short cwd keeps the footer's token counters inside the 88-column diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index cf0a2b544f..3560d6bc9d 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent-loop" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/session" }, @@ -29,6 +32,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../session-title/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..21eb750454 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-tui': + specifier: workspace:^ + version: link:../../packages/ui/tui + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) apps/web: dependencies: @@ -3836,6 +3842,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..6be2d7ed83 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -52,6 +52,7 @@ export const LINK_MAP: Record = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', + SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', ApprovalPolicy: 'approval.md', @@ -94,6 +95,7 @@ export const LINK_MAP: Record = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', + SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..1472499819 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -379,6 +379,11 @@ "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", @@ -389,6 +394,11 @@ "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionLogSnapshot", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSurfaceSnapshot", From 54d986ed87a022643d1de9b890712635942882bc Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 12:58:53 +0800 Subject: [PATCH 2/7] fix(tui): close resume handoff races --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/module-graph.md | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 10 +- .../tests/jsonl.spec.ts | 7 + .../session-persistence-sqlite/README.md | 1 + .../session-persistence-sqlite/src/index.ts | 6 +- .../tests/sqlite.spec.ts | 18 +- .../session-persistence/README.md | 1 + .../session-persistence/src/index.ts | 7 +- .../session-persistence/src/lease.ts | 93 +++++---- .../session-persistence/tests/lease.spec.ts | 30 +++ packages/ui/tui/README.md | 2 +- packages/ui/tui/package.json | 3 - packages/ui/tui/src/index.ts | 65 +++++-- packages/ui/tui/tests/harness.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 182 ++++++++++++++++++ 20 files changed, 375 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 42370c5dad..c04f198e4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 -2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 +2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4 +2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 23755696a9..cd08b56f1e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -14,9 +14,9 @@ The original `/resume` printed shell commands. It did not let a keyboard user in `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. -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, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. +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 flushes the current session and 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 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 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`. `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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index cd2e19a2ef..ef641292dd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -14,9 +14,9 @@ Status: implemented `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 -预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 `resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5b3f7c4afb..f0ee05d705 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -983,7 +983,7 @@ isLive(id: SessionId): Promise 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) -Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..212fd723a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -674,11 +674,13 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_query pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill @@ -898,7 +900,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 4aaf30f45a..86a8c0f1d6 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -69,6 +69,6 @@ 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 liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **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. - **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. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a3ed611a4c..8d96a17681 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -314,7 +314,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi 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 || sessionLeaseProcessIsLive(current.pid)) { + if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } const reclaimPath = `${path}.reclaim` @@ -335,7 +335,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi 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 (sessionLeaseProcessIsLive(latest.pid)) { + if (sessionLeaseOwnerIsLive(latest, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } await rm(path, { force: true }) @@ -356,13 +356,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Report one non-stale process lease and clean up a crashed owner's record. */ + /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { 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 (sessionLeaseProcessIsLive(current.pid)) return true + if (sessionLeaseOwnerIsLive(current, owner)) return true return false } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 517cf7bb04..269d56f886 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,6 +249,13 @@ describe('SessionPersistenceJsonl: cross-process live leases', () => { 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() diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 374da93faa..42421b2e81 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -57,3 +57,4 @@ 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. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4399ee9c90..091880e87e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current !== undefined && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseProcessIsLive(current.pid)) { + 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) @@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current === undefined) return false if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseProcessIsLive(current.pid)) return true + || 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 diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a3aba22323..b670520a5c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +afterEach(async () => { + vi.restoreAllMocks() + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) +}) async function expectFlushError(promise: Promise, message: RegExp): Promise { try { @@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => { 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', process.pid, 'another-owner') + 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) @@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => { 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() diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 2bfa3a31b1..0aec3a4bd5 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -85,3 +85,4 @@ 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. diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3aa602c8c0..d6bbee0616 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -13,7 +13,12 @@ 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 { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.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. */ diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts index 5cc51117ab..148d8e117f 100644 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -8,7 +8,7 @@ const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' export interface SessionLiveOwner { /** Operating-system process id; retained across an `execve` handoff. */ readonly pid: number - /** Per-process-start nonce that distinguishes PID reuse. */ + /** Exec-stable process-start nonce used when the observer has the same PID. */ readonly nonce: string } @@ -42,9 +42,26 @@ export function sessionLeaseProcessIsLive(pid: number): boolean { } } +/** + * 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> + finalizing?: Promise } const sharedLeases = new Map() @@ -59,40 +76,48 @@ export async function shareSessionLiveLease( key: string, acquire: () => Promise<() => Promise>, ): Promise<() => Promise> { - let entry = sharedLeases.get(key) - 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 | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { + 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 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ - 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 releaseTask until settlement */ - if (releaseTask === wrapped) releaseTask = undefined throw error - }) - releaseTask = wrapped - return wrapped + } + let releaseTask: Promise | 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 + } } } diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts index e35bba9311..8c38aac874 100644 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { randomUUID } from 'node:crypto' import { + sessionLeaseOwnerIsLive, sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease, @@ -21,10 +22,14 @@ describe('process live-session lease helpers', () => { 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 }) @@ -59,4 +64,29 @@ describe('process live-session lease helpers', () => { 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() + const firstPhysicalRelease = vi.fn(() => releaseGate.promise) + const secondPhysicalRelease = vi.fn(() => Promise.resolve()) + const releases: Array<() => Promise> = [firstPhysicalRelease, secondPhysicalRelease] + let acquisitions = 0 + const acquire = vi.fn<() => Promise<() => Promise>>((): Promise<() => Promise> => { + 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) + }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ee3e787c3f..db13d8733e 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/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, flushes it, 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, 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, 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`. `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. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 76e9c9c7aa..3242f5a2a3 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -53,9 +53,6 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, - "@deepseek-ai/dsh-goal": { - "optional": true - }, "@deepseek-ai/dsh-skill": { "optional": true } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7317f3000b..90297fc478 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -77,9 +77,9 @@ import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' -// Side-effect type import: declaration-merges the optional `sessionPersistence` +// Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -1841,6 +1841,8 @@ 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 } @@ -1853,6 +1855,12 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed + const releaseResumeReservation = async (): Promise => { + 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). @@ -2436,6 +2444,8 @@ 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')) @@ -2871,6 +2881,7 @@ export function createTuiChat( const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { if (resumeInFlight) return resumeInFlight = true + let terminalReleased = false try { const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume @@ -2884,30 +2895,52 @@ 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 + } 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 + if (disposed) return if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) await overlay.close() resumeOverlay = undefined await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return ui.stop() - try { - 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; pre-commit rejection keeps it live */ - if (!disposed) { + 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(error)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') } } - } catch (error: unknown) { - /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ - if (!disposed) { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } } finally { resumeInFlight = false } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6b9bd143d4..22692026e5 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,6 +10,7 @@ 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,6 +54,7 @@ export interface TuiHarnessOptions { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> isLive?(id: ReturnType): Promise + claimLive?(id: ReturnType): Promise } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -138,7 +140,9 @@ export async function createTuiTestHarness) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), - claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + claimLive: persistence.claimLive === undefined + ? () => Promise.resolve({ release: () => Promise.resolve() }) + : (id: ReturnType) => persistence.claimLive!(id), isLive: persistence.isLive === undefined ? () => Promise.resolve(false) : (id: ReturnType) => persistence.isLive!(id), diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a6f8d30341..635446b041 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -562,6 +562,8 @@ 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>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -569,6 +571,7 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), + claimLive, }, }) result.terminal.send('/resume') @@ -579,6 +582,8 @@ 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) @@ -630,6 +635,183 @@ 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') + const handoff = vi.fn>() + const flush = vi.fn() + 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')), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Reservation race') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + 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 () => { + const target = header('query-without-persistence', 10, '/workspace') + const handoff = vi.fn>() + 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: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + 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() + 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 }>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + 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() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => flushing.promise) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during flush') + result.terminal.send('\r') + await tick() + const disposing = dispose(result) + 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() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.drainInput.mockImplementationOnce(() => draining.promise) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during drain') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() }) + 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 + const result = await setup({ + cwd: '/workspace', + handoffResume: () => Promise.reject(new Error('host rejected')), + 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(), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Release failure') + 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 dispose(result) + expect(releases).toBe(2) + }) + it('rejects a candidate whose cwd changes between listing and preflight', async () => { const target = header('moving-workspace', 10, '/workspace') let listings = 0 From c440217fde2e5355c1ec44b3ade1ab2301fc7816 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 16:07:49 +0800 Subject: [PATCH 3/7] refactor(tui): defer cross-process resume locking --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 14 +- .../2026-07-21-tui-resume-command.zh.md | 14 +- apps/cli/README.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 6 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 24 +-- docs/core-data-structures/persistence.md | 12 -- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 -- packages/core/agent-loop/src/index.ts | 31 +-- packages/core/agent-loop/tests/resume.spec.ts | 45 ----- packages/examples/tui-demo/README.md | 2 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +---------- .../tests/fixtures/live-lease-child.ts | 16 -- .../tests/fixtures/live-lease-race-child.ts | 33 --- .../tests/jsonl.spec.ts | 189 +----------------- .../session-persistence-sqlite/README.md | 5 +- .../session-persistence-sqlite/src/index.ts | 67 +------ .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 51 +---- .../session-persistence/README.md | 9 +- .../session-persistence/src/coordinator.ts | 86 +------- .../session-persistence/src/index.ts | 43 ---- .../session-persistence/src/lease.ts | 123 ------------ .../session-persistence/tests/lease.spec.ts | 92 --------- .../tests/persistence.spec.ts | 58 +----- packages/ui/tui/README.md | 3 +- packages/ui/tui/src/index.ts | 51 +---- packages/ui/tui/tests/harness.ts | 9 - packages/ui/tui/tests/tui.spec.ts | 152 +++++++------- scripts/gen-cordis-catalog.ts | 1 - scripts/type-equiv.manifest.json | 5 - 39 files changed, 133 insertions(+), 1183 deletions(-) delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts delete mode 100644 packages/session-persistence/session-persistence/src/lease.ts delete mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index c04f198e4a..62dc61c019 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index cd08b56f1e..526c3775bc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index ef641292dd..d333a5bb22 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -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 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 86bda3fbf5..e6a33247ca 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 ` 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 ` 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. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ea83179477..466a96dd30 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 -architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 +architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 +architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b diff --git a/docs/architecture.md b/docs/architecture.md index f0ce115d0b..5a0ff63413 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `-session-`; `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 `-session-`; `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 diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index eef66e3b9d..e1fb143982 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 +未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程会在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b841214c5..040b15baf3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 9bda12f6b0..6becfd9434 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0ee05d705..5858799e7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise - -/** - * 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 - -/** - * 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 ``` -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) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index ffd4304376..f45eb0417a 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -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 -} -``` - ## `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`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7f7ecc4f2d..179ad0dcee 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 032a82bdaf..2e87df0a27 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/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 `; 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= 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 `. 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= 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 diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 9c198870ad..8c93a004c2 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -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-', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f49cc75b1c..6939ec9927 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,14 +480,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise', 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', - 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', - 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;\n}', - }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 15ece492e3..bdaa3f2401 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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) | 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, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2d19db7a0d..fdf5c39514 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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>>() - const claimStarted = Promise.withResolvers() - 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 - 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) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index bba7ae7f7e..10ff7872c5 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -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-` 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-` 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 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 86a8c0f1d6..bf86bf8633 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,9 +6,6 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / - .live/ - .lock # PID + nonce cross-process live lease - .lock.reclaim # ephemeral stale-owner takeover guard cwd-/ # per-project bucket (or _no-cwd/ when no cwd) .jsonl.zstd # default: checksummed header frame + append frames .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/.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. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 8d96a17681..629c0e3ff1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -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 { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise { - 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> { - 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> { - 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> - 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 { - 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 { - 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> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts deleted file mode 100644 index 2a42b8c402..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts +++ /dev/null @@ -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 ') - -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(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts deleted file mode 100644 index b4ef3b6e59..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts +++ /dev/null @@ -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 ') -} - -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(() => { setInterval(() => {}, 60_000) }) -} catch (error) { - await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) - await ctx.fiber.dispose() -} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 269d56f886..2b49b7d55b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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(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(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(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 () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 42421b2e81..f1f4bc1f7b 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -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. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 091880e87e..5804c18282 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -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 { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise { - 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> { - await this.ready - return shareSessionLiveLease( - `sqlite:${this.storeIdentity}:${id}`, - () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), - ) - } - - private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise { - 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 { - 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 { 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 diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 6a5be76eb9..8b8dcd78e0 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -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 - `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index b670520a5c..3976e71549 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -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, message: RegExp): Promise { 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') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 0aec3a4bd5..25429bd720 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -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` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | 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` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | -| `isLive(id): Promise` | 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` 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. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 8ea1790b3e..fb46aa4877 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -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 { /** List all stored (materialized) sessions' metadata. */ list(): Promise - /** Optionally acquire a backend-owned cross-process live-session lease. */ - acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> - - /** Optionally inspect and reclaim a backend-owned live-session lease. */ - inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise - /** * 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 flush: Promise | undefined - lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -170,12 +161,6 @@ export class PersistenceCoordinator { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map>() - /** One backend lease with process-local reference counting per session id. */ - private liveClaims = new Map Promise - }>() - private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() @@ -288,61 +273,6 @@ export class PersistenceCoordinator { 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 { - 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 | 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 { - 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 { 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 { private async retireCore(session: Session): Promise { 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 { 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 } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index d6bbee0616..c785c9354c 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -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() - 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 - - /** - * 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 { - 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 { - return Promise.resolve(this.localLiveClaims.has(id)) - } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts deleted file mode 100644 index 148d8e117f..0000000000 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ /dev/null @@ -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 -} - -/** - * 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> - finalizing?: Promise -} - -const sharedLeases = new Map() - -/** - * 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>, -): Promise<() => Promise> { - 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 | 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 - } - } -} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts deleted file mode 100644 index 8c38aac874..0000000000 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ /dev/null @@ -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>(() => Promise.resolve()) - const acquire = vi.fn<() => Promise<() => Promise>>(() => 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() - const firstPhysicalRelease = vi.fn(() => releaseGate.promise) - const secondPhysicalRelease = vi.fn(() => Promise.resolve()) - const releases: Array<() => Promise> = [firstPhysicalRelease, secondPhysicalRelease] - let acquisitions = 0 - const acquire = vi.fn<() => Promise<() => Promise>>((): Promise<() => Promise> => { - 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) - }) -}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 36192c37d7..6b31d0843b 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -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> { - return async () => { - this.releaseAttempts += 1 - if (this.releaseAttempts === 1) throw new Error('lease release failed') - } - } - inspectLive(): Promise { - 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() - }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index db13d8733e..8dc10f20f8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/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. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 90297fc478..372a0c9497 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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, ): 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 => { - 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, ): Promise => { 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 { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 22692026e5..97d0947536 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -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 load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> - isLive?(id: ReturnType): Promise - claimLive?(id: ReturnType): Promise } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -140,12 +137,6 @@ export async function createTuiTestHarness) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), - claimLive: persistence.claimLive === undefined - ? () => Promise.resolve({ release: () => Promise.resolve() }) - : (id: ReturnType) => persistence.claimLive!(id), - isLive: persistence.isLive === undefined - ? () => Promise.resolve(false) - : (id: ReturnType) => persistence.isLive!(id), } as never) } if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 635446b041..0ec1d4a6e2 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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>() + 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>(() => 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() const handoff = vi.fn>() 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>() + const handoff = vi.fn>( + () => 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 }>() - const release = vi.fn(() => Promise.resolve()) - const handoff = vi.fn>() - 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() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn>() 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() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn>() 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() + const handoff = vi.fn>(() => 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 () => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6be2d7ed83..d87fba3d08 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -95,7 +95,6 @@ export const LINK_MAP: Record = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', - SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1472499819..db5fc0b85d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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", From d3b00bbdff2d16727c651632d2b0c5afb67983c8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 16:13:13 +0800 Subject: [PATCH 4/7] test(tui): await fresh model selector frames --- packages/ui/tui/tests/tui.spec.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0ec1d4a6e2..c6426d4c67 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2249,22 +2249,27 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() result.agent.status = 'running' + const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') - expect(result.terminal.output).toContain('alpha/a1') - expect(result.terminal.output).toContain('Alpha One — Fast — current') + await vi.waitFor(() => { + const output = result.terminal.output.slice(runningSelectorOutput) + expect(output).toContain('Select model') + expect(output).toContain('alpha/a1') + expect(output).toContain('Alpha One — Fast — current') + }) result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -2276,9 +2281,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).not.toContain('50% context tools:collapsed') + const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') From 33ee34b58ea0d46601e544b589133f48f8ae580f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:40:17 -0700 Subject: [PATCH 5/7] fix(tui): make resume picker full-screen --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/config-catalog.md | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 146 +++++++++++------- .../snapshots/resume-sessions.expected.txt | 112 ++++++-------- packages/ui/tui/tests/tui.spec.ts | 20 +-- 9 files changed, 156 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 62dc61c019..d470b61414 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd -2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d +2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b +2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 526c3775bc..86f62e16f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,7 +10,7 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## 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 sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. 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 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. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -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. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, 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 full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index d333a5bb22..06e58f8144 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 040b15baf3..49a0a1510f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1609,10 +1609,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1635,7 +1631,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 8c93a004c2..efbe11099f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -270,7 +270,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, { waitFor: 'Resume selector design', send: 'Resume selector design' }, - { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: '⌕ Resume selector design', send: '\r' }, { waitFor: 'Preserve restored state', send: '/exit\r' }, ], }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8dc10f20f8..3dae79bf73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/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, 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`. +`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. 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. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. 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. @@ -49,8 +49,6 @@ The footer sums the session's reported usage as `↑ | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | -| `resumeDialogWidth` | `88` | Resume-selector width in columns | -| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 372a0c9497..2f7aab15af 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -205,10 +205,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -239,8 +235,6 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) -const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -260,8 +254,6 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, - resumeDialogWidth: resumeDialogWidthSchema, - resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -302,8 +294,6 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, - resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -324,8 +314,6 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number - resumeDialogWidth: number - resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -370,8 +358,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - resumeDialogWidth: config?.resumeDialogWidth ?? 88, - resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1346,9 +1332,9 @@ function summarizeResumeCandidate( } } -/** Searchable keyboard selector over detached, preflighted resume summaries. */ -class ResumeDialog implements Component, Focusable { - private query = '' +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +class ResumePicker implements Component, Focusable { + private readonly search = new Input() private selectedIndex = 0 private error = '' focused = false @@ -1356,87 +1342,133 @@ class ResumeDialog implements Component, Focusable { constructor( private readonly candidates: readonly ResumeCandidate[], private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, private readonly palette: Palette, private readonly done: (candidate: ResumeCandidate) => void, private readonly cancel: () => void, ) {} - invalidate(): void {} + invalidate(): void { + this.search.invalidate() + } private filtered(): ResumeCandidate[] { - const query = this.query.trim().toLocaleLowerCase() + const query = this.search.getValue().trim().toLocaleLowerCase() if (query === '') return [...this.candidates] return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) || candidate.record.header.id.toLocaleLowerCase().includes(query)) } handleInput(data: string): void { - this.invalidate() const filtered = this.filtered() - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + if (matchesKey(data, Key.ctrl('c'))) { this.cancel() return } - if (matchesKey(data, Key.up)) { + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + filtered.length - 1) % filtered.length } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.maxVisible, + ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' else if (selected.disabledReason !== undefined) this.error = selected.disabledReason else this.done(selected) - } else if (data === '\x7f' || data === '\b') { - this.query = Array.from(this.query).slice(0, -1).join('') - this.selectedIndex = 0 - this.error = '' - } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { - this.query += data - this.selectedIndex = 0 - this.error = '' + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } } + this.invalidate() } render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) const filtered = this.filtered() if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - filtered.length - this.maxVisible, - )) - const end = Math.min(filtered.length, start + this.maxVisible) - const body: string[] = [ - this.query === '' - ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` - : this.palette.text(`Search: ${displayText(this.query)}`), + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, '', ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) + const visibleCount = Math.min(this.maxVisible, candidateBudget) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } for (let index = start; index < end; index += 1) { const candidate = filtered[index] as ResumeCandidate - const selected = index === this.selectedIndex + const active = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : 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)}` - body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) if (candidate.disabledReason !== undefined) { - body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } } - if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) - if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) - body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) - if (this.error !== '') body.push(this.palette.error(displayText(this.error))) - return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) } } @@ -2932,18 +2964,20 @@ export function createTuiChat( || a.record.header.id.localeCompare(b.record.header.id)) if (isDisposed() || scan !== resumeScan) return const session = overlayManager.open({ - create: () => new ResumeDialog( + create: host => new ResumePicker( candidates, resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, () => { void session.close() }, ), options: { - width: resolved.resumeDialogWidth, - maxHeight: resolved.resumeDialogMaxHeight, - anchor: 'center', - margin: 1, + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, }, }) resumeOverlay = session diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index d78aa6d31f..db54654115 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,69 +1,51 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=31 bufferRow=31 +cursor hidden column=6 viewportRow=4 bufferRow=4 buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-8| -9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " - style 2-89 fg=bright-blue -10| " │ Search: title or session id │ " - style 2-2 fg=bright-blue - style 4-10 fg=bright-black - style 12-30 dim - style 89-89 fg=bright-blue -11| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -12| " │ › Untitled session │ " - style 2-2 fg=bright-blue - style 4-21 fg=bright-blue bold - style 89-89 fg=bright-blue -13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " - style 2-2 fg=bright-blue - style 4-69 fg=bright-black - style 89-89 fg=bright-blue -14| " │ current · live · main-session │ " - style 2-2 fg=bright-blue - style 4-34 dim - style 89-89 fg=bright-blue -15| " │ unavailable: current session │ " - style 2-2 fg=bright-blue - style 4-33 fg=yellow - style 89-89 fg=bright-blue -16| " │ Resume selector design │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " - style 2-2 fg=bright-blue - style 4-76 fg=bright-black - style 89-89 fg=bright-blue -18| " │ persisted · earlier-session │ " - style 2-2 fg=bright-blue - style 4-32 dim - style 89-89 fg=bright-blue -19| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " - style 2-2 fg=bright-blue - style 4-60 dim - style 89-89 fg=bright-blue -21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " - style 2-89 fg=bright-blue -22-31| +0| " " +1| " Resume session (1 of 2) " + style 2-24 fg=bright-blue bold +2| " " +3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ " + style 2-89 dim +4| " │ ⌕ │ " + style 2-2 dim + style 6-6 inverse + style 89-89 dim +5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 dim +6| " " +7| " /workspace/project " + style 2-19 fg=bright-black +8| " " +9| " ❯ Untitled session " + style 2-19 fg=bright-blue bold +10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " + style 2-67 fg=bright-black +11| " current · live · main-session " + style 2-32 dim +12| " unavailable: current session " + style 2-31 fg=yellow +13| " Resume selector design " +14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro " + style 2-74 fg=bright-black +15| " persisted · earlier-session " + style 2-30 dim +16| " " +17| " " +18| " " +19| " " +20| " " +21| " " +22| " " +23| " " +24| " " +25| " " +26| " " +27| " " +28| " " +29| " " +30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel " + style 2-70 dim +31| " " diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c6426d4c67..127d5c8807 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -159,8 +159,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, - resumeDialogWidth: 88, - resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -179,8 +177,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -198,8 +194,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -269,7 +263,7 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => { const older = header('older-session', 500, '/workspace') const newer = header('newer-session', 2000, '/workspace') const handoff = vi.fn>() @@ -296,7 +290,11 @@ describe('resume command and /resume', () => { expect(output).not.toContain('foreign-session') result.terminal.send('Older') await tick() - expect(result.terminal.output).toContain('Search: Older') + expect(result.terminal.output).toContain('⌕ Older') + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .not.toContain('⌕ Older') result.terminal.send('\x1b') await tick() expect(handoff).not.toHaveBeenCalled() @@ -325,7 +323,9 @@ describe('resume command and /resume', () => { result.terminal.send('\x7f') result.terminal.send('\x7f') await tick() - expect(result.terminal.output).toContain('Search: title or session id') + const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(cleared).toContain('⌕ ') + expect(cleared).not.toContain('zz') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('current session') @@ -349,7 +349,7 @@ describe('resume command and /resume', () => { result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('1/3') + expect(result.terminal.output).toContain('(1 of 3)') await dispose(result) }) From 12dbc001665e472d2f970a267fc9cc150ecb01c9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:15:26 -0700 Subject: [PATCH 6/7] fix(tui): harden resume picker input --- packages/ui/tui/src/index.ts | 56 ++++++++++++++++++++++++++--- packages/ui/tui/tests/tui.spec.ts | 59 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2f7aab15af..2ade1937e3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -393,6 +393,11 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) => } const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu +const BRACKETED_PASTE_START = '\u001B[200~' +const BRACKETED_PASTE_END = '\u001B[201~' /** * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. @@ -408,6 +413,15 @@ function displayInlineText(text: string): string { return displayText(text).replaceAll('\n', '\\x0a') } +/** Remove terminal controls from clipboard text before an editable field stores it. */ +function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} + /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body @@ -1335,6 +1349,7 @@ function summarizeResumeCandidate( /** Full-viewport keyboard selector over detached, preflighted resume summaries. */ class ResumePicker implements Component, Focusable { private readonly search = new Input() + private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' focused = false @@ -1360,7 +1375,39 @@ class ResumePicker implements Component, Focusable { || candidate.record.header.id.toLocaleLowerCase().includes(query)) } + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return const filtered = this.filtered() if (matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -1380,11 +1427,11 @@ class ResumePicker implements Component, Focusable { } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) } else if (matchesKey(data, Key.pageDown)) { this.selectedIndex = Math.min( Math.max(0, filtered.length - 1), - this.selectedIndex + this.maxVisible, + this.selectedIndex + this.visibleCandidateCount(), ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] @@ -1421,7 +1468,7 @@ class ResumePicker implements Component, Focusable { const searchInnerWidth = Math.max(1, contentWidth - 4) lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') lines.push( `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, @@ -1431,8 +1478,7 @@ class ResumePicker implements Component, Focusable { '', ) - const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) - const visibleCount = Math.min(this.maxVisible, candidateBudget) + const visibleCount = this.visibleCandidateCount() const start = Math.max(0, Math.min( this.selectedIndex - Math.floor(visibleCount / 2), filtered.length - visibleCount, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 127d5c8807..73be9abe76 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -333,6 +333,65 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('sanitizes bracketed-paste terminal controls before storing the search query', async () => { + const target = header('safe-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Safe target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[200~Safe\x1b]0;own') + result.terminal.send('ed\x07 target\x1b[31m\x1b[201~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('⌕ Safe target') + expect(rendered).not.toContain('owned') + expect(rendered).not.toContain('[31m') + result.terminal.send('\x1b') + result.terminal.send('Safe\x1b[200~\x1b[201~ target') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕ Safe target') + await dispose(result) + }) + + it('pages by the number of candidates that fit the current viewport', async () => { + const targets = Array.from({ length: 8 }, (_, index) => + header(`paged-${index}`, 1000 - index, '/workspace')) + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[6~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('❯ Paged 3') + result.terminal.send('\x1b[5~') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('❯ Untitled session') + result.terminal.resize(10) + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕') + result.terminal.send('\x03') + await dispose(result) + }) + it('clips candidate count through the configured visible-session limit', async () => { const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] const result = await setup({ From 6b686aa8320d7ae7c9d0ab4ddd16d0fa49bd6aae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:07:23 +0800 Subject: [PATCH 7/7] docs(i18n): refresh session query snapshot contract --- docs/core-data-structures/session-query.i18n.yaml | 4 ++-- docs/core-data-structures/session-query.zh.md | 12 +++++++++++- scripts/type-equiv.manifest.json | 5 +++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index 4a4a6e31fe..2cba0f9b42 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session-query.md: f91b438bd6448ce5f0d871d595704556262698e8 -session-query.zh.md: 3db50df96c302a6a090fc795085b2e52b70256dd +session-query.md: c6dde8714a0875d46cf7b49cc181daab8f44afe0 +session-query.zh.md: 44be8b7f89e1576e54c8dc3cb60619d6728c6895 diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 3db50df96c..44be8b7f89 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -27,7 +27,17 @@ interface SessionRecord { } ``` -`SessionSurfaceSnapshot` 表示一次精确读取所得的观测,而不是持续保留的订阅。它的原始日志边界与折叠后的事件来自同一次优先使用 live 数据的加载。 +`SessionLogSnapshot` 是供恢复预检使用的完整原始日志:它脱离运行时,并经过回放验证。`SessionSurfaceSnapshot` 表示一次精确读取的 surface 观测结果,而不是持续保留的订阅。 + +```ts type-equiv +/** One validated detached observation of a logical session's complete raw log. */ +interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} +``` ```ts type-equiv /** One atomic live-preferred observation of a session's current model surface. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ac5d19e6f3..a15fc21a96 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1512,6 +1512,11 @@ "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionLogSnapshot", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionSurfaceSnapshot",