refactor(session): exclude live-session registry foundation
This commit is contained in:
50 files changed
+6
-2246
No files matched your search
@@ -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 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
|
||||
2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9
|
||||
2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e
|
||||
2026-07-28-cross-workspace-resume.md: be455496346b9242585c1aace18f4f55cba905c0
|
||||
2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11
|
||||
@@ -43,7 +43,6 @@ The shared base states that precedence in the row itself: `apps/cli/base.cordis.
|
||||
## Consequences
|
||||
|
||||
- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration.
|
||||
- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope.
|
||||
- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
|
||||
- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根
|
||||
## Consequences
|
||||
|
||||
- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
|
||||
- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。
|
||||
- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
|
||||
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
|
||||
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
|
||||
2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e
|
||||
2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8
|
||||
@@ -1,65 +0,0 @@
|
||||
# Agent Note: live-session registry and `dsh list-sessions`
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam.
|
||||
|
||||
[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one.
|
||||
|
||||
### Liveness is derived, never stored
|
||||
|
||||
`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped.
|
||||
|
||||
### Two independent concurrency layers
|
||||
|
||||
The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms.
|
||||
|
||||
Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it.
|
||||
|
||||
### Records carry their own title
|
||||
|
||||
The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend.
|
||||
|
||||
### Subagents are invisible by construction
|
||||
|
||||
Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional.
|
||||
|
||||
**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead.
|
||||
|
||||
**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own.
|
||||
|
||||
**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool.
|
||||
|
||||
**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite.
|
||||
|
||||
**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path.
|
||||
|
||||
**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next.
|
||||
|
||||
Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time.
|
||||
|
||||
`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only.
|
||||
|
||||
Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch.
|
||||
|
||||
Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Agent Note: 活跃会话注册表与 `dsh list-sessions`
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。
|
||||
|
||||
[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。
|
||||
|
||||
### 存活状态是推导出来的,绝不存储
|
||||
|
||||
`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。
|
||||
|
||||
### 两层相互独立的并发机制
|
||||
|
||||
该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。
|
||||
|
||||
跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。
|
||||
|
||||
### 记录自带标题
|
||||
|
||||
标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。
|
||||
|
||||
### subagent 在设计上就不可见
|
||||
|
||||
只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。
|
||||
|
||||
**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。
|
||||
|
||||
**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。
|
||||
|
||||
**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。
|
||||
|
||||
**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。
|
||||
|
||||
**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。
|
||||
|
||||
**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。
|
||||
|
||||
## 后果
|
||||
|
||||
注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。
|
||||
|
||||
标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。
|
||||
|
||||
`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。
|
||||
|
||||
存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。
|
||||
|
||||
## 测试
|
||||
|
||||
单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。
|
||||
|
||||
已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
# The session store root is the launcher's policy, not a plugin's: `dsh` shares
|
||||
# one store under the Harness home across every cwd, so `/resume` and
|
||||
# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps
|
||||
# `/resume` spans workspaces. Without a launcher the project-local fallback keeps
|
||||
# an embedder's sessions beside its project.
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
@@ -47,10 +47,6 @@ flowchart LR
|
||||
pkg_workspace["workspace"]
|
||||
svc_workspace["ctx.workspace<br/>Workspace entity registry"]
|
||||
pkg_apiproxy["apiproxy"]
|
||||
pkg_session_registry["session-registry"]
|
||||
svc_sessionRegistry["ctx.sessionRegistry<br/>Live-session registry"]
|
||||
pkg_session_registry_file["session-registry-file"]
|
||||
pkg_session_registry_live["session-registry-live"]
|
||||
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
|
||||
pkg_session_reference["session-reference"]
|
||||
pkg_tool_session_query["tool-session-query"]
|
||||
@@ -200,8 +196,6 @@ flowchart LR
|
||||
pkg_session_query --> svc_sessionQuery
|
||||
pkg_session_query_sqlite --> svc_sessionQuery
|
||||
pkg_session_reference --> svc_sessionReferences
|
||||
pkg_session_registry --> svc_sessionRegistry
|
||||
pkg_session_registry_file --> svc_sessionRegistry
|
||||
pkg_session_telemetry --> svc_telemetry
|
||||
pkg_session_telemetry_otel --> svc_telemetry
|
||||
pkg_session_title --> svc_sessionTitle
|
||||
@@ -283,7 +277,6 @@ flowchart LR
|
||||
svc_sessionQuery --> pkg_session_reference
|
||||
svc_sessionQuery --> pkg_tool_session_query
|
||||
svc_sessionReferences --> pkg_tui
|
||||
svc_sessionRegistry --> pkg_session_registry_live
|
||||
svc_sessions --> pkg_agent
|
||||
svc_sessions --> pkg_agent_loop
|
||||
svc_sessions --> pkg_cli_demo
|
||||
@@ -344,7 +337,6 @@ flowchart LR
|
||||
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
|
||||
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
|
||||
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
|
||||
| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. |
|
||||
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
|
||||
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
|
||||
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
|
||||
|
||||
@@ -1153,26 +1153,6 @@ export interface Config {
|
||||
|
||||
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-registry-file`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config as callers write it: `root` is required — a cwd fallback would
|
||||
* scatter registries — while the lock tunables are optional because
|
||||
* `static Config` supplies their defaults.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Directory holding the registry file; created `0o700` on demand. */
|
||||
root: string
|
||||
/** Milliseconds after which a held lock is considered abandoned and reclaimed. */
|
||||
lockStaleMs?: number
|
||||
/** Retries before a contended acquisition fails loud. */
|
||||
lockRetries?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-telemetry-otel`
|
||||
|
||||
Requires: `sessions`
|
||||
@@ -2194,7 +2174,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts))
|
||||
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
|
||||
@@ -2217,7 +2196,6 @@ Abstract service classes — a deployment loads a concrete implementation packag
|
||||
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
|
||||
|
||||
@@ -1464,44 +1464,6 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s
|
||||
|
||||
Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts)
|
||||
|
||||
## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam)
|
||||
|
||||
Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Publish this process's record, replacing any stale record for the same
|
||||
* session id, and prune records whose process is gone.
|
||||
* @param registration - the session, surface, and workspace to publish.
|
||||
* @returns the effect disposer that removes this record again; awaiting it
|
||||
* waits for the removal to reach durability.
|
||||
*/
|
||||
abstract register(registration: SessionRegistration): Promise<() => Promise<void>>
|
||||
|
||||
/**
|
||||
* Replace the recorded title of a session this process registered.
|
||||
*
|
||||
* Titles arrive after registration and can be revised, so this is the one
|
||||
* mutable field. Only a record matching this process and incarnation is
|
||||
* touched, leaving a same-id record owned by another process alone. An unknown
|
||||
* session id is a no-op rather than an error: a title can resolve after the
|
||||
* session's record has already been removed.
|
||||
* @param sessionId - the session whose recorded title changes.
|
||||
* @param title - the new title text.
|
||||
*/
|
||||
abstract retitle(sessionId: SessionId, title: string): Promise<void>
|
||||
|
||||
/**
|
||||
* List live sessions, pruning records whose process no longer exists.
|
||||
* @returns one record per live registered session, newest registration last.
|
||||
*/
|
||||
abstract list(): Promise<SessionRegistryRecord[]>
|
||||
```
|
||||
|
||||
Types: [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts)
|
||||
|
||||
## `ctx.sessions` — `SessionStore`
|
||||
|
||||
In-memory session store (`ctx.sessions`).
|
||||
|
||||
@@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
|
||||
@@ -288,7 +288,6 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/session-registry/session-registry-file": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/fixtures/register-once.ts"
|
||||
|
||||
@@ -688,24 +688,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionRegistry',
|
||||
summary: 'Cross-process live-session registry.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise<void>>',
|
||||
jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract retitle(sessionId: SessionId, title: string): Promise<void>',
|
||||
jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract list(): Promise<SessionRegistryRecord[]>',
|
||||
jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
summary: 'In-memory session store (`ctx.sessions`).',
|
||||
@@ -2270,12 +2252,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionRegistration',
|
||||
declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionRegistryRecord',
|
||||
declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionResultFilter',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-registry/README.md
|
||||
README.md: c79caccd05d6cbdda0663dd897490fb6004b8250
|
||||
README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f
|
||||
@@ -1,15 +0,0 @@
|
||||
# session-registry/ — live-session registry family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` |
|
||||
| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — |
|
||||
| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — |
|
||||
|
||||
The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
|
||||
|
||||
This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists.
|
||||
@@ -1,15 +0,0 @@
|
||||
# session-registry/:活跃会话注册表家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` |
|
||||
| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — |
|
||||
| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — |
|
||||
|
||||
这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。
|
||||
|
||||
这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md
|
||||
README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73
|
||||
README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827
|
||||
@@ -1,42 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry-file
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`<root>/sessions.json`).
|
||||
|
||||
## Liveness and crash safety
|
||||
|
||||
Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Both layers are required and neither substitutes for the other.
|
||||
|
||||
- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium.
|
||||
- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing.
|
||||
|
||||
Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file.
|
||||
|
||||
## Durable format
|
||||
|
||||
`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand |
|
||||
| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed |
|
||||
| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud |
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it.
|
||||
@@ -1,42 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry-file
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`<root>/sessions.json`)。
|
||||
|
||||
## 存活状态与崩溃安全
|
||||
|
||||
存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。
|
||||
|
||||
## 并发
|
||||
|
||||
两层机制都是必需的,任何一层都无法替代另一层。
|
||||
|
||||
- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。
|
||||
- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。
|
||||
|
||||
写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。
|
||||
|
||||
## 持久化格式
|
||||
|
||||
`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 |
|
||||
| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 |
|
||||
| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 |
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-registry-file",
|
||||
"description": "Lock-guarded JSON-file backend for the dsh live-session registry seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"schemastery": "^3.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry": "workspace:^",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Registry file format: the durable boundary between independent `dsh`
|
||||
* processes. Every field is validated on read because the medium is shared,
|
||||
* user-visible, and writable by other harness versions — a foreign or truncated
|
||||
* file must not crash `dsh list-sessions` into an empty listing that hides live sessions.
|
||||
* @module @deepseek-ai/dsh-session-registry-file/file
|
||||
*/
|
||||
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
|
||||
/**
|
||||
* On-disk format version. Pinned at `0` under the pre-release stance: a
|
||||
* differing version is rejected rather than migrated, matching every other
|
||||
* harness backend.
|
||||
*/
|
||||
export const SESSION_REGISTRY_FORMAT_VERSION = 0
|
||||
|
||||
/** The complete registry file: a version stamp plus the live records. */
|
||||
export interface RegistryFileContents {
|
||||
/** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */
|
||||
readonly version: number
|
||||
/** One record per registered process, in no significant order. */
|
||||
readonly records: readonly SessionRegistryRecord[]
|
||||
}
|
||||
|
||||
/** An empty registry: the value a missing file reads as. */
|
||||
export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] }
|
||||
|
||||
/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */
|
||||
function parseRecord(value: unknown): SessionRegistryRecord | undefined {
|
||||
if (typeof value !== 'object' || value === null) return undefined
|
||||
const row = value as Record<string, unknown>
|
||||
const { sessionId, pid, cwd, startedAt, bootId } = row
|
||||
if (typeof sessionId !== 'string' || sessionId === '') return undefined
|
||||
// A non-integer or non-positive pid cannot be probed for liveness.
|
||||
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
|
||||
if (typeof cwd !== 'string' || cwd === '') return undefined
|
||||
if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined
|
||||
if (typeof bootId !== 'string' || bootId === '') return undefined
|
||||
// An absent title is legal (a fresh session has none); a present but
|
||||
// non-string one is a damaged row rather than a missing optional field.
|
||||
const { title } = row
|
||||
if (title !== undefined && typeof title !== 'string') return undefined
|
||||
return {
|
||||
sessionId: SessionId(sessionId),
|
||||
pid,
|
||||
cwd,
|
||||
startedAt,
|
||||
bootId: BootId(bootId),
|
||||
...title !== undefined && { title },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse registry file text into records, dropping individually unusable rows.
|
||||
*
|
||||
* A row that cannot be interpreted is dropped rather than rejected wholesale:
|
||||
* one malformed record written by a different harness version must not hide
|
||||
* every other live session. Unparsable text and a version mismatch yield an
|
||||
* empty registry for the same reason — the caller republishes the whole file, so
|
||||
* the next write heals the medium.
|
||||
* @param text - the raw file contents.
|
||||
* @returns the records that parsed, and whether the text was fully understood.
|
||||
*/
|
||||
export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
// Swallows only SyntaxError from this one JSON.parse: a torn or foreign
|
||||
// file heals on the next write, and nothing else can reach this catch.
|
||||
return { records: [], intact: false }
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false }
|
||||
const file = parsed as Record<string, unknown>
|
||||
if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false }
|
||||
if (!Array.isArray(file.records)) return { records: [], intact: false }
|
||||
const records: SessionRegistryRecord[] = []
|
||||
let intact = true
|
||||
for (const row of file.records) {
|
||||
const record = parseRecord(row)
|
||||
if (record === undefined) intact = false
|
||||
else records.push(record)
|
||||
}
|
||||
return { records, intact }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize records as registry file text.
|
||||
* @param records - the live records to publish.
|
||||
* @returns pretty-printed JSON with a trailing newline, for a legible medium.
|
||||
*/
|
||||
export function serializeRegistry(records: readonly SessionRegistryRecord[]): string {
|
||||
const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records }
|
||||
return `${JSON.stringify(file, undefined, 2)}\n`
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* File-backed live-session registry: one lock-guarded JSON file under the
|
||||
* Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every
|
||||
* operation is a read-modify-write under an advisory lock, because concurrent
|
||||
* launchers write the same file — the storage-hub JSON backend documents
|
||||
* last-write-wins for exactly this case and cannot be reused. Liveness is
|
||||
* derived at read time from the recorded pid.
|
||||
* @module @deepseek-ai/dsh-session-registry-file
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import lockfile from 'proper-lockfile'
|
||||
import z from 'schemastery'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SessionRegistry, BootId,
|
||||
type SessionRegistration, type SessionRegistryRecord,
|
||||
} from '@deepseek-ai/dsh-session-registry'
|
||||
import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts'
|
||||
import { isPidAlive } from './liveness.ts'
|
||||
|
||||
export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts'
|
||||
export type { RegistryFileContents } from './file.ts'
|
||||
export { isPidAlive } from './liveness.ts'
|
||||
|
||||
/** The file name holding the registry, relative to {@link Config.root}. */
|
||||
export const REGISTRY_FILE_NAME = 'sessions.json'
|
||||
|
||||
/** Default lock staleness threshold; a held lock older than this is reclaimed. */
|
||||
const DEFAULT_LOCK_STALE_MS = 10_000
|
||||
|
||||
/** Default retry budget for a contended lock acquisition. */
|
||||
const DEFAULT_LOCK_RETRIES = 10
|
||||
|
||||
/**
|
||||
* Plugin config as callers write it: `root` is required — a cwd fallback would
|
||||
* scatter registries — while the lock tunables are optional because
|
||||
* `static Config` supplies their defaults.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Directory holding the registry file; created `0o700` on demand. */
|
||||
root: string
|
||||
/** Milliseconds after which a held lock is considered abandoned and reclaimed. */
|
||||
lockStaleMs?: number
|
||||
/** Retries before a contended acquisition fails loud. */
|
||||
lockRetries?: number
|
||||
}
|
||||
|
||||
/** The file-backed {@link SessionRegistry} implementation. */
|
||||
export class SessionRegistryFile extends SessionRegistry {
|
||||
static Config: z<Config> = z.object({
|
||||
root: z.string().required(),
|
||||
lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS),
|
||||
lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES),
|
||||
})
|
||||
|
||||
/** Absolute path of the registry file this service reads and writes. */
|
||||
readonly file: string
|
||||
|
||||
/** Directory holding {@link file}, created `0o700` on demand. */
|
||||
private readonly root: string
|
||||
|
||||
/** Tail of the in-process serialization chain; see {@link mutate}. */
|
||||
private chain: Promise<void> = Promise.resolve()
|
||||
|
||||
/** Resolved lock staleness threshold in milliseconds, fixed at construction. */
|
||||
private readonly stale: number
|
||||
|
||||
/** Resolved contended-acquisition retry budget, fixed at construction. */
|
||||
private readonly retries: number
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, BootId(randomUUID()))
|
||||
this.root = config.root
|
||||
this.file = join(this.root, REGISTRY_FILE_NAME)
|
||||
// Resolve the optional tunables here, once: `static Config` supplies these
|
||||
// same defaults for a Loader mount, and a direct programmatic mount that
|
||||
// omits them gets them too rather than an undefined lock option.
|
||||
this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS
|
||||
this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async register(registration: SessionRegistration): Promise<() => Promise<void>> {
|
||||
const record: SessionRegistryRecord = {
|
||||
sessionId: registration.sessionId,
|
||||
pid: process.pid,
|
||||
cwd: registration.cwd,
|
||||
startedAt: Date.now(),
|
||||
bootId: this.bootId,
|
||||
...registration.title !== undefined && { title: registration.title },
|
||||
}
|
||||
await this.mutate(records => [
|
||||
...records.filter(other => other.sessionId !== record.sessionId),
|
||||
record,
|
||||
])
|
||||
// The disposer is awaited by Cordis teardown, so the record is durably gone
|
||||
// before disposal completes rather than racing process exit. A failure here
|
||||
// is reported, not thrown: the record is already pid-prunable, and an
|
||||
// unwinding teardown must not be turned into a rejection.
|
||||
return this.ctx.effect(() => async () => {
|
||||
try {
|
||||
await this.mutate(records => records.filter(other => !this.isSelf(other, record)))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async retitle(sessionId: SessionId, title: string): Promise<void> {
|
||||
await this.mutate(records => records.map(record =>
|
||||
record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId
|
||||
? { ...record, title }
|
||||
: record))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async list(): Promise<SessionRegistryRecord[]> {
|
||||
// Pruning is a write, so the read path takes the same lock: a listing that
|
||||
// observed a half-written file could omit a live session.
|
||||
return this.mutate(records => [...records])
|
||||
}
|
||||
|
||||
/** True when a stored record is this exact registration (pid AND incarnation). */
|
||||
private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean {
|
||||
return candidate.sessionId === self.sessionId
|
||||
&& candidate.pid === self.pid
|
||||
&& candidate.bootId === self.bootId
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one read-modify-write cycle against every other cycle in THIS
|
||||
* process, then run it under the cross-process lock.
|
||||
*
|
||||
* Both layers are required and neither substitutes for the other. The advisory
|
||||
* lock excludes other processes but is tracked per process, so it rejects a
|
||||
* same-process concurrent acquisition outright (`ELOCKED`) instead of queueing
|
||||
* — and a composition that creates several sessions at once really does
|
||||
* overlap these calls. This chain gives those callers a queue; the lock gives
|
||||
* independent processes exclusion.
|
||||
*/
|
||||
private mutate(
|
||||
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
|
||||
): Promise<SessionRegistryRecord[]> {
|
||||
// Failures must not poison the chain for later callers, so the tail only
|
||||
// tracks settlement, never the rejection itself.
|
||||
const result = this.chain.then(() => this.mutateExclusively(change))
|
||||
this.chain = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one locked read-modify-write cycle: read, prune dead records, apply
|
||||
* `change`, and republish when the result differs from what was stored.
|
||||
*/
|
||||
private async mutateExclusively(
|
||||
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
|
||||
): Promise<SessionRegistryRecord[]> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
// proper-lockfile needs the target to exist before it can guard it; an
|
||||
// exclusive create loses harmlessly to a concurrent launcher doing the same.
|
||||
await this.ensureFile()
|
||||
const release = await lockfile.lock(this.file, {
|
||||
stale: this.stale,
|
||||
retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 },
|
||||
})
|
||||
try {
|
||||
const before = await this.read()
|
||||
const live = before.records.filter(record => isPidAlive(record.pid))
|
||||
const next = change(live)
|
||||
// Republish when a record changed or the medium itself was damaged, so a
|
||||
// foreign or torn file heals instead of being re-parsed on every read.
|
||||
if (!before.intact || !sameRecords(before.records, next)) await this.write(next)
|
||||
return next
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the registry file if absent, without disturbing existing content. */
|
||||
private async ensureFile(): Promise<void> {
|
||||
try {
|
||||
const handle = await open(this.file, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records))
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch (error) {
|
||||
// Swallows only EEXIST: another launcher created the file first, which is
|
||||
// the intended outcome. Every other errno propagates.
|
||||
/* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and parse the registry file; a missing file reads as empty. */
|
||||
private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> {
|
||||
// The caller holds the lock, and acquiring it requires the file to exist, so
|
||||
// a read failure here is real corruption rather than an absent registry and
|
||||
// propagates: a swallowed error would report "no live sessions" for a medium
|
||||
// that could not be read.
|
||||
return parseRegistry(await readFile(this.file, 'utf8'))
|
||||
}
|
||||
|
||||
/** Publish the complete record set via temp-write plus atomic rename. */
|
||||
private async write(records: readonly SessionRegistryRecord[]): Promise<void> {
|
||||
const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`)
|
||||
await writeFile(temp, serializeRegistry(records), { mode: 0o600 })
|
||||
await rename(temp, this.file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare record lists by identity fields, to decide whether a write is needed. */
|
||||
function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((record, index) => {
|
||||
const other = right[index]
|
||||
return other !== undefined
|
||||
&& record.sessionId === other.sessionId
|
||||
&& record.pid === other.pid
|
||||
&& record.bootId === other.bootId
|
||||
&& record.cwd === other.cwd
|
||||
&& record.startedAt === other.startedAt
|
||||
&& record.title === other.title
|
||||
})
|
||||
}
|
||||
|
||||
export default SessionRegistryFile
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`.
|
||||
* @module @deepseek-ai/dsh-session-registry-file/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-registry-file-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the relations a reader must trust (unique live session
|
||||
* ids, attributable pids) are contract-level and validated by the seam's
|
||||
* companion around the authoritative `list()`, whatever backend serves it. The
|
||||
* file medium's own correctness — locking, atomic republication, and
|
||||
* foreign-row rejection — requires cross-process round-trip tests, not a
|
||||
* continuously observable in-process relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Process-liveness probe for stored registry records.
|
||||
* @module @deepseek-ai/dsh-session-registry-file/liveness
|
||||
*/
|
||||
|
||||
/**
|
||||
* Signal-0 probe: report whether a pid currently exists.
|
||||
*
|
||||
* `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such
|
||||
* process. `EPERM` means the process exists but is owned by another user, which
|
||||
* is still alive — reporting it dead would drop a live record. Any other errno
|
||||
* is unexpected and propagates rather than being read as a liveness answer.
|
||||
* @param pid - the operating-system process id to probe.
|
||||
* @param kill - signal sender, defaulting to `process.kill`; injected by tests.
|
||||
* @returns whether a process with this pid exists.
|
||||
*/
|
||||
export function isPidAlive(
|
||||
pid: number,
|
||||
kill: (pid: number, signal: number) => void = process.kill.bind(process),
|
||||
): boolean {
|
||||
try {
|
||||
kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ESRCH') return false
|
||||
if (code === 'EPERM') return true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Concurrency-test driver: register one session in a real separate process,
|
||||
* report readiness on stdout, then stay alive until the parent closes stdin.
|
||||
*
|
||||
* Staying alive is load-bearing. The registry prunes records whose process is
|
||||
* gone, so a driver that exited after writing would be pruned by the next
|
||||
* writer — the test would then measure pruning instead of the concurrent
|
||||
* read-modify-write it exists to cover. Argv: `<root> <sessionId>`.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
|
||||
|
||||
const [root, sessionId] = process.argv.slice(2)
|
||||
if (root === undefined || sessionId === undefined) throw new Error('usage: register-once <root> <sessionId>')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 })
|
||||
await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() })
|
||||
process.stdout.write('registered\n')
|
||||
|
||||
// Hold the process open so its record stays live; the parent ends the run by
|
||||
// closing stdin, and never disposes the fiber, so no deregistration races the
|
||||
// parent's read.
|
||||
process.stdin.resume()
|
||||
process.stdin.on('end', () => { process.exit(0) })
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* Tests for the cross-process live-session registry: records survive a
|
||||
* round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign
|
||||
* record, the file format rejects foreign and torn media without hiding live
|
||||
* sessions, disposal deregisters, and concurrent registrations from independent
|
||||
* processes all survive (the failure the advisory lock exists to prevent).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId } from '@deepseek-ai/dsh-session-registry'
|
||||
import SessionRegistryFile, {
|
||||
REGISTRY_FILE_NAME,
|
||||
SESSION_REGISTRY_FORMAT_VERSION,
|
||||
isPidAlive,
|
||||
parseRegistry,
|
||||
serializeRegistry,
|
||||
} from '@deepseek-ai/dsh-session-registry-file'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Mount the service on a fresh Cordis fiber, returning it with its context. */
|
||||
async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionRegistryFile, { root })
|
||||
return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile }
|
||||
}
|
||||
|
||||
const file = (): string => join(root, REGISTRY_FILE_NAME)
|
||||
|
||||
describe('config resolution', () => {
|
||||
it('applies the shipped lock defaults when a caller omits them', async () => {
|
||||
// `ctx.plugin` runs the schema, which fills these in, so the constructor's
|
||||
// own resolution is reachable only by constructing the service directly —
|
||||
// the path a programmatic embedder takes.
|
||||
const ctx = new Context()
|
||||
const service = new SessionRegistryFile(ctx, { root })
|
||||
await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' })
|
||||
expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('honors explicitly configured lock tunables', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 })
|
||||
await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' })
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('register and list', () => {
|
||||
it('publishes a record readable by an independent service instance', async () => {
|
||||
const first = await service()
|
||||
await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' })
|
||||
|
||||
// A second instance stands in for another process reading the same file.
|
||||
const reader = await service()
|
||||
const listed = await reader.registry.list()
|
||||
expect(listed).toHaveLength(1)
|
||||
expect(listed[0]).toMatchObject({
|
||||
sessionId: 'sess-1',
|
||||
cwd: '/tmp/project',
|
||||
pid: process.pid,
|
||||
})
|
||||
await first.ctx.fiber.dispose()
|
||||
await reader.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('replaces an earlier record for the same session id', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' })
|
||||
const listed = await registry.list()
|
||||
expect(listed).toHaveLength(1)
|
||||
// The later registration wins: `cwd` distinguishes the two calls.
|
||||
expect(listed[0]?.cwd).toBe('/b')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('creates the registry root private and the file owner-only', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
|
||||
expect(statSync(root).mode & 0o777).toBe(0o700)
|
||||
expect(statSync(file()).mode & 0o777).toBe(0o600)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('liveness pruning', () => {
|
||||
it('drops a record whose process is gone', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('live'), cwd: '/a' })
|
||||
|
||||
// A real exited pid: spawn a process, wait for it, then claim its id. The
|
||||
// kernel has reaped it, so signal 0 reports ESRCH.
|
||||
const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'])
|
||||
const deadPid = Number(dead.stdout)
|
||||
expect(isPidAlive(deadPid)).toBe(false)
|
||||
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
|
||||
writeFileSync(file(), serializeRegistry([
|
||||
...stored,
|
||||
{ sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') },
|
||||
]))
|
||||
|
||||
const listed = await registry.list()
|
||||
expect(listed.map(record => record.sessionId)).toEqual(['live'])
|
||||
// The prune is durable, not just filtered in memory.
|
||||
expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps a live record owned by another user (EPERM means alive)', () => {
|
||||
const eperm = (): never => {
|
||||
const error = new Error('operation not permitted') as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
}
|
||||
expect(isPidAlive(1, eperm)).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates an unexpected errno instead of guessing liveness', () => {
|
||||
const einval = (): never => {
|
||||
const error = new Error('invalid') as NodeJS.ErrnoException
|
||||
error.code = 'EINVAL'
|
||||
throw error
|
||||
}
|
||||
expect(() => isPidAlive(1, einval)).toThrow('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pid recycling', () => {
|
||||
it('deregistration removes only this incarnation, not a namesake pid', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' })
|
||||
|
||||
// A foreign record reusing THIS live pid under a different session and boot
|
||||
// id: deregistering must not delete it.
|
||||
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
|
||||
writeFileSync(file(), serializeRegistry([
|
||||
...stored,
|
||||
{ sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
|
||||
]))
|
||||
|
||||
// Awaiting the disposer is the contract: the record is durably gone when it
|
||||
// settles, so the assertion needs no timing slack.
|
||||
await disposer()
|
||||
const listed = await registry.list()
|
||||
expect(listed.map(record => record.sessionId)).toEqual(['other'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('file format', () => {
|
||||
it('round-trips records', () => {
|
||||
const records = [{
|
||||
sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'),
|
||||
}]
|
||||
expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true })
|
||||
})
|
||||
|
||||
it('stamps the format version', () => {
|
||||
const stamped = JSON.parse(serializeRegistry([])) as { version: number }
|
||||
expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['torn json', '{"version":0,"records":[{'],
|
||||
['a foreign version', '{"version":99,"records":[]}'],
|
||||
['a non-object root', '[]'],
|
||||
['a null root', 'null'],
|
||||
['a non-array records field', '{"version":0,"records":{}}'],
|
||||
])('reads %s as an empty, non-intact registry', (_label, text) => {
|
||||
expect(parseRegistry(text)).toEqual({ records: [], intact: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }],
|
||||
['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }],
|
||||
['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }],
|
||||
['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }],
|
||||
['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }],
|
||||
['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }],
|
||||
['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }],
|
||||
['a non-object row', 'nonsense'],
|
||||
])('drops a row with %s but keeps its intact siblings', (_label, row) => {
|
||||
const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }
|
||||
const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] })
|
||||
const parsed = parseRegistry(text)
|
||||
expect(parsed.records.map(record => record.sessionId)).toEqual(['keep'])
|
||||
expect(parsed.intact).toBe(false)
|
||||
})
|
||||
|
||||
it('heals a damaged medium on the next locked write', async () => {
|
||||
writeFileSync(file(), 'not json at all')
|
||||
const { ctx, registry } = await service()
|
||||
await registry.list()
|
||||
expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads a missing file as no live sessions', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
rmSync(file(), { force: true })
|
||||
expect(await registry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('failure reporting', () => {
|
||||
it('tolerates a registry file another process created first', async () => {
|
||||
// Two services racing `ensureFile`: the loser sees EEXIST, which is the
|
||||
// intended outcome rather than an error, and both still publish.
|
||||
const first = await service()
|
||||
const second = await service()
|
||||
await Promise.all([
|
||||
first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }),
|
||||
second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }),
|
||||
])
|
||||
expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b'])
|
||||
await first.ctx.fiber.dispose()
|
||||
await second.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('warns instead of throwing when deregistration fails during teardown', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })
|
||||
// Make the registry path unusable, so the disposer's own write fails while the
|
||||
// fiber is already unwinding. Teardown must still complete.
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
|
||||
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('propagates a read failure that is not a missing file', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' })
|
||||
// A directory where the file belongs makes the read fail with EISDIR, which
|
||||
// is corruption rather than "no live sessions" and must not read as empty.
|
||||
rmSync(file(), { force: true })
|
||||
mkdirSync(file(), { recursive: true })
|
||||
await expect(registry.list()).rejects.toThrow()
|
||||
rmSync(file(), { recursive: true, force: true })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retitle', () => {
|
||||
it('replaces the recorded title of a session this process owns', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
|
||||
expect((await registry.list())[0]?.title).toBeUndefined()
|
||||
|
||||
await registry.retitle(SessionId('sess-1'), 'first')
|
||||
expect((await registry.list())[0]?.title).toBe('first')
|
||||
await registry.retitle(SessionId('sess-1'), 'second')
|
||||
expect((await registry.list())[0]?.title).toBe('second')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts a registration that already carries a title', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' })
|
||||
expect((await registry.list())[0]?.title).toBe('preset')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('leaves a same-id record owned by another incarnation untouched', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
// Same live pid, different boot id: another incarnation's record must not be
|
||||
// retitled by this one.
|
||||
writeFileSync(file(), serializeRegistry([
|
||||
{ sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
|
||||
]))
|
||||
await registry.retitle(SessionId('foreign'), 'not mine')
|
||||
expect((await registry.list())[0]?.title).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores an unknown session id, since a title can resolve after removal', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined()
|
||||
expect(await registry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('same-process concurrency', () => {
|
||||
it('keeps every record when one process registers several sessions at once', async () => {
|
||||
// The advisory lock is tracked per process, so same-process callers contend
|
||||
// for it through its bounded retry budget instead of queueing. Past a dozen
|
||||
// or so overlapping calls that budget runs out and a registration rejects —
|
||||
// and callers publish fire-and-forget, so the rejection is swallowed and the
|
||||
// session silently vanishes from the listing. The service therefore
|
||||
// serializes its own callers; the lock only excludes other processes.
|
||||
const { ctx, registry } = await service()
|
||||
// Register once first so the file and directory already exist: without that,
|
||||
// the concurrent calls serialize behind their own mkdir/create awaits and the
|
||||
// overlap under test never happens.
|
||||
await registry.register({ sessionId: SessionId('warm'), cwd: '/w' })
|
||||
const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) =>
|
||||
registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` })))
|
||||
|
||||
// Every call must SUCCEED, not merely leave the file consistent. Callers
|
||||
// publish fire-and-forget, so a rejection is swallowed and the session
|
||||
// silently vanishes from the listing rather than failing loudly.
|
||||
expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([])
|
||||
const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort()
|
||||
expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps serving later callers after one cycle fails', async () => {
|
||||
const { ctx, registry } = await service()
|
||||
// A directory sitting where the registry file must be makes one cycle fail
|
||||
// without breaking the shared chain for the calls queued behind it.
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
|
||||
await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow()
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
await registry.register({ sessionId: SessionId('after'), cwd: '/w' })
|
||||
expect((await registry.list()).map(record => record.sessionId)).toEqual(['after'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-process concurrency', () => {
|
||||
it('keeps every record when independent processes register at once', async () => {
|
||||
// The regression that motivates the advisory lock: unlocked whole-file
|
||||
// republication loses records under concurrent writers. Real processes are
|
||||
// required — same-process promises would serialize on the event loop.
|
||||
const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url))
|
||||
const count = 8
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
|
||||
// Source plane: tsx resolves the workspace import through the root
|
||||
// tsconfig `paths` to `src`, so this runs without a build step.
|
||||
const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }
|
||||
|
||||
const children = Array.from({ length: count }, (_unused, index) =>
|
||||
spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
}))
|
||||
try {
|
||||
// Every child must have committed its record AND still be alive when the
|
||||
// file is read, so the assertion sees concurrent writes rather than prunes.
|
||||
await Promise.all(children.map(child => new Promise<void>((resolve, reject) => {
|
||||
child.stdout.once('data', () => { resolve() })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) })
|
||||
})))
|
||||
|
||||
const stored = parseRegistry(readFileSync(file(), 'utf8'))
|
||||
expect(stored.intact).toBe(true)
|
||||
expect(stored.records.map(record => record.sessionId).sort()).toEqual(
|
||||
Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(),
|
||||
)
|
||||
} finally {
|
||||
for (const child of children) child.stdin.end()
|
||||
await Promise.all(children.map(child => new Promise<void>((resolve) => { child.once('exit', () => { resolve() }) })))
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../session-registry"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md
|
||||
README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040
|
||||
README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515
|
||||
@@ -1,32 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry-live
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front.
|
||||
|
||||
## Behavior
|
||||
|
||||
Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions.
|
||||
|
||||
A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show.
|
||||
|
||||
`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one.
|
||||
|
||||
Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune.
|
||||
|
||||
## Config
|
||||
|
||||
None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check.
|
||||
- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision.
|
||||
@@ -1,32 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry-live
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。
|
||||
|
||||
## 行为
|
||||
|
||||
注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。
|
||||
|
||||
会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。
|
||||
|
||||
`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。
|
||||
|
||||
发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。
|
||||
|
||||
## 配置
|
||||
|
||||
无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。
|
||||
- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-registry-live",
|
||||
"description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-registry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry-file": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Publishes every live session in this process into the cross-process session
|
||||
* registry, so `dsh list-sessions` lists sessions a server creates on demand rather than
|
||||
* only the one a launcher minted up front.
|
||||
*
|
||||
* Mounted in a composition whose sessions come and go — the browser UI creates
|
||||
* one per conversation — this plugin follows `session/created` and
|
||||
* `session/disposed` instead of registering a single launcher-known identity.
|
||||
* A session with no `cwd` in its header is skipped: the registry's workspace
|
||||
* column would have nothing truthful to show, and a subagent child is exactly
|
||||
* that case. Titles are mirrored into the record as `session/title` events
|
||||
* arrive, so a reader never has to parse a backend's log format.
|
||||
* @module @deepseek-ai/dsh-session-registry-live
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the Context merges this plugin relies on: the
|
||||
// `sessionRegistry` service and the `session/title` session event.
|
||||
import type {} from '@deepseek-ai/dsh-session-registry'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'session-registry-live'
|
||||
|
||||
/** Services required before sessions can be followed and records published. */
|
||||
export const inject = ['sessions', 'sessionRegistry']
|
||||
|
||||
/**
|
||||
* Follow session lifecycle and keep the registry in step.
|
||||
* @param ctx - context carrying the session store and the registry service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
/**
|
||||
* Per-session registration state. `'disposing'` is a tombstone written when a
|
||||
* session ends while its registration is still in flight: without it the
|
||||
* late-arriving disposer would be stored for a session that no longer exists
|
||||
* and its record would outlive the session until a pid-based prune.
|
||||
*/
|
||||
const registered = new Map<Session, (() => Promise<void>) | 'disposing'>()
|
||||
|
||||
const publish = (session: Session): void => {
|
||||
const cwd = session.header.cwd
|
||||
// A session without a workspace has no listable location; skipping keeps the
|
||||
// registry free of rows `dsh list-sessions` could not render truthfully.
|
||||
if (cwd === undefined) return
|
||||
void ctx.sessionRegistry.register({ sessionId: session.id, cwd })
|
||||
.then((dispose) => {
|
||||
if (registered.get(session) === 'disposing') {
|
||||
registered.delete(session)
|
||||
void dispose()
|
||||
return
|
||||
}
|
||||
registered.set(session, dispose)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
registered.delete(session)
|
||||
ctx.logger.warn('failed to publish session %s: %s', session.id, String(error))
|
||||
})
|
||||
}
|
||||
|
||||
for (const session of ctx.sessions.list()) publish(session)
|
||||
ctx.on('session/created', (session) => { publish(session) }, { global: true })
|
||||
ctx.on('session/disposed', (session) => {
|
||||
const entry = registered.get(session)
|
||||
if (typeof entry === 'function') {
|
||||
registered.delete(session)
|
||||
void entry()
|
||||
return
|
||||
}
|
||||
// Registration is still in flight; leave a tombstone for it to observe.
|
||||
registered.set(session, 'disposing')
|
||||
}, { global: true })
|
||||
|
||||
// Mirror title revisions onto the record. A title arrives after registration
|
||||
// and may be replaced, so the listing tracks the latest logged value.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'session/title') return
|
||||
const { title } = event.data
|
||||
void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => {
|
||||
ctx.logger.warn('failed to retitle %s: %s', session.id, String(error))
|
||||
})
|
||||
}, { global: true })
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`.
|
||||
* @module @deepseek-ai/dsh-session-registry-live/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-registry-live-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this plugin owns no durable state of its own — the
|
||||
* uniqueness and liveness relations over published records are checked by the
|
||||
* companion in `@deepseek-ai/dsh-session-registry`, which owns that file.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* Tests for the live-session publisher over the REAL session store, so
|
||||
* publication follows the store's actual lifecycle dispatch rather than a
|
||||
* hand-built event emitter: sessions created after mount are published,
|
||||
* disposal removes their records, a session without a workspace is skipped, and
|
||||
* logged title revisions are mirrored onto the record so a reader never parses a
|
||||
* backend's log format.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
|
||||
import * as live from '@deepseek-ai/dsh-session-registry-live'
|
||||
// Empty type import carries the `session/title` event into the session-event map.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) })
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** Mount the real store plus the publisher. */
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
await ctx.plugin(live)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Let the publisher's fire-and-forget registration reach durability. */
|
||||
const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 200) })
|
||||
|
||||
/** Read the registry through an independent service, as `dsh list-sessions` would. */
|
||||
async function listExternally(): Promise<SessionRegistryRecord[]> {
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
const records = await reader.sessionRegistry.list()
|
||||
await reader.fiber.dispose()
|
||||
return records
|
||||
}
|
||||
|
||||
describe('publishing', () => {
|
||||
it('publishes sessions that already exist when the plugin mounts', async () => {
|
||||
// A composition may mount the publisher after sessions exist (a resumed
|
||||
// session, or plugin order), so mount-time adoption is its own path.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } })
|
||||
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
|
||||
await ctx.plugin(live)
|
||||
await settle()
|
||||
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('publishes a session created after mount', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } })
|
||||
await settle()
|
||||
|
||||
const listed = await ctx.sessionRegistry.list()
|
||||
expect(listed).toHaveLength(1)
|
||||
expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips a session with no workspace, having nothing truthful to list', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('no-cwd'))
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('has no title until one is logged', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('mirrors the latest logged title onto the record', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } })
|
||||
await settle()
|
||||
|
||||
session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess')
|
||||
|
||||
// A revision replaces the previous value rather than accumulating.
|
||||
session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores session events other than a title revision', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } })
|
||||
await settle()
|
||||
const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle')
|
||||
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await settle()
|
||||
expect(retitle).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retitles only the session that logged the event', async () => {
|
||||
const ctx = await mount()
|
||||
const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } })
|
||||
ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } })
|
||||
await settle()
|
||||
|
||||
first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title]))
|
||||
expect(byId.get(SessionId('one'))).toBe('only mine')
|
||||
expect(byId.get(SessionId('two'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('publishes every concurrently created session', async () => {
|
||||
const ctx = await mount()
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } })
|
||||
}
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort())
|
||||
.toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure and race handling', () => {
|
||||
it('removes the record when a session is disposed mid-registration', async () => {
|
||||
// The tombstone path: the session ends before its registration resolves, so
|
||||
// the late disposer must be applied instead of stored for a dead session.
|
||||
const ctx = await mount()
|
||||
let owner: Context | undefined
|
||||
await ctx.plugin({
|
||||
inject: ['sessions'],
|
||||
apply: (child: Context) => {
|
||||
owner = child
|
||||
child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } })
|
||||
},
|
||||
})
|
||||
// No settle: dispose while `register` is still in flight.
|
||||
await owner?.fiber.dispose()
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('warns and drops the record when publication fails', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } })
|
||||
await settle()
|
||||
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('warns when a title revision cannot be recorded', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } })
|
||||
await settle()
|
||||
|
||||
ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } })
|
||||
await settle()
|
||||
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('removes a record when its own session is disposed, keeping the others', async () => {
|
||||
const ctx = await mount()
|
||||
// A session belongs to the fiber that created it, so a child plugin fiber
|
||||
// gives one session an independent lifetime without disposing the services.
|
||||
let owner: Context | undefined
|
||||
await ctx.plugin({
|
||||
inject: ['sessions'],
|
||||
apply: (child: Context) => {
|
||||
owner = child
|
||||
child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } })
|
||||
},
|
||||
})
|
||||
ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } })
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
|
||||
|
||||
// Disposing only that fiber ends its session, which the publisher follows.
|
||||
await owner?.fiber.dispose()
|
||||
await settle()
|
||||
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('leaves no record behind after the whole tree unloads', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } })
|
||||
ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } })
|
||||
await settle()
|
||||
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
expect(await listExternally()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../session-registry"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md
|
||||
README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586
|
||||
README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33
|
||||
@@ -1,30 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service.
|
||||
|
||||
## Shape
|
||||
|
||||
- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability.
|
||||
- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone.
|
||||
- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom.
|
||||
|
||||
Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
|
||||
|
||||
## Record vocabulary
|
||||
|
||||
`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing.
|
||||
- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress.
|
||||
@@ -1,30 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-registry
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。
|
||||
|
||||
## 形状
|
||||
|
||||
- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。
|
||||
- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。
|
||||
- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。
|
||||
|
||||
后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。
|
||||
|
||||
## 记录词汇
|
||||
|
||||
`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。
|
||||
- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-registry",
|
||||
"description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry
|
||||
* of live `dsh` sessions, so a separate short-lived process such as
|
||||
* `dsh list-sessions` can answer "what am I running right now".
|
||||
*
|
||||
* This package owns only the service contract and the record vocabulary; a
|
||||
* backend (the lock-guarded JSON file in
|
||||
* `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the
|
||||
* medium. Whatever the medium, liveness is part of the contract: {@link list}
|
||||
* returns only records whose process existed at observation time, so a process
|
||||
* killed without running its disposer leaves no permanent phantom.
|
||||
* @module @deepseek-ai/dsh-session-registry
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId, type SessionRegistryRecord } from './types.ts'
|
||||
|
||||
export { BootId } from './types.ts'
|
||||
export type { SessionRegistryRecord } from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionRegistry: SessionRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/** What one process publishes about itself; the service supplies pid and timing. */
|
||||
export interface SessionRegistration {
|
||||
/** The session this process runs. */
|
||||
sessionId: SessionId
|
||||
/** Absolute workspace directory the session acts on. */
|
||||
cwd: string
|
||||
/** Human-readable session title, when one already exists. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-process live-session registry. Reads prune dead records, so every
|
||||
* returned record's process existed at observation time. Backends serialize
|
||||
* mutations against concurrent registrars — other processes and overlapping
|
||||
* calls in this one — so records are never lost to a torn read-modify-write.
|
||||
*/
|
||||
export abstract class SessionRegistry extends Service {
|
||||
/** This process incarnation's id, stamped into every record it publishes. */
|
||||
protected readonly bootId: BootId
|
||||
|
||||
constructor(ctx: Context, bootId: BootId) {
|
||||
super(ctx, 'sessionRegistry')
|
||||
this.bootId = bootId
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish this process's record, replacing any stale record for the same
|
||||
* session id, and prune records whose process is gone.
|
||||
* @param registration - the session, surface, and workspace to publish.
|
||||
* @returns the effect disposer that removes this record again; awaiting it
|
||||
* waits for the removal to reach durability.
|
||||
*/
|
||||
abstract register(registration: SessionRegistration): Promise<() => Promise<void>>
|
||||
|
||||
/**
|
||||
* Replace the recorded title of a session this process registered.
|
||||
*
|
||||
* Titles arrive after registration and can be revised, so this is the one
|
||||
* mutable field. Only a record matching this process and incarnation is
|
||||
* touched, leaving a same-id record owned by another process alone. An unknown
|
||||
* session id is a no-op rather than an error: a title can resolve after the
|
||||
* session's record has already been removed.
|
||||
* @param sessionId - the session whose recorded title changes.
|
||||
* @param title - the new title text.
|
||||
*/
|
||||
abstract retitle(sessionId: SessionId, title: string): Promise<void>
|
||||
|
||||
/**
|
||||
* List live sessions, pruning records whose process no longer exists.
|
||||
* @returns one record per live registered session, newest registration last.
|
||||
*/
|
||||
abstract list(): Promise<SessionRegistryRecord[]>
|
||||
}
|
||||
|
||||
export default SessionRegistry
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`.
|
||||
* @module @deepseek-ai/dsh-session-registry/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { SessionRegistryRecord } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-registry-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Cross-check every published listing against the relations the seam contract
|
||||
* owns: a session id identifies at most one live record, and each listed record
|
||||
* carries the identity fields a reader must be able to trust. Only a backend's
|
||||
* mutation path can break either, so the check wraps the authoritative read
|
||||
* rather than inspecting any medium.
|
||||
*
|
||||
* Liveness itself is deliberately not re-probed here. A backend derives it at
|
||||
* read time, so a second probe would race the first and report a process that
|
||||
* exited in between as a violation of a contract the seam never made.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const service = ctx.sessionRegistry
|
||||
const listed = service.list.bind(service)
|
||||
ctx.effect(() => {
|
||||
service.list = async (): Promise<SessionRegistryRecord[]> => {
|
||||
const records = await listed()
|
||||
const seen = new Set<string>()
|
||||
for (const record of records) {
|
||||
if (seen.has(record.sessionId)) {
|
||||
fail(`session ${record.sessionId} appears in more than one live registry record`)
|
||||
}
|
||||
seen.add(record.sessionId)
|
||||
// A record a reader cannot attribute to a process is unusable: `dsh list-sessions`
|
||||
// renders the pid and derives liveness from it.
|
||||
if (!Number.isSafeInteger(record.pid) || record.pid <= 0) {
|
||||
fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
return () => { service.list = listed }
|
||||
})
|
||||
}, { inject: ['sessionRegistry'] })
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Registry record vocabulary: the durable shape one live `dsh` process
|
||||
* publishes about itself and `dsh list-sessions` reads back.
|
||||
* @module @deepseek-ai/dsh-session-registry/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Identifies one process incarnation. Minted per registering process, so a
|
||||
* record whose `pid` was recycled by the operating system cannot be mistaken
|
||||
* for the original: the boot id differs even when the pid matches.
|
||||
*/
|
||||
export type BootId = Branded<'BootId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link BootId}.
|
||||
* @param id - the raw boot id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function BootId(id: string): BootId {
|
||||
return id as BootId
|
||||
}
|
||||
|
||||
/**
|
||||
* One live session's self-published registration. Every field is immutable for
|
||||
* the lifetime of the registration: a process publishes once at startup and
|
||||
* removes the record on exit, never mutating it in place.
|
||||
*
|
||||
* Only top-level surfaces a user starts directly register: in-process subagents
|
||||
* have no process of their own, and out-of-process subagent backends spawn
|
||||
* `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry.
|
||||
*/
|
||||
export interface SessionRegistryRecord {
|
||||
/** The session this process is running. Unique across live records. */
|
||||
readonly sessionId: SessionId
|
||||
/** Operating-system process id, used with `bootId` to decide liveness. */
|
||||
readonly pid: number
|
||||
/** Absolute workspace directory the session acts on. */
|
||||
readonly cwd: string
|
||||
/** Non-negative safe-integer Unix epoch milliseconds when the process registered. */
|
||||
readonly startedAt: number
|
||||
/** This process incarnation's id, distinguishing a recycled `pid`. */
|
||||
readonly bootId: BootId
|
||||
/**
|
||||
* Human-readable session title, as the registering process last knew it.
|
||||
*
|
||||
* Carried in the record rather than read from the session log: the log's
|
||||
* location, file format, and compression are per-deployment backend choices,
|
||||
* so an independent reader cannot portably parse one. Absent until a title
|
||||
* exists — a fresh session has none.
|
||||
*/
|
||||
readonly title?: string
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Tests for the registry's invariant companion: each acceptance path is proven
|
||||
* to REJECT an invalid case, since a check that cannot fail is not a check.
|
||||
* The backend is a minimal in-memory stub — the companion owns contract-level
|
||||
* relations over `list()` results, whatever medium serves them.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts'
|
||||
|
||||
/** Minimal in-memory backend whose listings the test scripts directly. */
|
||||
class StubRegistry extends SessionRegistry {
|
||||
records: SessionRegistryRecord[] = []
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, BootId('stub-boot'))
|
||||
}
|
||||
|
||||
register(registration: SessionRegistration): Promise<() => Promise<void>> {
|
||||
this.records.push({
|
||||
sessionId: registration.sessionId,
|
||||
pid: process.pid,
|
||||
cwd: registration.cwd,
|
||||
startedAt: Date.now(),
|
||||
bootId: this.bootId,
|
||||
})
|
||||
return Promise.resolve(() => Promise.resolve())
|
||||
}
|
||||
|
||||
retitle(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
list(): Promise<SessionRegistryRecord[]> {
|
||||
return Promise.resolve([...this.records])
|
||||
}
|
||||
}
|
||||
|
||||
/** One record with the given identity fields, live by construction. */
|
||||
function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord {
|
||||
return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) }
|
||||
}
|
||||
|
||||
/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */
|
||||
async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(StubRegistry)
|
||||
const stub = ctx.sessionRegistry as StubRegistry
|
||||
if (records !== undefined) stub.records = records
|
||||
await ctx.plugin(invariant)
|
||||
return { ctx, stub }
|
||||
}
|
||||
|
||||
describe('listing invariants', () => {
|
||||
it('accepts a well-formed listing', async () => {
|
||||
const { ctx } = await mount()
|
||||
await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' })
|
||||
await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a listing where one session id appears twice', async () => {
|
||||
// Two live records for one session: only a broken mutation path (or an
|
||||
// out-of-band writer) can produce this, and it would make
|
||||
// `dsh list-sessions` show one session twice.
|
||||
const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')])
|
||||
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a listing whose record carries an unusable pid', async () => {
|
||||
// A record no reader could attribute to a process: `dsh list-sessions`
|
||||
// renders the pid and derives liveness from it.
|
||||
const { ctx } = await mount([record('ghost', 'boot-x', 0)])
|
||||
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('stops checking, and keeps working, when the companion unloads', async () => {
|
||||
// A duplicate-id listing the mounted companion rejects, so the post-disposal
|
||||
// read proves the wrapper is gone rather than merely bypassed.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(StubRegistry)
|
||||
;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')]
|
||||
const companion = await ctx.plugin(invariant)
|
||||
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/)
|
||||
|
||||
await companion.dispose()
|
||||
await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-96
@@ -3889,70 +3889,6 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-registry/session-registry:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-registry/session-registry-file:
|
||||
dependencies:
|
||||
proper-lockfile:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
schemastery:
|
||||
specifier: ^3.15.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../session-registry
|
||||
'@types/proper-lockfile':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-registry/session-registry-live:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../session-registry
|
||||
'@deepseek-ai/dsh-session-registry-file':
|
||||
specifier: workspace:^
|
||||
version: link:../session-registry-file
|
||||
'@deepseek-ai/dsh-session-title':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-title/session-title
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-title/session-title:
|
||||
dependencies:
|
||||
schemastery:
|
||||
@@ -8063,9 +7999,6 @@ packages:
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/proper-lockfile@4.1.4':
|
||||
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
@@ -9169,9 +9102,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
@@ -10159,9 +10089,6 @@ packages:
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
proper-lockfile@4.1.2:
|
||||
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
@@ -10265,10 +10192,6 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
retry@0.12.0:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
retry@0.13.1:
|
||||
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -10410,9 +10333,6 @@ packages:
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -12982,10 +12902,6 @@ snapshots:
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/proper-lockfile@4.1.4':
|
||||
dependencies:
|
||||
'@types/retry': 0.12.0
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.31
|
||||
@@ -14287,8 +14203,6 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
handlebars@4.7.9:
|
||||
@@ -15469,12 +15383,6 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
proper-lockfile@4.1.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
retry: 0.12.0
|
||||
signal-exit: 3.0.7
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
protobufjs@7.6.4:
|
||||
@@ -15624,8 +15532,6 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
@@ -15862,8 +15768,6 @@ snapshots:
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
@@ -229,8 +229,6 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
|
||||
SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md',
|
||||
SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md',
|
||||
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
|
||||
DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
|
||||
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
|
||||
|
||||
@@ -171,15 +171,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['apiproxy'],
|
||||
note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.',
|
||||
},
|
||||
{
|
||||
key: 'sessionRegistry',
|
||||
pkg: 'session-registry',
|
||||
title: 'Live-session registry',
|
||||
mode: 'seam',
|
||||
implementations: ['session-registry-file'],
|
||||
consumers: ['session-registry-live'],
|
||||
note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.',
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
pkg: 'session-query',
|
||||
|
||||
@@ -98,9 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' },
|
||||
'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' },
|
||||
'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
"./packages/session-persistence/*/src/invariant.ts",
|
||||
"./packages/session-projection/*/src/invariant.ts",
|
||||
"./packages/session-query/*/src/invariant.ts",
|
||||
"./packages/session-registry/*/src/invariant.ts",
|
||||
"./packages/telemetry/*/src/invariant.ts",
|
||||
"./packages/acp/*/src/invariant.ts",
|
||||
"./packages/storage/*/src/invariant.ts",
|
||||
@@ -180,7 +179,6 @@
|
||||
"./packages/session-persistence/*/src",
|
||||
"./packages/session-projection/*/src",
|
||||
"./packages/session-query/*/src",
|
||||
"./packages/session-registry/*/src",
|
||||
"./packages/session-title/*/src",
|
||||
"./packages/telemetry/*/src",
|
||||
"./packages/acp/*/src",
|
||||
|
||||
@@ -181,9 +181,6 @@
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" },
|
||||
{ "path": "./packages/session-registry/session-registry" },
|
||||
{ "path": "./packages/session-registry/session-registry-file" },
|
||||
{ "path": "./packages/session-registry/session-registry-live" },
|
||||
{ "path": "./apps/cli" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user