fix(session): migrate historical subagents only when opened

This commit is contained in:
Dudu-0223 committed 2026-09-19 22:47:01 +08:00
1 parent 128d7d81d0
commit a9985b49ae
143 files changed
+613 -686

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/architecture/2026-08-31-released-session-format-migrations.md
2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
2026-08-31-released-session-format-migrations.md: 11c35eb80862ccebbead566164e61b5f4a7792fb
2026-08-31-released-session-format-migrations.zh.md: a79bf77ab19ae85bc4dc9c6a942022b8bd198378
@@ -105,7 +105,7 @@ Preparation forwards cancellation through source reads and observes it at the ex
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
The bulk V4 migration command shares one JSONL persistence Context across a bounded job queue, retaining the backend’s two-Worker verification limit and cache for reusing recently decoded logs. A child’s concurrent publication can change its parent’s selected child generation; the ordinary revision check refuses that attempt. The command defers only this source-change error for one sequential retry after the initial queue drains, collecting fresh evidence without relaxing publication checks or retrying unrelated failures.
The bulk V4 migration command explicitly requests each Session through a bounded queue and shares one JSONL Context, including its two-Worker verification limit. Each publication validates only its own source. The command may retry a source-change refusal after the queue drains; a child publication does not invalidate a parent preparation.
### Durable format and publication rules
@@ -131,20 +131,20 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
### Parent catalog prerequisites
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
Original runtime policy (superseded by [Session-local migration](../bug-fix/2026-09-19-session-local-subagent-migration.md)): The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
<a id="catalog-scan-measurements"></a>
### Catalog scan measurements
The package-local [diagnostic](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts) measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.
On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.
The table records the eager catalog-scan implementation on macOS arm64 with Node v26.0.0 before Session-local runtime migration. The 1,000-unrelated medians were 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These historical measurements explain why metadata-only access was insufficient while preparation still decoded child bodies; they are not current timings or cross-host thresholds.
| Unrelated Sessions | Historical stat | Cold read | Memo read | Publish | Current warm read |
|---:|---:|---:|---:|---:|---:|
@@ -105,7 +105,7 @@ Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms D
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
批量 V4 迁移命令在有界任务队列中共享一个 JSONL persistence Context,保留 backend 的双 Worker 校验上限,以及复用近期已解码日志的缓存。子 Session 并发发布可能改变父 Session 选中的子代际;普通修订检查会拒绝这次尝试。命令只把这种源变化错误延后到初始队列清空后串行重试一次,重新收集证据,不放宽发布检查,也不重试无关失败。
批量 V4 迁移命令通过有界队列显式请求各个会话,共享一个 JSONL Context 及其两个 Worker 的校验限制。每次发布只校验自身源文件。命令可在队列排空后重试源变化拒绝;子会话发布不会使父会话准备结果失效。
### Durable format 与 publication 规则
@@ -131,20 +131,20 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
### 父目录前置事实
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
原运行时规则(已由[会话独立迁移](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代):通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
<a id="catalog-scan-measurements"></a>
### 目录扫描测量
包内[诊断脚本](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts)测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。
以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。
下表记录会话独立运行时迁移之前,macOS arm64、Node v26.0.0 上主动 catalog 扫描实现的测量。1,000 个无关会话时,中位数为 stat 84.14 ms、冷读 237.97 ms、缓存读 110.98 ms、发布 268.46 ms、同进程当前格式读取 0.41 ms。这些历史测量说明:当准备仍解码子正文时,仅优化元数据访问并不足够;它们不是当前耗时或跨主机阈值。
| 无关 Session | 历史 stat | 冷读 | 缓存读取 | 发布 | 当前格式热读 |
|---:|---:|---:|---:|---:|---:|
@@ -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/architecture/2026-09-01-parent-owned-subagent-catalog.md
2026-09-01-parent-owned-subagent-catalog.md: 536ec33148bd675a974622b98ee23de812691dc0
2026-09-01-parent-owned-subagent-catalog.zh.md: ec78ff9e4b7d54a7309baf21c383ec5ff83a19c8
2026-09-01-parent-owned-subagent-catalog.md: 1c65813cd92aebbd2e45acc6b1f121aba442646d
2026-09-01-parent-owned-subagent-catalog.zh.md: 7eded398ca53997ad80c3910aad333b4a1476286
@@ -44,6 +44,8 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
Session observations and client snapshots expose durable catalog facts through `projections.values.subagentCatalog`. The change feed publishes complete lists, so D creations can incur O(D²) cumulative view work. Historical children may lack these facts: `listChildren()` supplements one parent observation with header-only discovery and returns unresolved rows without reading child bodies. The Web derives the same missing entries from its existing Session list. This preserves the catalog as the sole durable creation record while accepting O(N) header enumeration for explicit service discovery. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
The [Session-local migration decision](../bug-fix/2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
@@ -44,6 +44,8 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
会话观察和客户端快照通过 `projections.values.subagentCatalog` 暴露持久化 catalog 事实。变化流发布完整列表,因此 D 次创建可能累计 O(D²) 视图工作。历史子会话可能缺少这些事实:`listChildren()` 在一次父观察之外补充仅头部发现,返回未解析条目,不读子正文。Web 从已有会话列表派生同类缺失条目。这保留 catalog 作为唯一持久化创建记录,同时接受显式服务发现的 O(N) 头部枚举成本。[Web 投影消费](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 负责浏览器加载与同步。
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
[会话独立迁移决策](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md
2026-09-19-session-local-subagent-migration.md: c3da2812ac2d48fe4894b62b3b5743664564633a
2026-09-19-session-local-subagent-migration.zh.md: 43875dd8cd34635d33f711745aa9725641e4cec3
@@ -0,0 +1,33 @@
# Agent Note: Migrate each Session independently of its children
Status: implemented
English | [中文](2026-09-19-session-local-subagent-migration.zh.md)
## Problem
Runtime V3→V4 preparation collected every direct child's descriptor to complete the parent catalog. This decoded whole child logs before opening the parent, and unrelated unreadable headers could prevent complete membership classification. A corrupt child therefore blocked valid parent history. Historical revisions also fingerprinted the entire root, invalidating a parent's cached preparation when unrelated files changed.
## Decision
JSONL migrates only the Session explicitly opened by a reader or writer. It preserves existing catalog facts and does not read child bodies or collect cross-session evidence. Read opens prepare current logical events without publishing; write opens validate the selected source and exclusively publish its immutable successor. Public revisions and preparation reuse depend only on the selected file. Predecessor generations remain byte-identical.
Discovery combines parent catalog facts with header relationships. The subagent service returns unresolved direct-child entries when no catalog fact exists; the Web derives them from its existing Session list. Menu and branch refreshes enumerate headers without requesting child projections. Selecting a child opens its own history, validates its direct parent and descriptor, and resolves mode and label. Until then the address grants no continuation authority and the composer stays read-only. A child open failure remains local. Explicit programmatic reads and continuation requests also migrate their selected Session on demand; explicit descendant inspection and bulk migration still read the Sessions they request.
The catalog remains the durable source of successful creation facts. Unresolved discovery rows are neither persisted events nor projection values. No new Session format or persistent index is needed. Offline fixture preparation and Preview packing may still explicitly supply child evidence for catalog completion; their caller already owns that corpus.
This partially supersedes runtime catalog prerequisites and corpus revisions in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md), the discovery-only assumption in [parent catalogs](../architecture/2026-09-01-parent-owned-subagent-catalog.md), and unopened-branch reads in [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md). Their format, creation, ordering, and transport decisions remain active. [Incomplete evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md) continues to govern optional offline completion.
## Alternatives considered
**Catch child failures and continue backfill.** This still decodes every healthy child, hides failures, and permanently omits discovery facts when the parent successor is published.
**Supply an empty child set without changing discovery.** This removes migration coupling but makes historical children disappear from catalog-only consumers.
**Persist placeholder catalog events or another child index.** Unknown mode and label are not successful creation facts. A second persistent index adds write coordination and repair without improving header-based navigation.
## Consequences
Explicit direct-child discovery now enumerates N headers in addition to materializing D catalog facts. It avoids all child-body decoding, while ordinary parent migration has no corpus-dependent preparation. Historical rows may initially show an id instead of a label and cannot promise resumability. Headers that cannot be decoded cannot contribute a navigation row, but do not block opening another known Session.
Raw and compressed persistence regressions cover V0–V3, child corruption and future formats, independent publication, unchanged predecessors, and stable parent revisions across child mutations. Service tests verify only the parent is observed. Client and Host tests verify unresolved addresses, selected-child decoding, mode resolution, and ownership rejection. The shipped Web preset-migration snapshot round-trips parent and child generations with a corrupt sibling and checks that only explicitly opened Sessions migrate.
@@ -0,0 +1,33 @@
# Agent Note: 独立于子会话迁移每个会话
Status: implemented
[English](2026-09-19-session-local-subagent-migration.md) | 中文
## 问题
运行时 V3→V4 准备收集每个直接子会话的 descriptor 以补全父 catalog。这在打开父会话之前解码完整子日志,而无关的不可读头部也可能阻止完整成员分类。因此,一个损坏子会话就会阻断有效父历史。历史版本标识还对整个根目录取指纹,无关文件变化也使父缓存准备失效。
## 决策
JSONL 只迁移读写方显式打开的会话。它保留已有 catalog 事实,不读取子正文或收集跨会话证据。读 open 准备当前逻辑事件但不发布;写 open 校验所选源并独占发布不可变后继。公开版本标识和准备复用只依赖所选文件。前代文件字节保持不变。
发现机制合并父 catalog 事实与头部关系。没有 catalog 事实时,子代理服务返回未解析直接子条目;Web 从已有会话列表派生这些条目。菜单和分支刷新枚举头部,不请求子投影。选择子会话时打开自身历史,校验直接父归属和 descriptor,并解析模式与标签。此前地址不授予续接权限,输入区保持只读。子打开失败仅影响自身。显式程序读取和续接请求同样按需迁移选中会话;显式后代检查和批量迁移仍读取它们所请求的会话。
Catalog 仍是成功创建事实的持久化来源。未解析发现行既不是持久化事件,也不是投影值。无需新会话格式或持久化索引。离线夹具准备和 Preview 打包仍可显式提供子证据来补全 catalog,因为调用者已拥有该语料库。
本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的运行时 catalog 前提与全库版本标识、[父 catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)中的仅靠 catalog 发现假设,以及 [Web 投影消费](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)中的未打开分支读取。它们的格式、创建、排序和传输决策仍有效。[不完整证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)继续约束可选离线补全。
## 考虑过的替代方案
**捕获子错误并继续回填。** 这仍解码所有健康子会话、隐藏失败,并在父后继发布时永久省略发现事实。
**传入空子集合而不改发现机制。** 这消除了迁移耦合,却让历史子会话从仅消费 catalog 的调用方消失。
**持久化占位 catalog 事件或另建子索引。** 未知模式和标签不是成功创建事实。第二份持久化索引增加写协调和修复工作,并不能改善基于头部的导航。
## 影响
显式直接子发现除物化 D 条 catalog 事实外,还枚举 N 个头部。它避免所有子正文解码,而普通父迁移没有依赖全库的准备工作。历史行最初可能显示 id 而非标签,且不能承诺可续接。无法解码的头部不能贡献导航行,但不会阻止打开另一个已知会话。
原始与压缩持久化回归覆盖 V0–V3、子损坏与未来格式、独立发布、前代不变,以及子变化期间父版本标识稳定。服务测试确认只观察父会话。Client 和 Host 测试验证未解析地址、选中子解码、模式解析与归属拒绝。正式 Web preset-migration 快照在损坏兄弟会话存在时往返父子代次,并检查只有显式打开的会话才迁移。
@@ -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/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038
2026-09-19-v3-incomplete-child-catalog-evidence.md: d9cab54d0d381667e840d666a48608298464441e
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 248cc0cc7f899837f06e2cc082b174e87d125262
@@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi
## Decision
The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
Optional V3→V4 catalog completion for offline fixture preparation and Preview packing appends a missing parent entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new fact; existing parent entries and child events remain intact. Runtime JSONL migration does not collect this evidence and restores each Session independently, as specified in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures.
Supplied child creation times must match existing parent entries. Mode, label, and descriptor fields are checked only for exactly one supported own descriptor. These evidence checks belong to explicit offline completion; child corruption or unsupported generations never prevent runtime parent migration.
## Alternatives considered
@@ -24,6 +24,8 @@ Known child creation times must still match existing parent entries. Mode/label
## Consequences
A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
A historical child without a usable descriptor remains discoverable from a readable parent-linked header even when no parent catalog entry exists. Runtime migration preserves that absence rather than inventing a fact. The child’s own open performs decoding and reports any failure locally; publishing a parent successor never requires successful child decoding.
Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
Pure completion and Preview tests retain unavailable-evidence, existing-entry, and conflict coverage. JSONL read/write regressions cover independent parent and child opens, stable parent revisions across child changes, and unchanged predecessor bytes.
The [Session-local migration decision](./2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
离线夹具准备和 Preview 打包中的可选 V3→V4 catalog 补全,仅在恰好一个受支持的自身子 descriptor 提供发现字段时追加缺失父条目。其他 descriptor 数量或不支持的版本不贡献新事实,已有父条目和子事件保持完整。运行时 JSONL 迁移不收集这些证据,并按[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)独立恢复各会话。
已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
传入子创建时间必须匹配已有父条目。只有恰好一个受支持自身 descriptor 时才检查模式、标签和 descriptor 字段。这些证据检查属于显式离线补全;子会话损坏或不支持的代次不会阻止运行时父会话迁移。
## Alternatives considered
@@ -24,6 +24,8 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字
## Consequences
没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
即使没有父 catalog 条目,缺少可用 descriptor 的历史子会话仍可通过可读且关联父会话的头部被发现。运行时迁移保留该缺失状态,不虚构事实。子会话自身 open 执行解码并局部报告失败;发布父后继从不要求子解码成功。
单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
纯补全与 Preview 测试保留不可用证据、已有条目和冲突覆盖。JSONL 读写回归覆盖父子独立打开、子变化期间父版本标识稳定,以及前代字节不变。
[会话独立迁移决策](./2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
@@ -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/simplification/2026-09-08-web-subagent-catalog-projections.md
2026-09-08-web-subagent-catalog-projections.md: 766d33e581bcac53844b0836f23cda615858fc27
2026-09-08-web-subagent-catalog-projections.zh.md: ef14db2530ed10a7aa34408c5a6fada6f0a28858
2026-09-08-web-subagent-catalog-projections.md: f714418a8641b76228cf8723657205fbfda22133
2026-09-08-web-subagent-catalog-projections.zh.md: c23b29f47795a6b8113d5be5f7a57c4713e203d9
@@ -10,9 +10,9 @@ The parent catalog projection already publishes complete membership through the
## Decision
The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened catalog branches still use explicit reads.
The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened branches use header relationships from the Session list instead of requesting child projections. Missing historical catalog entries remain non-durable unresolved navigation rows until the child is opened.
Feature consumers select `subagentCatalog` from `projectionsBySession` and derive row activity from Session-list baselines and status events. Session summaries report Agent availability separately; Agent creation and disposal republish the existing summary event. Completed subagents retain their summaries and display projections after removal. Generic projection reads own loading and error state; parent availability comes from the Session list independently of membership. Opening a loaded catalog reuses its data; an initial failure can be retried. Reconnect cancels previous reads and starts fresh reads for requested catalogs. Session removal cancels its pending read and marks parent availability false; projection responses cannot change Agent availability.
Feature consumers select `subagentCatalog` from `projectionsBySession`, supplement missing rows from parent-linked subagent summaries, and derive activity from Session-list baselines and status events. Header-discovered rows carry unresolved mode and grant no continuation authority. Menu and branch refreshes update only the lightweight Session list. Opening the selected child validates its descriptor and parent and resolves the mode; a failed open stays local to that child. Existing projection ordering, generic explicit projection reads, and independent parent availability remain unchanged.
The [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns durable creation facts and ordering. This decision supersedes the dedicated membership-refresh mechanism in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md); that note remains active for navigation, control, and presentation decisions.
@@ -29,3 +29,5 @@ Host summaries replace running and availability state; local create/fork placeho
Membership changes use the existing control stream without extra catalog notifications or reads. Whole-value projection publication still costs O(D) for D children; this change does not introduce delta transport. Initial reads return the observation's complete projection baseline, including values beyond the catalog, so they reuse standard baseline semantics at the cost of that additional initial payload. Session sequence ordering does not establish identity across reused Session ids.
Manager tests cover pushed membership, stale initial responses, lazy loading, status composition, retained completion metadata, retry, removal of empty catalogs, late create/fork responses, and reconnect cancellation. Host tests exercise the shared control stream and cold observations. The persisted-subagent Web scenario covers nested historical navigation and continuation through the shipped application.
The [Session-local migration decision](../bug-fix/2026-09-19-session-local-subagent-migration.md) supersedes runtime child-body collection and unopened-branch projection reads; optional offline completion and durable catalog semantics remain active.
@@ -10,9 +10,9 @@ Status: implemented
## 决策
Client 仅在标准逐 Session projection store 中保留目录成员关系。初始 `session.projections` 读取通过一次 live-preferred Session observation 返回完整 projection 基线。endpoint 接受任意 Session id,不含目录专用依赖或校验。值与序号游标来自同一次 observation。初始基线与实时 control 帧使用 store 既有的序号排序,因此旧响应不能替换新值。该 endpoint 不激活 Agent,也不采样子代理活动状态。打开会话时使用其 follow baseline,不再额外调度一次投影读取;未打开的目录分支仍使用显式读取。
Client 仅在标准每会话投影存储中保留 catalog 事实。显式 `session.projections` 读取从一次优先存活来源的会话观察返回完整基线;端点接受任意会话 id,不依赖 catalog 专用校验。观察同时提供数值和序列游标;初始基线与实时控制帧遵循已有序列排序,旧响应不能覆盖新值。端点不激活 Agent 或采样子活动。打开对话使用 follow 基线,不另行请求投影。未打开分支使用会话列表的头部关系,不请求子投影;缺失的历史 catalog 条目保持为非持久化、未解析的导航行,直到打开子会话。
功能消费者从 `projectionsBySession` 中选择 `subagentCatalog`,并从 Session 列表基线与状态事件派生行活动状态。Session 摘要单独报告 Agent 可用性;Agent 创建与销毁重新发布已有摘要事件。完成的子代理在移除后保留摘要与展示 projection。通用 projection 读取拥有加载与错误状态;父 Agent 可用性来自 Session 列表,独立于成员关系。打开已加载目录会复用数据,初始失败可以重试。重连取消此前的读取,并为已请求的目录重新读取。Session 移除会取消其待完成读取并将父 Agent 可用性标为 false;projection 响应不能改变 Agent 可用性。
功能消费者从 `projectionsBySession` 选择 `subagentCatalog`,用关联父会话的子代理摘要补充缺失行,并从会话列表基线和状态事件派生活动。头部发现的条目携带未解析模式,不授予续接权限。菜单与分支刷新只更新轻量会话列表。打开选中的子会话时校验 descriptor 和父归属并解析模式,失败仅影响该子会话。已有投影排序、通用显式投影读取和独立父可用性保持不变。
[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明持久创建事实与顺序。本决策取代 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 中的专用成员刷新机制;该记录继续保留导航、控制与展示决策。
@@ -29,3 +29,5 @@ Host 摘要替换运行状态与可用性;本地 create/fork 占位摘要只
成员变化使用既有 control stream,无需额外目录通知或读取。对 D 个子级发布完整 projection 值仍需 O(D);本变更不引入增量传输。初始读取返回 observation 的完整 projection 基线,其中包括目录以外的值,以额外初始载荷换取标准基线语义的复用。Session 序号排序不确立复用 Session id 之间的身份关系。
Manager 测试覆盖成员推送、过时初始响应、按需加载、状态组合、完成元数据保留、重试、空目录移除、晚到的 create/fork 响应与重连取消。Host 测试覆盖共享 control stream 与冷 observation。持久化子代理 Web 场景通过发布应用验证嵌套历史导航与续传。
[会话独立迁移决策](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代运行时子正文收集与未打开分支投影读取;可选离线补全和持久化 catalog 语义仍有效。
@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller'
import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot'
import { launchWebScaffold, webSnapshotMode } from './scaffold.ts'
const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url))
const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => {
@@ -32,6 +33,27 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
await mkdir(dirname(predecessor), { recursive: true })
await writeFile(predecessor, source)
const childId = SessionId('preset-migration-child')
const brokenId = SessionId('preset-migration-broken-child')
const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n')
.map((line): Record<string, unknown> => JSON.parse(line) as Record<string, unknown>)
const childRows: Record<string, unknown>[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 }))
const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd }
const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n'))))
const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd')
const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd')
await mkdir(dirname(childPath), { recursive: true })
await mkdir(dirname(brokenPath), { recursive: true })
await writeFile(childPath, childSource)
await writeFile(brokenPath, Buffer.concat([
zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')),
zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')),
]))
const brokenSource = await readFile(brokenPath)
const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal)
expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId]))
const reader = await scaffold.ctx.sessionPersistence.open(id, 'read')
try {
expect(reader.header.agentPreset).toBe('ptc')
@@ -49,6 +71,30 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
.filter(event => event.type === 'agent-preset/selected')
.map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : [])
const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd')
await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
const signal = new AbortController().signal
const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'unresolved' } as const
const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal)
expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type']))
await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId }, throughSeq: 1 }, signal))
.rejects.toThrow()
await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal))
.resolves.toMatchObject({ hasMore: false })
const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write')
await childWriter.close()
const childPublished = await readFile(childSuccessor)
const childText = Buffer.concat(scanZstdFrames(childPublished).frames
.map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8')
expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([
{ ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows,
])
expect(await readFile(childPath)).toEqual(childSource)
expect(await readFile(brokenPath)).toEqual(brokenSource)
await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd')))
.rejects.toMatchObject({ code: 'ENOENT' })
await scaffold.ctx.sessions.flush(resolved.agent.session)
const publishedBytes = await readFile(successor)
const published = Buffer.concat(scanZstdFrames(publishedBytes).frames
.map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8')
@@ -60,6 +106,9 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
: row),
// Agent activation closes its restored prefix with a fresh seed marker.
{ type: 'session/end-seed', seq: rows.length, time: 0, data: {} },
{ type: 'permission/preset', seq: rows.length + 1, time: 0, data: { preset: 'workspace-write' } },
{ type: 'sandbox/mode', seq: rows.length + 2, time: 0, data: { mode: 'workspace-write' } },
{ type: 'approval/policy', seq: rows.length + 3, time: 0, data: { policy: 'ask' } },
].map(row => JSON.stringify(row)).join('\n') + '\n'
const context = { sessionIds: [id], cwd: scaffold.workspaceCwd }
expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context))
+2 -2
View File
@@ -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 docs/config-catalog.md
config-catalog.md: 9f9a47ff24f67dfda288c046b482ec8a1127787c
config-catalog.zh.md: 95d9bb8415f5f7bb116f58bbd8f9a46c3cf42182
config-catalog.md: 8d0f32cc4d1487dd7170737ee12b5c2a3ed70bc6
config-catalog.zh.md: 677c025fff4486da212a8f1b53d5d8390e410a32
+1 -1
View File
@@ -2208,7 +2208,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
Source: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
Source: [`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
<a id="deepseek-aidsh-session-projection-cache"></a>
+1 -1
View File
@@ -2210,7 +2210,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
来源:[`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts)
来源:[`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
<a id="deepseek-aidsh-session-projection-cache"></a>
+2 -2
View File
@@ -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 docs/subsystems/subagent.md
subagent.md: d642fe1658926b17cb29f280803f12e61228be60
subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2
subagent.md: dc3ebd74d2f6674f3f81c1a77e36cceeaf0664ab
subagent.zh.md: 03ada2686dd9a55f0ebaa3dc7e8cf5c1ac9c1db1
+5 -5
View File
@@ -268,7 +268,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn
The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed.
`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` retains corpus traversal, child-identity diagnostics, and complete `hasChildren` computation. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
`SubagentRuntime.listChildren(parentSessionId, signal?)` owns one parent observation and supplements its catalog with Session-header discovery. It returns `SubagentDiscoveryEntry[]`: catalog facts in event order, followed by `{ id: SessionId; createdAt: number; mode: 'unresolved' }` rows for children without a catalog fact. It never opens child bodies. Query failures propagate; a missing parent catalog projection fails explicitly. `listDescendants()` explicitly reads child identities and returns diagnostics and complete `hasChildren` information. [Session-local migration](../../.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md) owns lazy discovery and migration isolation.
`SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity:
@@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct
- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap.
- **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics.
`SubagentCatalogEntry` describes a durable successful-creation fact; `SubagentCatalogState` is its host projection state. `SubagentDiscoveryEntry` adds non-durable unresolved header rows for direct listing. Browser consumers combine catalog facts with parent-linked summaries and cached child identities. `SubagentAddress` carries parent and child ids with known or unresolved mode; history opening validates the child descriptor before continuation. `SubagentCatalogRow` belongs to complete-descendant listing.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -574,15 +574,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* Read the parent's catalog and discover missing direct children from Session headers.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @returns catalog children in event order, then unresolved children whose bodies remain unread.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDiscoveryEntry[]>
/**
* Enumerate the root's complete session-backed subagent tree in stable
+5 -5
View File
@@ -268,7 +268,7 @@ interface ContinuableCreateSpec {
模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 持有一次父观察,并以会话头部发现补充 catalog。它返回 `SubagentDiscoveryEntry[]`:先按事件顺序返回 catalog 事实,再为缺少 catalog 事实的子会话返回 `{ id: SessionId; createdAt: number; mode: 'unresolved' }` 行。不打开任何子正文。查询失败向上传播,缺少父 catalog 投影时显式报错。`listDescendants()` 显式读取子身份,返回诊断和完整 `hasChildren` 信息。[会话独立迁移](../../.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md) 负责按需发现与迁移隔离。
`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
@@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
- **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。
`SubagentCatalogEntry` 描述持久化成功创建事实,`SubagentCatalogState` 是其 Host 投影状态。`SubagentDiscoveryEntry` 为直接列表增加非持久化、未解析的头部行。浏览器消费者合并 catalog 事实、关联父会话的摘要与缓存子身份。`SubagentAddress` 携带父子 id 及已知或未解析模式;历史打开在续接前校验子会话 descriptor。`SubagentCatalogRow` 属于完整后代列表。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -578,15 +578,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* Read the parent's catalog and discover missing direct children from Session headers.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @returns catalog children in event order, then unresolved children whose bodies remain unread.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDiscoveryEntry[]>
/**
* Enumerate the root's complete session-backed subagent tree in stable
+2 -2
View File
@@ -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 docs/tool-catalog.md
tool-catalog.md: 2f5746fc63e73b1caf2692a05ff942c13399e6bc
tool-catalog.zh.md: 6fb80b689ca569834e8f585c765547d23cec50df
tool-catalog.md: 03e2813a528ed1415f9e89593ce4dc8fa884b7e5
tool-catalog.zh.md: d66c7677a0452fac8557596cd2c666136c0630b1
+1 -1
View File
@@ -1886,7 +1886,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
```json
{
+1 -1
View File
@@ -1892,7 +1892,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法在未打开时确定身份的子级会作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
```json
{
@@ -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 packages/api/session-controller/README.md
README.md: 228ed88ed20b9f9581496468e47079afd1542e4f
README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
README.md: 01701383352458de80d82bbfa1183136fb0bdade
README.zh.md: 4ac801506db13faceee1f8c7971f90a6aae0bfff
+1 -1
View File
@@ -29,7 +29,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. Header-discovered subagent history accepts `mode: 'unresolved'`; opening still validates the exact parent and the child’s own descriptor. The Client derives the usable mode from the opened child projection, and a failed child open does not invalidate its parent.
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
+1 -1
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 从头部发现的子代理历史接受 `mode: 'unresolved'`;打开时仍校验精确父会话和子会话自身 descriptor。Client 从已打开子会话的投影确定可用模式,子会话打开失败不会使父会话失效。
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
@@ -165,6 +165,14 @@ export class SessionManager {
}
}
}
const summary = this.summaries.find(item => item.sessionId === sessionId)
if (summary?.origin === 'subagent' && summary.parentSessionId !== undefined) {
return {
parentSessionId: summary.parentSessionId,
childSessionId: sessionId,
mode: this.projectionStores.get(sessionId)?.values().subagent?.mode ?? 'unresolved',
}
}
return undefined
}
@@ -666,10 +674,6 @@ export class SessionManager {
this.listInflight = null
void this.refreshList()
const parents = new Set(this.projectionLoads.keys())
for (const id of this.sessions.keys()) {
const address = this.addresses.get(id)
if (address !== undefined) parents.add(address.parentSessionId)
}
for (const { controller } of this.projectionInflight.values()) controller.abort()
this.projectionInflight.clear()
this.projectionLoads.clear()
@@ -812,6 +812,7 @@ export class Session implements SessionFace {
}
private buildSnapshot(): SessionSnapshot {
const identity = this.projections.values().subagent
return {
sessionId: this.sessionId,
pendingSubmissions: this.pendingSubmissions,
@@ -819,7 +820,9 @@ export class Session implements SessionFace {
subagent: this.address === undefined
? null
: {
address: this.address,
address: this.address.mode === 'unresolved' && identity != null
? { ...this.address, mode: identity.mode }
: this.address,
...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
},
removed: this.removed,
@@ -364,7 +364,7 @@ function validateAddress(
reason: 'unsupported',
})
}
if (identity.mode !== address.mode) {
if (address.mode !== 'unresolved' && identity.mode !== address.mode) {
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
+1 -1
View File
@@ -393,7 +393,7 @@ export type SessionAddress =
readonly kind: 'subagent'
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
readonly mode: 'one-shot' | 'continuable'
readonly mode: 'one-shot' | 'continuable' | 'unresolved'
}
/** One non-activating Session projection read. */
@@ -417,6 +417,33 @@ describe('Host Remote event routing', () => {
})
describe('subagent catalogs', () => {
it.for(['one-shot', 'continuable'] as const)('opens a header-discovered child on demand and resolves %s mode', async (mode, { mock, remote, start }) => {
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] })))
mock.load(sessionWorld)
mock.stream(FOLLOW, followScript(ok({
records: [], hasMore: false,
projections: { asOfSeq: 0, values: { subagent: { mode, label: 'restored', seq: 0 } } },
})))
const client = await start()
const manager = new SessionManager(client.ctx.remote)
try {
await manager.refreshList()
manager.resolveTarget(S2)
expect(manager.subagentAddress(S2)).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unresolved' })
expect(remote.session.projections).not.toHaveBeenCalled()
expect(mock.log.requests(FOLLOW)).toEqual([])
const child = manager.get(S2)
await child.open()
expect(mock.log.requests(FOLLOW)).toEqual([{
address: { kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'unresolved' },
assistantStream: true, maxMessages: 50,
}])
expect(child.getSnapshot().subagent?.address.mode).toBe(mode)
} finally { await manager.dispose() }
})
it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
@@ -1010,33 +1037,24 @@ describe('connected generation', () => {
expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
})
it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => {
it('retains the durable parent address across reconnect without opening the parent', async ({ mock, remote }) => {
const address = {
parentSessionId: S1,
childSessionId: S2,
mode: 'continuable' as const,
}
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [summary(S1)] as never[] })))
const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => parent.promise)
const manager = makeManager(mock, remote)
manager.resolveTarget(address)
manager.get(S2)
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } }))
await vi.waitFor(() => {
expect(remote.session.list.mock.calls.map(([request]) => request)).toHaveLength(1)
})
await vi.waitFor(() => {
expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([
{ sessionId: S1 },
])
})
expect(remote.session.projections).not.toHaveBeenCalled()
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
parentAvailable: true,
@@ -641,6 +641,27 @@ describe('SessionHistoryController', () => {
.rejects.toMatchObject({ code: 'subagent/unauthorized' })
})
it('resolves a header-discovered address while retaining child ownership and descriptor validation', async () => {
const bench = await setup()
const parentSessionId = SessionId('unresolved-parent')
const childSessionId = SessionId('unresolved-child')
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION, id: childSessionId, createdAt: 1, cwd: '/workspace',
isSeeded: false, origin: 'subagent', parentSession: parentSessionId,
}
cold(bench.ctx, header, [event('subagent/descriptor', SessionSeq(0), {
version: 3, mode: 'one-shot', provider: 'spawn',
})])
const address = { kind: 'subagent', parentSessionId, childSessionId, mode: 'unresolved' } as const
await expect(bench.transport.page({ address, throughSeq: 0 }, signal())).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
})
await expect(bench.transport.page({ address: { ...address, parentSessionId: SessionId('wrong') }, throughSeq: 0 }, signal()))
.rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(bench.transport.page({ address: { ...address, mode: 'continuable' }, throughSeq: 0 }, signal()))
.rejects.toMatchObject({ code: 'subagent/unauthorized' })
})
it('reports an unavailable descriptor when an observed child has no projection value', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+2 -2
View File
@@ -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 packages/client/ui-subagent/README.md
README.md: 1404e893add386b88a24d78675a4f0be06f497a0
README.zh.md: 97c743b580e25cd50bcb01a3a7547cb7415a0e08
README.md: 33d8da78b2cf27ccce1cfa8ee7c26ddb43b3d083
README.zh.md: 9cc1fff725d8539abc648ab7d198413194674160
+4 -4
View File
@@ -25,17 +25,17 @@ Use this package to browse every subagent conversation beneath a parent session,
<a id="use-this-package"></a>
## Use this package
The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger for known direct children or a catalog error. Rows combine existing parent catalog facts with Session-list header relationships, so historical children remain discoverable without opening their logs. Opening the menu or expanding a branch refreshes the lightweight Session list. Selecting a row opens only that child through its exact `{parentSessionId, childSessionId, mode}` address; a header-only row uses `mode: 'unresolved'` until its own descriptor is read. The trailing arrow opens the same address in the right Sidebar.
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource refreshes the lightweight Session list before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
### Browsing the tree
Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
Rows display mode plus activity and an optional log-backed title; unresolved rows display “Not opened” and use their id until identity data is available. Known parent catalog labels remain authoritative. Running, completed, and other inactive children use the shared ongoing, success, and idle indicators. Cached usage and duration remain optional. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree. A header-discovered child with no listed descendants or cached catalog entries is a leaf without a body read.
### Continuing a conversation
A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable.
An unresolved child keeps a read-only composer until opening its history supplies a valid identity. A continuable child with a live parent keeps the ordinary input chrome: follow-ups join the child’s FIFO inbox, and Stop routes through `subagents/interruptByParent`. A continuable child whose parent is unavailable and which is not running uses a read-only composer; while it runs, input and Send remain disabled but Stop stays usable. One-shot history remains read-only.
### The `@` reference source
+4 -4
View File
@@ -25,17 +25,17 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
会话头部保留当前标题作为层级导航,并为已知直接子会话或 catalog 错误追加 `/` 数量入口。列表行合并已有父 catalog 事实和会话列表中的头部关系,让历史子会话无需打开日志即可被发现。打开菜单或展开分支只刷新轻量会话列表。选择一行时,通过精确的 `{parentSessionId, childSessionId, mode}` 地址只打开该子会话;只有头部的条目使用 `mode: 'unresolved'`,直到读取自身 descriptor。末尾箭头将同一地址打开到右侧 Sidebar。
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源和内置 Sidebar 标签类型。资源在持有子会话 `SessionReference` 前刷新轻量会话列表,并在标签记录关闭时释放引用。标签通过 `sidebar.chat.conversation` 渲染共享的 `conversation.content` Factory,将局部 View 固定为 Chat,省略主 Conversation 头部和宽度控件。
### 浏览目录
行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
列表行显示模式、活动状态和可选的日志标题;未解析条目显示“尚未打开”,在身份数据可用前使用 id。已有父 catalog 标签仍具有权威性。运行、完成和其他空闲子会话分别使用共享的进行中、成功和空闲标记。缓存的用量和耗时为可选项。ArrowRight/ArrowLeft 展开或收起分支,ArrowUp/ArrowDown、Home、End、Escape 用于导航或关闭树。头部发现的子会话若没有列出的后代或缓存 catalog 条目,无需读取正文即可作为叶节点。
### 续接对话
确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。
未解析子会话保持只读输入区,直到打开历史记录并得到有效身份。父代理在线的可继续子会话使用普通输入区:后续消息进入子代理 FIFO 收件箱,Stop 经 `subagents/interruptByParent` 路由。父代理不可用且未运行的可继续子会话使用只读输入区;仍运行时禁用输入和 Send,但保留 Stop。一次性子会话历史始终只读。
### `@` 引用 source
@@ -17,7 +17,7 @@ import css from './SubagentHeaderLineage.module.css'
type SubagentCatalogSnapshot = Omit<SessionProjectionSnapshot, 'values' | 'state'> & {
state: 'loading' | 'ready' | 'error'
entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[]
entries: { id: SessionId; mode: SubagentAddress['mode']; label?: string; activity: 'running' | 'inactive' }[]
}
type Catalogs = Readonly<Record<SessionId, SubagentCatalogSnapshot>>
@@ -238,7 +238,8 @@ function CatalogRows({
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
const mode = entry.mode === 'unresolved' ? t('mode.unresolved')
: entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
const completed = entry.activity === 'inactive'
&& summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true
const activity = entry.activity === 'running'
@@ -451,15 +452,31 @@ function CatalogDropdown({
const projections = useSessions(state => state.projectionsBySession)
const summaries = useSessions(state => state.byId)
const statuses = useSessionStatus(value => value)
const catalogs = useMemo<Catalogs>(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
state: snapshot.state === 'idle'
? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error,
entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
})),
}])), [projections, summaries, statuses])
const catalogs = useMemo<Catalogs>(() => {
const result: Record<SessionId, SubagentCatalogSnapshot> = Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
state: snapshot.state === 'idle'
? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error,
entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
})),
}]))
const known = new Map(Object.entries(result).map(([id, catalog]) => [id, new Set(catalog.entries.map(entry => entry.id))]))
for (const summary of Object.values(summaries)) {
if (summary.origin !== 'subagent' || summary.parentId === undefined) continue
result[summary.id] ??= { state: 'ready', error: null, entries: [] }
const catalog = result[summary.parentId] ??= { state: 'ready', error: null, entries: [] }
if (known.get(summary.parentId)?.has(summary.id)) continue
const identity = summary.projectionValues?.subagent
catalog.entries.push({
id: summary.id, mode: identity?.mode ?? 'unresolved',
...(identity?.label === undefined ? {} : { label: identity.label }),
activity: (statuses.get(summary.id)?.running ?? summary.running) ? 'running' : 'inactive',
})
}
return result
}, [projections, summaries, statuses])
const catalog = catalogs[rootSessionId]
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState<CSSProperties>()
@@ -473,7 +490,7 @@ function CatalogDropdown({
? undefined
: catalog?.entries.find(entry => entry.id === currentSessionId)
const switcherDisplayTitle = currentEntry !== undefined
? currentEntry.label ?? currentEntry.id
? currentEntry.label ?? (currentEntry.mode === 'unresolved' ? displayTitle : currentEntry.id)
: displayTitle
const directChildren = catalog?.entries ?? []
const directCount = directChildren.length
@@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css'
/** Why a catalog-addressed conversation cannot accept human input. */
export interface SubagentReadOnlyMatch {
reason: 'one-shot' | 'parent-unavailable'
reason: 'one-shot' | 'parent-unavailable' | 'unresolved'
}
/** Full chain props after the read-only subagent selector accepts the owner currency. */
@@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
<div className={css.frame} role="status">
<strong>{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')}</strong>
<span>
{t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
{t(matched.reason === 'unresolved' ? 'readonly.unresolved.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
</span>
</div>
)
@@ -37,6 +37,7 @@ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRig
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
const subagent = owner.session?.subagent
if (subagent === undefined || subagent === null) return null
if (subagent.address.mode === 'unresolved') return { reason: 'unresolved' }
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
// Until a Host summary establishes parent availability, keep the normal
// disabled composer instead of claiming that the parent is offline.
@@ -67,8 +68,8 @@ export function apply(ctx: ClientContext): void {
preferNewPane: true,
})
},
refresh(parentSessionId: SessionId) {
void sessions.refreshProjections(parentSessionId)
refresh() {
void sessions.refresh()
},
})
ctx.slots.inject(
@@ -22,6 +22,7 @@ export const zh = {
'loading.label': '正在加载子代理…',
'load.error': '无法加载子代理',
'retry': '重试',
'mode.unresolved': '尚未打开',
'mode.oneShot': '一次性',
'mode.continuable': '可继续',
'activity.running': '正在运行',
@@ -40,6 +41,7 @@ export const zh = {
'readonly.oneShot.title': '一次性子代理记录',
'readonly.title': '此子代理暂时只读',
'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
'readonly.unresolved.body': '打开会话后才能确认是否支持后续消息。',
'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。',
} as const
@@ -62,6 +64,7 @@ export const en: Record<SubagentKey, string> = {
'loading.label': 'Loading subagents…',
'load.error': 'Unable to load subagents',
'retry': 'Retry',
'mode.unresolved': 'Not opened',
'mode.oneShot': 'one-shot',
'mode.continuable': 'continuable',
'activity.running': 'running',
@@ -80,6 +83,7 @@ export const en: Record<SubagentKey, string> = {
'readonly.oneShot.title': 'One-shot subagent record',
'readonly.title': 'This subagent is read-only for now',
'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.',
'readonly.unresolved.body': 'Open this conversation to check whether it supports follow-up messages.',
'readonly.body': 'The parent session is offline; reopen it to continue sending messages.',
}
@@ -73,7 +73,7 @@ export function parseSubagentChatAddress(value: string): SubagentAddress | undef
if (parts.length !== 2 || parts[0] !== 'session') return undefined
const parentSessionId = url.searchParams.get('parent')
const mode = url.searchParams.get('mode')
if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) {
if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable' && mode !== 'unresolved')) {
return undefined
}
try {
@@ -106,7 +106,7 @@ function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'su
const address = parseSubagentChatAddress(resourceAddress)
if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`)
if (isAbortRequested(signal)) return
await sessions.refreshProjections(address.parentSessionId)
await sessions.refresh()
if (isAbortRequested(signal)) return
const reference = sessions.retain(address, { source: 'sidebarChat', signal })
try {
@@ -2,7 +2,7 @@
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type {
SessionListState, SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
@@ -51,8 +51,8 @@ function sessionsWith(sessions: SessionSummary[]) {
subagentAddress: (childSessionId: SessionId) => childSessionId === address.childSessionId
? address
: undefined,
refreshProjections: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshProjections', args: [parentSessionId] })
refresh: () => {
actionCalls.push({ method: 'refresh', args: [] })
return Promise.resolve()
},
}
@@ -74,6 +74,10 @@ async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
const face = sessionsWith(sessions)
ctx.provide('sessions', face)
const registerResources = vi.fn(() => () => {})
const registerTab = vi.fn(() => () => {})
ctx.provide('resources', { register: registerResources })
ctx.provide('sidebarRightTabs', { register: registerTab })
ctx.provide('uiWorkspace', {
openSession: (address: SubagentAddress) => {
face.actionCalls.push({ method: 'openSession', args: [address] })
@@ -89,7 +93,7 @@ async function fullBench(sessions: SessionSummary[]) {
await provideSlotFaces(ctx)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { face, ctx }
return { face, ctx, registerResources, registerTab }
}
const FAMILY: SessionSummary[] = [
@@ -108,7 +112,9 @@ describe('apply', () => {
})
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
const { ctx, face } = await fullBench(FAMILY)
const { ctx, face, registerResources, registerTab } = await fullBench(FAMILY)
expect(registerResources).toHaveBeenCalledOnce()
expect(registerTab).toHaveBeenCalledOnce()
const catalogEntry = ctx.slots.entries('conversation.session.header.lineage')
.find(entry => entry.component === SubagentHeaderLineage)!
const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
@@ -129,7 +135,7 @@ describe('apply', () => {
{ kind: 'subagentchat', preferNewPane: true },
],
},
{ method: 'refreshProjections', args: [sid('parent')] },
{ method: 'refresh', args: [] },
])
const composerEntry = ctx.slots.entries('conversation.composer')
@@ -146,6 +152,8 @@ describe('apply', () => {
pendingInteraction: undefined,
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'unresolved' }, parentAvailable: true })))
.toEqual({ reason: 'unresolved' })
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
@@ -182,7 +182,7 @@ describe('SubagentHeaderLineage', () => {
expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('does not count Sessions outside projected membership', () => {
it('adds header-discovered children without counting ordinary forks or their descendants', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentHeaderLineage {...props(catalog({
@@ -198,7 +198,7 @@ describe('SubagentHeaderLineage', () => {
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
})} />)
const trigger = screen.getByRole('button', { name: '1 个子代理' })
const trigger = screen.getByRole('button', { name: '2 个子代理' })
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
@@ -571,7 +571,30 @@ describe('SubagentHeaderLineage', () => {
})
})
it('shows one generic state while a child catalog loads', () => {
it('shows a loading branch until its child headers arrive', () => {
const input = props(catalog(), { [CHILD]: catalog({ entries: [], state: 'loading' }) })
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
})
it('uses an already opened child identity without loading the parent catalog', () => {
const input = props(undefined, {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent',
projectionValues: { subagent: { mode: 'one-shot', label: 'restored', seq: 0 } },
},
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ }))
fireEvent.click(screen.getByRole('treeitem', { name: /restored/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
})
})
it('shows header-discovered descendants before their identity projections load', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[CHILD]: { ...summary(CHILD, 1), running: true },
@@ -589,15 +612,18 @@ describe('SubagentHeaderLineage', () => {
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(deferred.refresh).toHaveBeenCalledWith(CHILD)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getAllByRole('treeitem', { name: /尚未打开/ })).toHaveLength(2)
fireEvent.click(screen.getByRole('treeitem', { name: /grandchild-2/ }))
expect(deferred.openChild).toHaveBeenCalledWith({ parentSessionId: CHILD, childSessionId: secondGrandchild, mode: 'unresolved' })
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentHeaderLineage {...loading} />)
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getAllByRole('treeitem', { name: /尚未打开/ })).toHaveLength(2)
const ready = props(catalog(), {
[CHILD]: catalog({
@@ -680,7 +706,7 @@ describe('SubagentHeaderLineage', () => {
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
})
it('does not expose summary-only descendants as catalog rows', () => {
it('keeps header-discovered children available without parent catalog facts', () => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
@@ -692,11 +718,11 @@ describe('SubagentHeaderLineage', () => {
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentHeaderLineage {...absent} />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('button', { name: /子代理/ })).toBeTruthy()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentHeaderLineage {...staleEmpty} />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('button', { name: /子代理/ })).toBeTruthy()
})
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
@@ -889,6 +915,21 @@ describe('SubagentHeaderLineage', () => {
expect(input.refresh).not.toHaveBeenCalled()
})
it('shows a loading switcher before the restored child header arrives', () => {
const input = {
...props(undefined, {}, {}, {
parentSessionId: PARENT, childSessionId: CHILD, mode: 'unresolved',
}),
lineageSessionId: CHILD,
displayTitle: 'restored child',
}
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: '切换子代理:restored child' }))
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
expect(input.refresh).toHaveBeenCalledWith(PARENT)
expect(input.openChild).not.toHaveBeenCalled()
})
it('keeps a nested title switcher scoped to its direct-parent catalog', () => {
const input = {
...props(catalog(), {
@@ -921,6 +962,11 @@ describe('SubagentHeaderLineage', () => {
})
describe('SubagentReadOnlyComposer', () => {
it('keeps an unresolved child read-only', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'unresolved' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain(zh['readonly.unresolved.body'])
})
it('explains the exact missing-parent recovery path', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
@@ -38,6 +38,8 @@ describe('Sidebar chat address', () => {
expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS)
expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' })))
.toEqual({ ...ADDRESS, mode: 'one-shot' })
expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'unresolved' })))
.toEqual({ ...ADDRESS, mode: 'unresolved' })
})
it.each([
@@ -64,7 +66,7 @@ describe('Sidebar chat registration', () => {
[Symbol.dispose]: release,
} as unknown as SessionReference
const retain = vi.fn(() => reference)
const refreshProjections = vi.fn(() => Promise.resolve())
const refresh = vi.fn(() => Promise.resolve())
const list = {
getSnapshot: () => ({
ids: [],
@@ -84,7 +86,7 @@ describe('Sidebar chat registration', () => {
let definition: SidebarRightTabDefinition | undefined
const registrations: { options: Record<string, unknown>; component: unknown }[] = []
const ctx = {
sessions: { retain, refreshProjections, list } as unknown as ISessions,
sessions: { retain, refresh, list } as unknown as ISessions,
resources: { register: (value: ResourceProvider<'subagentchat'>) => { provider = value; return () => {} } },
sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } },
slots: {
@@ -114,7 +116,7 @@ describe('Sidebar chat registration', () => {
const controller = new AbortController()
const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]()
expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } })
expect(refreshProjections).toHaveBeenCalledWith(PARENT)
expect(refresh).toHaveBeenCalledWith()
expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal })
const completion = stream.next()
await Promise.resolve()
@@ -130,7 +132,7 @@ describe('Sidebar chat registration', () => {
expect(release).toHaveBeenCalledTimes(2)
let finishRefresh: (() => void) | undefined
refreshProjections.mockImplementationOnce(() => new Promise<void>((resolve) => { finishRefresh = resolve }))
refresh.mockImplementationOnce(() => new Promise<void>((resolve) => { finishRefresh = resolve }))
const abortedDuringRefresh = new AbortController()
const pending = provider!.open(
subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal },
@@ -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 packages/client/ui-workspace/README.md
README.md: dc9ecee1df515f8e9b4183b6d97c29301c848652
README.zh.md: 065f77ac442943b5683f0d492b56ab7d6668ba99
README.md: 1f0b518644b80ea87cc701c8c656c27e36a317fc
README.zh.md: ad49fd683e04ed47aa7db0e8004cfb61b3e2a89e
+1 -1
View File
@@ -65,7 +65,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
-----
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Restoring a subagent address opens only the selected child, without requesting its parent projections. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
+1 -1
View File
@@ -65,7 +65,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
-----
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 恢复子代理地址时只打开选中的子会话,不请求其父投影。
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
@@ -274,7 +274,6 @@ class UiWorkspaceService extends Service implements UiWorkspace {
private async restoreSelection(workspaces: WorkspaceSnapshot, sessions: SessionListState): Promise<void> {
const saved = this.selection.getSnapshot()
if (saved.subagentAddress !== undefined) {
void this.sessions.refreshProjections(saved.subagentAddress.parentSessionId)
this.replaceMain(saved.subagentAddress, this.lifetime.signal, 'preserve')
return
}
@@ -715,7 +715,6 @@ describe('UiWorkspaceService', () => {
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' })
expect(b.sessions.refreshProjections.mock.calls).toEqual([
[address.parentSessionId],
[address.childSessionId],
])
})
@@ -2603,10 +2603,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'],
},
{
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>',
description: 'Read the parent\'s durable direct-child catalog without loading or resuming an Agent. The service owns and releases the live-preferred Session observation.',
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDiscoveryEntry[]>',
description: 'Read the parent\'s catalog and discover missing direct children from Session headers. The service owns and releases the live-preferred Session observation.',
parameters: [{ name: 'parentSessionId', description: 'parent whose direct children are requested.' }, { name: 'signal', description: 'cancellation forwarded to the Session query.' }],
returns: 'catalog children in parent event order.',
returns: 'catalog children in event order, then unresolved children whose bodies remain unread.',
throws: ['{@link SubagentError} when query or catalog projection is unavailable.', 'SessionQueryError when the parent cannot be read or the query is cancelled.'],
},
{
@@ -5737,7 +5737,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionAddress',
declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};',
declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unresolved\';\n};',
},
{
name: 'SessionAssistantStreamAttempt',
@@ -6435,6 +6435,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentDescriptorData',
declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;',
},
{
name: 'SubagentDiscoveryEntry',
declaration: 'export type SubagentDiscoveryEntry = SubagentCatalogEntry | {\n readonly id: SessionId;\n readonly createdAt: number;\n readonly mode: \'unresolved\';\n};',
},
{
name: 'SubagentInterruptAuthority',
declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};',
@@ -6485,7 +6489,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRuntime',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z<Config>;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z<Config>;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDiscoveryEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentSendMessageOptions',
@@ -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 packages/session/session-format-catalog/README.md
README.md: bb86bcaffee2c2e17afffd4ce7fbd917bf1c30ee
README.zh.md: 156361ae330967169ae82e204ddeff26c50b05bd
README.md: 82c1635ced88651ddcb250bd2d6b03e021faa176
README.zh.md: 07758cd5ab14010a16889073f4b5b046e9dd0ba7
@@ -33,7 +33,7 @@ Import this library from persistence and test-support readers that need the comp
```text
const descriptor = sessionFormatCatalog.readHeader(physicalHeader)
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const catalog = sessionFormatCatalog
const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
for (const row of physicalRows) restore.decodeRow(row)
const current = restore.finish()
@@ -47,7 +47,7 @@ Production historical reads select `{ recovery: 'recoverable', validation: 'tran
The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners.
`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 event vocabulary to collect child prerequisites without recursively completing their catalogs. An ignorable V3 extension remains opaque even when the installed writer knows its name. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog.
`sessionFormatCatalog` restores historical bodies independently of other Sessions. `createSessionFormatCatalogWithChildren(childFacts)` optionally supplements parent catalogs for offline fixture preparation and Preview packing; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 vocabulary for that evidence collection. An ignorable V3 extension stays opaque even when the current writer knows its name. Supplied evidence remains unchanged for the catalog’s lifetime, and every restore owns independent stage state.
-----
@@ -33,7 +33,7 @@ kind: "package-library"
```text
const descriptor = sessionFormatCatalog.readHeader(physicalHeader)
const catalog = createSessionFormatCatalogWithChildren(childFacts)
const catalog = sessionFormatCatalog
const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' })
for (const row of physicalRows) restore.decodeRow(row)
const current = restore.finish()
@@ -47,7 +47,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform
该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。
`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 事件词汇恢复 V0–V3,收集子日志前置事实而不递归补齐其目录。即使已安装写入方认识事件名,可忽略 V3 扩展仍保持不透明。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。
`sessionFormatCatalog` 独立于其他会话恢复历史正文。`createSessionFormatCatalogWithChildren(childFacts)` 可在离线夹具准备和 Preview 打包时补充父会话 catalog;参见 [catalog 补全规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 词汇恢复 V0–V3,为此收集证据。即使当前 writer 已认识某个名称,可忽略的 V3 扩展仍保持不透明。传入证据在 catalog 生命周期内保持不变,每次恢复拥有独立的阶段状态。
-----
@@ -7,7 +7,7 @@ import { sessionFormatCatalogOptions } from './generated.ts'
/**
* Assemble a catalog whose V3→V4 edge knows one parent's historical children.
* @param children - complete child evidence retained unchanged for the catalog's lifetime; an empty array declares no children.
* @param children - complete child evidence retained unchanged for the catalog's lifetime; an empty array requests no backfill.
* @returns a catalog with independent restore state per artifact and unchanged current-format readers.
*/
export function createSessionFormatCatalogWithChildren(children: readonly SessionFormatJsonValue[]): SessionFormatCatalog {
@@ -7,9 +7,9 @@ const child = (id: string) => ({ childId: id, childCreatedAt: 2, descriptorCount
descriptor: { version: 3, provider: 'spawn', mode: 'one-shot' } })
describe('parent-specific catalog assembly', () => {
it('requires explicit children for historical bodies while retaining header and native V4 reads', () => {
it('restores historical bodies independently while retaining optional catalog completion', () => {
expect(sessionFormatCatalog.readHeader(header).status).toBe('migration-required')
expect(() => sessionFormatCatalog.createRestore(header, policy)).toThrow('explicit historical child facts')
expect(sessionFormatCatalog.createRestore(header, policy).finish().events).toEqual([])
expect(createSessionFormatCatalogWithChildren([]).createRestore(header, policy).finish().events).toEqual([])
expect(sessionFormatCatalog.createRestore({ ...header, version: 4 }, policy).finish().events).toEqual([])
})
@@ -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 packages/session/session-format-v3-to-v4/README.md
README.md: 2c2d3b4342d68cec5e9a9f94f969a380bbbf15f2
README.zh.md: a92ae34a73f134253880fec3f931815e067bc344
README.md: cb23641b3cc3b9a60ee1dc3429ab529809df6c93
README.zh.md: 67148ff09cc43eed9aed523f5ae7670c3348403b
@@ -48,7 +48,7 @@ Header-only migration validates and advances metadata without reading the body o
const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
```
Historical body restoration requires explicit child evidence. `sessionFormatV3ToV4.createStage()` refuses without that binding; an empty array explicitly declares no children. Persistence must collect the complete available direct-child set, while isolated transcript replay can supply its deliberately empty set. Keep the evidence unchanged for the catalog's lifetime.
Historical body restoration through `sessionFormatV3ToV4` requires only the requested Session. It preserves existing catalog entries without reading children. Offline fixture preparation may explicitly supply child evidence through `createSessionFormatCatalogWithChildren()` to append missing catalog facts; an empty array requests no backfill. Keep supplied evidence unchanged for the catalog’s lifetime.
```text
const catalog = createSessionFormatCatalogWithChildren(childFacts)
@@ -154,7 +154,7 @@ Catalog version 0 requires string `childId`, nonnegative safe-integer `childCrea
The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count.
Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Incomplete descriptor evidence only prevents that child’s backfill. Unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication.
The supplemental evidence rules apply only when a caller requests catalog completion. Runtime JSONL migration does not collect child evidence; it migrates each Session on its own open. The package reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, source revision checks, and publication.
<a id="sequence-references"></a>
### Sequence references and inheritance
@@ -48,7 +48,7 @@ kind: "package-library"
const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
```
历史正文恢复要求显式的子级证据。没有该绑定时,`sessionFormatV3ToV4.createStage()` 会拒绝;空数组明确声明没有子级。持久化层必须收集完整的可用直属子级集合,而隔离的 transcript 回放可以提供其刻意为空的集合。Catalog 存续期间,证据必须保持不变。
通过 `sessionFormatV3ToV4` 恢复历史正文只需要所请求的会话。它保留已有 catalog 条目,不读取子会话。离线夹具准备可以通过 `createSessionFormatCatalogWithChildren()` 显式传入子会话证据,追加缺失的 catalog 事实;空数组表示不回填。传入证据在 catalog 生命周期内必须保持不变。
```text
const catalog = createSessionFormatCatalogWithChildren(childFacts)
@@ -154,7 +154,7 @@ Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt
Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。
存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。不完整的 descriptor 证据只阻止补填对应子 Session 的目录项。无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。
补充证据规则仅适用于调用者请求 catalog 补全时。运行时 JSONL 迁移不收集子会话证据,每个会话在自身 open 时独立迁移。本包不读取文件;[持久化](../session-persistence-jsonl/README.zh.md) 负责编码、锁、取消、源文件版本检查和发布。
<a id="sequence-references"></a>
### 序号引用与继承
@@ -10,7 +10,7 @@ import { namespaceV3OpaqueEvent, RELEASED_V3_EVENT_TYPES } from './extension-ide
import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts'
import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts'
/** Header-only migration declaration; body restoration requires explicit child evidence. */
/** Convert one Session independently, preserving its existing catalog entries. */
export const sessionFormatV3ToV4 = defineSessionFormatMigration({
name: '@deepseek-ai/dsh-session-format-v3-to-v4',
fromVersion: 3,
@@ -19,15 +19,13 @@ export const sessionFormatV3ToV4 = defineSessionFormatMigration({
assertReleasedV3Header(header)
return { ...header, version: 4 }
},
createStage() {
throw new SessionFormatUnsupportedMigrationError('V3 catalog migration requires explicit historical child facts, including an empty array for a parent without children')
},
createStage: input => new ReleasedV3ToV4Stage(input, []),
validateTargetHeader: assertReleasedV4Header,
})
/**
* Bind one parent's historical child evidence to its V3→V4 migration.
* @param children - complete child evidence retained unchanged for the lifetime of this declaration; an empty array declares no children.
* @param children - complete child evidence retained unchanged for the lifetime of this declaration; an empty array requests no backfill.
* @returns an adjacent migration that creates independent stages with the supplied evidence.
*/
export function createSessionFormatV3ToV4(children: readonly SessionFormatJsonValue[]): SessionFormatMigration {
@@ -71,9 +71,9 @@ describe('V3 parent catalog completion', () => {
expect(() => migrate([opaque], [])).toThrow('complete version 0')
})
it('requires explicit corpus facts and refuses conflicting identities', () => {
it('accepts independent migration and refuses conflicting supplemental identities', () => {
expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 },
sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts')
sourceKind: 'decoded', sourceInheritedEventCount: 0 })).not.toThrow()
expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0')
expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts')
})
@@ -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 packages/session/session-persistence-jsonl/README.md
README.md: 96c333d9f845943ff44c8d5d539c40d07b9dc0f6
README.zh.md: 71126dd909c92aabdee5c07d23505f708dc5e038
README.md: 2581be5ccdee5e5539e383ac0598ea1a285b6376
README.zh.md: 7eddf277cc82e078283961f3309c979214e0cb9f
@@ -79,11 +79,11 @@ The current-generation scanner applies the current codec owner’s structural ad
### Reading the logs
`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Revisions identify only the selected Session file, independently of other Sessions. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid fields in exactly one supported own descriptor still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events.
Historical preparation reads and migrates only the requested Session. It preserves existing parent catalog events without collecting child descriptors or validating other Session headers and bodies. Child corruption, unsupported generations, and child publication cannot invalidate a parent preparation. The [subagent service](../../subagent/subagent/README.md) discovers missing child entries from headers; opening a selected child performs its own migration and reports its own errors. Current reads validate catalog fields, uniqueness, and delivery ownership before exposing events.
Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements).
Historical revisions and memo reuse require no corpus fingerprint or child-body scan. Listing still enumerates Session headers; targeted generation lookup may enumerate directories to locate an uncached id. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat only the requested Session’s decoding. The [diagnostic measurements](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements) retain the earlier eager-scan baseline.
-----
@@ -79,11 +79,11 @@ kind: "package-reference"
### 读取日志
`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
`open(id, 'read'|'write')` 选择最高规范代次。当前输入走普通快速路径。对于历史输入,读 open 解码并迁移源文件一次,校验当前逻辑结果,不发布后继文件。写 open 复用按源版本缓存的准备结果,或重新准备,再分块写同目录临时文件,经 Worker Thread 校验、复核源文件版本后,以不覆盖方式发布当前后继文件。源文件字节保持不变。准备后的源文件变化会拒绝该次写 open,已返回的逻辑历史不变;后续写 open 重新准备。缓存前深度冻结的事件图标记为 `shared-frozen`,读取和切片(包括空切片)保持这一状态;只有尚未物化的待写日志报告 `detached`。`stat(id)` 和 `list()` 只选择并转换最高代次的头部,不读事件行或启动迁移;快照携带所选文件的 `sizeBytes` 和尽力获取的版本标识。版本标识只对应所选会话文件,与其他会话无关。配置 `compression: 'none'` 时,外部工具可以直接读取逐行文本;压缩默认格式必须经后端读取。
历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。恰好一个受支持的自身 descriptor 字段无效时,仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。
历史准备只读取并迁移所请求的会话。它保留已有父会话 catalog 事件,不收集子会话 descriptor,也不校验其他会话的头部或正文。子会话损坏、不支持的代次和子会话后继发布不会使父会话准备结果失效。[子代理服务](../../subagent/subagent/README.zh.md) 从头部发现缺失的子会话条目;打开选中的子会话时才独立迁移并报告其错误。当前读取在暴露事件前校验 catalog 字段、唯一性和交付归属。
历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。
历史版本标识和缓存复用不需要全库指纹或子会话正文扫描。列表仍枚举会话头部;定位尚未缓存的 id 时,定向代次查找可能枚举目录。只读访问不发布升级,因此冷进程和缓存淘汰后只重复所请求会话的解码。[诊断测量](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements) 保留此前主动扫描的基线。
-----
@@ -1,82 +0,0 @@
/** Collect historical discovery facts without recursively preparing related current generations. */
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import { SessionFormatError, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { parseGenerationLogFilename } from './format.ts'
import type { JsonlCompression } from './format.ts'
import { JsonlGenerationSourceChangedError, readDecodedJsonlSource } from './generation.ts'
import type { JsonlPhysicalIdentity } from './generation.ts'
/** Per-parent supplemental facts and the source checks required before serving or publishing them. */
interface PreparedCatalogFacts {
readonly facts: readonly SessionFormatJsonObject[]
/** @returns resolves while every decoded child still has the captured physical revision. */
validate(): Promise<void>
}
/**
* Collect each related child's own descriptor through existing historical codecs.
* @param parentId - parent whose incoming migration consumes these facts.
* @param sources - header-indexed direct children in the selected source corpus.
* @param compression - configured source encoding.
* @param signal - cancellation forwarded through each source read.
* @returns compact facts; complete child event arrays are released after extraction.
*/
export async function prepareCatalogFacts(
parentId: SessionId,
sources: readonly { readonly header: SessionHeader; readonly path: string }[],
compression: JsonlCompression,
signal: AbortSignal,
): Promise<PreparedCatalogFacts> {
const facts: SessionFormatJsonObject[] = []
const witnesses: { path: string; identity: JsonlPhysicalIdentity }[] = []
for (const source of sources) {
signal.throwIfAborted()
const version = parseGenerationLogFilename(basename(source.path), compression)
if (version === undefined) throw new SessionFormatUnsupportedMigrationError(`unrecognized historical child generation ${source.path}`)
let restored: Awaited<ReturnType<typeof readDecodedJsonlSource>>
try {
restored = await readDecodedJsonlSource(source.path, version, compression, {
createRestore: header => (version <= 3 ? historicalSessionFormatCatalog : sessionFormatCatalog).createRestore(header, {
recovery: 'recoverable', validation: 'current',
}),
}, signal)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedMigrationError) {
throw new SessionFormatUnsupportedError(
`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`,
{ kind: 'jsonl', path: source.path },
)
}
if (error instanceof SessionFormatError) {
throw new SessionPersistenceCorruptionError(`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, { cause: error })
}
throw error
}
const header = restored.artifact.header
if (header.id !== source.header.id || header.createdAt !== source.header.createdAt
|| header.parentSession !== parentId || header.origin !== 'subagent'
|| ['cwd', 'isSeeded', 'delegationDepth', 'agentPreset'].some(key => header[key] !== source.header[key as keyof SessionHeader])) {
throw new JsonlGenerationSourceChangedError(source.path)
}
facts.push({ ...historicalChildCatalogSource(restored.artifact), sourcePath: source.path })
witnesses.push({ path: source.path, identity: restored.identity })
}
return {
facts,
async validate() {
for (const witness of witnesses) {
const current = await stat(witness.path, { bigint: true })
if (current.dev !== witness.identity.dev || current.ino !== witness.identity.ino
|| current.size !== witness.identity.size || current.mtimeNs !== witness.identity.mtimeNs
|| current.ctimeNs !== witness.identity.ctimeNs) throw new JsonlGenerationSourceChangedError(witness.path)
}
},
}
}
@@ -28,7 +28,6 @@ import { constants, createZstdCompress } from 'node:zlib'
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import { SessionFormatError } from '@deepseek-ai/dsh-session-format'
import type {
SessionFormatArtifact,
SessionFormatJsonValue,
@@ -69,8 +68,6 @@ export interface JsonlGenerationFormatAdapter {
/** Inputs for preparing one historical generation and publishing its current successor later. */
export interface PrepareJsonlMigrationOptions {
/** Revalidate related source facts before preparation returns and immediately before publication. */
readonly validateRelatedSources?: () => Promise<void>
/** Immutable generation selected by the backend resolver. */
readonly sourcePath: string
/** Version selected from the source filename and independently checked against its header. */
@@ -903,7 +900,6 @@ async function publishPreparedMigration(
throw new Error('staged session generation changed during verification')
}
await internals.barrier('before-source-check', 1)
await options.validateRelatedSources?.()
const beforePublish = await internals.fs.stat(sourcePath)
if (identity(beforePublish) !== identity(sourceIdentity)) {
throw new JsonlGenerationSourceChangedError(sourcePath)
@@ -978,7 +974,6 @@ async function prepareMigration(
if (artifact.header.version !== format.currentVersion) {
throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
}
await options.validateRelatedSources?.()
const sourceIdentity = source.identity
let publication: Promise<JsonlPhysicalIdentity> | undefined
return {
@@ -1029,31 +1024,3 @@ export function createJsonlGenerationRuntime(
}
const defaultGenerationRuntime = createJsonlGenerationRuntime()
/**
* Read one stable source through the shared streaming parser without publishing a generation.
* @param path - selected source generation path.
* @param version - physical source version identified by its filename.
* @param compression - source encoding.
* @param format - codec/restore factory, independent of current-generation publication.
* @param signal - cancellation observed during source reads and decode yields.
* @returns decoded artifact and physical source identity for later revalidation.
* @throws SessionFormatError for physical decoding failures; storage, cancellation, and unsupported migration errors retain their category.
*/
export async function readDecodedJsonlSource(
path: string,
version: number,
compression: JsonlCompression,
format: Pick<JsonlGenerationFormatAdapter, 'createRestore'>,
signal?: AbortSignal,
): Promise<{ artifact: SessionFormatArtifact; identity: JsonlPhysicalIdentity }> {
const source = await readStableJsonlFile(path, signal)
let artifact: SessionFormatArtifact
try {
artifact = await decodeStreamingMigration(source.bytes, compression, version, format, undefined, signal)
} catch (error: unknown) {
if (signal?.aborted || error instanceof SessionFormatError) throw error
throw new SessionFormatError(String(error), { cause: error })
}
return { artifact, identity: source.identity }
}
@@ -9,7 +9,6 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import {
createSessionFormatCatalogWithChildren,
SessionFormatUnsupportedMigrationError,
sessionFormatCatalog,
} from '@deepseek-ai/dsh-session-format-catalog'
@@ -18,7 +17,7 @@ import { open, mkdir, readdir, realpath, link, rm, stat, truncate } from 'node:f
import { dirname, join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import { createHash, randomBytes } from 'node:crypto'
import { randomBytes } from 'node:crypto'
import {
SessionPersistence, SessionPersistenceRevision, SessionFormatUnsupportedError,
SessionPersistenceCorruptionError,
@@ -45,7 +44,6 @@ import {
} from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
import { verifyCurrentGenerationInWorker } from './migration-verifier.ts'
import { prepareCatalogFacts } from './catalog-migration.ts'
import {
JsonlGenerationSourceChangedError,
JsonlGenerationUnsupportedMigrationError,
@@ -124,7 +122,6 @@ interface CurrentStoredLog extends StoredLogBase {
/** A migrated historical generation retained until an explicit write open publishes it. */
interface PreparedStoredLog extends StoredLogBase {
readonly status: 'prepared'
readonly validateRelatedSources: () => Promise<void>
readonly publication: {
readonly source: ResolvedJsonlGeneration
readonly value: PreparedJsonlMigration
@@ -255,7 +252,7 @@ class JsonlSessionPersistence extends SessionPersistence {
private compression: JsonlCompression
private rootEncodingCheck: Promise<void> | undefined
private readonly tracker = new JsonlBackendTracker(this.name)
private readonly generationFormat: Omit<JsonlGenerationFormatAdapter, 'createRestore'>
private readonly generationFormat: JsonlGenerationFormatAdapter
/**
* Bounded LRU of parsed, validated stored logs keyed by session id and
* guarded by the stat-derived revision, so an immediate cold-read handoff
@@ -281,6 +278,9 @@ class JsonlSessionPersistence extends SessionPersistence {
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.generationFormat = {
currentVersion: sessionFormatCatalog.currentVersion,
createRestore: header => sessionFormatCatalog.createRestore(header, {
recovery: 'recoverable', validation: 'transformed',
}),
encodeHeader: (header, inheritedEventCount) =>
sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount),
encodeEvent: event => sessionFormatCatalog.encodeCurrentEvent(event),
@@ -348,13 +348,7 @@ class JsonlSessionPersistence extends SessionPersistence {
if (pending !== undefined) {
return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount }))
}
let stored: StoredLog
try {
stored = await this.requireStoredLog(id, options?.signal)
} catch (error: unknown) {
if (!(error instanceof JsonlGenerationSourceChangedError)) throw error
stored = await this.requireStoredLog(id, options?.signal)
}
const stored = await this.requireStoredLog(id, options?.signal)
let state: StorageHandleState
if (stored.status === 'prepared') {
state = {
@@ -454,9 +448,7 @@ class JsonlSessionPersistence extends SessionPersistence {
options?.signal?.throwIfAborted()
return {
header,
revision: selected.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`)
: fileRevision(identity),
revision: fileRevision(identity),
sizeBytes: Number(identity.size),
}
} catch (error: unknown) {
@@ -481,8 +473,6 @@ class JsonlSessionPersistence extends SessionPersistence {
// predate the scan), so create-to-list visibility never has a hole.
const pending = [...this.tracker.pendingEntries()]
const artifacts = await this.listArtifacts(signal)
const corpusRevision = artifacts.some(artifact => artifact.sourceVersion < SESSION_FORMAT_VERSION)
? await this.historicalCorpusRevision(signal) : undefined
for (const artifact of artifacts) {
signal?.throwIfAborted()
try {
@@ -491,9 +481,7 @@ class JsonlSessionPersistence extends SessionPersistence {
listed.add(artifact.header.id)
snapshots.push({
header: artifact.header,
revision: artifact.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`)
: fileRevision(identity),
revision: fileRevision(identity),
sizeBytes: Number(identity.size),
})
} catch (error: unknown) {
@@ -586,12 +574,6 @@ class JsonlSessionPersistence extends SessionPersistence {
signal.throwIfAborted()
const memoized = this.coldLogMemo.get(id)
if (memoized?.status === 'prepared' && memoized.revision === sourceRevision) {
try {
await memoized.validateRelatedSources()
} catch (error: unknown) {
this.coldLogMemo.delete(id)
throw this.generationFailure(id, selected, error)
}
this.coldLogMemo.delete(id)
this.coldLogMemo.set(id, memoized)
return memoized
@@ -627,33 +609,13 @@ class JsonlSessionPersistence extends SessionPersistence {
signal: AbortSignal,
): Promise<PreparedStoredLog> {
let prepared: Awaited<ReturnType<typeof prepareJsonlMigration>>
let validateRelatedSources: () => Promise<void>
try {
const children = async () => (await this.listArtifacts(signal, selected.sourcePath))
.filter(source => source.header.origin === 'subagent' && source.header.parentSession === id)
const sources = await children()
const related = await prepareCatalogFacts(id, sources, this.compression, signal)
const membership = sources.map(source => source.path).sort()
validateRelatedSources = async () => {
const current = (await children()).map(source => source.path).sort()
const before = new Set(membership)
const after = new Set(current)
const changed = current.find(path => !before.has(path)) ?? membership.find(path => !after.has(path))
if (changed !== undefined) throw new JsonlGenerationSourceChangedError(changed)
await related.validate()
}
prepared = await prepareJsonlMigration({
sourcePath: selected.sourcePath,
sourceVersion: selected.sourceVersion,
currentPath: selected.currentPath,
compression: this.compression,
format: {
...this.generationFormat,
createRestore: header => createSessionFormatCatalogWithChildren(related.facts).createRestore(header, {
recovery: 'recoverable', validation: 'transformed',
}),
},
validateRelatedSources,
format: this.generationFormat,
verifyCurrentFile: verifyCurrentGenerationInWorker,
validateHistoricalHeader: headerValue => this.validateSourceIdentity(
selected,
@@ -672,7 +634,6 @@ class JsonlSessionPersistence extends SessionPersistence {
validateStoredEvents(meta, events, { kind: 'jsonl', path: selected.sourcePath })
const stored: PreparedStoredLog = {
status: 'prepared',
validateRelatedSources,
meta,
...freezeStoredEvents(events),
tornTruncateTo: undefined,
@@ -1031,28 +992,8 @@ class JsonlSessionPersistence extends SessionPersistence {
return sources
}
/** Historical logical events depend on the corpus, including members with unreadable headers. */
private async historicalCorpusRevision(signal?: AbortSignal): Promise<string> {
const paths = (await this.listGenerations(signal)).map(source => source.sourcePath).sort()
const hash = createHash('sha256')
for (const path of paths) {
signal?.throwIfAborted()
let revision: string
try {
revision = fileRevision(await stat(path, { bigint: true }))
} catch (error: unknown) {
if (!isENOENT(error)) throw error
revision = 'missing'
}
hash.update(JSON.stringify([path, revision]))
}
signal?.throwIfAborted()
return hash.digest('hex')
}
private async listArtifacts(
signal?: AbortSignal,
migrationSource?: string,
): Promise<Array<{ header: SessionHeader; path: string; sourceVersion: number }>> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
@@ -1061,26 +1002,14 @@ class JsonlSessionPersistence extends SessionPersistence {
const ids = new Set<SessionId>()
for (const selected of await this.listGenerations(signal)) {
signal?.throwIfAborted()
if (migrationSource !== undefined && selected.sourceVersion > SESSION_FORMAT_VERSION) {
throw new SessionFormatUnsupportedError(
`catalog migration cannot inspect Session format v${selected.sourceVersion}; upgrade the harness (raw log: ${selected.sourcePath})`,
{ kind: 'jsonl', path: selected.sourcePath },
)
}
let header: SessionHeader | undefined
try {
header = await this.readGenerationHeader(selected, undefined, signal)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedError && migrationSource === undefined) continue
if (error instanceof SessionFormatUnsupportedError) continue
throw error
}
if (header === undefined) {
if (migrationSource !== undefined && selected.sourcePath !== migrationSource) {
throw new SessionFormatUnsupportedError(
`cannot establish complete catalog membership from an unreadable Session header (raw log: ${selected.sourcePath})`,
{ kind: 'jsonl', path: selected.sourcePath },
)
}
continue
}
if (ids.has(header.id)) {
@@ -1,5 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { afterEach, describe, expect, it } from 'vitest'
@@ -7,7 +7,6 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { generationLogPath } from '../src/format.ts'
import { prepareCatalogFacts } from '../src/catalog-migration.ts'
import { compressZstdFrame } from '../src/zstd.ts'
const roots: string[] = []
@@ -57,30 +56,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session')
})
it('does not publish incomplete membership after a child header becomes unreadable', async () => {
const f = await fixture()
const path = await f.write('child', [], true)
const broken = '{broken header\n'
await writeFile(path, compression === 'none' ? broken : await compressZstdFrame(broken))
await expect(f.read()).rejects.toThrow(path)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(path)
expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => {
const f = await fixture()
const revision = async () => (await f.ctx.sessionPersistence.stat(f.parent))!.revision
const first = await revision()
expect(await revision()).toBe(first)
await f.write('child', [f.descriptor], true)
const added = await revision()
expect(added).not.toBe(first)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(added)
await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: {} }], true)
expect(await revision()).not.toBe(added)
})
it('reports malformed native catalog data without migration terminology', async () => {
const f = await fixture()
await f.write(f.parent, [{ type: 'subagent/catalog', seq: 0, time: 1,
@@ -102,48 +77,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
})
it('invalidates historical revisions when selected children disappear or gain an opaque successor', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
await rm(path)
const removed = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
expect(removed).not.toBe(first)
await f.write('child', [], true, 5)
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).not.toBe(removed)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed)
})
it('reports a corrupt child body at the child location', async () => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 1,
data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } }
const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4)
await expect(f.read()).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
await expect(f.read()).rejects.toThrow(path)
})
it.each(['read', 'write'] as const)('locates physical child JSON corruption during %s access', async (access) => {
const f = await fixture()
const childPath = await f.write('child', [], true)
const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n'
// A sealed turn makes the malformed row corruption rather than a recoverable tail.
const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'completed' } } }) + '\n'
await writeFile(childPath, compression === 'none' ? first + body
: Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)]))
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
const error: unknown = await f.ctx.sessionPersistence.open(f.parent, access).catch((error: unknown) => error)
expect(error).toBeInstanceOf(SessionPersistenceCorruptionError)
expect((error as Error).message).toContain(childPath)
expect((error as Error).message).not.toContain(parentPath)
expect((error as Error).message).toContain('row 1 is not valid JSON')
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('preserves opaque inherited catalogs through read preparation and write publication', async () => {
const f = await fixture()
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
@@ -165,15 +98,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
expect(await readFile(parentPath)).toEqual(original)
})
it.skipIf(compression !== 'zstd')('locates invalid compressed child frames at the child path', async () => {
const f = await fixture()
const childPath = await f.write('child', [], true)
await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)]))
const error: unknown = await f.read().catch((error: unknown) => error)
expect(error).toBeInstanceOf(SessionPersistenceCorruptionError)
expect((error as Error).message).toContain(childPath)
})
it('keeps a complete parent catalog when a published child never writes a descriptor', async () => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 2,
@@ -188,18 +112,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
})
it.each([1, 2, 3])('backfills a missing catalog from descriptor v%i', async (version) => {
const f = await fixture()
const data = version === 1 ? { version, provider: 'spawn', label: 'old child' }
: { ...f.descriptor.data, version }
await f.write('child', [{ ...f.descriptor, data }], true)
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: {
childId: 'child', childCreatedAt: 2, mode: 'continuable', label: 'old child',
} }])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it.each(['missing', 'unknown', 'multiple'] as const)('publishes the parent without invented %s child discovery information', async (kind) => {
const f = await fixture()
const events = kind === 'missing' ? [] : kind === 'unknown'
@@ -219,37 +131,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
.toEqual(compression === 'none' ? ['session.v3.jsonl', 'session.v4.jsonl'] : ['session.v3.jsonl.zstd', 'session.v4.jsonl.zstd'])
})
it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => {
const f = await fixture()
await f.write('child', [f.descriptor], true)
if (prepared) expect(await f.read()).toHaveLength(1)
await f.write('child', [], true, 5)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
})
it('requires a readable corpus even when an unknown header cannot identify its parent', async () => {
const f = await fixture()
await f.write('future-root', [], false, 5)
expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id)).toEqual([f.parent])
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
})
it('preserves unsupported child decoding errors instead of reporting parent corruption', async () => {
const f = await fixture()
const path = await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true)
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(f.read()).rejects.toThrow(path)
})
it('locates incomplete historical descriptor fields at their child source', async () => {
const f = await fixture()
const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true)
await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(f.read()).rejects.toThrow(path)
})
it.each(['read', 'write'] as const)('rejects duplicate own catalogs through a current %s open', async (access) => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 2,
@@ -258,123 +139,73 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('duplicate catalog child')
})
it('recollects a changed child within the same read open', async () => {
const f = await fixture()
await f.write('child', [f.descriptor], true)
expect(await f.read()).toHaveLength(1)
await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: { text: 'appended' } }], true)
expect(await f.read()).toHaveLength(1)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('detects children added after preparation instead of publishing incomplete membership', async () => {
const f = await fixture()
expect(await f.read()).toEqual([])
await f.write('child', [f.descriptor], true)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(generationLogPath(f.root, undefined, SessionId('child'), 3, compression))
expect(await f.read()).toHaveLength(1)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('recollects newly available discovery fields before publishing a prepared parent', async () => {
it.each(['read', 'write'] as const)('isolates malformed child JSON until explicit %s access', async (access) => {
const f = await fixture()
const childPath = await f.write('child', [], true)
expect(await f.read()).toEqual([])
await f.write('child', [f.descriptor], true)
await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath)
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock'))
const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n'
const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'completed' } } }) + '\n'
await writeFile(childPath, compression === 'none' ? first + body
: Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)]))
const original = await readFile(childPath)
const parent = await f.ctx.sessionPersistence.open(f.parent, access)
try { expect((await parent.read()).events).toEqual([]) } finally { await parent.close() }
await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
expect(await readFile(childPath)).toEqual(original)
expect((await readdir(dirname(childPath))).filter(name => name !== 'session.lock'))
.toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', label: 'old child' } }])
expect(await f.read()).toEqual([])
})
it.each([true, false])('opens the parent independently of unsupported related=%s generations', async (related) => {
const f = await fixture()
await f.write('future', [], related, 5)
expect(await f.read()).toEqual([])
const parent = await f.ctx.sessionPersistence.open(f.parent, 'write')
await parent.close()
await expect(f.ctx.sessionPersistence.open(SessionId('future'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
})
it('keeps unreadable child headers outside parent migration', async () => {
const f = await fixture()
const path = await f.write('child', [], true)
await writeFile(path, compression === 'none' ? '{broken\n' : await compressZstdFrame('{broken\n'))
expect(await f.read()).toEqual([])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('reads current children without recursive migration and preserves current parent fast reads', async () => {
it('keeps revisions and prepared parent history independent of child membership and contents', async () => {
const f = await fixture()
await f.write('child', [f.descriptor], true, 4)
expect(await f.read()).toHaveLength(1)
await f.write(f.parent, [], false, 4)
await f.write('child', [], true, 4)
const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
expect(await f.read()).toEqual([])
})
it('refuses publication when a related child disappears', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const header = { ...f.header, id: SessionId('child'), origin: 'subagent', parentSession: f.parent, createdAt: 2, delegationDepth: 1 } as SessionHeader
const prepare = () => prepareCatalogFacts(f.parent, [{ path, header }], compression, new AbortController().signal)
const prepared = await prepare()
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
await f.write('child', [{ ...f.descriptor, data: { ...f.descriptor.data, label: 'changed' } }], true)
expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(first)
await rm(path)
await expect(prepared.validate()).rejects.toMatchObject({ code: 'ENOENT' })
await expect(prepare()).rejects.toMatchObject({ code: 'ENOENT' })
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() }
})
it('rejects changed header identities and invalid source selection', async () => {
it.each([0, 1, 2, 3])('migrates a V%i child only on its own write open and preserves predecessors', async (version) => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const header = { ...f.header, id: SessionId('wrong-child'), origin: 'subagent', parentSession: f.parent } as SessionHeader
const signal = new AbortController().signal
await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, signal)).rejects.toThrow('changed')
await expect(prepareCatalogFacts(f.parent, [{ path: path + '.foreign', header }], compression, signal)).rejects.toThrow('unrecognized historical child generation')
const controller = new AbortController()
controller.abort(new Error('cancelled child collection'))
await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, controller.signal)).rejects.toThrow('cancelled child collection')
})
it.each([0, 1, 2])('composes a V%i parent and child through all preceding edges', async (version) => {
const f = await fixture()
const initial = generationLogPath(f.root, undefined, f.parent, 3, compression)
await rm(initial)
await rm(generationLogPath(f.root, undefined, f.parent, 3, compression))
const parentPath = await f.write(f.parent, [], false, version)
const childPath = await f.write('child', [f.descriptor], true, version)
const before = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', seq: 0, data: { childId: 'child', mode: 'continuable', label: 'old child' } }])
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toEqual([])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(before)
expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')).toHaveLength(2)
})
it('lists historical headers, prepares read-only membership, and publishes an unchanged-prefix successor', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-catalog-migration-'))
roots.push(root)
const parent = SessionId('parent')
const child = SessionId('child')
const header = { type: 'session', version: 3, id: parent, createdAt: 1, isSeeded: false, delegationDepth: 0 }
const childHeader = { ...header, id: child, createdAt: 2, origin: 'subagent', parentSession: parent, delegationDepth: 1 }
const descriptor = { type: 'subagent/descriptor', seq: 0, time: 2, data: { version: 3, mode: 'one-shot', provider: 'spawn', label: 'old child' } }
const sourcePaths: string[] = []
for (const [meta, events] of [[header, []], [childHeader, [descriptor]]] as const) {
const path = generationLogPath(root, undefined, meta.id, 3, compression)
sourcePaths.push(path)
await mkdir(dirname(path), { recursive: true })
const first = Buffer.from(JSON.stringify(meta) + '\n')
const body = Buffer.from(events.map(event => JSON.stringify(event) + '\n').join(''))
await writeFile(path, compression === 'none' ? Buffer.concat([first, body])
: Buffer.concat([await compressZstdFrame(first), ...(body.length === 0 ? [] : [await compressZstdFrame(body)])]))
}
const original = await Promise.all(sourcePaths.map(path => readFile(path)))
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression })
expect((await ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual([child, parent])
const reader = await ctx.sessionPersistence.open(parent, 'read')
let expected
try {
expect(reader.header.version).toBe(4)
expected = (await reader.read()).events
expect(expected).toEqual([{ type: 'subagent/catalog', seq: 0, time: 1, data: {
version: 0, childId: child, childCreatedAt: 2, mode: 'one-shot', label: 'old child',
} }])
expect(await readdir(dirname(sourcePaths[0]!))).toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd'])
} finally { await reader.close() }
const writer = await ctx.sessionPersistence.open(parent, 'write')
try { expect((await writer.read()).events).toEqual(expected) } finally { await writer.close() }
expect(await Promise.all(sourcePaths.map(path => readFile(path)))).toEqual(original)
const reopened = await ctx.sessionPersistence.open(parent, 'read')
try { expect((await reopened.read()).events).toEqual(expected) } finally { await reopened.close() }
const childSuccessor = generationLogPath(f.root, undefined, SessionId('child'), 4, compression)
await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
const childReader = await f.ctx.sessionPersistence.open(SessionId('child'), 'read')
try { expect((await childReader.read()).events).toEqual([f.descriptor]) } finally { await childReader.close() }
await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
const childWriter = await f.ctx.sessionPersistence.open(SessionId('child'), 'write')
await childWriter.close()
expect((await readFile(childSuccessor)).length).toBeGreaterThan(0)
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
})
})
@@ -23,7 +23,6 @@ import {
JsonlGenerationTargetConflictError,
JsonlGenerationUnsupportedMigrationError,
prepareJsonlMigration,
readDecodedJsonlSource,
verifyJsonlCurrentGeneration,
type JsonlGenerationFormatAdapter,
type PrepareJsonlMigrationOptions,
@@ -87,19 +86,6 @@ function header(version: number, id = 'generation-test'): Record<string, unknown
const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
it('preserves cancellation raised while creating a child source decoder', async () => {
const path = join(await tempRoot(), 'session.v3.jsonl')
await writeFile(path, line(header(3)))
const controller = new AbortController()
const reason = new Error('child decoding cancelled')
await expect(readDecodedJsonlSource(path, 3, 'none', {
createRestore() {
controller.abort(reason)
throw reason
},
}, controller.signal)).rejects.toBe(reason)
})
const assistantUsage = { inputTokens: 3, outputTokens: 2 }
const assistantReplayState = { response: { id: 'response' } }
@@ -1211,7 +1211,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
.rejects.toThrow(/released v0 physical header lacks required member "type"/)
})
it('tracks a disappearing corpus member and propagates its storage faults in historical revisions', async () => {
it('keeps historical revisions independent of another Session storage faults', async () => {
const parent = meta('corpus-revision-parent', '/work')
const child = meta('corpus-revision-child', '/work')
for (const header of [parent, child]) {
@@ -1224,10 +1224,10 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
statFailure.error = Object.assign(new Error('member disappeared'), { code: 'ENOENT' })
const missing = await ctx.sessionPersistence.stat(parent.id)
expect(missing).toBeDefined()
expect(missing?.revision).not.toBe(present?.revision)
expect(missing?.revision).toBe(present?.revision)
expect((await ctx.sessionPersistence.stat(parent.id))?.revision).toBe(missing?.revision)
statFailure.error = Object.assign(new Error('member denied'), { code: 'EACCES' })
await expect(ctx.sessionPersistence.stat(parent.id)).rejects.toBe(statFailure.error)
expect((await ctx.sessionPersistence.stat(parent.id))?.revision).toBe(present?.revision)
})
it('surfaces source-read storage faults and aborts unwrapped during migration', async () => {
+2 -2
View File
@@ -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 packages/subagent/subagent/README.md
README.md: 7d62e423189906c49d9479bd7a6dbd14b7303afd
README.zh.md: af62ad97bc4821222d46ed73509c823851953e1d
README.md: 8351ff4aac8c3585ab0230cf5dbd1c2a4613adc7
README.zh.md: 90806e0728acf61178e3d9914ec4c7bf0fa1a83c
+1 -1
View File
@@ -60,7 +60,7 @@ One-shot children run once and settle with a single result, plus an optional str
### Messaging, interrupting, and discovering
Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection. `listChildren(parentSessionId, signal?)` owns a live-preferred Session observation and returns the catalog asynchronously without reading child logs. It forwards cancellation and releases the observation after materialization. Materialization preserves parent event order in O(D) time for D facts. Complete descendant discovery retains the Session corpus and child identity projection; neither path loads or resumes a child Agent.
Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection and Session headers. `listChildren(parentSessionId, signal?)` returns catalog entries in parent event order, then header-discovered children with `mode: 'unresolved'`; it never opens child bodies. It forwards cancellation and releases the parent observation after materialization. Header enumeration costs O(N) for N Sessions, while catalog materialization costs O(D) for D facts. Complete descendant discovery explicitly reads child identity projections; neither path loads or resumes a child Agent.
### Failure and recovery
+1 -1
View File
@@ -60,7 +60,7 @@ kind: "package-reference"
### 消息、中断与发现
每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。 直接子级发现读取 parent 自有的 `subagentCatalog` projection。`listChildren(parentSessionId, signal?)` 持有一次优先实时来源的 Session 观察,异步返回目录,不读取子级日志。它转发取消信号,并在物化后释放观察。物化以 O(D) 时间保留 D 条事实的父日志事件顺序。完整后代发现保留 Session 语料库与子级身份 projection;两条路径都不加载或恢复子级 Agent。
每个存活 Agent 都可以通过 `sendMessage()` 向直接可继续子代理发送消息;驻留的可继续子代理也可以向直接父代理发送。工作中的目标在最近步骤通过 Steer 接收消息,空闲目标启动新轮次,只有直接子代理支持冷恢复。父代理可以随时中断运行中的后代或列出子代理。浏览器续接独立选择 Queue 或 Steer,支持图片:Host 在子代理收件箱接收消息前准入并持久化图片,模型不支持图片输入时拒绝交付。直接子代理发现读取父会话的 `subagentCatalog` 投影和会话头部。`listChildren(parentSessionId, signal?)` 按父事件顺序返回 catalog 条目,再返回头部发现的 `mode: 'unresolved'` 子会话,不打开子会话正文。它传递取消信号,并在物化后释放父会话观察。N 个会话的头部枚举为 O(N),D 条事实的 catalog 物化为 O(D)。完整后代发现显式读取子会话身份投影;两条路径均不加载或恢复子 Agent。
### 失败与恢复
@@ -9,6 +9,7 @@ import type { PromptContentPart } from '@deepseek-ai/dsh-attachment/types'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SubagentCatalogEntry } from './projection-types.ts'
/**
* Client-minted identity of one browser prompt, persisted on the exact accepted
@@ -71,6 +72,13 @@ export type SubagentListEntry =
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/** A direct child whose identity is known from the catalog or awaits an explicit child read. */
export type SubagentDiscoveryEntry = SubagentCatalogEntry | {
readonly id: SessionId
readonly createdAt: number
readonly mode: 'unresolved'
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -80,6 +88,8 @@ export type SubagentAddress =
& (
| { readonly mode: 'one-shot' }
| { readonly mode: 'continuable' }
/** Header-discovered child; opening it resolves its descriptor without granting continuation. */
| { readonly mode: 'unresolved' }
)
/** One human message addressed to a continuable direct child. */
+4 -4
View File
@@ -76,7 +76,7 @@ import type { SubagentDescendantListEntry } from './list-children.ts'
import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
import { establishCatalogChild, subagentCatalogProjectionDefinition } from './catalog.ts'
import type { SubagentCatalogEntry } from './projection-types.ts'
import type { SubagentDiscoveryEntry } from './control-types.ts'
import { deliverSubagentPrompt } from './internal.ts'
export type {} from './catalog.ts'
@@ -366,15 +366,15 @@ export class SubagentRuntime extends TypertRemoteService {
}
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* Read the parent's catalog and discover missing direct children from Session headers.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @returns catalog children in event order, then unresolved children whose bodies remain unread.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]> {
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDiscoveryEntry[]> {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
@@ -1,7 +1,8 @@
/**
* Direct-child discovery from a parent-owned `subagent/catalog` projection,
* plus complete descendant-tree enumeration from the Session corpus. A direct
* listing owns one parent observation and reads no child log. Descendant
* with header-only discovery of missing entries, plus complete descendant-tree
* enumeration from the Session corpus. A direct listing observes one parent
* and reads no child log. Descendant
* enumeration retains the complete corpus and the child identity projection
* because ordinary Sessions and one-shot children remain traversal nodes.
*
@@ -17,7 +18,7 @@ import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-se
import type { SubagentListEntry } from './control-types.ts'
import { SubagentError } from './error.ts'
import type { SubagentIdentityProjection } from './projection-types.ts'
import type { SubagentCatalogEntry } from './projection-types.ts'
import type { SubagentDiscoveryEntry } from './control-types.ts'
export type { SubagentListEntry } from './control-types.ts'
@@ -61,14 +62,14 @@ interface PositionedCandidate {
* @param ctx - context carrying the Session query service.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session observation.
* @returns direct-child rows in parent catalog event order.
* @returns catalog rows in event order, followed by header-discovered children without reading their bodies.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
*/
export async function listChildren(
ctx: Context,
parentSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentCatalogEntry[]> {
): Promise<SubagentDiscoveryEntry[]> {
const query = ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
@@ -86,7 +87,15 @@ export async function listChildren(
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
)
}
return entries
const discovered: SubagentDiscoveryEntry[] = [...entries]
const known = new Set(entries.map(entry => entry.id))
const records = await query.listSessions(signal)
for (const { header } of records) {
if (header.origin === 'subagent' && header.parentSession === parentSessionId && !known.has(header.id)) {
discovered.push({ id: header.id, createdAt: header.createdAt, mode: 'unresolved' })
}
}
return discovered
}
/**
@@ -184,7 +184,7 @@ function mutateStoredHeader(
}
describe('SubagentRuntime.listChildren', () => {
it('lists historical children through a migrated parent while preserving V3 files', async () => {
it('discovers historical children from headers while leaving their bodies unread', async () => {
const { ctx, root } = await setup([], { compression: 'none' })
const parent = SessionId('historical-parent')
const child = SessionId('historical-child')
@@ -204,16 +204,18 @@ describe('SubagentRuntime.listChildren', () => {
return { path, content }
})
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
expect(await ctx.subagents.listChildren(parent)).toEqual([{
id: child, createdAt: 2, mode: 'continuable', label: 'historical child',
id: child, createdAt: 2, mode: 'unresolved',
}])
expect(observe.mock.calls.map(([id]) => id)).toEqual([parent])
for (const { path, content } of originals) {
expect(readFileSync(path, 'utf8')).toBe(content)
expect(readdirSync(dirname(path))).toEqual(['session.v3.jsonl'])
}
})
it('lists provider-established children from the parent catalog without a corpus or child read', async () => {
it('lists provider-established children from the parent catalog without a child body read', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
const oneShot = await ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'finish once' }],
@@ -234,7 +236,7 @@ describe('SubagentRuntime.listChildren', () => {
{ id: continuableId, label: 'continuable child', mode: 'continuable' },
])
expect(children.every(child => Number.isFinite(child.createdAt))).toBe(true)
expect(listSessions).not.toHaveBeenCalled()
expect(listSessions).toHaveBeenCalledOnce()
expect(observeSession).toHaveBeenCalledOnce()
})
@@ -338,7 +340,7 @@ describe('SubagentRuntime.listChildren', () => {
}
expect(chunkLengths).toEqual([1, ...Array.from({ length: 16 }, () => 64)])
const entries = await ctx.subagents.listChildren(parent.id)
expect(listSessions).not.toHaveBeenCalled()
expect(listSessions).toHaveBeenCalledOnce()
expect(entries).toHaveLength(1_025)
expect(entries.find(entry => entry.id === 'child-064')).toEqual({
id: SessionId('child-064'), createdAt: 64, mode: 'one-shot',
@@ -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 packages/subagent/tool-subagent-control/README.md
README.md: 2cfa000f1ba9f7950b28369a9b7bd3523610b579
README.zh.md: f2154fde965899e7b4f30271846bce52414b010d
README.md: 1be0e2b15f6ff36235275f659770c0c92557006e
README.zh.md: 3ee5e80aabd97df50feee46ea9183dee2916faf2
@@ -54,7 +54,7 @@ Stops only the target's current turn: queued messages stay parked until a later
### list_agents
Lists the continuable children below the calling agent: `children` (default) reads direct children from the parent catalog without opening child logs; `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running` or `inactive`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics only in `descendants` scope.
Lists the continuable children below the calling agent: `children` (default) combines parent catalog facts with header discovery without opening child logs; unresolved identities appear as `unavailable` diagnostics. `descendants` explicitly reads identities throughout the tree in stable pre-order and includes durable direct-parent ids and depths. Status comes from the live Agent registry: `running` or `inactive`. Known one-shot children are omitted because they cannot accept `send_message`.
-----
@@ -150,7 +150,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = executing a turn; `inactive` = no turn executing, whether loaded or stored; neither status describes task completion or outcome). Only `descendants` scope adds `<id> [diagnostic: <reason>]` for a candidate that could not be read. The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection.
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = executing a turn; `inactive` = no turn executing, whether loaded or stored; neither status describes task completion or outcome). Either scope adds `<id> [diagnostic: <reason>]` for a candidate that could not be read. The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection.
#### Token effect
@@ -54,7 +54,7 @@ kind: "package-reference"
### list_agents
列出调用方 agent 下方的可继续子级:`children`(默认)从父目录读取直接子级,不打开子日志;`descendants` 按稳定前序遍历整棵树,并为每个条目标注其持久化直接父级会话 ID 与深度。状态来自在线 Agent 注册表——`running` 或 `inactive`。一次性子级因无法接受 `send_message` 而被有意排除,无法读取的候选项仅在 `descendants` 作用域中以诊断信息呈现。
列出调用代理下的可继续子代理:`children`(默认)合并父 catalog 事实与头部发现,不打开子日志;未解析身份显示为 `unavailable` 诊断。`descendants` 按稳定前序显式读取整棵树的身份,并包含持久化直接父 id 和深度。状态取自存活 Agent 注册表:`running` 或 `inactive`。已知一次性子代理不支持 `send_message`,因此省略。
-----
@@ -150,7 +150,7 @@ kind: "package-reference"
#### 模型看到什么
按稳定目录顺序,每个可继续子级占一行:`<id> [<status>] — <label>`(`running` 表示正在执行轮次;`inactive` 表示没有轮次在执行,包括已加载和仅存于存储的情况;两种状态均不表示任务完成或结果)。仅 `descendants` 作用域会为无法读取的候选项添加 `<id> [diagnostic: <reason>]`。`descendants` 作用域会在每行标签的破折号之前按前序插入 ` parent=<id> depth=<n>`。一次性子级会被有意排除;`(no subagents)` 表示投影后没有留下可继续子级或诊断信息。
按稳定目录顺序,每个可继续子级占一行:`<id> [<status>] — <label>`(`running` 表示正在执行轮次;`inactive` 表示没有轮次在执行,包括已加载和仅存于存储的情况;两种状态均不表示任务完成或结果)。两个 scope 都会为身份不可用的候选项添加 `<id> [diagnostic: <reason>]`。`descendants` 作用域会在每行标签的破折号之前按前序插入 ` parent=<id> depth=<n>`。一次性子级会被有意排除;`(no subagents)` 表示投影后没有留下可继续子级或诊断信息。
#### Token 影响
@@ -12,7 +12,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
SubagentCatalogEntry, SubagentDescendantListEntry, SubagentListEntry,
SubagentDiscoveryEntry, SubagentDescendantListEntry, SubagentListEntry,
} from '@deepseek-ai/dsh-subagent'
import { assertNever } from '@deepseek-ai/dsh-util-values'
@@ -59,13 +59,16 @@ function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: Session
/** Project one service row into the model-facing entry, or omit a one-shot child. */
function project(
agents: { get(id: SessionId): Agent | undefined },
entry: SubagentCatalogEntry | SubagentListEntry,
entry: SubagentDiscoveryEntry | SubagentListEntry,
position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
): ListAgentsEntry | undefined {
const at = position === undefined ? {} : { parent: position.parentId, depth: position.depth }
if ('kind' in entry && entry.kind === 'diagnostic') {
return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
}
if (entry.mode === 'unresolved') {
return { kind: 'diagnostic', id: entry.id, reason: 'unavailable', ...at }
}
// One-shot children cannot be continued by send_message, so the model
// never selects them; discovery still traversed them for descendants.
if (entry.mode !== 'continuable') return undefined
@@ -94,7 +97,7 @@ export function apply(ctx: Context): void {
+ 'or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` '
+ 'candidate in every status. The snapshot is not a delivery '
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
+ 'not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` '
+ 'not be identified without opening them are reported as diagnostics. Scope `descendants` '
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
+ 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
+ 'candidates for `interrupt_agent` only.',
@@ -131,6 +131,17 @@ describe('dsh-tool-subagent-control/list-agents', () => {
expect(text(result)).toBe('(no subagents)')
})
it('reports header-discovered children without reading their unresolved identities', async () => {
const { ctx, parent } = await setup([])
vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([
{ id: SessionId('unopened'), createdAt: 1, mode: 'unresolved' },
])
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toContain('unopened')
expect(text(result)).toContain('unavailable')
})
it('renders direct children in array order with registry statuses', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
+1
View File
@@ -561,6 +561,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ContinuableStartSpec: 'subagent.md',
AgentMessageSource: 'subagent.md',
SubagentCatalogEntry: 'subagent.md',
SubagentDiscoveryEntry: 'subagent.md',
SubagentCatalogState: 'subagent.md',
SessionProjectionsRequest: 'session.md',
SessionProjectionsValue: 'session.md',
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -857,7 +857,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -133,7 +133,7 @@ interface ToolArgsMap {
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
timeout_ms?: number;
} & Record<string, JsonValue>;
/** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */
/** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */
list_agents: {
/** children (default) lists direct children only; descendants walks the complete tree below you. */
scope?: "children" | "descendants";
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
@@ -245,7 +245,7 @@
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more