fix(subagent): retain unreadable children in migrated catalogs

This commit is contained in:
Dudu-0223 committed 2026-09-20 14:30:02 +08:00
1 parent f44b78a622
commit 17a52a8fe3
73 files changed
+908 -120

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: 21c95503ad05abf54b00c9cb20bec563cbd885ae
2026-08-31-released-session-format-migrations.zh.md: 46e26b14d10adcc66cf4f0c0aea7573bae3f235a
2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1
2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720
@@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
### Parent catalog prerequisites
Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. 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).
Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. 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; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, 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. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. 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 collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. 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.
@@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
### 父目录前置事实
本段的运行时子会话错误处理部分由[逐会话目录准备](../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)。
本段的运行时子会话错误处理部分由[逐会话目录准备](../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 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
@@ -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-session-local-subagent-migration.md
2026-09-19-session-local-subagent-migration.md: ee06345246f69b7a176170105d77e3e9ecca8ac8
2026-09-19-session-local-subagent-migration.zh.md: 050f64877860dd8f63244a34a8ba9d8d3945189c
2026-09-19-session-local-subagent-migration.md: fcdbb7830a12bca645b2a20c68178fff97d74626
2026-09-19-session-local-subagent-migration.zh.md: bd6aaf5c434ec109cae2d3c68ddd29249debabf2
@@ -10,9 +10,9 @@ Opening a historical parent requires discovery facts from its direct children. A
## Decision
Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery and `list_agents` retain their parent-catalog behavior and existing result types.
Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children.
JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and omits that child's supplemental fact, while preserving healthy siblings and existing parent entries. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical.
JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog-unknown`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical.
This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active.
@@ -20,12 +20,14 @@ This partially supersedes child-failure propagation in [released migrations](../
**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers.
**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label.
**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies.
**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields.
## Consequences
Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. A failed child's missing catalog entry is not automatically repaired after parent publication; the child remains addressable by id, as with other incomplete historical descriptor evidence.
Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. A separate required-on-read event preserves the finalized V4 catalog payload while making unknown membership durable; older readers that lack this event refuse the log.
Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role.
@@ -10,9 +10,9 @@ Status: implemented
## 决策
打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现和 `list_agents` 保留读取父 catalog 的行为及现有结果类型。
打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。
JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并跳过该子的补充事实,同时保留健康兄弟项和已有父目录项。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。
JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog-unknown`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。
本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。
@@ -20,12 +20,14 @@ JSONL 在发现时跳过不可读或不支持的 header。子正文或 descripto
**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。
**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。
**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。
**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。
## 后果
首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。父发布后不会自动修复失败子项缺失的目录记录;该子会话仍可按 id 寻址,与其他不完整历史 descriptor 证据一致。
首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。独立的必读事件保留已定稿 V4 目录载荷,同时持久记录未知成员;不认识该事件的旧读取器拒绝日志。
原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。
@@ -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: 30aac77c231b34f04daaa8095f19fbba17c02ed8
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: f4f8ee9870e697f675c838284401f5dd943a389e
2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739
@@ -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).
The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). 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).
The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. 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.
The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication.
## Alternatives considered
@@ -24,6 +24,6 @@ The JSONL failure-propagation rules below are partially superseded by [session-l
## 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 with a readable header but no usable descriptor retains unknown-mode membership in 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 mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。
下述 JSONL 错误传播规则部分由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)取代;转换器仍校验提供的事实及冲突。已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。
## Alternatives considered
@@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字
## Consequences
没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。
单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
+10 -6
View File
@@ -71,6 +71,7 @@ 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'] : [])
expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([
{ id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' },
{ id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' },
])
@@ -80,7 +81,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } 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))
await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, 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 })
@@ -107,14 +108,17 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
&& (row['data'] as { agentPreset: string }).agentPreset === 'code'
? { ...row, data: { agentPreset: 'ptc' } }
: row),
{ type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: {
{ type: 'subagent/catalog-unknown', seq: rows.length, time: rows.at(-1)?.['time'], data: {
version: 0, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown',
} },
{ type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: {
version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child',
} },
// Agent activation closes its restored prefix with a fresh seed marker.
{ type: 'session/end-seed', seq: rows.length + 1, time: 0, data: {} },
{ type: 'permission/preset', seq: rows.length + 2, time: 0, data: { preset: 'workspace-write' } },
{ type: 'sandbox/mode', seq: rows.length + 3, time: 0, data: { mode: 'workspace-write' } },
{ type: 'approval/policy', seq: rows.length + 4, time: 0, data: { policy: 'ask' } },
{ type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} },
{ type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } },
{ type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } },
{ type: 'approval/policy', seq: rows.length + 5, 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/persistence-catalog.md
persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1
persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b
persistence-catalog.md: 2f483ff9dfa831765a25f1ddd20399e47f150b8c
persistence-catalog.zh.md: 782c30226d9df359b46f3aca02451478307d067e
+68 -1
View File
@@ -58,6 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) |
| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) |
| `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) |
| `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) |
| `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) |
@@ -900,7 +901,21 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/
'subagent/catalog': SubagentCatalogEvent
```
Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
Source: [`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts)
<a id="subagentcatalog-unknown--log-only"></a>
#### `subagent/catalog-unknown` — log-only
```ts persistence-catalog
/**
* A historical child's header identity when its descriptor cannot establish a mode.
* @param data - child identity retained for discovery and later history reads.
*/
'subagent/catalog-unknown': UnknownCatalogEvent
```
Source: [`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts)
<a id="subagentdescriptor--log-only"></a>
@@ -2475,6 +2490,14 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2`
`"subagent/catalog"`
<a id="persistence-type-sha256-d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89"></a>
### `"subagent/catalog-unknown"`
SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89`
`"subagent/catalog-unknown"`
<a id="persistence-type-sha256-5ebce1317eacd2c148456627a09668e927ee15c204f0643f5ea76b46b54a2647"></a>
### `"subagent/descriptor"`
@@ -2787,6 +2810,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c`
`"unavailable"`
<a id="persistence-type-sha256-05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04"></a>
### `"unknown"`
SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04`
`"unknown"`
<a id="persistence-type-sha256-0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9"></a>
### `"usage"`
@@ -5087,6 +5118,26 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src
| `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
| `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
<a id="persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca"></a>
<a id="persistence-type-packagessubagentsubagentsrccatalogtsunknowncatalogevent"></a>
<a id="persistence-type-unknowncatalogevent"></a>
### `UnknownCatalogEvent`
SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca`
Sources: [`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts)
| Property | Presence | Type |
|---|---|---|
| `childCreatedAt` | required | `number` |
| `childId` | required | `string` |
| `label` | optional | `string` |
| `mode` | required | `"unknown"` |
| `version` | required | `0` |
<a id="persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586"></a>
<a id="persistence-type-packagesllmllmsrcmessagetsusermessage"></a>
@@ -8237,6 +8288,22 @@ SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871`
| `time` | required | `number` |
| `type` | required | `"subagent/catalog"` |
<a id="persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21"></a>
<a id="persistence-type-eventsubagentcatalog-unknown"></a>
### `{ type: "subagent/catalog-unknown" }`
SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21`
| Property | Presence | Type |
|---|---|---|
| `data` | required | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `time` | required | `number` |
| `type` | required | `"subagent/catalog-unknown"` |
<a id="persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5"></a>
<a id="persistence-type-eventsubagentdescriptor"></a>
+68 -1
View File
@@ -60,6 +60,7 @@
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) |
| `event:subagent/catalog-unknown` | event | `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21` | [`{ type: "subagent/catalog-unknown" }`](#persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21) |
| `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) |
| `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) |
| `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) |
@@ -902,7 +903,21 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'subagent/catalog': SubagentCatalogEvent
```
来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
来源:[`packages/subagent/subagent/src/catalog.ts:48`](../packages/subagent/subagent/src/catalog.ts)
<a id="subagentcatalog-unknown--log-only"></a>
#### `subagent/catalog-unknown` — log-only
```ts persistence-catalog
/**
* A historical child's header identity when its descriptor cannot establish a mode.
* @param data - child identity retained for discovery and later history reads.
*/
'subagent/catalog-unknown': UnknownCatalogEvent
```
来源:[`packages/subagent/subagent/src/catalog.ts:53`](../packages/subagent/subagent/src/catalog.ts)
<a id="subagentdescriptor--log-only"></a>
@@ -2477,6 +2492,14 @@ SHA-256: `44c8c77aa9aa100b0d74d2deee85474b29faa6eee0ac0f662f7abe9931ddd7f2`
`"subagent/catalog"`
<a id="persistence-type-sha256-d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89"></a>
### `"subagent/catalog-unknown"`
SHA-256: `d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89`
`"subagent/catalog-unknown"`
<a id="persistence-type-sha256-5ebce1317eacd2c148456627a09668e927ee15c204f0643f5ea76b46b54a2647"></a>
### `"subagent/descriptor"`
@@ -2789,6 +2812,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c`
`"unavailable"`
<a id="persistence-type-sha256-05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04"></a>
### `"unknown"`
SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04`
`"unknown"`
<a id="persistence-type-sha256-0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9"></a>
### `"usage"`
@@ -5089,6 +5120,26 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
| `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
| `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
<a id="persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca"></a>
<a id="persistence-type-packagessubagentsubagentsrccatalogtsunknowncatalogevent"></a>
<a id="persistence-type-unknowncatalogevent"></a>
### `UnknownCatalogEvent`
SHA-256: `c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca`
来源:[`packages/subagent/subagent/src/catalog.ts:34`](../packages/subagent/subagent/src/catalog.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `childCreatedAt` | 必需 | `number` |
| `childId` | 必需 | `string` |
| `label` | 可选 | `string` |
| `mode` | 必需 | `"unknown"` |
| `version` | 必需 | `0` |
<a id="persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586"></a>
<a id="persistence-type-packagesllmllmsrcmessagetsusermessage"></a>
@@ -8239,6 +8290,22 @@ SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871`
| `time` | 必需 | `number` |
| `type` | 必需 | `"subagent/catalog"` |
<a id="persistence-type-sha256-d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21"></a>
<a id="persistence-type-eventsubagentcatalog-unknown"></a>
### `{ type: "subagent/catalog-unknown" }`
SHA-256: `d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21`
| 属性 | 存在性 | 类型 |
|---|---|---|
| `data` | 必需 | [`UnknownCatalogEvent`](#persistence-type-sha256-c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `time` | 必需 | `number` |
| `type` | 必需 | `"subagent/catalog-unknown"` |
<a id="persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5"></a>
<a id="persistence-type-eventsubagentdescriptor"></a>
@@ -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 docs/persistence-changes/2026-09-20-unknown-child-catalog.md
2026-09-20-unknown-child-catalog.md: 80de55e616d63730bc1d0bbc0ffa43923f833923
2026-09-20-unknown-child-catalog.zh.md: 1ee8a04e7318013ec27a7c736fadd540a96cfc0d
@@ -0,0 +1,48 @@
---
description: "Records a persistence type transition and its compatibility acknowledgement."
kind: persistence-change
---
# 2026-09-20-unknown-child-catalog
English | [中文](2026-09-20-unknown-child-catalog.zh.md)
## Summary
Add subagent/catalog-unknown to retain historical direct-child header identity when a descriptor cannot establish the mode.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: 2026-09-20-unknown-child-catalog
baseline: false
changes:
- root: "event:subagent/catalog-unknown"
previous: null
after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21"
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
This adds an ordinary required-on-read event without changing the finalized subagent/catalog payload, Session header, or envelope. Existing logs remain valid. Older readers that do not know the new event refuse logs containing it; current readers project it into the existing catalog with unknown mode and keep continuation requests restricted to supported child descriptors.
<a id="verification"></a>
## Verification
pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts: 16 tests passed, covering unknown identity restoration, malformed payload rejection, duplicate child rejection, and shared catalog projection.
<a id="dev-note"></a>
## Dev Note
None.
@@ -0,0 +1,104 @@
{
"formatVersion": 2,
"roots": [
{
"key": "event:subagent/catalog-unknown",
"kind": "event",
"event": "subagent/catalog-unknown",
"surface": false,
"digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 6,
"optional": true
},
{
"name": "seq",
"type": 2,
"optional": false
},
{
"name": "time",
"type": 2,
"optional": false
},
{
"name": "type",
"type": 7,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "childCreatedAt",
"type": 2,
"optional": false
},
{
"name": "childId",
"type": 3,
"optional": false
},
{
"name": "label",
"type": 3,
"optional": true
},
{
"name": "mode",
"type": 4,
"optional": false
},
{
"name": "version",
"type": 5,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": "unknown"
},
{
"kind": "literal",
"value": 0
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "subagent/catalog-unknown"
}
]
}
}
],
"types": []
}
@@ -0,0 +1,48 @@
---
description: "记录持久化类型更改及其兼容性确认。"
kind: persistence-change
---
# 2026-09-20-unknown-child-catalog
[English](2026-09-20-unknown-child-catalog.md) | 中文
## 概述
新增 subagent/catalog-unknown,在 descriptor 无法确定模式时保留历史直属子会话的 header 身份。
## 目录
- [声明](#declaration)
- [兼容性](#compatibility)
- [验证](#verification)
- [开发备注](#dev-note)
<a id="declaration"></a>
## 声明
```yaml persistence-change
schemaVersion: 1
id: 2026-09-20-unknown-child-catalog
baseline: false
changes:
- root: "event:subagent/catalog-unknown"
previous: null
after: "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21"
decision: same-version
```
<a id="compatibility"></a>
## 兼容性
新增普通的必读事件,不修改已定稿的 subagent/catalog 载荷、Session header 或事件信封。已有日志仍有效。不认识新事件的旧读取器拒绝包含它的日志;当前读取器将其投影为现有目录中的未知模式项,继续执行请求仍受支持的子 descriptor 约束。
<a id="verification"></a>
## 验证
pnpm exec vitest run packages/session/session-format-v3-to-v4/tests/validation.spec.ts packages/subagent/subagent/tests/catalog.spec.ts:16 个测试通过,覆盖未知身份恢复、畸形载荷拒绝、重复 child 拒绝及共享目录投影。
<a id="dev-note"></a>
## 开发备注
无。
@@ -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/persistence-changes/historical-formats/README.md
README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301
README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5
README.md: 386b0bd9091cbd509843bd6464c8a95650accb99
README.zh.md: e93e8033daa639eee3a5035efc2df8ef123f563e
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 587 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 587 |
<!-- persistence-format-index:end -->
+283
View File
@@ -14985,6 +14985,104 @@
]
}
},
{
"key": "event:subagent/catalog-unknown",
"kind": "event",
"event": "subagent/catalog-unknown",
"surface": false,
"digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 6,
"optional": true
},
{
"name": "seq",
"type": 2,
"optional": false
},
{
"name": "time",
"type": 2,
"optional": false
},
{
"name": "type",
"type": 7,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "childCreatedAt",
"type": 2,
"optional": false
},
{
"name": "childId",
"type": 3,
"optional": false
},
{
"name": "label",
"type": 3,
"optional": true
},
{
"name": "mode",
"type": 4,
"optional": false
},
{
"name": "version",
"type": 5,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": "unknown"
},
{
"kind": "literal",
"value": 0
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "subagent/catalog-unknown"
}
]
}
},
{
"key": "event:subagent/descriptor",
"kind": "event",
@@ -21691,6 +21789,20 @@
"names": [],
"sources": []
},
{
"digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04",
"schema": {
"root": 0,
"nodes": [
{
"kind": "literal",
"value": "unknown"
}
]
},
"names": [],
"sources": []
},
{
"digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf",
"schema": {
@@ -73477,6 +73589,67 @@
"names": [],
"sources": []
},
{
"digest": "c8d70349934748fe4cf79c49654c72459f4073c57b65e01ff1e8f49dfcce49ca",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "childCreatedAt",
"type": 1,
"optional": false
},
{
"name": "childId",
"type": 2,
"optional": false
},
{
"name": "label",
"type": 2,
"optional": true
},
{
"name": "mode",
"type": 3,
"optional": false
},
{
"name": "version",
"type": 4,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": "unknown"
},
{
"kind": "literal",
"value": 0
}
]
},
"names": [
"packages/subagent/subagent/src/catalog.ts#UnknownCatalogEvent"
],
"sources": [
"packages/subagent/subagent/src/catalog.ts:34"
]
},
{
"digest": "c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff",
"schema": {
@@ -76958,6 +77131,102 @@
"names": [],
"sources": []
},
{
"digest": "d21dd6c60a43d9cb0d93da371e482f66ffd55daa36544e80978a0e7b32737b21",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 6,
"optional": true
},
{
"name": "seq",
"type": 2,
"optional": false
},
{
"name": "time",
"type": 2,
"optional": false
},
{
"name": "type",
"type": 7,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "childCreatedAt",
"type": 2,
"optional": false
},
{
"name": "childId",
"type": 3,
"optional": false
},
{
"name": "label",
"type": 3,
"optional": true
},
{
"name": "mode",
"type": 4,
"optional": false
},
{
"name": "version",
"type": 5,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": "unknown"
},
{
"kind": "literal",
"value": 0
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "subagent/catalog-unknown"
}
]
},
"names": [],
"sources": []
},
{
"digest": "d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2",
"schema": {
@@ -76972,6 +77241,20 @@
"names": [],
"sources": []
},
{
"digest": "d437ebdbc1839ffcf3f6fa201a003126ec63945186b0c0792f1db466bf8ebd89",
"schema": {
"root": 0,
"nodes": [
{
"kind": "literal",
"value": "subagent/catalog-unknown"
}
]
},
"names": [],
"sources": []
},
{
"digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d",
"schema": {
+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: 67f9bc31b46617ea2f7b1ec81bf8c52fd62720cb
subagent.zh.md: a69e032ed96a24a980badaf9e14d924311e1d175
+2 -2
View File
@@ -8,7 +8,7 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics.
The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. Historical children with unavailable descriptors have `mode: 'unknown'`; their header identity remains discoverable without granting continuation capabilities.
## Two kinds of capability, discovered two ways
@@ -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 direct child with complete or unknown-mode discovery information; `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.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+2 -2
View File
@@ -8,7 +8,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。
`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。历史子会话的 descriptor 不可用时使用 `mode: 'unknown'`,保留 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 使用的 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) 规定持久事实与读取语义。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -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: a0e18910d14f879171a1415476a7e05a0d261a8a
README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034
README.md: 49eac824a0f9752fa5e2af6eb8c93d0b7b3bbfd9
README.zh.md: d129ce01cf073dee679b965d83b19b48831d0188
+1 -1
View File
@@ -61,7 +61,7 @@ Attachment authorization reads declared content fields of built-in Session event
`sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures.
References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules.
References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity.
<a id="session-media-references"></a>
## Session media references
+1 -1
View File
@@ -61,7 +61,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
`sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。
引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。
引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。
<a id="session-media-references"></a>
## 会话媒体引用
@@ -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 === 'unknown' && 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 !== 'unknown' && 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' | 'unknown'
}
/** One non-activating Session projection read. */
@@ -501,6 +501,25 @@ describe('subagent catalogs', () => {
})
})
it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => {
mock.load(sessionWorld)
const client = await start()
const manager = new SessionManager(client.ctx.remote)
const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }]
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries })
const address = manager.subagentAddress(S2)!
expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' })
manager.resolveTarget(address)
const child = manager.get(S2)
expect(child.getSnapshot().subagent?.address.mode).toBe('unknown')
manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0,
value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } })
await child.open()
expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' })
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries)
await manager.dispose()
})
it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true }))
@@ -451,6 +451,16 @@ describe('SessionHistoryController', () => {
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
})
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' },
throughSeq: 0,
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
})
await expect(transport.page({
address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' },
throughSeq: 0,
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(transport.page({
address: {
kind: 'subagent',
+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-plan/README.md
README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b
README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce
README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec
README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157
+1 -1
View File
@@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface
The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint.
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate.
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor.
</details>
+1 -1
View File
@@ -51,7 +51,7 @@ kind: "package-reference"
徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。
</details>
+1 -1
View File
@@ -74,7 +74,7 @@ export function parsePlanAddress(address: string): PlanAddress | undefined {
if (parts.length === 2) return {
session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId,
}
if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return {
if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable' || parts[3] === 'unknown')) return {
session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] },
callId: parts[4] as ToolCallId,
}
@@ -36,7 +36,7 @@ describe('plan history resource', () => {
expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
expect(b.closed).toHaveBeenCalledOnce()
})
it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => {
it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const address = `dsh-resource://plan/subagent/parent/child/${mode}/call`
const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode }
+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: d0dc10f17b2b83d7a605ee622ea027698ebeac77
README.zh.md: 1019fd3e7d6fbd8b259c6e94dd830c47ebb2058e
+2
View File
@@ -65,6 +65,8 @@ Each visible catalog level advances its own clock while it contains a running ch
One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool.
Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation.
</details>
-----
+2
View File
@@ -65,6 +65,8 @@ kind: "package-reference"
one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。
模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。
</details>
-----
@@ -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 === 'unknown' ? t('mode.unknown')
: 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'
@@ -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' | 'unknown'
}
/** 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 === 'unknown' ? 'readonly.unknown.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 === 'unknown') return { reason: 'unknown' }
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.
@@ -24,6 +24,8 @@ export const zh = {
'retry': '重试',
'mode.oneShot': '一次性',
'mode.continuable': '可继续',
'mode.unknown': '模式未知',
'readonly.unknown.body': '读取子会话后才能确定是否可继续。',
'activity.running': '正在运行',
'activity.completed': '已完成',
'activity.inactive': '当前未运行',
@@ -64,6 +66,8 @@ export const en: Record<SubagentKey, string> = {
'retry': 'Retry',
'mode.oneShot': 'one-shot',
'mode.continuable': 'continuable',
'mode.unknown': 'unknown mode',
'readonly.unknown.body': 'Read the child session to determine whether it can be continued.',
'activity.running': 'running',
'activity.completed': 'completed',
'activity.inactive': 'not running',
@@ -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 !== 'unknown')) {
return undefined
}
try {
@@ -152,6 +152,8 @@ describe('apply', () => {
// One-shot stays read-only even while running: it has no stop action.
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true })))
.toEqual({ reason: 'unknown' })
expect(select(owner({ address }))).toBeNull()
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
@@ -25,7 +25,7 @@ const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh)
type CatalogFixture = { entries: readonly SubagentCatalogRow[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] }
type CatalogFixture = { entries: readonly (SubagentCatalogRow | { id: SessionId; mode: 'unknown'; label?: string; activity: 'inactive' })[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] }
function catalog(over: Partial<CatalogFixture> = {}): CatalogFixture {
return {
@@ -402,6 +402,16 @@ describe('SubagentHeaderLineage', () => {
})
})
it('keeps unknown catalog children clickable by their durable parent address', () => {
const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] }))
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /子代理/ }))
const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) })
expect(row.textContent).toContain('模式未知')
fireEvent.keyDown(row, { key: 'Enter' })
expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'unknown' })
})
it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => {
const now = 2_000_000_000_000
const minute = 60_000
@@ -926,6 +936,11 @@ describe('SubagentReadOnlyComposer', () => {
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('keeps an unknown child read-only until its descriptor is available', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'unknown' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} 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: 'unknown' })))
.toEqual({ ...ADDRESS, mode: 'unknown' })
})
it.each([
@@ -47,7 +49,7 @@ describe('Sidebar chat address', () => {
'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable',
'dsh-resource://subagentchat/session/child?mode=continuable',
'dsh-resource://subagentchat/session/child?parent=&mode=continuable',
'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown',
'dsh-resource://subagentchat/session/child?parent=parent&mode=invalid',
'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable',
])('rejects %s', (address) => {
expect(parseSubagentChatAddress(address)).toBeUndefined()
@@ -58,6 +58,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'step/end',
'step/start',
'subagent/catalog',
'subagent/catalog-unknown',
'subagent/descriptor',
'subagent/model-selection-policy',
'system/message',
@@ -5745,7 +5745,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\' | \'unknown\';\n};',
},
{
name: 'SessionAssistantStreamAttempt',
@@ -6429,7 +6429,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentCatalogEntry',
declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});',
declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});',
},
{
name: 'SubagentCatalogRow',
@@ -647,6 +647,9 @@ describe('HarnessClient', () => {
const inject = (method: string, params: Record<string, unknown>): void => {
(client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
}
const unknownChild = { type: 'subagent/catalog-unknown', seq: 0, time: 1,
data: { version: 0, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } }
inject('session.event', { sessionId: 'root', event: unknownChild })
inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
@@ -658,6 +661,7 @@ describe('HarnessClient', () => {
inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
inject('subagent.finished', { childSessionId: 'root' })
expect(await tree.next()).toEqual({ method: 'session.event', params: { sessionId: 'root', event: unknownChild } })
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).method).toBe('subagent.started')
expect((await tree.next()).params.sessionId).toBe('grandchild')
@@ -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: 4a6a58a0b1375ff3f318d7cf94bf008ec16ab9df
README.zh.md: 7383dff61fd921976772a6cdaad9d98474f6c150
README.md: 6721d43e7653942b055443a2da99d7fca1d6fd57
README.zh.md: 48b7e5cbd00a19a81730cbf1310e462196cd8ca1
@@ -144,13 +144,13 @@ There is no recursive source search. Captured request text, assistant replay sta
|---|---|
| One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. |
| One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. |
| Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. |
| More than one own descriptor | Retain an existing parent entry without mode/label comparison; do not create a missing entry. |
| Zero descriptors, or an unsupported descriptor version | Retain an existing parent entry; otherwise append unknown-mode membership. |
| More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. |
| Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. |
| Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. |
| Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. |
| Missing own parent entry without complete evidence | Append `subagent/catalog-unknown` with header identity and unknown mode, without inventing a label. |
Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments.
Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. `subagent/catalog-unknown` separately records version 0, child id, creation time, and `mode: 'unknown'`; both event types contribute to the same unique-child catalog. Existing `subagent/catalog` payloads retain their mode requirements.
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.
@@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc
| `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. |
| `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. |
| Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. |
| Native `subagent/catalog` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. |
| Native `subagent/catalog` and `subagent/catalog-unknown` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. |
| Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. |
These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration.
@@ -334,7 +334,7 @@ The edge preserves the recorded request prefix. Provider cache availability and
- **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction.
- **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them.
- **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain.
- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission.
- **Unknown historical modes** — without exactly one supported own descriptor, a missing parent entry records unknown mode. Current reads do not rewrite that entry; opening the child resolves available descriptor information or reports its own error.
- **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration.
<a id="dev-note"></a>
@@ -144,13 +144,13 @@ V3 未知内容标签变为 `plugin:<original-type>`,其他字段原样保留
|---|---|
| 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 |
| 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 |
| 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 |
| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;不创建缺失项。 |
| 没有 descriptor,或 descriptor 版本不受支持 | 保留已有父目录项;否则追加模式未知的目录项。 |
| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 |
| 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 |
| 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 |
| 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 |
| 缺少自身父目录项,且证据不完整 | 追加 `subagent/catalog-unknown`,记录 header 身份和未知模式,不编造标签。 |
Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。
Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 `subagent/catalog-unknown` 单独记录 version 0、child id、创建时间和 `mode: 'unknown'`;两种事件共同组成 child id 唯一的目录。已有 `subagent/catalog` 载荷保留其 mode 要求。
Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。
@@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类
| `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 |
| `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 |
| Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 |
| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 |
| 原生 `subagent/catalog` 与 `subagent/catalog-unknown` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 |
| 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 |
这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。
@@ -334,7 +334,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接
- **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。
- **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。
- **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。
- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。
- **历史模式未知**——没有恰好一个受支持的自身 descriptor 时,缺失的父目录项记录未知模式。当前读取不重写该项;打开子会话时解析可用的 descriptor 信息,或报告该子会话的错误。
- **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。
<a id="dev-note"></a>
@@ -78,6 +78,21 @@ export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/c
return value
}
/**
* Validate a current catalog entry retained without a supported child descriptor.
* @param value - decoded unknown-child payload.
* @returns the validated identity with unknown mode.
*/
export function unknownCatalogFact(value: SessionFormatJsonValue): SessionFormatJsonObject {
if (!isSessionFormatJsonObject(value) || value['version'] !== 0
|| typeof value['childId'] !== 'string' || value['mode'] !== 'unknown'
|| (value['label'] !== undefined && typeof value['label'] !== 'string')) {
throw new SessionFormatError('subagent/catalog-unknown requires a version 0 identity with unknown mode')
}
sessionFormatCount(value['childCreatedAt'], 'catalog child creation time')
return value
}
/**
* Name the child and its optional storage-owned location in a migration diagnostic.
* @param source - validated supplemental child evidence.
@@ -122,9 +122,10 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
}
continue
}
if (fact === undefined) continue
existingCatalogs.set(id, fact)
context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact })
const entry = fact ?? { version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' }
existingCatalogs.set(id, entry)
context.emitEvent({ type: fact === undefined ? 'subagent/catalog-unknown' : 'subagent/catalog',
seq: this.nextSeq++, time: this.time, data: entry })
}
return cut
}
@@ -6,7 +6,7 @@ import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh
import { assertV4DeveloperData } from './developer.ts'
import { assertV4LifecycleRelationships } from './relationships.ts'
import { assertV4MessageSources } from './message-sources.ts'
import { catalogFact } from './facts.ts'
import { catalogFact, unknownCatalogFact } from './facts.ts'
import { assertV4RetiredSyntax } from './retired-syntax.ts'
import { assertV4SystemMessageFields } from './system-message.ts'
import { assertV4ForkResult } from './fork-result.ts'
@@ -119,8 +119,8 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k
&& deliveryId !== artifact.header.id) {
throw new SessionFormatError('current-generation delivery marker names the wrong Session')
}
if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) {
const fact = catalogFact(event.data)
if ((event.type === 'subagent/catalog' || event.type === 'subagent/catalog-unknown') && event.seq >= artifact.inheritedEventCount) {
const fact = event.type === 'subagent/catalog' ? catalogFact(event.data) : unknownCatalogFact(event.data)
const id = fact['childId'] as string
if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`)
ids.add(id)
@@ -74,7 +74,7 @@ describe('V3 parent catalog completion', () => {
it('requires explicit corpus facts and refuses conflicting identities', () => {
expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 },
sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts')
expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0')
expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('complete version 0')
expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts')
})
@@ -88,8 +88,8 @@ describe('V3 parent catalog completion', () => {
],
}
expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child)
expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toEqual([])
expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toEqual([])
expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }])
expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }])
})
it('orders tied and differently dated children independently of directory enumeration', () => {
const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }]
@@ -114,11 +114,11 @@ describe('V3 parent catalog completion', () => {
expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session')
})
it('skips unavailable descriptors while requiring complete discovery fields for new facts', () => {
it('retains unknown children while validating available descriptor fields', () => {
const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] }
expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent')
for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) {
expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toEqual([])
expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toMatchObject([{ data: { childId: 'parent', mode: 'unknown' } }])
}
for (const value of [null, { ...child, version: 1 }, { ...child, childId: null },
{ ...child, label: null }, { ...child, childCreatedAt: -1 }]) {
@@ -148,7 +148,8 @@ describe('V3 parent catalog completion', () => {
const unavailable = { childId: 'unavailable', childCreatedAt: 1,
descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor }
expect(migrate([], [child, unavailable]).events).toEqual([
{ type: 'subagent/catalog', seq: 0, time: 1, data: child },
{ type: 'subagent/catalog-unknown', seq: 0, time: 1, data: { version: 0, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } },
{ type: 'subagent/catalog', seq: 1, time: 1, data: child },
])
})
@@ -113,6 +113,23 @@ describe('restoreReleasedV4Artifact', () => {
.toThrow(/unseeded format v4 Session has inherited events/)
})
it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => {
const known = new Set([...KNOWN, 'subagent/catalog', 'subagent/catalog-unknown'])
const data = { version: 0, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' }
const event = { type: 'subagent/catalog-unknown', seq: 0, time: 1, data }
const valid = artifact([event])
expect(restoreReleasedV4Artifact(valid, known)).toBe(valid)
expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow()
for (const invalid of [null, { ...data, version: 1 }, { ...data, childId: 1 },
{ ...data, childCreatedAt: -1 }, { ...data, mode: 'one-shot' }, { ...data, label: null }]) {
expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow()
}
expect(() => restoreReleasedV4Artifact(artifact([event, { ...event, seq: 1 }]), known)).toThrow('duplicate catalog child')
expect(() => restoreReleasedV4Artifact(artifact([event, { type: 'subagent/catalog', seq: 1, time: 1,
data: { ...data, mode: 'one-shot' } }]), known)).toThrow('duplicate catalog child')
expect(() => restoreReleasedV4Artifact(artifact([{ ...event, type: 'subagent/catalog' }]), known)).toThrow('complete version 0')
})
it('validates only the envelope in physical mode', () => {
const unknown = conversation()
unknown.splice(2, 0, { type: 'campaign/unknown', seq: 0, time: 1, data: { x: 1 } })
@@ -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: af64ea90850ea0777ceb7c61d4c43dd1a0bc6707
README.zh.md: b61d64e72aa76f58b3ed9534da15c8657952e46a
README.md: 066b43d6a0f7c0e29b8c2ebbba17fa460103187a
README.zh.md: 61693b96d904b8d2c3d679c065f3667eb3bdc46e
@@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad
`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.
Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and omit its backfill; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors provide no new fact. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events.
Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers are omitted from discovery. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog-unknown` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current 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).
@@ -81,7 +81,7 @@ 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'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告,并跳过该项补填;健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 不提供新事实。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。
历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header 不参与发现。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog-unknown` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。
历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。
@@ -45,6 +45,8 @@ export async function prepareCatalogFacts(
path: source.path, identity: await stat(source.path, { bigint: true }),
}
witnesses.push(witness)
const unavailable = { childId: source.header.id, childCreatedAt: source.header.createdAt,
descriptorCount: 0, descriptor: null, sourcePath: source.path }
let restored: Awaited<ReturnType<typeof readDecodedJsonlSource>>
try {
restored = await readDecodedJsonlSource(source.path, version, compression, {
@@ -55,6 +57,7 @@ export async function prepareCatalogFacts(
} catch (error: unknown) {
signal.throwIfAborted()
failures.push({ path: source.path, error })
facts.push(unavailable)
continue
}
witness.identity = restored.identity
@@ -69,6 +72,7 @@ export async function prepareCatalogFacts(
fact = historicalChildCatalogSource(restored.artifact)
} catch (error: unknown) {
failures.push({ path: source.path, error })
facts.push(unavailable)
continue
}
facts.push({ ...fact, sourcePath: source.path })
@@ -634,7 +634,7 @@ class JsonlSessionPersistence extends SessionPersistence {
const sources = await children()
const related = await prepareCatalogFacts(id, sources, this.compression, signal)
for (const failure of related.failures) {
this.ctx.logger.warn(`${this.name}: session "${id}" catalog omitted an unreadable child (raw log: ${failure.path}): ${String(failure.error)}`)
this.ctx.logger.warn(`${this.name}: session "${id}" catalog retained a child with unknown descriptor (raw log: ${failure.path}): ${String(failure.error)}`)
}
const membership = sources.map(source => source.path).sort()
validateRelatedSources = async () => {
@@ -120,7 +120,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
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)
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining(path))
await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
})
@@ -136,7 +136,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
const original = await readFile(childPath)
await f.write('healthy', [f.descriptor], true)
const parent = await f.ctx.sessionPersistence.open(f.parent, access)
try { expect((await parent.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'healthy' } }]) } finally { await parent.close() }
try { expect((await parent.read()).events).toMatchObject([
{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } },
{ type: 'subagent/catalog', data: { childId: 'healthy', mode: 'continuable' } },
]) } finally { await parent.close() }
await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
expect(await readFile(childPath)).toEqual(original)
await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' })
@@ -167,16 +170,16 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
const f = await fixture()
const childPath = await f.write('child', [], true)
await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)]))
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
})
it('keeps a complete parent catalog when a published child never writes a descriptor', async () => {
it.each([false, true])('keeps a complete parent catalog when child metadata is unavailable (corrupt=%s)', async (corrupt) => {
const f = await fixture()
const catalog = { type: 'subagent/catalog', seq: 0, time: 2,
data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot', label: 'failed startup' } }
const parentPath = await f.write(f.parent, [catalog])
const childPath = await f.write('child', [], true)
const childPath = await f.write('child', corrupt ? [{ type: 'future/required', seq: 0, time: 2, data: {} }] : [], true)
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toEqual([catalog])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
@@ -205,10 +208,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
const childPath = await f.write('child', events, true)
const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression)
const original = await Promise.all([readFile(parentPath), readFile(childPath)])
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() }
expect(await f.read()).toEqual([])
try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() }
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read')
try { expect((await child.read()).events).toEqual(events) } finally { await child.close() }
expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original)
@@ -234,18 +237,18 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
expect(await f.read()).toEqual([])
})
it('omits unsupported child contents without migrating the child', async () => {
it('retains unknown child identity without migrating unsupported contents', async () => {
const f = await fixture()
await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true)
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
})
it('omits invalid descriptor fields and reports the child source', async () => {
it('retains unknown child identity and reports invalid descriptor fields', async () => {
const f = await fixture()
const warn = vi.spyOn(f.ctx.logger, 'warn')
const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true)
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining(path))
})
@@ -280,7 +283,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
it('recollects newly available discovery fields before publishing a prepared parent', async () => {
const f = await fixture()
const childPath = await f.write('child', [], true)
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
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)
@@ -380,13 +383,27 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
it('recollects a repaired child before publishing a prepared parent', async () => {
const f = await fixture()
await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true)
expect(await f.read()).toEqual([])
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog-unknown', data: { childId: 'child', mode: 'unknown' } }])
await f.write('child', [f.descriptor], true)
expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }])
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
})
it('keeps published unknown membership while a repaired child opens independently', async () => {
const f = await fixture()
await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true)
const writer = await f.ctx.sessionPersistence.open(f.parent, 'write')
await writer.close()
const current = generationLogPath(f.root, undefined, f.parent, 4, compression)
const published = await readFile(current)
await f.write('child', [f.descriptor], true)
const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read')
try { expect((await child.read()).events).toEqual([f.descriptor]) } finally { await child.close() }
expect(await f.read()).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }])
expect(await readFile(current)).toEqual(published)
})
it('completes only the opened parent catalog and defers its child catalog', async () => {
const f = await fixture()
const childPath = await f.write('child', [f.descriptor], true)
+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: 94da1be172b441aec1fd16d3853596eaee74ca1b
README.zh.md: 462bc3aff2155b96f87d9f69c5aae1aa5002a9ca
+1 -1
View File
@@ -109,7 +109,7 @@ A request is validated against the provider's advertised capabilities, a durable
The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream.
Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives.
Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Historical migration may instead append `subagent/catalog-unknown` from a readable child header. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative.
### Ownership and invariants
+1 -1
View File
@@ -109,7 +109,7 @@ kind: "package-reference"
管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。
本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。
本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 历史迁移可根据可读子 header 追加 `subagent/catalog-unknown`。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。
### 所有权与不变式
+29 -7
View File
@@ -31,6 +31,14 @@ export type SubagentCatalogEvent =
| { readonly mode: 'continuable'; readonly label: string }
)
type UnknownCatalogEvent = {
readonly version: 0
readonly childId: SessionId
readonly childCreatedAt: number
readonly mode: 'unknown'
readonly label?: string
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
@@ -38,13 +46,18 @@ declare module '@deepseek-ai/dsh-session/types' {
* @param data - versioned parent-owned catalog entry.
*/
'subagent/catalog': SubagentCatalogEvent
/**
* A historical child's header identity when its descriptor cannot establish a mode.
* @param data - child identity retained for discovery and later history reads.
*/
'subagent/catalog-unknown': UnknownCatalogEvent
}
}
/** Host fold state for one parent catalog. */
export interface SubagentCatalogState {
readonly inheritedEventCount: SessionLogOffset
readonly head?: ChunkedList<SubagentCatalogEvent> | undefined
readonly head?: ChunkedList<SubagentCatalogEvent | UnknownCatalogEvent> | undefined
}
const sessionIdSchema = z.string() as unknown as z.ZodType<SessionId>
@@ -62,10 +75,13 @@ const continuableCatalogSchema = z.object({
mode: z.literal('continuable'),
label: z.string(),
}).strict()
const unknownCatalogSchema = oneShotCatalogSchema.extend({ mode: z.literal('unknown') })
const completeCatalogSchema = z.union([oneShotCatalogSchema, continuableCatalogSchema])
const eventDataSchema = z.union([
oneShotCatalogSchema,
continuableCatalogSchema,
]) as unknown as z.ZodType<SubagentCatalogEvent>
unknownCatalogSchema,
]) as unknown as z.ZodType<SubagentCatalogEvent | UnknownCatalogEvent>
const viewSchema = z.array(z.union([
oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({
id: sessionIdSchema,
@@ -75,6 +91,10 @@ const viewSchema = z.array(z.union([
id: sessionIdSchema,
createdAt: continuableCatalogSchema.shape.childCreatedAt,
}),
unknownCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({
id: sessionIdSchema,
createdAt: unknownCatalogSchema.shape.childCreatedAt,
}),
])) as unknown as z.ZodType<SubagentCatalogEntry[]>
const stateSchema: z.ZodType<SubagentCatalogState> = z.object({
inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType<SessionLogOffset>,
@@ -88,14 +108,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
}
/**
* Materialize direct children from their parent's successful creation facts.
* Materialize complete and unknown-mode child identities from parent catalog events.
* @param state - parent catalog fold state.
* @returns current direct-child rows in parent catalog event order.
*/
function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] {
const entries: SubagentCatalogEntry[] = []
for (const data of iterateChunkedList(state.head)) {
entries.push(data.mode === 'one-shot'
entries.push(data.mode !== 'continuable'
? {
id: data.childId,
createdAt: data.childCreatedAt,
@@ -118,10 +138,12 @@ export const subagentCatalogProjectionDefinition = {
stateSchema,
init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }),
apply: (state, event: SessionEvent) => {
if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state
return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) }
if ((event.type !== 'subagent/catalog' && event.type !== 'subagent/catalog-unknown') || event.seq < state.inheritedEventCount) return state
const data = (event.type === 'subagent/catalog-unknown' ? unknownCatalogSchema : completeCatalogSchema)
.parse(event.data) as SubagentCatalogEvent | UnknownCatalogEvent
return { ...state, head: appendChunkedList(state.head, data) }
},
stateVersion: 2,
stateVersion: 3,
wire: { viewSchema, view: subagentCatalogEntries },
} satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState>
@@ -71,7 +71,7 @@ export type SubagentListEntry =
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/** Durable parent/child address that selects subagent transport in the client. */
/** Durable parent/child browsing address; unknown mode is resolved when child history is read. */
export type SubagentAddress =
& {
readonly parentSessionId: SessionId
@@ -80,6 +80,7 @@ export type SubagentAddress =
& (
| { readonly mode: 'one-shot' }
| { readonly mode: 'continuable' }
| { readonly mode: 'unknown' }
)
/** One human message addressed to a continuable direct child. */
@@ -15,6 +15,7 @@ export type SubagentCatalogEntry =
& (
| { readonly mode: 'one-shot'; readonly label?: string }
| { readonly mode: 'continuable'; readonly label: string }
| { readonly mode: 'unknown'; readonly label?: string }
)
/** Durable active-turn timing for one descriptor-backed child session. */
@@ -107,6 +107,8 @@ describe('subagent catalog projection', () => {
fact(0, 'child-b', 1, { mode: 'one-shot' }),
fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }),
fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }),
{ type: 'subagent/catalog-unknown', seq: SessionSeq(3), time: 0,
data: { version: 0, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } },
]
const state = fold(events)
@@ -121,6 +123,7 @@ describe('subagent catalog projection', () => {
{ id: SessionId('child-b'), createdAt: 1, mode: 'one-shot' },
{ id: SessionId('child-a'), createdAt: 1, mode: 'one-shot', label: 'once' },
{ id: SessionId('child-d'), createdAt: 3, mode: 'continuable', label: 'later' },
{ id: SessionId('unreadable'), createdAt: 4, mode: 'unknown' },
])
})
+9
View File
@@ -595,6 +595,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
client = HarnessClient()
with client.subscribe_session_notifications("main") as first:
unknown_child = {
"type": "subagent/catalog-unknown", "seq": 0, "time": 1,
"data": {"version": 0, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"},
}
client._handle_message({
"jsonrpc": "2.0", "method": "session.event",
"params": {"sessionId": "main", "event": unknown_child},
})
assert first.next().payload == {"sessionId": "main", "event": unknown_child}
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.started",