Merge remote-tracking branch 'origin/master' into fix/windows-directory-junction
This commit is contained in:
commit
6f1de05d54
1374 files changed
+201464
-46913
No files matched your search
+2
-2
@@ -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-11-session-controller-fork-turn-cut.md
|
||||
2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
|
||||
2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db
|
||||
2026-09-11-session-controller-fork-turn-cut.md: 9223acd4c0c7bbcc84a0bfeb72f3b57d4bf828e2
|
||||
2026-09-11-session-controller-fork-turn-cut.zh.md: cae022ae97896c0b43633edb310afb305d690b4a
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Session Controller forks stop at the selected turn end
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-16
|
||||
|
||||
English | [中文](2026-09-11-session-controller-fork-turn-cut.zh.md)
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
# Agent Note: Session Controller 分叉截到选中轮次的结束事件
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-16
|
||||
|
||||
English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
|
||||
[English](2026-09-11-session-controller-fork-turn-cut.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -751,6 +751,9 @@
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
|
||||
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
|
||||
|
||||
+2
-2
@@ -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-06-11-content-block-vocabulary.md
|
||||
2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2
|
||||
2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b
|
||||
2026-06-11-content-block-vocabulary.md: 917dd5225fdc4ab391e9e4629769abbb68095731
|
||||
2026-06-11-content-block-vocabulary.zh.md: 9d228eb34dafbdebab44b45dc3b290c9327d3b2b
|
||||
@@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log,
|
||||
|
||||
## Decision
|
||||
|
||||
Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
|
||||
Own the vocabulary: user, assistant, and system messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. Tool results are first-class tool-role messages whose own content holds result blocks; released Session format v3 retains the historical `tool-result` content block inside a user-role wrapper, and the adjacent V3-to-V4 edge lifts that wrapper into the first-class role. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
|
||||
|
||||
In-session context injection (`context/message`) and mid-turn steering originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
|
||||
|
||||
## 决策
|
||||
|
||||
自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
|
||||
自主拥有词汇:user、assistant 与 system 消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。Tool result 是一等 tool-role message,结果块属于该 message 自己的 content;已发布的 Session format v3 仍将历史 `tool-result` content block 保存在 user-role 包装中,邻接的 V3 到 V4 边会将该包装提升为一等角色。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
|
||||
|
||||
会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
|
||||
|
||||
|
||||
+2
-2
@@ -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-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a
|
||||
+2
@@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
|
||||
|
||||
`@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@[email protected]`](../../../../patches/@[email protected]) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched.
|
||||
|
||||
The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg/<sha256 of the .node file>/` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind.
|
||||
|
||||
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
|
||||
|
||||
### The serving interface is a plugin inside the dsh application
|
||||
|
||||
+2
@@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
`@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@[email protected]`](../../../../patches/@[email protected]) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。
|
||||
|
||||
同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。
|
||||
|
||||
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
|
||||
|
||||
### 对外服务接口是 dsh 应用中的插件
|
||||
|
||||
+2
-2
@@ -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-07-20-routed-model-context-and-compaction-policy.md
|
||||
2026-07-20-routed-model-context-and-compaction-policy.md: 26d8a4a85c284e50b9635017f0dd0bf905a4427f
|
||||
2026-07-20-routed-model-context-and-compaction-policy.zh.md: 8656200a4b25c2ca70160e8f5949a7518e390168
|
||||
2026-07-20-routed-model-context-and-compaction-policy.md: d276354cb21f45eaec1c4e3202c7be1770745efb
|
||||
2026-07-20-routed-model-context-and-compaction-policy.zh.md: b6e2a0b914f78e19675d80b86d8d962e8ba8c80f
|
||||
+5
-5
@@ -16,7 +16,7 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and
|
||||
|
||||
`LlmAdapter.resolveModel(provider, model, signal?)` returns aggregate metadata for one exact route, with optional `LlmModelContext` under its `context` field. `LlmRuntime.resolveModelInfo()` selects the registered route owner, validates a positive integer `contextWindow`, and returns detached metadata. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and an absent `context` means only that the adapter cannot describe capacity.
|
||||
|
||||
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The two built-in model entries each publish an exact 256,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
|
||||
The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The built-in model entries each declare a 1,000,000-token context capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model.
|
||||
|
||||
### Token measurement remains model-agnostic
|
||||
|
||||
@@ -26,17 +26,17 @@ The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each config
|
||||
|
||||
Compact-basic owns consumer policy. Top-level fields define defaults; `modelPolicies` contains partial overrides keyed by the exact `{ provider, model }` pair. Duplicate targets and unknown or invalid fields fail plugin load. `thresholdRatio` defaults to `0.8`, and retention defaults to `retainRatio: 0.16`; callers may use an absolute `retainTokens` instead, but the two retention forms are mutually exclusive. After inheritance, a ratio retention that is not below its threshold ratio also fails plugin load because no model capacity can make that policy valid.
|
||||
|
||||
For proactive pressure, compaction-basic reads the latest durable request route, resolves its adapter capacity and exact-target policy, and scales ratios into a `ResolvedCompactSpec`. It performs this resolution on every check, so a provider or model switch in one session changes capacity and policy immediately. An absolute retained budget that is not below the scaled threshold fails when the target capacity first makes that comparison possible.
|
||||
For proactive pressure, compaction-basic reads the latest durable route and resolves its adapter capacity `W`, request output reservation `O`, and exact-target policy. The trigger is `floor(min(W × thresholdRatio, W − O − headroomTokens))`, with default headroom of 65,536 tokens. The window fraction limits history growth while the second cap leaves extra room after a full output for compaction input and generation; it does not bound arbitrary tool-result growth. Retention scales `W − O` independently. `O` is the effective envelope’s `maxTokens`, else the adapter’s per-request default, else zero. Resolution runs on every check, so route or output-cap changes immediately affect policy. A nonpositive pressure budget or retention at least as large as the trigger fails when target capacity makes that comparison possible.
|
||||
|
||||
The same exact-target override can select summarization provider/model, summarization output cap, convergence retries, and overflow retry cap. These are compaction concerns and never enter an LLM provider.
|
||||
|
||||
### Target-specific pressure failures preserve optional composition
|
||||
|
||||
An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same per-route suppression applies when resolved capacity exposes an invalid absolute retention budget, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress.
|
||||
An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same suppression applies when output plus headroom exhausts capacity or retention reaches the trigger, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress.
|
||||
|
||||
## Testing
|
||||
|
||||
Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek exact/default/unlisted resolution, invalid capacities, and pi-ai exact descriptor resolution. Compact tests cover ratio scaling, exact provider/model overrides, load-time rejection of invalid merged ratios, runtime absolute-budget validation, same-model-id provider switches, target-specific warning suppression, and capacity-independent overflow recovery. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters.
|
||||
Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek exact/default/unlisted resolution, invalid capacities, and pi-ai exact descriptor resolution. Compact tests cover both trigger caps, default and overridden headroom, envelope output caps and adapter fallback, independent retention scaling, invalid budgets, exact-target overrides, provider switches, warning suppression, and capacity-independent overflow recovery. The keyless `compaction-output-reserve` headless scenario records a nonzero request cap, proactive compaction before the next step, and replacement history. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -52,6 +52,6 @@ Service tests cover detached context metadata, invalid adapter output, catalog i
|
||||
- The same compaction-basic instance safely handles different windows, provider switches, and identical model ids under different providers without consulting discovery metadata.
|
||||
- LLM-only and meter-only compositions remain valid; loading compaction-basic adds no reverse dependency from adapters.
|
||||
- DeepSeek deployments may set exact per-model capacities, or use `defaultContextWindow` for entries without capacity and unlisted pass-through ids.
|
||||
- Ratio defaults scale naturally across models, while exact-target absolute retention remains available for deployment-specific behavior.
|
||||
- Ratio defaults scale naturally across models and exclude the output tokens each request reserves, while exact-target absolute retention remains available for deployment-specific behavior.
|
||||
|
||||
This note supersedes the global-capacity and no-model-policy parts of the [replay token meter service Agent Note](../../archived/architecture/2026-07-15-replay-token-meter-service.md). Its single-fold measurement decision remains unchanged.
|
||||
+5
-5
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmRuntime.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回与适配器内部状态分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。
|
||||
|
||||
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 256,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
|
||||
手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。内置模型项各自声明 1,000,000 token 的上下文容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。
|
||||
|
||||
### Token 计量保持模型无关
|
||||
|
||||
@@ -26,17 +26,17 @@ Status: implemented
|
||||
|
||||
Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolicies` 包含以精确 `{ provider, model }` 组合为键的部分覆盖。重复目标、未知字段或无效字段都会让插件加载失败。`thresholdRatio` 默认为 `0.8`,保留策略默认为 `retainRatio: 0.16`;调用方也可以改用绝对 `retainTokens`,但两种保留形式互斥。完成继承后,如果保留比例不小于阈值比例,插件也会加载失败,因为任何模型容量都无法让该策略有效。
|
||||
|
||||
对于主动压力检查,compaction-basic 读取最新持久请求路由,解析其适配器容量与精确目标策略,再把比例缩放为 `ResolvedCompactSpec`。每次检查都会重新解析,因此同一会话切换提供方或模型后,容量与策略会立即变化。若绝对保留预算不小于缩放后的阈值,系统会在目标容量首次允许比较两者时失败。
|
||||
对于主动压力检查,compaction-basic 读取最新持久路由,解析适配器容量 `W`、请求输出预留 `O` 和精确目标策略。触发阈值为 `floor(min(W × thresholdRatio, W − O − headroomTokens))`,默认余量为 65,536 tokens。窗口比例限制历史增长,第二项则在一次完整输出后为压缩输入与生成留出额外空间;它无法限制任意工具结果的增长。保留预算独立按 `W − O` 缩放。`O` 取自生效信封的 `maxTokens`,否则取适配器按请求的默认值,再回退到零。每次检查都会重新解析,因此路由或输出上限变化会立即影响策略。若压力预算非正,或保留预算不低于触发阈值,会在目标容量允许比较时失败。
|
||||
|
||||
同一精确目标覆盖还可以选择摘要提供方/模型、摘要输出上限、收敛重试次数与溢出重试上限。这些都属于压缩问题,不会进入任何 LLM 提供方。
|
||||
|
||||
### 目标专用压力错误仍保留可选组合
|
||||
|
||||
缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大且平衡的缩减,并在替换无法证明进展时保留原始提供方错误。
|
||||
缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当输出预留与余量耗尽容量,或保留预算达到触发阈值时,也会抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大且平衡的缩减,并在替换无法证明进展时保留原始提供方错误。
|
||||
|
||||
## 测试
|
||||
|
||||
服务测试覆盖与适配器内部状态分离的上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture(测试前置数据)会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。
|
||||
服务测试覆盖与适配器内部状态分离的上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的精确容量、默认容量、未列出模型解析及无效容量,以及 pi-ai 的精确描述符解析。压缩测试覆盖两项阈值上限、默认和覆盖余量、信封输出上限与适配器回退、独立保留预算缩放、无效预算、精确目标覆盖、提供方切换、警告抑制和不依赖容量的溢出恢复。无需密钥的 `compaction-output-reserve` headless 场景记录非零请求上限、下一步之前的主动压缩和替换后的历史。Loader fixture(测试前置数据)会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -52,6 +52,6 @@ Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolici
|
||||
- 同一个 compaction-basic 实例无需查询发现元数据,就能安全处理不同窗口、提供方切换,以及不同提供方下的相同模型 id。
|
||||
- 仅 LLM 与仅 meter 的组合仍然有效;加载 compaction-basic 不会让适配器产生反向依赖。
|
||||
- DeepSeek 部署可以设置精确的逐模型容量,也可以让未提供容量的模型项与未列出的透传 id 使用 `defaultContextWindow`。
|
||||
- 比例默认值会随模型自然缩放,同时仍可按精确目标使用绝对保留值,以满足部署专用行为。
|
||||
- 比例默认值会随模型自然缩放,并扣除每次请求预留的输出 token;同时仍可按精确目标使用绝对保留值,以满足部署专用行为。
|
||||
|
||||
本记录取代[回放式 token 计量服务 Agent Note](../../archived/architecture/2026-07-15-replay-token-meter-service.md) 中的全局容量与无模型策略部分,单折叠计量决策保持不变。
|
||||
+2
-2
@@ -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-07-25-web-client-session-scope-and-provide-channel.md
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 32be87f3a0a4f8da92100ec7351af788862532f7
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d
|
||||
+10
-9
@@ -28,7 +28,7 @@ Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one pi
|
||||
- Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
|
||||
- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity.
|
||||
- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
|
||||
- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live.
|
||||
- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live.
|
||||
|
||||
### Agent scope: the actx is the sole session carrier in the client-side cordis world
|
||||
|
||||
@@ -63,14 +63,14 @@ Session instances share the scope's lifecycle, while the catalog reports discove
|
||||
|
||||
A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable):
|
||||
|
||||
- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md).
|
||||
- The Host criterion is absence of `turn/start`: the `sessionListMetadata.blank` projection starts true and only that event clears it, so slash-command, configuration, and creation-checkpoint events leave it true even after they reach disk — blank does not mean unpersisted. Missing metadata falls back to `session.seq === 0` for live Sessions and false for cold ones; cold summaries otherwise read cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md).
|
||||
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
|
||||
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
|
||||
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
|
||||
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
|
||||
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
|
||||
- Client display also reflects accepted/running observations, whose retention the [blank rollback decision](../bug-fix/2026-09-15-client-session-blank-reconciliation.md) owns:
|
||||
- The sender's own tab: the **successful response** to the first `prompt()` converts the current `New Session` row in place, adding no list row — acceptance is display memory, not proof that a turn or user message reached durable history. A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
|
||||
- Other tabs: the `host/session-status (running:true)` frame converts it — a blank session never runs, so the first running necessarily means no longer blank;
|
||||
- Reconnect alignment: a list pull updates Host summaries while the Manager keeps those observations, and a `sessionListMetadata` hint of existing history prevents re-blanking on top of that; an empty-history response cannot mark a converted session back to blank.
|
||||
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally.
|
||||
- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup.
|
||||
- Blank Sessions can persist through creation checkpoints and command events, and have no automatic garbage collection: browser reloads, Host restarts, and Workspace connections reuse eligible blanks after acquiring their writer and skip occupied candidates, while list-loading and multi-tab races can still create additional blank Sessions, whose old logs remain on disk.
|
||||
|
||||
### connectWorkspace: the sole entry point of New Session
|
||||
|
||||
@@ -129,12 +129,13 @@ Blank Sessions retain the header's leading and corner slots so navigation contro
|
||||
| Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props |
|
||||
| Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump |
|
||||
| Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary |
|
||||
| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type |
|
||||
| A dedicated conversion frame | Existing acceptance and running signals provide the observations used by the display rule; a new frame is unnecessary for that rule |
|
||||
|
||||
## Consequences
|
||||
|
||||
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
|
||||
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
|
||||
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse.
|
||||
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local.
|
||||
|
||||
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval.
|
||||
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built).
|
||||
+10
-9
@@ -28,7 +28,7 @@ host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作
|
||||
- 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
|
||||
- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。
|
||||
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
|
||||
- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。
|
||||
- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。
|
||||
|
||||
### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体
|
||||
|
||||
@@ -63,14 +63,14 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持
|
||||
|
||||
「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变):
|
||||
|
||||
- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。
|
||||
- Host 判据是没有 `turn/start`:`sessionListMetadata.blank` 投影初始为 true,且只有该事件会清除它,因此 slash 命令、配置事件与创建检查点即使已落盘也不会清除——blank 不代表未持久化。metadata 缺失时,live Session 回退到 `session.seq === 0`,cold Session 回退到 false;cold summary 其余情况读缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。
|
||||
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
|
||||
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
|
||||
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
|
||||
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
|
||||
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
|
||||
- 客户端展示还受已观察到的受理/运行影响,其保留期限由 [blank 回退修复决策](../bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 拥有:
|
||||
- 发送方本地:首次 `prompt()` 的**成功响应**把当前 `New Session` 行原地转正,不新增列表行——受理是展示记忆,不证明轮次或用户消息已写入持久历史。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
|
||||
- 其他端:`host/session-status (running:true)` 帧转正——blank 会话从不 running,首次 running 必然已非 blank;
|
||||
- 重连对齐:列表拉取更新 Host 摘要,Manager 则保留这些观察;在此基础上,`sessionListMetadata` 提示已有历史也会阻止回退 blank,因此空历史响应不能把已转正的会话标回 blank。
|
||||
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。
|
||||
- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。
|
||||
- blank Session 可以通过创建检查点与命令事件持久化,且没有自动垃圾回收:浏览器刷新、Host 重启与工作区连接会在取得写锁后复用符合条件的空白会话并跳过被占用的候选;但列表加载与多 tab 竞态仍可能创建额外的 blank Session,其旧日志保留在磁盘上。
|
||||
|
||||
### connectWorkspace:New Session 的唯一入口
|
||||
|
||||
@@ -129,12 +129,13 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入
|
||||
| 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共 API 收敛为 hooks + 稳定 props |
|
||||
| Hero 无会话视图与会话 Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 |
|
||||
| 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 |
|
||||
| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 |
|
||||
| 专用「转正」帧 | 既有受理和运行信号已经提供展示规则所需的观察;该规则不需要新增帧 |
|
||||
|
||||
## 后果
|
||||
|
||||
- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
|
||||
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。
|
||||
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。
|
||||
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。
|
||||
|
||||
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。
|
||||
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。
|
||||
+2
-2
@@ -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-07-28-identified-immutable-message-values.md
|
||||
2026-07-28-identified-immutable-message-values.md: 9d2b22a5a1fc87ea7b0ff8eb24b2e8c4b86543e1
|
||||
2026-07-28-identified-immutable-message-values.zh.md: 6b36cfec0a24196ca526b0ffb21180967aa640eb
|
||||
2026-07-28-identified-immutable-message-values.md: 73c9c14479117cf654d10b3da23d155cca77054d
|
||||
2026-07-28-identified-immutable-message-values.zh.md: 7a5b9d6524beff01ac8a9e2020f75093271b7ab3
|
||||
+1
-1
@@ -16,7 +16,7 @@ This made identity a routing side effect rather than a message invariant. Produc
|
||||
|
||||
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content plus provider, model, and optional replay state. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
|
||||
|
||||
The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to a tool-role message carrying the result content, error state, and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
|
||||
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
|
||||
|
||||
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容,以及提供方、模型和可选的回放状态。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。
|
||||
|
||||
消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与携带结果内容、错误状态及来源的 tool-role 消息耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
|
||||
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
|
||||
|
||||
|
||||
+2
-2
@@ -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-10-session-log-version-mechanism.md
|
||||
2026-08-10-session-log-version-mechanism.md: 513b126d3b00841f71893715cc296cb67619543e
|
||||
2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0
|
||||
2026-08-10-session-log-version-mechanism.md: 9cb0940f767aa27d5a5106fb78b528c1e3e2695b
|
||||
2026-08-10-session-log-version-mechanism.zh.md: e1424f24e5dd74b243fe1f0aebceb5d420b9b8c3
|
||||
@@ -18,6 +18,8 @@ Session logs must be upgradable after release, and the runtime that ships first
|
||||
|
||||
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
|
||||
|
||||
The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) prevents an accepted transition from reusing its header increase for later breaking changes. Backward-compatible evolution uses new acknowledgement records at the same Session version; breaking changes require a higher writer. The checkpoint does not change the unknown-event protocol.
|
||||
|
||||
### Writer and publication authority
|
||||
|
||||
`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers.
|
||||
|
||||
@@ -18,6 +18,8 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
|
||||
|
||||
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
|
||||
|
||||
[已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)阻止已接受转换复用原来的头版本递增来授权后续破坏性变更。向后兼容的演进在相同 Session 版本上使用新的确认记录;破坏性变更要求更高的写入器版本。检查点不改变未知事件协议。
|
||||
|
||||
### 写入器与发布真源
|
||||
|
||||
`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。
|
||||
|
||||
+2
-2
@@ -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-25-electron-desktop-packaging-and-updates.md
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: c4940942a8d5188faf1846158873c052a43e1fc1
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 011624980f6f7239a33fb2b6c6803646b7d41dd3
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763
|
||||
+2
@@ -40,6 +40,8 @@ The Desktop Host exposes the shared Web plugin manager for its reserved profile
|
||||
|
||||
The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload provides boot readiness and failure reporting, native directory selection, theme synchronization, and Windows menu and appearance adapters. It exposes no raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron menus and native dialogs use typed English or Chinese copy with English fallback; Windows follows the main document’s language. The shared Web plugin manager owns its client copy.
|
||||
|
||||
Update confirmations use the shell static origin because they must remain available without a ready Web Host. Their packaged documents and assets retain the same method and path restrictions as other local static assets.
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
```text
|
||||
|
||||
+2
@@ -40,6 +40,8 @@ Desktop Host 为保留 profile 提供共享 Web 插件管理器,并通过启
|
||||
|
||||
渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 提供启动就绪与失败报告、原生目录选择、主题同步,以及 Windows 菜单和外观适配。它不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 菜单与原生对话框使用类型化中英文文案,并以英文回退;Windows 跟随主文档语言。共享 Web 插件管理器负责自身客户端文案。
|
||||
|
||||
更新确认使用 shell 静态 origin,因为 Web Host 尚未就绪时也必须可用。其打包文档和资源保留与其他本地静态资源相同的方法和路径限制。
|
||||
|
||||
## 文件系统布局
|
||||
|
||||
```text
|
||||
|
||||
+2
-2
@@ -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-25-session-observations-and-projection-owned-client-state.md
|
||||
2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d
|
||||
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91
|
||||
2026-08-25-session-observations-and-projection-owned-client-state.md: 8573ea89fae6f52a9d28cc75261aea529df4f075
|
||||
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 55c477e994df72ef8f6b6e4bb97b8b35e5783c9b
|
||||
+2
-2
@@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi
|
||||
| Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
|
||||
| Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
|
||||
|
||||
The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence.
|
||||
The Client stores one row per key. A row the connected Host computed carries its sequence number: a newer baseline or frame replaces it, an equal or older input is ignored, and reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. A list hint viewed from the persisted cache carries no comparable sequence number and yields to every Host-sequenced write ([projection cache listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)).
|
||||
|
||||
The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority.
|
||||
|
||||
The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row.
|
||||
The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames. Higher-sequence-wins applies among Host-sequenced values; cached list hints sit below all of them. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer sequenced row.
|
||||
|
||||
Data that is not derived from one Session remains outside projections. `session/modelCatalog` owns the Host-generation model catalog, and `agentPresets/list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict.
|
||||
|
||||
|
||||
+2
-2
@@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同:
|
||||
| Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
|
||||
| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
|
||||
|
||||
Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。
|
||||
Client 为每个 key 保存一行。由建连 Host 算出的行带 sequence number:更新的 baseline 或 frame 会替换它,相同或更旧的输入被忽略,因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。从持久化缓存看出来的 list hint 不带可比的 sequence number,让位于所有 Host 排序过的写入([Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。
|
||||
|
||||
List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。
|
||||
|
||||
每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。
|
||||
每个 Session 的 Client projection store 接收 list hints、follow baseline 和后续 whole-value frame。higher-sequence-wins 只在 Host 排序过的值之间生效;cached list hint 排在它们全部之下。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 sequenced row。
|
||||
|
||||
不由单个 Session 派生的数据不进入 projection。`session/modelCatalog` 持有当前 Host generation 的 model catalog,`agentPresets/list` 持有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。
|
||||
|
||||
|
||||
+2
-2
@@ -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-28-subprocess-native-containment.md
|
||||
2026-08-28-subprocess-native-containment.md: 64fd59e13ea38d7f6e066d5dadec4be830c9c0af
|
||||
2026-08-28-subprocess-native-containment.zh.md: 59e51c5a5c19f55058eb3881928d4f218a22a59c
|
||||
2026-08-28-subprocess-native-containment.md: 88acee2d41fab878a0e50380cbb87eaece8e7296
|
||||
2026-08-28-subprocess-native-containment.zh.md: 53406f99531953a0330abf08be150da845f37aab
|
||||
@@ -46,6 +46,8 @@ Linux falls back before target execution when the exact bootstrap, modern readab
|
||||
|
||||
Normal Cordis disposal starts direct-result and range observation independently, requests termination, and waits for every owned range. Consumer teardown does not inspect an ordinary PID; it retains the original operation or startup error while attempting terminate and final wait, and preserves cleanup failures in the consumer's existing error order. A confirmed empty range permanently disables later signalling against stale identities.
|
||||
|
||||
Native Windows fixtures retain the same Job-owned termination and final wait on success and failure. A descendant PID is observation-only: issuing `taskkill` after its exit can terminate an unrelated process that has reused the number, including another concurrent test worker.
|
||||
|
||||
During a JavaScript-observable host exit, `LocalSubprocessRuntime` synchronously force-terminates every still-live handle without promises or timers. Linux sends the existing direct fallback kill and the exact scope kill; Windows kills the runner so its only Job handle closes; PTY fallback scans remain best effort. Per-handle failures are contained and do not change the host's exit result. Termination modes in which JavaScript cannot run remain outside this listener's guarantee.
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
@@ -46,6 +46,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
|
||||
|
||||
正常 Cordis dispose 会独立启动 direct-result 与 range observation、请求终止,并等待每个自有 range。消费方 teardown 不检查普通 PID;它会保留原始 operation 或 startup error,同时尝试 terminate 与 final wait,并按消费方既有错误顺序保留 cleanup failure。range 一旦被确认为空,就会永久禁止后续向陈旧 identity 发送信号。
|
||||
|
||||
原生 Windows fixture 在成功和失败时都通过同一个 Job owner 请求终止并完成最终等待。后代 PID 仅用于观察:进程退出后再执行 `taskkill`,可能终止已复用该编号的无关进程,包括并发运行的其他测试 worker。
|
||||
|
||||
在 JavaScript 可观察的 host exit 期间,`LocalSubprocessRuntime` 会同步强制终止每个仍存活的句柄,不使用 Promise 或 timer。Linux 会发送既有 direct fallback kill 与准确 scope kill;Windows 会终止 runner,使其唯一 Job handle 关闭;PTY fallback 扫描仍是 best effort。每个句柄的失败相互隔离,也不改变宿主退出结果。JavaScript 无法运行的终止形态不属于该 listener 的保证。
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
+2
-2
@@ -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: d566459af64f4177ed0135813e75aaf77480823c
|
||||
2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2
|
||||
2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
|
||||
2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
|
||||
+46
-1
@@ -59,6 +59,7 @@ JSONL record
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ v3-to-v4 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
@@ -66,6 +67,8 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
|
||||
|
||||
### Adjacent version ownership
|
||||
|
||||
The [V3-to-V4 specification](../../../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) specifies parent catalog completion: it advances the header version, preserves admitted events and inherited cuts, and appends missing own catalog entries from child evidence. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.
|
||||
|
||||
The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
|
||||
|
||||
The [V2-to-V3 README](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is the single specification for that edge's transformations, preservation, and refusal; its separate [native admission section](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [system-prompt](2026-09-02-system-prompt-as-surface-node.md), [PTC](../feature/2026-06-15-ptc.md), and [canonical-envelope](2026-09-06-v3-canonical-session-envelopes.md) notes retain their independent rationale, not duplicate conversion specifications. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
|
||||
@@ -76,7 +79,7 @@ Preset renames cover the creation header and every selection event because the l
|
||||
|
||||
A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
|
||||
|
||||
The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs.
|
||||
The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming-converter fixes affect later historical conversions, not existing target files; the [cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns compatibility review and alpha support policy. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. Integration tests use isolated disposable homes and unchanged historical inputs.
|
||||
|
||||
The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
|
||||
|
||||
@@ -96,10 +99,14 @@ Current encoding is record based. The provider serializes about 1 MiB of plainte
|
||||
|
||||
The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.
|
||||
|
||||
The [browser preview packer](../../../../packages/experimental/webworker-packer/README.md) prepares current successors for bundled Session data in Node, using same-root direct-child descriptors as parent catalog evidence. The browser host does not implement `node:worker_threads`, so this preparation preserves both immutable fixture generations and runtime JSONL’s Worker verification requirement.
|
||||
|
||||
Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
|
||||
|
||||
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
|
||||
|
||||
The bulk V4 migration command shares one JSONL persistence Context across a bounded job queue, retaining the backend’s two-Worker verification limit and cache for reusing recently decoded logs. A child’s concurrent publication can change its parent’s selected child generation; the ordinary revision check refuses that attempt. The command defers only this source-change error for one sequential retry after the initial queue drains, collecting fresh evidence without relaxing publication checks or retrying unrelated failures.
|
||||
|
||||
### Durable format and publication rules
|
||||
|
||||
Canonical filenames encode physical format generation: v0 is `session.jsonl[.zstd]` and positive generations use `session.vN.jsonl[.zstd]`. Migration never moves, replaces, or deletes a committed generation and writes only the final current target; intermediate versions exist only as stage state.
|
||||
@@ -122,6 +129,44 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
|
||||
| Verification repeats on the main thread | At most two complete-generation Workers | Keeps verification CPU off the main thread |
|
||||
| Production and fixture migration use different APIs | Catalog `createRestore()` with explicit policies | One decoder/chain implementation |
|
||||
|
||||
### Parent catalog prerequisites
|
||||
|
||||
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
|
||||
|
||||
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
|
||||
|
||||
Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only `stat`/`list` and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.
|
||||
|
||||
The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.
|
||||
|
||||
<a id="catalog-scan-measurements"></a>
|
||||
### Catalog scan measurements
|
||||
|
||||
The package-local [diagnostic](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts) measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.
|
||||
|
||||
On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.
|
||||
|
||||
| Unrelated Sessions | Historical stat | Cold read | Memo read | Publish | Current warm read |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
|
||||
| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
|
||||
| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
|
||||
| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
|
||||
| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
|
||||
| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
|
||||
| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
|
||||
| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
|
||||
| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
|
||||
|
||||
Run from the repository root after building the runtime:
|
||||
|
||||
```sh
|
||||
pnpm run build:lib:host
|
||||
cd packages/session/session-persistence-jsonl
|
||||
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
|
||||
node .artifacts/perf/catalog-migration.perf.mjs
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.
|
||||
|
||||
+46
-1
@@ -59,6 +59,7 @@ JSONL record
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ v3-to-v4 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
@@ -66,6 +67,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
### 相邻版本所有权
|
||||
|
||||
[V3 到 V4 规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)规定父目录补全:它推进 header 版本,保留已接纳的事件与继承切点,并根据子 Session 证据追加缺失的自身目录记录。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。
|
||||
|
||||
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
|
||||
|
||||
[V2 到 V3 README](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是该迁移边转换、保留与拒绝规则的单一规范真源;单列的[原生准入章节](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[系统提示词](2026-09-02-system-prompt-as-surface-node.zh.md)、[PTC](../feature/2026-06-15-ptc.zh.md)和[规范信封](2026-09-06-v3-canonical-session-envelopes.zh.md)记录保留各自独立依据,而非重复转换规范。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
|
||||
@@ -76,7 +79,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
|
||||
|
||||
[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
|
||||
[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器修复影响之后的历史转换,不影响已有目标格式文件;[cookbook](../../../../docs/cookbook/adding-a-session-format-version.zh.md)拥有兼容性评审和 alpha 支持政策。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
|
||||
|
||||
[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
|
||||
|
||||
@@ -96,10 +99,14 @@ Current encode 以单条 record 为单位。Provider 在主线程每个 slice
|
||||
|
||||
发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。
|
||||
|
||||
[浏览器 preview 打包器](../../../../packages/experimental/webworker-packer/README.zh.md)在 Node 中为随包 Session 数据准备当前代际后继,并使用同一根目录中直属子 Session 的 descriptor 作为父目录证据。浏览器 host 未实现 `node:worker_threads`,因此打包阶段的准备同时保留不可变 fixture 代际与运行时 JSONL 的 Worker 校验要求。
|
||||
|
||||
Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
|
||||
|
||||
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
|
||||
|
||||
批量 V4 迁移命令在有界任务队列中共享一个 JSONL persistence Context,保留 backend 的双 Worker 校验上限,以及复用近期已解码日志的缓存。子 Session 并发发布可能改变父 Session 选中的子代际;普通修订检查会拒绝这次尝试。命令只把这种源变化错误延后到初始队列清空后串行重试一次,重新收集证据,不放宽发布检查,也不重试无关失败。
|
||||
|
||||
### Durable format 与 publication 规则
|
||||
|
||||
规范文件名编码 physical format generation:v0 使用 `session.jsonl[.zstd]`,正 generation 使用 `session.vN.jsonl[.zstd]`。Migration 不会移动、覆盖或删除任何 committed generation,并且只写最终 current target;中间版本只存在于 stage state。
|
||||
@@ -122,6 +129,44 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
|
||||
| 主线程重复执行完整 verification | 最多两个 complete-generation Worker | Verification CPU 不占用主线程 |
|
||||
| Production 与 fixture 使用不同 migration API | Catalog `createRestore()` 加显式 policy | 只保留一套 decoder/chain 实现 |
|
||||
|
||||
### 父目录前置事实
|
||||
|
||||
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
|
||||
|
||||
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
|
||||
|
||||
历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 `stat`/`list` 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。
|
||||
|
||||
通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。
|
||||
|
||||
<a id="catalog-scan-measurements"></a>
|
||||
### 目录扫描测量
|
||||
|
||||
包内[诊断脚本](../../../../packages/session/session-persistence-jsonl/tests/catalog-migration.perf.ts)测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。
|
||||
|
||||
以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。
|
||||
|
||||
| 无关 Session | 历史 stat | 冷读 | 缓存读取 | 发布 | 当前格式热读 |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 0 | 5.17 | 19.00 | 1.36 | 42.88 | 0.47 |
|
||||
| 0 | 3.30 | 14.87 | 1.45 | 35.75 | 0.41 |
|
||||
| 0 | 3.18 | 16.71 | 1.50 | 34.51 | 0.47 |
|
||||
| 100 | 29.44 | 77.48 | 17.01 | 60.66 | 0.43 |
|
||||
| 100 | 12.42 | 42.71 | 13.69 | 60.19 | 0.43 |
|
||||
| 100 | 11.19 | 41.06 | 12.61 | 57.18 | 0.39 |
|
||||
| 1000 | 84.14 | 237.97 | 109.86 | 284.96 | 0.48 |
|
||||
| 1000 | 99.41 | 259.88 | 123.50 | 268.46 | 0.41 |
|
||||
| 1000 | 75.40 | 236.64 | 110.98 | 247.69 | 0.39 |
|
||||
|
||||
在仓库根目录构建运行时后执行:
|
||||
|
||||
```sh
|
||||
pnpm run build:lib:host
|
||||
cd packages/session/session-persistence-jsonl
|
||||
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
|
||||
node .artifacts/perf/catalog-migration.perf.mjs
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
|
||||
2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848
|
||||
2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb
|
||||
2026-09-01-parent-owned-subagent-catalog.md: 536ec33148bd675a974622b98ee23de812691dc0
|
||||
2026-09-01-parent-owned-subagent-catalog.zh.md: ec78ff9e4b7d54a7309baf21c383ec5ff83a19c8
|
||||
@@ -44,6 +44,6 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
|
||||
|
||||
## Consequences
|
||||
|
||||
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
|
||||
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
|
||||
|
||||
Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs.
|
||||
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
|
||||
+2
-2
@@ -44,6 +44,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
|
||||
|
||||
## 后果
|
||||
|
||||
Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
|
||||
Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
|
||||
|
||||
不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实。
|
||||
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
|
||||
2026-09-02-projcache-cross-version-read-compat.md: 82f7f26d5cd0484f5adcd51db17748f41a298294
|
||||
2026-09-02-projcache-cross-version-read-compat.zh.md: 58705ec8f15fbffff6af31872a69f1f19ef5e3c5
|
||||
2026-09-02-projcache-cross-version-read-compat.md: 160c5c796bacc6f672b06acc0df4246d7008c52b
|
||||
2026-09-02-projcache-cross-version-read-compat.zh.md: cff7742f6c1f887b905a2c139e4157782aef677c
|
||||
+1
-1
@@ -39,7 +39,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write
|
||||
5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values.
|
||||
6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `<key>.json.bak.<YYYYMMDDHHmm>`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage.
|
||||
|
||||
7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint uses `asOfSeq: -1` rather than the stored row sequence because a cardinality-changing log migration can remap that coordinate. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent.
|
||||
7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint carries the stored title row's own watermark as `asOfSeq`; because a cardinality-changing log migration can remap that coordinate, the Session list labels the block `kind: 'cached'` and the client never compares it with the connected Session's seqs ([listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent.
|
||||
|
||||
### v3-v6 → v7 disposition
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只
|
||||
5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。
|
||||
6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `<key>.json.bak.<YYYYMMDDHHmm>`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。
|
||||
|
||||
7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 使用 `asOfSeq: -1`,而不使用存储 row 的序号,因为改变日志事件数量的迁移会重新映射该坐标。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。
|
||||
7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 的 `asOfSeq` 是存储 title row 自己的水位;因为改变日志事件数量的迁移会重新映射该坐标,Session list 把该 block 标为 `kind: 'cached'`,客户端不会拿它与建连 Session 的 seq 比较([只读面按 lifecycle 身份匹配与 cached 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。
|
||||
|
||||
### v3-v6 → v7 处置
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md
|
||||
2026-09-06-v3-canonical-session-envelopes.md: 17ec331cc1ea21f31d0b51eab86d22725b6deef8
|
||||
2026-09-06-v3-canonical-session-envelopes.zh.md: a18bb3b4d71c164e3fba7288cc686a1cb7c7b4df
|
||||
2026-09-06-v3-canonical-session-envelopes.md: 68d4386b9e06025fd44d6d9054a95fb51ec8bc3e
|
||||
2026-09-06-v3-canonical-session-envelopes.zh.md: d3bfc17fa263f8edb0b6f73ce59c626d772dbc1e
|
||||
@@ -14,7 +14,7 @@ Session format V3 uses one canonical event envelope. Every `system/message`, `us
|
||||
|
||||
`SurfaceOp` is exactly `'append'` or `{ op: 'replace', startSeq, endSeq }`, with `SessionSeq` endpoints and no aliases or extra keys. Both endpoints precede the replacing event and identify an inclusive span in current surface order, not numeric sequence order. Session acceptance additionally verifies current membership, ordered endpoints, complete cited coverage, and content-only single-node tool-result replacement. Compaction payload fields such as `shadowedRange.start/end` and fold-result fields retain their own names; this is not a recursive payload rename.
|
||||
|
||||
Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.content[0].isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
|
||||
Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
|
||||
|
||||
### Validation ownership
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Session 格式 V3 使用一种规范事件信封。每个 `system/message`、`us
|
||||
|
||||
`SurfaceOp` 恰好为 `'append'` 或 `{ op: 'replace', startSeq, endSeq }`,端点使用 `SessionSeq`,不接受别名或额外键。两个端点都早于替换事件,并按当前 surface 顺序而非数值序号顺序标识闭区间。Session 接纳还验证当前成员关系、端点顺序、完整引用覆盖与仅修改内容的单节点工具结果替换。`shadowedRange.start/end` 等压缩(compaction)载荷字段与折叠结果字段保留各自名称;这不是对载荷进行递归重命名。
|
||||
|
||||
当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.content[0].isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
|
||||
当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
|
||||
|
||||
### 校验所有权
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
|
||||
2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
|
||||
2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62
|
||||
2026-09-09-plugin-management-in-the-web-sidebar.md: ba359aeefe3883445fd5f3e3dc164262e0613219
|
||||
2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 91664deeb422c1dc6c34f826a85f398183128d1c
|
||||
+4
-4
@@ -10,9 +10,9 @@ Installed packages belong to the running profile, while Settings is a modal over
|
||||
|
||||
## Decision
|
||||
|
||||
**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own.
|
||||
**Management is a sidebar entry.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. It lists installed bundles and installation-provided optional bundles. Settings keeps the read-only plugin inventory, including the installation's own bundles (`dsh-base`, `dsh-web-app`); both inventory groups start collapsed, and the inventory carries no management controls. Configuration placement follows the [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md).
|
||||
|
||||
**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
|
||||
**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration forms use the existing global settings bindings.
|
||||
|
||||
**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
|
||||
|
||||
@@ -20,11 +20,11 @@ Installed packages belong to the running profile, while Settings is a modal over
|
||||
|
||||
**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
|
||||
|
||||
**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
|
||||
**Configuration placement.** The [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md) owns this choice and its alternatives; sidebar navigation and installation-request ownership remain independent of it.
|
||||
|
||||
## Consequences
|
||||
|
||||
The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
|
||||
The web bundle's **Plugins** sidebar entry opens profile management. Settings exposes the read-only inventory, while plugin forms live on the Plugins page. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar; `plugin-config` and `settings-chrome` exercise the forms and read-only inventory respectively.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
# Agent Note:插件管理移到 Web 侧栏
|
||||
# Agent Note: 插件管理移到 Web 侧栏
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -10,9 +10,9 @@ Status: implemented
|
||||
|
||||
## 决定
|
||||
|
||||
**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。
|
||||
**管理位于侧栏。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。它列出已安装的组合包与安装随附的可选组合包。设置保留只读插件清单,包括随安装提供的组合包(`dsh-base`、`dsh-web-app`);清单的两个分组默认收起,且不带管理控件。配置的位置遵循[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)。
|
||||
|
||||
**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
|
||||
**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置表单使用原有全局 settings 绑定。
|
||||
|
||||
**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
|
||||
|
||||
@@ -20,11 +20,11 @@ Status: implemented
|
||||
|
||||
**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
|
||||
|
||||
**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
|
||||
**配置的位置。** 该选择及其替代方案由[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)维护;侧栏导航与安装请求的归属独立于它。
|
||||
|
||||
## 后果
|
||||
|
||||
web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
|
||||
web bundle 的**插件**侧栏入口打开 profile 管理。设置提供只读清单,插件表单位于插件页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器;`plugin-config` 与 `settings-chrome` 分别验证表单和只读清单。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md
|
||||
2026-09-09-producer-owned-message-sources.md: 83f8cb9368b516b7b6fde8717f9e5f8e9d4f7d4e
|
||||
2026-09-09-producer-owned-message-sources.zh.md: f5cdfc48286f7b92883938c8e316a7751580f52c
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Producer-owned message sources at the V4 edge
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-09-producer-owned-message-sources.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Released V3 plugin attribution uses the shared wrapper `{ kind: 'plugin', plugin: '<producer>' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper.
|
||||
|
||||
## Decision
|
||||
|
||||
Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:` before the complete original name, while direct source kinds retain their original names. The prefix keeps a plugin named `user` separate from the human-user kind; this edge leaves direct attribution unchanged. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data.
|
||||
|
||||
Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules.
|
||||
|
||||
The source rewrite is owned by the V3-to-V4 migration stage and runs once while historical events become V4 events. The current V4 codec retains only the released physical framing and native V4 admission; it never sends a V4 row through the released V3 validator and has no source-conversion view in its encode, decode, restore, or row-admission paths.
|
||||
|
||||
Recorded current V4 fixtures and writer, notification, and stream-json sidecars are producer-owned; released V2/V3 fixtures and sidecars keep plugin attribution and convert at read time through the migration. The [released session-format migrations](2026-08-31-released-session-format-migrations.md) note owns the versioning rules this edge follows.
|
||||
|
||||
The [source-attribution policy](2026-09-17-persistence-attribution-policy.md) distinguishes durable source declarations from request-only user inputs and records the narrowly scoped compatibility promise for new attribution kinds. Frozen migration rules retain historical producer kinds even when no active producer declares them.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the released plugin wrapper as the current shape.** Consumers would keep switching on one synthetic kind and the producer identity stays an untyped string; the current semantic validators would keep a second, unreleased source vocabulary.
|
||||
|
||||
**Rewrite released rows only at scan time instead of migrating them.** Scan admission is deliberately shape-only and refuses, never repairs; a scan-time rewrite would blur released generations with current rows and leave the current artifact with version-inappropriate source identities.
|
||||
|
||||
**Reject plugin-kind rows as ordinary corruption in the scanner.** That would let a stale released row silently truncate a current Session instead of surfacing a refusal, and would misclassify rows that full decoding still needs to diagnose.
|
||||
|
||||
## Consequences
|
||||
|
||||
The recorded corpus for this migration applies the frozen rename table, while current-generation validation consumes producer-owned kinds directly. Web context labels render the producer kind (`runtime-context`) instead of the released plugin string. The shared rewriter reports malformed released sources as `SessionFormatError`, so a malformed historical source is a migration refusal rather than a silent payload loss.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: V4 边缘上的生产者归属消息源
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-09-producer-owned-message-sources.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
已发布 V3 的插件归属使用共享包装 `{ kind: 'plugin', plugin: '<producer>' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。
|
||||
|
||||
## Decision
|
||||
|
||||
消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:`,直接 source kind 则保留原名。该前缀将名为 `user` 的插件与人类用户 kind 区分开;本边保持直接归属不变。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。
|
||||
|
||||
原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。
|
||||
|
||||
来源重写由 V3 到 V4 迁移 Stage 负责,在历史事件转换为 V4 事件时只执行一次。当前 V4 编解码器只保留已发布的物理分帧和原生 V4 准入;它不会让 V4 行进入已发布 V3 校验器,编码、解码、恢复和行准入路径也没有来源转换视图。
|
||||
|
||||
录制的当前 V4 fixtures 与 writer、notification、stream-json 侧车是生产者归属的;已发布的 V2/V3fixtures 与侧车保留 plugin 归属,读取时经迁移转换。[released session-format migrations](2026-08-31-released-session-format-migrations.zh.md)笔记拥有本边所遵循的版本规则。
|
||||
|
||||
[消息源归属策略](2026-09-17-persistence-attribution-policy.zh.md)区分持久化 source 声明与仅用于请求的 user 输入,并记录新增归属 kind 的受限兼容性承诺。即使没有活动生产者声明,冻结迁移规则仍保留历史生产者 kind。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 released plugin 包装作为当前形态。** 消费方将继续特殊对待一个合成 kind,生产者身份仍是无类型字符串;当前语义校验器也会保留第二套非发布的消息源词汇。
|
||||
|
||||
**只在扫描时重写 released 行而不是迁移它们。** 扫描准入刻意只查形态并拒绝、从不修复;扫描时重写会模糊 released 世代与当前行的界限,使当前产物保留与版本不符的来源身份。
|
||||
|
||||
**在 scanner 中把 plugin-kind 行当作普通损坏拒绝。** 这会让过期的 released 行静默截断当前 Session,而不是给出拒绝;也会把完整解码仍需要诊断的行错误分类。
|
||||
|
||||
## Consequences
|
||||
|
||||
本迁移的记录语料库应用冻结重命名表,而当前代校验器直接消费生产者自有 kind。Web 上下文标签渲染生产者 kind(`runtime-context`)而不是 released plugin 字符串。共享重写器将畸形历史来源报告为 `SessionFormatError`,因此迁移会拒绝它,而不会静默丢失载荷。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
|
||||
2026-09-10-image-offload-events.md: f540dc98cee8daf3e360b14234a2ac21d0f53aab
|
||||
2026-09-10-image-offload-events.zh.md: 868fca59ba5fc1941de752127f61b091aedb234f
|
||||
2026-09-10-image-offload-events.md: e80c4ae17b148d9eb46517b92111a8d768160def
|
||||
2026-09-10-image-offload-events.zh.md: 2aa2b8b1fbffd117b5d716aba28755a02a155bb8
|
||||
@@ -14,7 +14,7 @@ A request can exceed an image-capable route's byte or image-count budget. Recomp
|
||||
|
||||
Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged.
|
||||
|
||||
The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
|
||||
The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including tool-role result content and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
|
||||
|
||||
The event has no `surfaceOp`. It neither creates a message nor replaces a message node. The plugin's pure projection validates all targets before Session commits the event and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。
|
||||
|
||||
事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
|
||||
事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括 tool-role 结果内容和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
|
||||
|
||||
事件不带 `surfaceOp`,不创建消息,也不替换消息节点。插件的纯投影在 Session 提交事件前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
|
||||
2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44
|
||||
2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442
|
||||
2026-09-10-public-package-manifest.md: 2d35a3ad17c5aa62df4a5eccde351f325936a84f
|
||||
2026-09-10-public-package-manifest.zh.md: 7dc03e5160b3837ea07e19469b1d8a0a4f64ebe6
|
||||
@@ -14,7 +14,7 @@ Plugin authors need npm identity, runtime requirements, and DSH declarations fro
|
||||
|
||||
Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
|
||||
|
||||
The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
|
||||
The image packer owns `configTrees`, and the workspace catalog generator owns Session migration declarations. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
|
||||
|
||||
Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
|
||||
|
||||
镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
|
||||
镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
|
||||
|
||||
各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-client-session-references.md
|
||||
2026-09-15-client-session-references.md: 2d727809b237ca919296dac82a3b201ba2dd8fbf
|
||||
2026-09-15-client-session-references.zh.md: 34fa6526a57f43fd7636c68c7ed72bb982a129f2
|
||||
2026-09-15-client-session-references.md: 162ef08875e5b0591b3b4099c9bdcadb18e7d175
|
||||
2026-09-15-client-session-references.zh.md: 3af8932f7c002b3b63de21c689ae91e3017a164e
|
||||
@@ -73,7 +73,7 @@ The Controller owns source counts alongside each live generation's references. F
|
||||
|
||||
Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them.
|
||||
|
||||
The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
|
||||
The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` contains Host summaries, projected child-catalog rows, and fallback rows for retained subagents with a known direct-parent address. Acquisition publishes a missing subagent row synchronously so Provider discovery does not depend on parent-catalog arrival; title projections and reference-source counts continue to update that row. Ordinary retained Sessions do not synthesize fallback rows. `ids` remains the Host-list membership and ordering. Consumers of an unlisted generation use its binding and `retainInfo` directly. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
|
||||
|
||||
For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum.
|
||||
|
||||
@@ -101,7 +101,7 @@ Source counts remain authoritative in `SessionListState.byId[id].retainedBy`; UI
|
||||
|
||||
Pending domains retain `SessionPendingInteractionMap`, request identities, precedence, publication disposers, and teardown delegation. The unified status includes the same effective request object; it does not copy requests or create a second pending registry. Workspace status indicators and Conversation composer selection read `useSessionStatus` instead of separate `useSessionPendingInteraction` and `useCompletedSessionIds` hooks.
|
||||
|
||||
Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Catalog snapshots establish initial and reconnect baselines. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
|
||||
Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Host-list rows (`ids`) establish initial and reconnect running baselines; synthetic catalog and retained rows preserve independently observed status. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
|
||||
|
||||
- An initial idle baseline does not create a completion reminder.
|
||||
- Observing running clears an earlier reminder and records the running baseline.
|
||||
@@ -141,7 +141,7 @@ Scoped business objects capture their binding before awaiting work. They do not
|
||||
|
||||
### Main-area navigation and presentation
|
||||
|
||||
`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
|
||||
`uiWorkspace.openSession`, `openWorkspace`, and `startSession` are the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. Sidebar `forkSession` creates and renames the child without retaining it or changing selection. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
|
||||
|
||||
The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup.
|
||||
|
||||
@@ -160,7 +160,7 @@ Source metadata does not change when DOM focus moves or when a global panel hide
|
||||
|
||||
Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler.
|
||||
|
||||
Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
|
||||
Workspace connection preserves its navigation guards and panel-switch invalidation. Sidebar fork does not supersede an in-flight navigation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
|
||||
|
||||
### Presets and creation flows
|
||||
|
||||
@@ -221,6 +221,6 @@ Consumers must keep references until their real completion point; an unreleased
|
||||
|
||||
Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values.
|
||||
|
||||
Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation.
|
||||
Every public `retain`, including the temporary reference used by ordinary Session rename, starts the generation's shared initial history opening. Fork-title assignment instead sends rename directly and applies its returned projection without a Client reference or history I/O. A metadata-only fork operation therefore cannot create a temporary list row or switch the main view.
|
||||
|
||||
The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator.
|
||||
@@ -73,7 +73,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源
|
||||
|
||||
每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。
|
||||
|
||||
即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
|
||||
即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 包含 Host 摘要、子会话目录投影行,以及已保留且具有明确直接父子地址的 subagent 兜底行。获取引用时同步发布缺失的 subagent 行,使 Provider 发现不依赖父目录到达;标题投影和引用来源计数继续更新该行。普通被保留 Session 不合成兜底行。`ids` 仍表示 Host 列表成员关系与顺序。未列出 generation 的使用方直接读取其 binding 与 `retainInfo`。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
|
||||
|
||||
例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。
|
||||
|
||||
@@ -101,7 +101,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源
|
||||
|
||||
待处理领域保留 `SessionPendingInteractionMap`、请求身份、优先级、发布清理函数与卸载委托。统一状态包含同一个有效请求对象,不复制请求,也不创建第二套待处理注册表。Workspace 状态指示器与 Conversation composer 选择读取 `useSessionStatus`,不再分别读取 `useSessionPendingInteraction` 和 `useCompletedSessionIds` 钩子。
|
||||
|
||||
完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。目录快照建立初始与重连基线。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
|
||||
完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。Host 列表行(`ids`)建立初始与重连的 running 基线;目录合成行与 retained 行保留独立观察到的状态。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
|
||||
|
||||
- 初始 idle 基线不产生完成提醒。
|
||||
- 观察到 running 时清除旧提醒,并记录运行基线。
|
||||
@@ -141,7 +141,7 @@ Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或
|
||||
|
||||
### 主区域导航与展示
|
||||
|
||||
`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
|
||||
`uiWorkspace.openSession`、`openWorkspace` 和 `startSession` 是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。侧边栏 `forkSession` 创建并重命名子会话,不 retain 子会话,也不改变选择。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
|
||||
|
||||
现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。
|
||||
|
||||
@@ -160,7 +160,7 @@ DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据
|
||||
|
||||
Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。
|
||||
|
||||
Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
|
||||
Workspace 连接保持既有导航检查与面板切换失效规则。侧边栏 fork 不替代尚未完成的导航。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
|
||||
|
||||
### 预设与创建流程
|
||||
|
||||
@@ -221,6 +221,6 @@ Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Con
|
||||
|
||||
最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。
|
||||
|
||||
每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。
|
||||
每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括普通 Session 重命名所用的临时引用。Fork 标题设置直接发送 rename 并应用返回的投影,不获取 Client 引用,也不读取历史。因此,仅修改元数据的 fork 操作不会创建列表临时行或切换主视图。
|
||||
|
||||
目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md
|
||||
2026-09-15-first-class-tool-role-messages.md: 3749ad698890073634b1753d3370f9c9dc9a2e18
|
||||
2026-09-15-first-class-tool-role-messages.zh.md: c97d84a7b3ceaee17ebb6fe828bd739dba5fb0ea
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agent Note: First-class tool-role messages
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-first-class-tool-role-messages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The released Session format stores tool results inside a user-role `tool-result` content block, while the model conversation and provider protocols treat a tool result as a separate role. Keeping both descriptions active makes validation, replay, and adapter projections disagree.
|
||||
|
||||
## Decision
|
||||
|
||||
Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge.
|
||||
|
||||
Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. Opaque retention avoids inventing a shared field owner when the wrapper disappears. It retains duplicate values from distinct owners, and plugin consumers must use the V4 names. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules.
|
||||
|
||||
The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep wrapper-shaped V4 rows** — this preserves the released payload but leaves the canonical role and provider wire formats inconsistent.
|
||||
- **Accept both wrapper and native V4 rows** — dual current representations make encoding, decoding, recovery, and replay depend on which path produced the artifact.
|
||||
- **Flatten historical nested results** — leaf text does not retain nested call IDs or error status, and providers did not interpret nested content uniformly. Unsupported migration preserves the original data until a lossless conversion is available.
|
||||
- **Keep role extensions open** — an extension could reach a model request without a Session event and could not be reconstructed on replay.
|
||||
|
||||
## Consequences
|
||||
|
||||
The adjacent migration changes the logical message role without changing message identity, tool-call identity, or result content. Native V4 semantic failures are hard admission failures; recoverable decoding still applies to released V3 canonical tails. Adapters map the first-class role to their provider-specific tool-result representation. The duplicated message `toolCallId` and source `callId` remain as an explicit equality-checked provider field and source-identity field pair.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Agent Note:一等 tool-role 消息
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-first-class-tool-role-messages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
已发布的 Session 格式把工具结果存储在 user-role 的 `tool-result` 内容块中,而模型对话与提供方协议把工具结果视为独立角色。两种描述同时存在会使校验、回放和适配器投影互相不一致。
|
||||
|
||||
## 决策
|
||||
|
||||
Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。
|
||||
|
||||
移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。不透明保留避免在 wrapper 消失时虚构共同字段 owner。不同 owner 的重复值仍分别保留,插件消费者必须使用 V4 名称。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。
|
||||
|
||||
对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留包装形式的 V4 行**——保留已发布载荷,但规范角色与提供方 wire 格式仍不一致。
|
||||
- **同时接受包装和原生 V4 行**——两种当前表示会让编码、解码、恢复和回放依赖于产出 artifact 的路径。
|
||||
- **展平历史嵌套结果**——叶子文本无法保留嵌套调用 ID 或错误状态,而且提供方对嵌套内容的解释并不一致。拒绝不支持的迁移会保留原始数据,等待能够保留全部信息的转换。
|
||||
- **保持角色扩展开放**——扩展角色可能在没有 Session 事件的情况下到达模型请求,也无法在回放时重建。
|
||||
|
||||
## 后果
|
||||
|
||||
相邻迁移会改变逻辑消息角色,但不改变消息身份、工具调用身份或结果内容。原生 V4 语义失败属于硬准入失败;可恢复解码仍适用于已发布 V3 的规范尾部。适配器把一等角色映射为各提供方自己的工具结果表示。消息 `toolCallId` 与来源 `callId` 的重复仍作为一对显式且经过相等性校验的提供方字段与来源字段保留。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7
|
||||
+9
-7
@@ -10,21 +10,23 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
|
||||
|
||||
## Decision
|
||||
|
||||
**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `<package name>#<row id>`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin.
|
||||
**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `<package name>#<row id>`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-18-localized-package-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text.
|
||||
|
||||
The page passes `view: 'page'` for each form. Official plugin cards and details also use `view: 'summary'`; a row's detail page uses it only when the plugin has no display description. Bundle configuration renders only `page`. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. Sidebar navigation and installation-request ownership follow the [sidebar management decision](2026-09-09-plugin-management-in-the-web-sidebar.md).
|
||||
|
||||
**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema.
|
||||
|
||||
**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence.
|
||||
**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence.
|
||||
|
||||
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
|
||||
|
||||
**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example.
|
||||
**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
|
||||
- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page.
|
||||
- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded.
|
||||
- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page.
|
||||
- Settings lists the inventory only; the Settings browser goldens cover that read-only section.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,8 +34,8 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
|
||||
|
||||
**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor.
|
||||
|
||||
**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page.
|
||||
**Keeping the cards in Settings and linking to them from the page.** This separates a plugin's description and configuration across different pages.
|
||||
|
||||
## Testing
|
||||
|
||||
`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group.
|
||||
`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page, including description precedence over its summary; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the Official group.
|
||||
+11
-9
@@ -1,6 +1,6 @@
|
||||
# Agent Note:插件页上的插件配置
|
||||
# Agent Note: 插件页上的插件配置
|
||||
|
||||
状态:已实现
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文
|
||||
|
||||
@@ -10,21 +10,23 @@
|
||||
|
||||
## 决策
|
||||
|
||||
**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。
|
||||
**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-18-localized-package-metadata.zh.md);slot 键和技术身份不随展示文本改变。
|
||||
|
||||
页面通过 `view: 'page'` 渲染每个表单。官方插件卡片与详情还使用 `view: 'summary'`;行详情页只在插件没有展示描述时使用它。组合包配置仅渲染 `page`。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。侧栏导航与安装请求归属遵循[侧栏管理决策](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)。
|
||||
|
||||
**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。
|
||||
|
||||
**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。
|
||||
**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。
|
||||
|
||||
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
|
||||
|
||||
**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。
|
||||
**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
|
||||
|
||||
## 后果
|
||||
|
||||
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
|
||||
- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。
|
||||
- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。
|
||||
- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。
|
||||
- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -32,8 +34,8 @@
|
||||
|
||||
**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。
|
||||
|
||||
**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。
|
||||
**把卡片留在设置里、从插件页链接过去。** 这会把一个插件的描述和配置分散到不同页面。
|
||||
|
||||
## 测试
|
||||
|
||||
`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。
|
||||
`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面,包括描述优先于其摘要的规则;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录官方分组。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md
|
||||
2026-09-17-developer-session-changes.md: 0545dfa4344f8ed87fe6343e13901647a5ccdac4
|
||||
2026-09-17-developer-session-changes.zh.md: 81c53115aaf4447dac358febcbd1310bb2a36cb2
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Developer messages for incremental session changes
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-developer-session-changes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Dynamic tool changes need durable, ordered records before providers can preserve the cached conversation prefix while changing available tools. Encoding these changes as free-form reminders would lose their structured tool identity and couple session state to prompt wording.
|
||||
|
||||
## Decision
|
||||
|
||||
`DeveloperMessage` records admitted model-visible session changes in conversation order. Additions store only `toolName`; the containing event's `headerSeq` selects an earlier full `request/header` with exactly one schema for each added name. One event binds all its additions to one header revision. Additions replace the active same-name definition; removals identify the name without duplicating its schema. The header reference is absent when the message contains no additions. Same-name replacement, restart, fork, and surface replacement retain historical definition identity without consulting the current registry or latest request header. Intermediate registry changes that no admitted request exposes do not require developer records.
|
||||
|
||||
`developer/message` carries the message and its admitting turn/step coordinates. Native readers and Session admission reject missing, forward, unknown, non-header, and ambiguous schema references, incomplete referenced definitions, and reject retired inline definitions while preserving unrelated JSON fields. `sourceEventSeqs` remains independent derivation metadata. A replacement retains its selected header and cites the shadowed surface nodes; combining additions from different header revisions requires separate events. Forks retain the referenced prefix, and compaction does not delete header events. Future cardinality-changing migrations must remap `headerSeq` as a same-artifact event reference. Empty developer content retains its surface position without adding a model message or shadowing the protected system head. The tools package owns the `tool-registry` source kind under the [producer-owned source rules](2026-09-09-producer-owned-message-sources.md).
|
||||
|
||||
`ToolAdditionBlock.tool` is declared as optional `never` with `@persistenceReserved`. The schema history therefore records the rejected field: permitting any value later changes an existing value type and requires a format bump, while unrelated optional metadata remains additive.
|
||||
|
||||
`ToolSchema.deferLoading` requests deferred definition loading independently of tool-addition history. Tool construction, registry projection, and prompt assembly preserve the marker. Dynamic addition and definition loading are separate facts; a predeclared deferred definition need not acquire a fabricated addition record.
|
||||
|
||||
The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations.
|
||||
|
||||
The producer requirement has one scoped exception: finalized Session V4 reserves the persisted representation needed by issue #4146. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use text reminders as developer content.** The format represents structured session changes; model instructions belong in system messages. Future developer content is unspecified rather than constrained to reminders.
|
||||
|
||||
**Duplicate definitions in each addition.** Full request headers already preserve the exact schemas. A historical reference avoids a second persisted copy while retaining same-name replacement identity. Binding by the latest header or registry instead would reinterpret older additions.
|
||||
|
||||
**Treat deferred loading as proof of a tool-addition record.** That would prevent predeclared deferred tools. The schema flag describes loading, while the block records a dynamic addition.
|
||||
|
||||
## Consequences
|
||||
|
||||
Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:使用 developer 消息记录增量 Session 变更
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-developer-session-changes.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
动态工具变更需要持久且有序的记录,提供方才能在改变可用工具时保留对话的缓存前缀。将这些变更编码为自由文本提醒会丢失结构化的工具身份,并将 Session 状态与提示词措辞耦合。
|
||||
|
||||
## Decision
|
||||
|
||||
`DeveloperMessage` 按对话顺序记录已接纳且模型可见的 Session 变更。添加块仅存储 `toolName`;所在事件的 `headerSeq` 选择更早的完整 `request/header`,其中每个添加名称必须恰好对应一个模式。同一事件的所有添加绑定到同一个请求头版本。添加替换当前同名定义;移除仅记录名称,不重复模式。没有添加块的消息不携带请求头引用。同名替换、重启、fork 和 surface 替换保留历史定义身份,不查询当前注册表或最新请求头。没有被任何已接纳请求暴露的中间注册表变更无需 developer 记录。
|
||||
|
||||
`developer/message` 携带消息及其接纳 turn/step 坐标。原生读取方和 Session 接纳逻辑拒绝缺失、前向、未知、非请求头及歧义模式引用、不完整的被引用定义,并拒绝已退役的内嵌定义,同时保留无关 JSON 字段。`sourceEventSeqs` 保持为独立的派生元数据。替换保留选定的请求头,并引用被覆盖的 surface 节点;不同请求头版本的添加必须分属不同事件。fork 保留所引用的前缀,压缩不删除请求头事件。未来改变事件数量的迁移必须将 `headerSeq` 作为同一文件内的事件引用重映射。空 developer 内容保留其 surface 位置,不添加模型消息,也不能覆盖受保护的 system 头节点。tools 包按照[生产者自有来源规则](2026-09-09-producer-owned-message-sources.zh.md)拥有 `tool-registry` 来源种类。
|
||||
|
||||
`ToolAdditionBlock.tool` 声明为可选的 `never`,并标注 `@persistenceReserved`。模式历史因此记录这个被拒绝的字段:后续允许任何值都会改变现有值类型并要求格式递增,无关的可选元数据仍属于可兼容添加。
|
||||
|
||||
`ToolSchema.deferLoading` 请求延迟加载工具定义,与工具添加历史相互独立。工具构造、注册表投影与提示词组装保留该标记。动态添加与定义加载是不同事实;预先声明的延迟加载定义无需虚构添加记录。
|
||||
|
||||
V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。
|
||||
|
||||
产生方要求有一个限定例外:已定稿 Session V4 为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**将文本提醒作为 developer 内容。** 此格式表示结构化 Session 变更;模型指令属于 system 消息。未来的 developer 内容尚未指定,不将其限制为提醒。
|
||||
|
||||
**在每次添加中重复定义。** 完整请求头已经保留精确模式。历史引用避免第二份持久化副本,同时保留同名替换的身份。若绑定到最新请求头或注册表,则会重新解释较早的添加。
|
||||
|
||||
**将延迟加载视为存在工具添加记录的证据。** 这会阻止预先声明的延迟工具。模式标记描述加载方式,内容块记录动态添加。
|
||||
|
||||
## Consequences
|
||||
|
||||
Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.md
|
||||
2026-09-17-native-v4-read-validation.md: b111e9f1dd4aae6557b7893a9f334445ef0c85bc
|
||||
2026-09-17-native-v4-read-validation.zh.md: 20f1dc53b6eda42834e169c18f7b269e2933d27c
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Mandatory native V4 relationship validation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-native-v4-read-validation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Tool-role results cannot be checked through the released V3 user-role representation without projecting away current fields. Replacing that validation view requires a native reader to retain the earlier lifecycle, retired-syntax, and system-message checks. The representation change and its mandatory admission therefore belong together.
|
||||
|
||||
An event can satisfy its TypeScript declaration and message rules while contradicting earlier durable events. A tool result without an advertised call and a checkpoint naming another compaction both contain locally valid fields. Accepting either as a current Session makes later readers depend on damaged relationships, and tightening admission after release can strand already-written histories.
|
||||
|
||||
## Decision
|
||||
|
||||
The V4 format restorer and current JSONL scanner share one mandatory cross-event relationship pass before exposing a restored artifact or storage handle. It checks turn and step order, advertised tool calls and settlements, PTC parentage and settlement identity, retry chains and request providers, human-message title citations, command completion references, compaction ownership and current spans, and the protected system head. The installed event set controls semantic interpretation, including compaction prepasses and title references. Unknown ignorable records retain their payloads and original positions; known records remain validated even when marked ignorable. The check reads native event fields without altering artifacts or projecting them into a historical format. JSON argument comparisons inspect own properties in both Node and the worker; inherited prototype members cannot satisfy recorded fields. The [tool-role decision](2026-09-15-first-class-tool-role-messages.md) owns result representation.
|
||||
|
||||
An unfinished tail may retain an open turn, step, tool call, or compaction. A closing event must settle the relationships it closes. An inherited unfinished compaction ends at its `session/end-seed` marker; it does not constrain the child's lifecycle. Repair message identities retain their historical numeric suffix after earlier migrations change sequence coordinates. Fork repairs retain their separately validated native identities.
|
||||
|
||||
Installed Session adoption continues to own event envelopes, message roles and metadata, canonical request headers, and surface replacement reference coverage. Optional runtime invariants provide diagnostics; their installation is not required for durable admission. Native system-message admission also validates coordinates, identity, content blocks, and known image/tool-call fields before physical recovery can discard rows. Additional JSON fields and unknown nonempty content tags remain intact; retired tool-result wrappers are refused. Retired `request/header.header.system` and required `tool/code-dispatch*` tags remain hard refusals before recovery; obsolete ignorable PTC records remain opaque. Physical framing alone does not establish semantic validity.
|
||||
|
||||
A retired tool-result wrapper outside its owning `tool/result` row would require changing the event, role, or source to represent it in V4. The adjacent migration lifts only the canonical owned wrapper; target restoration refuses wrappers remaining in interpreted content. Native row admission applies the same refusal before recoverable tail suppression. The check follows declared message, content, and Assistant-stream slots instead of recursively inspecting JSON, so tool arguments, replay state, schema parameters, nested extensions, and unknown ignorable payloads retain their recorded values.
|
||||
|
||||
`EpochHeader.system` explicitly reserves the retired key as `system?: never` with `@persistenceReserved`. The catalog retains its empty value set so permitting a value is a change to a forbidden field, rather than an ordinary optional-field addition. The native reader and Session adoption continue to reject that key; system prompts remain `system/message` events.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Project V4 back into a frozen V3 validation view.** This couples current acceptance to retired representations and invites new fields to disappear in the projection. Frozen generations remain independently readable, while the current restorer interprets its own fields.
|
||||
|
||||
**Rely on optional runtime invariants.** Detached readers and restoration paths may not install diagnostic companions. A required relationship must be checked before publication or consumption of the restored Session.
|
||||
|
||||
## Consequences
|
||||
|
||||
The native restorer maintains one bounded state machine per read and rejects contradictions before successor publication. Frozen V0–V3 validators and committed Session generations remain unchanged. Public catalog regressions cover invalid relationships alongside partial tails, inherited cuts, immutable unknown attribution, and complete tool exchanges; these checks enforce the declared V4 representation without changing its schemas. Breaking changes require a successor; compatible additions use new acknowledgements, and implementation fixes preserve the declared meaning.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 原生 V4 关系的强制校验
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-native-v4-read-validation.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
tool 角色结果不能通过已发布的 V3 user 角色表示进行校验,否则投影会丢弃当前字段。替代该校验视图后,原生读取方必须保留原有的生命周期、退役语法和 system 消息检查。因此,表示变更与必需的接纳校验需要一同完成。
|
||||
|
||||
事件即使符合 TypeScript 声明和消息规则,也可能与较早的持久化事件矛盾。没有声明调用的工具结果、指向另一压缩事务的检查点,都可以拥有局部有效字段。将它们接纳为当前 Session 会让后续读取器依赖损坏的关系;发布后再收紧准入则可能让已写入的历史无法读取。
|
||||
|
||||
## Decision
|
||||
|
||||
V4 格式恢复器与当前 JSONL 扫描器共用同一强制跨事件关系校验,在公开恢复产物或存储句柄之前执行。它检查 turn 与 step 顺序、声明的工具调用及其结算、PTC 父子关系与结算身份、重试链与请求 provider、标题的人类消息引用、命令完成引用、压缩归属与当前区间,以及受保护的 system 首节点。已安装事件集合控制语义解释,包括压缩预扫描和标题引用。未知的可忽略记录保留其载荷与原始位置;已知记录即使标记为可忽略仍会接受校验。校验直接读取原生事件字段,不修改产物,也不将其投影为历史格式。Node 和 Worker 中的 JSON 参数比较都检查自有属性,继承的原型成员不能代替记录字段。[工具角色决策](2026-09-15-first-class-tool-role-messages.zh.md)负责结果表示。
|
||||
|
||||
未完成的尾部可以保留打开的 turn、step、工具调用或压缩事务。关闭事件必须结清它关闭的关系。继承的未完成压缩事务在其 `session/end-seed` 标记处结束,不约束子 Session 的生命周期。较早迁移改变序号坐标后,修复消息身份仍保留历史数字后缀。fork 修复保留经过独立校验的原生身份。
|
||||
|
||||
已安装的 Session 接纳器继续负责事件封装、消息角色与元数据、规范请求头,以及 surface 替换的引用覆盖。可选运行时不变式提供诊断;持久化准入不要求安装它们。原生 system 消息准入还会在物理恢复可能丢弃行之前,校验坐标、身份、内容块及已知 image/tool-call 字段。额外 JSON 字段和非空的未知内容标签保持不变,退役的 tool-result 包装会被拒绝。退役的 `request/header.header.system` 和必需的 `tool/code-dispatch*` 标签仍在恢复前强制拒绝;标记为可忽略的过时 PTC 记录保持不透明。仅通过物理分帧不能证明语义有效。
|
||||
|
||||
退役的 tool-result 包装若不属于对应的 `tool/result` 行,就需要改变事件、角色或来源才能在 V4 中表示。相邻迁移只提升规范的自有包装;目标恢复器拒绝解释内容中剩余的包装。原生行准入在可恢复尾部被忽略之前执行同样的拒绝。检查沿声明的消息、内容和 Assistant stream 字段进行,不递归扫描 JSON,因此工具参数、replay state、schema 参数、嵌套扩展和未知可忽略载荷保留其记录值。
|
||||
|
||||
`EpochHeader.system` 通过带有 `@persistenceReserved` 的 `system?: never` 显式保留退役键。目录保留其空值集合,使允许某个值被识别为对禁止字段的修改,而非普通可选字段添加。原生读取方和 Session 接纳继续拒绝该键;系统提示词仍由 `system/message` 事件承载。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**将 V4 投影回冻结的 V3 校验视图。** 这会将当前准入与退役表示绑定,并容易让新增字段在投影中消失。冻结代际仍可独立读取,当前恢复器解释自己的字段。
|
||||
|
||||
**依赖可选运行时不变式。** 分离读取器和恢复路径可能不安装诊断组件。必需关系必须在发布或消费恢复后的 Session 之前完成校验。
|
||||
|
||||
## Consequences
|
||||
|
||||
原生恢复器为每次读取维护独立状态机,在发布后继文件之前拒绝矛盾。冻结的 V0–V3 校验器及已提交的 Session 代际保持不变。公开目录回归覆盖无效关系、部分尾部、继承切分点、未知归属的不可变保存,以及完整工具交互;这些检查落实已声明的 V4 表示,不改变其 schema。破坏性变更要求后继版本;兼容新增使用新的确认记录,实现修复须保留声明的含义。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md
|
||||
2026-09-17-persistence-attribution-policy.md: 19bebc8bc186f4b2b7e2e561fb6a53ceadcebabf
|
||||
2026-09-17-persistence-attribution-policy.zh.md: 3561c1d1afca5402cb94b1578603c4f1a2fc89a4
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Recorded user-source attribution compatibility
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-persistence-attribution-policy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A producer-owned message source appears transitively in several persisted event types. A new attribution kind changes their union fingerprints even when existing readers preserve its fields without the producer. Request-only prompts need no durable identity, but constructing them as Session messages adds unnecessary source alternatives to that union.
|
||||
|
||||
## Decision
|
||||
|
||||
The core-owned user-message source property binds the [recorded compatibility policy](../process/2026-09-17-persistence-schema-review.md). A producer separately qualifies its literal wire kind as attribution-only. Both saved schemas must carry compatible policy state before a qualified addition receives a same-version classification. Existing alternatives remain structurally compared; missing policy, unqualified additions, removals, and changes to existing semantic groups retain strict classification. Fixed system, model, and tool source fields do not participate.
|
||||
|
||||
The tmux-context producer qualifies its location attribution. Its own projection uses the kind to avoid repeated injection, while readers without that producer retain the recorded content and every source JSON property. The qualification promises reader independence; it does not forbid producer-local duplicate suppression.
|
||||
|
||||
`RequestMessage` accepts a durable `Message` or a user-only `RequestUserInput` with content and no `id` or `source`. The LLM service and provider serializers accept both; Session writes, Agent delivery, and persisted title-request inputs require durable messages. Auto Review's outer prompt and the compaction summarizer's final instruction use request-only inputs and retain their content and freezing. The caller receives no implicit durable conversion. Assistant replay metadata and tool-result correlation retain their existing requirements.
|
||||
|
||||
The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its producer mappings and extension namespaces, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Treat every source addition as breaking.** This would require a Session-format increment for attribution that readers already retain safely, without distinguishing it from semantic changes.
|
||||
|
||||
**Relax every discriminated union or infer compatibility from current names.** A generic exception would admit semantic variants. Current declarations cannot establish what an older snapshot promised, and structural equality does not establish ownership of a source property.
|
||||
|
||||
**Use durable messages for every request.** Detached prompts would retain manufactured identities and unnecessary source declarations. The explicit user-only alternative permits their removal while typed Session interfaces retain durable identity requirements.
|
||||
|
||||
**Narrow historical context forms to current writer examples.** Writer examples do not establish every value admitted by released readers. Those read-facing variants remain represented.
|
||||
|
||||
## Consequences
|
||||
|
||||
A qualified addition still changes affected event fingerprints and requires an acknowledgement. Reviewers must verify the producer's attribution promise; type analysis cannot establish replay semantics or detect behavior hidden in opaque values.
|
||||
|
||||
Codec-to-Session tests retain unknown user attribution and extra JSON fields without the producer. Provider equality tests compare request-only and durable inputs, while type assertions reject request-only inputs at durable writes. Recorded Session generations and the V3-to-V4 conversion remain independent of catalog policy and rendering.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note:已记录的 user-source 归属兼容性
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-persistence-attribution-policy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
生产者拥有的消息 source 会传递性地出现在多个持久化事件类型中。即使已有读取方无需该生产者也能保留字段,新增归属 kind 仍会改变这些联合类型的指纹。仅供请求使用的提示不需要持久身份,但将其构造成 Session 消息会为联合类型增加不必要的 source 分支。
|
||||
|
||||
## 决策
|
||||
|
||||
核心拥有的 user-message source 属性绑定[已记录的兼容性策略](../process/2026-09-17-persistence-schema-review.zh.md)。生产者另行将其 wire 字面量 kind 声明为仅用于归属。双方保存的 schema 必须具有兼容的策略状态,符合条件的添加才能获得同版本分类。已有分支继续接受结构比较;缺失策略、未标记的添加、删除以及已有语义分组的变更继续采用严格分类。固定的 system、model 和 tool source 字段不参与。
|
||||
|
||||
tmux-context 生产者将位置归属标记为符合条件。其自身投影使用 kind 避免重复注入,而未安装该生产者的读取方保留已记录内容及 source 的每个 JSON 属性。该标记承诺读取方不依赖生产者,并不禁止生产者内部的去重。
|
||||
|
||||
`RequestMessage` 接受持久化 `Message`,或仅含 content、没有 `id` 和 `source` 的 user 专用 `RequestUserInput`。LLM 服务和 provider 序列化器接受两者;Session 写入、Agent 交付及持久化标题请求输入要求持久化消息。Auto Review 的外层提示和压缩摘要器的最终指令使用仅供请求使用的输入,并保留内容与冻结行为。调用方不会获得隐式持久化转换。Assistant 回放元数据和工具结果关联保持原有要求。
|
||||
|
||||
已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留生产者映射与扩展命名空间,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将所有 source 添加视为破坏性变更。** 这会要求为读取方已能安全保留的归属信息提升 Session 格式版本,无法区分它与语义变更。
|
||||
|
||||
**放宽所有可辨识联合或从当前名称推断兼容性。** 通用例外会接纳语义分支。当前声明不能确定旧快照作出的承诺,结构相等也不能确定 source 属性的归属。
|
||||
|
||||
**为每个请求使用持久化消息。** 独立提示会保留人为制造的身份和不必要的 source 声明。显式的 user 专用分支允许移除它们,同时让带类型的 Session 接口继续要求持久身份。
|
||||
|
||||
**将历史 context 形式收窄到当前写入方示例。** 写入方示例不能确定已发布读取方接纳的全部值。这些面向读取的分支继续保留。
|
||||
|
||||
## 影响
|
||||
|
||||
符合条件的添加仍会改变相关事件指纹,并要求确认记录。Review 者必须验证生产者的归属承诺;类型分析不能确定回放语义,也不能发现不透明值中隐藏的行为。
|
||||
|
||||
Codec 到 Session 的测试在未安装生产者时保留未知 user 归属和额外 JSON 字段。Provider 等价测试比较仅供请求使用的输入与持久化输入,类型断言则拒绝在持久化写入处使用仅供请求使用的输入。已记录的 Session 世代及 V3→V4 转换不依赖目录策略和渲染。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.md
|
||||
2026-09-17-session-row-menu-actions-slot.md: 034144b2b7c9d260f41565d3835687e52353c381
|
||||
2026-09-17-session-row-menu-actions-slot.zh.md: 6ad15d263fd41a0ad97a386506c8f581290d032f
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Session row actions as slot lists
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-session-row-menu-actions-slot.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Session row's "..." menu and its hover buttons were closed lists owned by `ui-workspace`: the browser built the menu `items` by row state, rendered the pin and archive buttons itself, and dispatched every verb through callbacks it threaded down the tree, together with the toast and rename dialog those verbs needed. A client plugin could add a sidebar control of its own, but not an action beside the shipped ones without editing the owning package or copying the menu interaction.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-workspace` declares two root-scoped `list` slots under its `sidebar.workspaces` registration — `sidebar.workspaces.session.menu.item` for the menu rows and `sidebar.workspaces.session.row.action` for the hover buttons — and registers its own actions into them from `apply` exactly as a client plugin would: `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100). The lists are the menu and the button strip: entries render in ascending `order`, a plugin action lands wherever its order says, and reusing a shipped id at another `priority` shadows that action through the registry's ordinary cell rules. There is no separate "extension group": the owner draws no separator, and a group start is the entry's own `separatorBefore`.
|
||||
|
||||
An entry receives only the row identity, `{ sessionId, displayTitle }`, and owns everything else. It reads the Host state it cares about through hooks its own face injects (`usePinned` and `useArchived`, Sets derived once per Workspace snapshot) and decides its own visibility from it: pin renders nothing on an archived row because the Host keeps the two sets exclusive; archive never consults pin. Its whole behavior lives in its registration's `inject` face, so a component only calls one callback: `pinSession` delegates to `uiWorkspace.pinSession`, which performs the Host pin and then fronts the Session in its accounts' saved orders with `pinSessionOrder`, reading the memberships current at completion from the object-layer snapshots (`pin-order.ts`), beside `archiveSession`, which the same service already owned; the archive action's callback adds the notice. The service writes view order through the viewing-store instance the apply creates and hands the browser's registration as its handle, the pattern `ui-layout` uses for its layout store. The registry-global pin and archive sets reach the entries as Sets through the faces' `hooks`, derived once per Workspace snapshot, so a row reads its own membership with one lookup.
|
||||
|
||||
The surfaces an action raises outlive the row menu, so they are this package's `shell.overlay` entries: the rename dialog and the row-action notice. An action reaches its surface through the plugin's own apply-closure observable, injected as a callback on the action side (`requestSessionRename`, `notify`) and as a `hooks` source on the surface side (`useRenameRequest`, `useToast`). The browser raises the same rename request from a title double-click and the same notice when an archived row is clicked; the archived notice's second action switches the archived filter to "show" through the same store instance, since the browser's view-options menu is no longer in the loop.
|
||||
|
||||
The shipped menu entries render `MenuItemButton`, the `ui-primitives` row component for menus whose rows are components: a plain row, markup and styling only. Any `role="menuitem"` button joins the menu's keyboard walk, submenu exclusivity, and focus return, because `Menu` decides all three from the DOM (the arrow walk queries the rows; submenu collapse and the post-selection focus return run on the list's own bubbles, for data rows and component rows alike) — a `cordis-client-runner` closure, which cannot import the primitive, renders such a button directly. Closing is the owner's state for both kinds of row: data rows through the owner's `onSelect`, component rows through the slot-level `useMenuOpenState` hook — the declaration carries `inject: { hooks: { menuOpenState } }`, `SessionNodeItem` passes its `[open, setOpen]` pair as the occurrence's `hookContext`, and the factory hands the pair back. That is the channel `sidebar.right.pane.tab` already uses for `useTabInfo`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A React context between `Menu` and the row component.** Rejected: business components see zero React contexts ([client rules](../../../../packages/client/AGENTS.md)); the Menu-to-row relationship is parent-to-child through the slot system, so its control travels through the slot channels.
|
||||
|
||||
**Owner-supplied row capabilities.** Passing `pinned`/`archived` and `pin`/`archive`/`rename`/`fork` callbacks to entries, through owner props or a slot-level hook, keeps the browser the owner of every verb and makes the shipped actions thin wrappers over it. Rejected: these are not capabilities of a menu; each action's behavior is that action's own, and an entry can read the Host state it needs directly.
|
||||
|
||||
**A dedicated slot appended below the shipped rows.** Rejected: it makes the shipped rows a privileged group a plugin can only follow, and it needs a second rendering path in the primitive for what is one ordered list.
|
||||
|
||||
**Entries that compute their own view-order writes.** A pin entry subscribing to the whole Session list and Workspace snapshot to assemble `pinSessionOrder`'s inputs re-renders every visible row on every list change and puts ordering detail into a menu row. Rejected: the injected callback owns the write and reads the snapshots once, at completion.
|
||||
|
||||
**Declared shared stores for the rename request and the notice.** The request and the notice are one plugin's transient facts, so they travel as apply-closure observables through the entries' own inject faces.
|
||||
|
||||
## Consequences
|
||||
|
||||
Plugins add, reorder, or shadow Session row actions in the menu and in the hover strip without editing `ui-workspace`, and the shipped actions follow the same rules and the same registration path. `WorkspaceBrowser` no longer threads action callbacks, renders a toast, or hosts the rename dialog; `Menu` grew `children` and made `items`/`onSelect` optional; `shell.overlay` gained two `ui-workspace` entries. The archived notice applies the "show archived" filter directly instead of opening the view-options menu.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Session 行 action 即 slot 列表
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-session-row-menu-actions-slot.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Session 行的 "..." 菜单和行尾悬停按钮原本是 `ui-workspace` 持有的封闭列表:browser 按行状态拼菜单 `items`、自己渲染置顶和归档按钮、把每个动作的回调一路传下去,连这些动作需要的 toast 和重命名对话框也由它持有。客户端插件可以自己添加侧边栏控件,却无法在不修改 owner 包或复制菜单交互的情况下把 action 放到内置项旁边。
|
||||
|
||||
## 决策
|
||||
|
||||
`ui-workspace` 在其 `sidebar.workspaces` 注册项下声明两个 root-scoped `list` slot——菜单行用 `sidebar.workspaces.session.menu.item`,悬停按钮用 `sidebar.workspaces.session.row.action`——并在 `apply` 里以客户端插件的同一方式注册自己的 action:`pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)。列表就是菜单和按钮条:条目按 `order` 升序渲染,插件 action 落在其 order 所指的位置,以另一个 `priority` 复用内置 id 则按注册表的普通 cell 规则遮蔽。没有单独的"扩展分组":owner 不画分隔线,分组起点是条目自己的 `separatorBefore`。
|
||||
|
||||
条目只接收行身份 `{ sessionId, displayTitle }`,其余一切自己负责。它用自己 face 注入的 hook 读自己关心的 Host 状态(`usePinned` 与 `useArchived`,每次 Workspace 快照只派生一次的 Set),并据此决定是否显示:Host 规定两个集合互斥,所以 pin 在归档行上不渲染;archive 从不参考 pin。整套行为放在注册项自己的 `inject` face 里,组件只调一个回调:`pinSession` 委托给 `uiWorkspace.pinSession`——先做 Host 置顶,成功后用 `pinSessionOrder` 把会话推到其记账保存顺序的最前,成员关系在完成时刻从对象层快照读取(`pin-order.ts`)——和这个服务原本就持有的 `archiveSession` 并列;归档 action 的回调在此之上加上提示。服务经 apply 自己创建、并作为 handle 交给 browser 注册项的那个 viewing store 实例写入视图顺序,与 `ui-layout` 处理 layout store 的方式相同。置顶与归档集合以 Set 形式经 face 的 `hooks` 到达条目,每次 Workspace 快照变化只重建一次,行读自己那一位只需一次查找。
|
||||
|
||||
action 发起的浮层必须活得比行菜单久,所以它们是本包注册在 `shell.overlay` 的条目:重命名对话框和行 action 提示。action 经本插件 apply 闭包里的观察源到达自己的浮层:action 一侧注入为回调(`requestSessionRename`、`notify`),浮层一侧注入为 `hooks` 源(`useRenameRequest`、`useToast`)。browser 在标题双击时发出同一个重命名请求,在点击归档行时发出同一种提示;归档提示的第二个动作经同一个 store 实例把归档筛选切到"显示",因为 browser 的视图选项菜单已不在链路上。
|
||||
|
||||
本包自己的菜单条目渲染 `MenuItemButton`——`ui-primitives` 为"行是组件"的菜单提供的行组件,只是一行普通 markup 与样式。任何 `role="menuitem"` 的按钮都能加入菜单的键盘走位、子菜单互斥与焦点归还,因为 `Menu` 从 DOM 上统一决定这三件事(方向键走位查询行元素;子菜单收起与选中后的焦点归还都在列表自己的冒泡上执行,数据行与组件行一视同仁)——无法 import 该 primitive 的 `cordis-client-runner` 闭包直接渲染这样一个按钮。两类行的关闭都是 owner 的状态:数据行通过 owner 的 `onSelect`,组件行通过 slot 级 `useMenuOpenState` hook——声明带 `inject: { hooks: { menuOpenState } }`,`SessionNodeItem` 把自己的 `[open, setOpen]` 作为该次渲染的 `hookContext` 传入,factory 把这一对原样交回。这正是 `sidebar.right.pane.tab` 用于 `useTabInfo` 的通道。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 `Menu` 与行组件之间放一个 React context。** 否决:业务组件不接触任何 React context([client 规则](../../../../packages/client/AGENTS.md));Menu 与行是经 slot 系统建立的父子关系,控制权就该走 slot 的通道。
|
||||
|
||||
**由 owner 提供行能力。** 把 `pinned`/`archived` 和 `pin`/`archive`/`rename`/`fork` 回调经 owner props 或 slot 级 hook 交给条目,会让 browser 继续拥有每个动作,内置 action 沦为薄封装。否决:这些不是菜单的能力,每个 action 的行为属于它自己,需要的 Host 状态条目可以直接读。
|
||||
|
||||
**在内置行下方追加一个专用 slot。** 否决:它把内置行变成插件只能跟在后面的特权分组,还要在 primitive 里为本来只是一个有序列表的东西开第二条渲染路径。
|
||||
|
||||
**让条目自己算置顶顺序写入。** pin 条目为了拼 `pinSessionOrder` 的输入订阅整个 Session 列表和 Workspace 快照,列表一变所有可见行都重渲染,排序细节还进了菜单行。否决:写入归注入的回调,在完成时刻读一次快照。
|
||||
|
||||
**用声明式共享 store 传重命名请求与提示。** 请求与提示是本插件自己的瞬时事实,所以经条目自己的 inject face 以 apply 闭包观察源传递。
|
||||
|
||||
## 后果
|
||||
|
||||
插件无需修改 `ui-workspace` 即可在菜单和悬停条里添加、重排或遮蔽 Session 行 action,内置 action 遵循相同规则、走相同注册路径。`WorkspaceBrowser` 不再传递 action 回调、渲染 toast 或承载重命名对话框;`Menu` 新增 `children`,`items`/`onSelect` 变为可选;`shell.overlay` 多了两个 `ui-workspace` 条目。归档提示直接应用"显示已归档"筛选,不再打开视图选项菜单。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
|
||||
2026-09-18-localized-package-metadata.md: a7e41cccbbce63547e8e5f175ec2350bcf515bc2
|
||||
2026-09-18-localized-package-metadata.zh.md: d70aed2a57db4c474245806d4bd3db0129f4beb4
|
||||
@@ -0,0 +1,87 @@
|
||||
# Agent Note: Plugin-owned localized display metadata
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-localized-package-metadata.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Plugin Manager needs readable titles and descriptions for installed bundles and individual plugins, including disabled plugins. One npm package can export several plugins with different purposes; a package-level introduction cannot describe each one. Registering introductions only during activation also leaves disabled or failed plugins without display text.
|
||||
|
||||
## Decision
|
||||
|
||||
Each plugin supplies optional `meta.title` and `meta.description` strings in its `locale/<language>.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses.
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "File Search",
|
||||
"description": "Search files in your workspace."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The configured Cordis plugin name owns the resource address. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code:
|
||||
|
||||
| Plugin form | Locale resource |
|
||||
|---|---|
|
||||
| npm package `my-plugins` | `my-plugins/locale/en.json`, exposed by `./locale/*.json` |
|
||||
| npm subpath `my-plugins/search` | `my-plugins/search/locale/en.json`, exposed by `./search/locale/*.json` |
|
||||
| JavaScript path `./plugins/search.js` | No sibling-resource lookup; the configured path is the final title fallback |
|
||||
|
||||
Locale addresses are not derived from a resolved JavaScript filename or an owning package directory. File paths and file URLs, including Windows drive and UNC paths, skip resource resolution and return no metadata; a file address alone is not a metadata error. They supply neither sibling locale metadata nor a neighboring package manifest. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns directory examples, export declarations, publication entries, and author verification.
|
||||
|
||||
Language files for one plugin share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings.
|
||||
|
||||
The Host reads installed bundles and their declared plugin rows without importing or activating them. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy.
|
||||
|
||||
Each field falls back independently before view-specific name formatting:
|
||||
|
||||
| Field | Existing locale fallback chain | Same-address package fallback | Final fallback |
|
||||
|---|---|---|---|
|
||||
| Title | `meta.title` | Non-empty `name` from `<plugin specifier>/package.json` | Complete configured Cordis plugin name |
|
||||
| Description | `meta.description` | Non-empty `description` from `<plugin specifier>/package.json` | No package description |
|
||||
|
||||
The package fallback obeys resource exports and belongs to the same plugin address; a subpath does not inherit its owning package's introduction. The Host supplies the package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations.
|
||||
|
||||
Plugin Manager uses this metadata on installed bundle cards and details, component lists, and component configuration details, preserving complete technical-name fallbacks. Settings uses it in the plugin inventory, including preset plugins, but shortens literal package-name and module-name fallbacks by removing their npm scope and Cordis/DSH prefixes. Translated titles remain verbatim in both views; full module names, entry ids, search identities, and operation targets stay unchanged. The [Settings inventory README](../../../../packages/client/ui-settings-plugin-inventory/README.md#use-this-package) owns the prefix rules.
|
||||
|
||||
A row configuration page uses its registered `summary` view only when the plugin has no display description. The bundle's raw `description` field remains available to Host and model consumers; it is not an additional UI fallback around resource exports. These rules preserve one displayed description while keeping configuration summaries available for plugins without one.
|
||||
|
||||
Disabled plugins remain readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged.
|
||||
|
||||
Built-in bundle introductions live in their exported language files. Beta markers, page grouping, configuration slot ownership, and enablement targets remain unchanged. Plugin introductions and component interaction copy retain separate owners.
|
||||
|
||||
## Existing decisions
|
||||
|
||||
[Public package declarations](../../implemented/architecture/2026-09-10-public-package-manifest.md) still govern manifest type and reader responsibilities; [locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md) still governs component wording and display identity; [plugin configuration pages](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md) still determine configuration slot ownership. Plugin metadata extends display data without replacing those decisions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep built-in package dictionaries in Plugin Manager.** Every package addition requires changing the manager, and external plugins cannot independently supply translations.
|
||||
|
||||
**Declare references in package.json.** This duplicates the declaration between a manifest and locale files, and one package-level declaration cannot distinguish its exported plugins.
|
||||
|
||||
**Read locale beside the resolved JavaScript file.** Build tools can put several plugin entries in the same directory. Resource exports preserve each plugin's identity independently of its compiled layout.
|
||||
|
||||
**Register copy when the displayed plugin activates.** Disabled packages, packages without a Client half, and packages that fail to load still need readable introductions.
|
||||
|
||||
**Add base.json or package-specific fallback.** This introduces a second policy alongside the existing English fallback and extension-language fallback chains.
|
||||
|
||||
**Download remote packages for pre-installation previews.** This expands lookup cost and the package contents that must be handled; this phase only reads files already present locally.
|
||||
|
||||
## Verification
|
||||
|
||||
- Bundles, child plugins, and read-only inventory display local metadata; disabled packages need no activation for reading.
|
||||
- Language switching updates titles and descriptions; Settings shortens only literal technical-name fallbacks, not translated titles, and keeps module identities and operation targets intact.
|
||||
- Direct metadata, unrelated locale content, absent resources, malformed JSON, missing display fields, and conflicting language identifiers have focused coverage.
|
||||
- Separate exports in one package and same-named packages under different parents retain independent metadata without executing plugin entries.
|
||||
- Windows drive paths, UNC paths, relative and absolute file paths, and file URLs return no metadata without invoking resource resolution.
|
||||
- Missing locale resources and individual fields fall back independently to address-local package fields, then the complete module specifier without a description.
|
||||
- Publication checks require readable resource exports and included language files, without duplicate ownership of built-in copy in the manager and plugins.
|
||||
- Management tools preserve existing model output, and pre-installation inspection adds no package-content lookup.
|
||||
- Focused tests and the complete `pnpm run build` succeed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-plugin diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Agent Note: 插件自有的多语言展示元信息
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-localized-package-metadata.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题和描述,包括尚未启用的插件。同一个 npm 包可以导出多个用途不同的插件,包级介绍无法分别描述它们。依赖激活来注册介绍,也会让禁用或加载失败的插件缺少展示文案。
|
||||
|
||||
## 决策
|
||||
|
||||
每个插件从 `locale/en.json` 开始,在自己的 `locale/<language>.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "File Search",
|
||||
"description": "Search files in your workspace."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
资源地址归 Cordis 配置中的插件名所有。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码:
|
||||
|
||||
| 插件形式 | locale 资源 |
|
||||
|---|---|
|
||||
| npm 包 `my-plugins` | 请求 `my-plugins/locale/en.json`,由 `./locale/*.json` 导出 |
|
||||
| npm 子路径 `my-plugins/search` | 请求 `my-plugins/search/locale/en.json`,由 `./search/locale/*.json` 导出 |
|
||||
| JavaScript 路径 `./plugins/search.js` | 不查找同级资源;配置中的路径名作为标题的最终回退 |
|
||||
|
||||
locale 地址不从解析后的 JavaScript 文件名或所属包目录推导。文件路径与文件 URL(包括 Windows 盘符路径和 UNC 路径)跳过资源解析,返回无元信息;文件地址本身不构成元信息错误。它们既不提供同级 locale 元信息,也不提供旁边的包 manifest(元数据清单)。目录示例、exports 声明、发布条目与作者验证步骤由[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护。
|
||||
|
||||
同一插件的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。
|
||||
|
||||
Host 读取已安装 bundle 及其声明的插件行,不 import 或激活它们。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。
|
||||
|
||||
每个字段先独立回退,再按页面规则格式化技术名称:
|
||||
|
||||
| 字段 | 现有 locale 语言回退链 | 同地址包字段回退 | 最终回退 |
|
||||
|---|---|---|---|
|
||||
| 标题 | `meta.title` | `<插件模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 |
|
||||
| 描述 | `meta.description` | `<插件模块名>/package.json` 中非空的 `description` | 不提供包描述 |
|
||||
|
||||
包字段回退遵守资源 exports,并且仍属于同一插件地址,子路径不继承所属包的介绍。字段有其他语言译文但没有英文值时,Host 将包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。
|
||||
|
||||
插件管理页在已安装 bundle 的卡片和详情、组件列表、组件配置详情中使用这些元信息,保留完整技术名回退。设置中的插件清单也使用它,包括预设内插件,但会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀。两个页面都原样展示翻译标题;完整模块名、条目 id、搜索身份与操作目标保持不变。具体前缀规则由[设置插件清单 README](../../../../packages/client/ui-settings-plugin-inventory/README.zh.md#use-this-package)维护。
|
||||
|
||||
行配置页仅在插件没有展示描述时使用注册组件的 `summary` 视图。组合包原始的 `description` 字段仍供 Host 与模型消费方使用,不作为绕过资源 exports 的额外 UI 回退。这些规则只展示一份描述,同时保留无描述插件的配置摘要。
|
||||
|
||||
禁用插件不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。
|
||||
|
||||
内置 bundle 的介绍放在其导出的语言文件中。beta 标记、页面分组、配置槽归属与启停目标保持原样。插件介绍和组件自身的交互文案各自保持所有权。
|
||||
|
||||
## 现有决策
|
||||
|
||||
[公共包声明](../../implemented/architecture/2026-09-10-public-package-manifest.zh.md) 继续约束 manifest 类型和读取者的职责;[locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md) 继续约束组件文案和展示身份;[插件配置页](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) 继续决定配置槽归属。插件元信息只扩展展示数据,不取代这些决策。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 Plugin Manager 中继续维护内置包词典。** 每增加一个包都需要修改管理器,而且外部插件无法独立提供翻译。
|
||||
|
||||
**在 package.json 中声明引用。** 将声明重复放在 manifest 与 locale 文件中,而且一份包级声明无法区分包内导出的插件。
|
||||
|
||||
**从解析后的 JavaScript 文件旁读取 locale。** 构建工具可能把多个插件入口放在同一目录。资源 exports 让各插件的身份独立于编译产物布局。
|
||||
|
||||
**由被展示插件激活时注册文案。** 未启用包、无 Client 半边的包和加载失败的包仍然需要可读的介绍。
|
||||
|
||||
**增加 base.json 或包级 fallback。** 会与现有 locale 的英文兜底和扩展语言回退链产生第二套规则。
|
||||
|
||||
**为安装前预览下载远程包。** 扩大查询成本及需要处理的包内容;本期只处理已经存在于本地的文件。
|
||||
|
||||
## 验证
|
||||
|
||||
- bundle、子插件与只读 inventory 展示本地元信息;禁用包不需要激活即可读取。
|
||||
- 切换语言更新标题和描述;设置页仅缩短字面技术名回退,不缩短翻译标题,也不改变模块身份和操作目标。
|
||||
- 直接元信息、无关 locale 内容、缺失资源、损坏 JSON、缺失展示字段及语言标识冲突均有针对性覆盖。
|
||||
- 同包的不同 exports,以及不同父目录中的同名包,各自保持独立元信息,读取不执行插件入口。
|
||||
- Windows 盘符路径、UNC 路径、相对与绝对文件路径及文件 URL 均不调用资源解析,直接返回无元信息。
|
||||
- locale 资源或个别字段缺失时,各字段独立回退到该插件地址的包字段,再回退到完整模块名且不显示描述。
|
||||
- 发布检查要求资源 exports 可解析且包含语言文件,内置文案不在管理器和插件中双份维护。
|
||||
- 管理工具保留现有模型输出,安装前查询不增加包内容探查。
|
||||
- 针对性测试以及完整 `pnpm run build` 通过。
|
||||
|
||||
## 影响
|
||||
|
||||
遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单插件诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
|
||||
2026-09-18-profile-plugin-host-runtime-instances.md: 76135318b3a935e4588c360df3210618a6c69216
|
||||
2026-09-18-profile-plugin-host-runtime-instances.zh.md: b2229574876b0ad326348ababd6b6fca6f3a1ac2
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Profile plugins share the installation's host runtime instances
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-profile-plugin-host-runtime-instances.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A plugin the profile installs out-of-tree resolves its bare specifiers along the ancestor `node_modules` chain, where the profile's own layer sits above the runtime resolution's interception layer ([lookup order](2026-09-19-profile-resolution-lookup-order.md)). It therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer.
|
||||
|
||||
Issue #4573 is that failure in production. While this package reached both runtimes through `dependencies`, a profile that mounted the Playwright MCP browser provider installed a second `dsh-scope`: the first Agent's MCP tools registered globally, the second Agent's tool synchronization collided, and `failOnStartupError: true` turned that collision into a rejected Agent creation. Matching the profile's package versions to the Host's did not change the outcome, because a duplicate exists at every version.
|
||||
|
||||
## Decision
|
||||
|
||||
An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile, and the import reaches the installation's single instance through the runtime resolution's interception layer at `$DSH_HOME/profiles/node_modules` ([lookup order](2026-09-19-profile-resolution-lookup-order.md)).
|
||||
|
||||
`@deepseek-ai/dsh-scope` carries that identity through `createScope` as well as through the readers `scopeOf`, `carrierKeyOf`, and `scopeTarget`: the writer mints the tag its readers compare. `@deepseek-ai/dsh-mcp-client` joins it because its live `serverName` reservations are module-local, so a second copy keeps a second reservation table for the same namespace. `@deepseek-ai/dsh-experimental-browser-use-runtime` therefore declares both as peers with matching dev entries and keeps `@deepseek-ai/schemastery` as its only runtime dependency, which carries no instance-local state.
|
||||
|
||||
`tests/shared-host-runtimes.spec.ts` pins those sections for that package. `tests/host-runtime-duplication.spec.ts` reproduces what the rule prevents: one second copy of both packages puts each Agent's MCP tools in the global layer and rejects the second Agent's creation, while the shipped single-instance layout keeps the global layer empty and gives both Agents their own client.
|
||||
|
||||
The [published dependency faces](../process/2026-08-26-published-dependency-faces.md) note owns `verify-package-dependencies`: its `peerRequiredHostExports` table classifies the `dsh-scope` readers, and its package selection covers Client-faced packages and an explicit Host list. That gate selects no package under `packages/experimental/`, and it does not classify `createScope`; this note owns the rule for the out-of-tree plugin packages the gate does not reach.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Mount the MCP client on `agent.ctx` instead of a scope this package mints.** It loses because the profile copy's `scopeOf` also cannot read the tag the installation wrote for `agent.ctx`, so the `serverName` reservation degrades to the process-global owner and the second Agent fails at that check — the error form seen on `0.1.6-alpha.1`. Removing the duplicate is the repair for both forms.
|
||||
|
||||
**Make `dsh-scope`'s tag a `Symbol.for`, so two copies agree.** That fixes the tag lookup alone. Scope parents and event-carrier keys stay per copy, so the chain walk and carrier admission of one copy remain blind to links the other recorded — a smaller visible failure with a more confusing cause. It also keeps the duplicate that the profile never needed.
|
||||
|
||||
**Match the profile's package versions to the Host's.** Equal versions still load two module instances from two directories, so the defect survives it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A released fix reaches a profile when the plugin dependency is upgraded and reinstalled. An already-installed profile keeps its copies until then, and no Host-side change can repair it.
|
||||
- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the runtime resolution supplies them; a plugin outside the installation's closure would fail to load rather than start with a second copy.
|
||||
- `docs/module-graph.md` renders these edges in its peer section, so the graph states the ownership boundary that the sections encode.
|
||||
- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so their own use does not reproduce this decision's failure. A profile that installs one of them beside this package hoists that dependency into the profile's own `node_modules`, above the interception layer, so this package's peer resolves to that copy: the two `dsh-mcp-client` instances keep separate `serverName` reservations, and a name reserved on one is not detected on the other. The scope split does not return, because none of them declares `@deepseek-ai/dsh-scope`. Issue #4628 tracks moving those three declarations to peers with their own verification.
|
||||
- `verify-package-dependencies` still selects no `packages/experimental/` package, so the sections of this package are pinned only by `tests/shared-host-runtimes.spec.ts`. Extending that gate's selection is the open coverage gap this note records.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Profile 插件共享安装自带的宿主运行时实例
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-profile-plugin-host-runtime-instances.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
profile 从 npm 装进来的 out-of-tree 插件,沿祖先 `node_modules` 链解析裸模块名,而 profile 自己那一层位于运行时解析(runtime resolution)的拦截层之上([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。于是它会为经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。
|
||||
|
||||
Issue #4573 就是这个问题在生产里的形态。当本包经 `dependencies` 到达这两个运行时时,挂载 Playwright MCP 浏览器提供方的 profile 多装了一份 `dsh-scope`:第一个 Agent 的 MCP 工具注册到全局层,第二个 Agent 的工具同步撞名,`failOnStartupError: true` 把这次撞名放大成 Agent 创建失败。把 profile 的包版本对齐到 Host 并不能改变结果,因为无论哪个版本,副本都是第二份模块实例。
|
||||
|
||||
## Decision
|
||||
|
||||
Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile;导入走到 `$DSH_HOME/profiles/node_modules` 时,由运行时解析(runtime resolution)的拦截层提供安装里的那一份实例([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。
|
||||
|
||||
`@deepseek-ai/dsh-scope` 的同一性既在读取者 `scopeOf`、`carrierKeyOf`、`scopeTarget` 上,也在写入者 `createScope` 上:标记由写入者铸造,再由读取者比较。`@deepseek-ai/dsh-mcp-client` 同属这一类,因为它在线的 `serverName` 保留表是模块级的,第二份副本会为同一命名空间保留第二张表。因此 `@deepseek-ai/dsh-experimental-browser-use-runtime` 把两者声明为 peer(并补匹配的 dev 条目),只把不携带实例内状态的 `@deepseek-ai/schemastery` 留在运行时依赖里。
|
||||
|
||||
`tests/shared-host-runtimes.spec.ts` 钉住这个包的分区。`tests/host-runtime-duplication.spec.ts` 复现这条规则拦下的问题:两份包各多一份副本时,每个 Agent 的 MCP 工具都落进全局层,第二个 Agent 的创建被拒;而实际发布的单实例布局下全局层为空,两个 Agent 各拿到自己的客户端。
|
||||
|
||||
[Published dependency faces](../process/2026-08-26-published-dependency-faces.zh.md) 拥有 `verify-package-dependencies`:它的 `peerRequiredHostExports` 表分类了 `dsh-scope` 的读取者,它的包选择覆盖 Client 面的包与一份显式 Host 名单。该 gate 不选择 `packages/experimental/` 下的任何包,也没有分类 `createScope`;gate 覆盖不到的 out-of-tree 插件包,由本记录拥有这条规则。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把 MCP 客户端挂到 `agent.ctx`,而不是本包自造的作用域。** 它不成立,是因为 profile 那份副本的 `scopeOf` 同样读不到安装为 `agent.ctx` 写下的标记,`serverName` 保留会退化成进程全局所有者,第二个 Agent 在那一步就失败——即 `0.1.6-alpha.1` 上看到的错误形态。消除重复副本才是两种形态共同的修复。
|
||||
|
||||
**把 `dsh-scope` 的标记改成 `Symbol.for`,让两份副本互相认得。** 这只修好标记查找。作用域父链与事件载体键仍各留一份,一份副本的链式遍历与载体准入看不见另一份记录的关系——可见错误更小,成因更难定位,而且 profile 本不需要的那份重复依然存在。
|
||||
|
||||
**把 profile 的包版本对齐到 Host。** 版本相同,两个目录依旧加载出两份模块实例,问题照旧。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 修复要生效,需要升级并重装 profile 里的插件依赖。在此之前,已装好的 profile 一直带着那份副本,Host 侧无法补救。
|
||||
- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,运行时解析能提供它们;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。
|
||||
- `docs/module-graph.md` 把这些边画进 peer 区,图与分区表达的是同一条所有权边界。
|
||||
- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,因此自身不会触发本决策要防的故障。若一个 profile 把其中之一与本包一起安装,hoisted 会把那份依赖平铺到 profile 自己的 `node_modules`,位置高于拦截层,于是本包的 peer 解析到那份副本:两份 `dsh-mcp-client` 各存一张 `serverName` 保留表,一份上保留的名字在另一份上不再被检测。作用域标记的分裂不会回来,因为它们都没有把 `@deepseek-ai/dsh-scope` 写成依赖。Issue #4628 记录把这三处声明改为 peer 并各自验证的后续工作。
|
||||
- `verify-package-dependencies` 仍不选择 `packages/experimental/` 下的包,所以本包的分区只由 `tests/shared-host-runtimes.spec.ts` 钉住。扩大该 gate 的选择范围,就是本记录留下的覆盖缺口。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md
|
||||
2026-09-19-lossless-projection-checkpoint-json.md: 9c7d15dbd2d570eaa544f168d2c2ef882558d5ca
|
||||
2026-09-19-lossless-projection-checkpoint-json.zh.md: 8c2b720358351af33a6d07888002c65d0cc30363
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Agent Note: Lossless projection checkpoint JSON
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-lossless-projection-checkpoint-json.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Projection checkpoints can contain opaque extension data and message metadata. A JSON key named `__proto__` is ordinary recorded data. The Zod JSON parser drops that own key while rebuilding objects, so reopening a valid checkpoint can yield different projection state from replaying the Session log.
|
||||
|
||||
## Decision
|
||||
|
||||
The checkpoint value schema uses the existing `isJsonValue` predicate from `dsh-util-values`. It enforces the same lossless JSON rules as the checkpoint writer's `snapshotJsonValue` without rebuilding valid objects. Validation still rejects non-JSON and lossy values. Storage-domain table values are immutable borrowed records; the validator does not supply a defensive-copy guarantee.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `z.json()`.** Its object reconstruction removes valid own keys, so a successful validation can still change the checkpoint value.
|
||||
- **Validate and clone with `snapshotJsonValue`.** This preserves keys but copies already immutable stored values. The read-only predicate matches the storage-domain borrowed-value contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
Domain reads preserve every valid own key in checkpoint values, including nested `__proto__` and `constructor` properties. A projection's `stateSchema` still owns its hydration value; opaque fields must use validation that preserves their keys. The borrowed-value validator does not create an independent copy or project to JSON Schema.
|
||||
|
||||
The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a synthetic fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. [Predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md) continue to own cache-version and identity compatibility.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Agent Note: 无损投影检查点 JSON
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-lossless-projection-checkpoint-json.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
投影检查点可以包含不透明扩展数据和消息元数据。名为 `__proto__` 的 JSON 键是普通记录数据。Zod JSON 解析器在重建对象时丢弃该自有键,因此重新打开有效检查点可能得到与回放 Session 日志不同的投影状态。
|
||||
|
||||
## 决策
|
||||
|
||||
检查点值 schema 使用 `dsh-util-values` 中既有的 `isJsonValue` 谓词。它执行与检查点写入器 `snapshotJsonValue` 相同的无损 JSON 规则,不重建有效对象。校验仍拒绝非 JSON 和有损值。Storage-domain 表值是不可变的借用记录;校验器不提供防御性复制保证。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **保留 `z.json()`。** 其对象重建会移除有效自有键,因此校验成功仍可能改变检查点值。
|
||||
- **用 `snapshotJsonValue` 校验并复制。** 这能保留键,但会复制已经不可变的存储值。只读谓词符合 storage-domain 的借用值约定。
|
||||
|
||||
## 影响
|
||||
|
||||
Domain 读取保留检查点值中的每个有效自有键,包括嵌套的 `__proto__` 和 `constructor` 属性。Projection 的 `stateSchema` 仍拥有其 hydration 值;不透明字段必须使用保留其键的校验器。借用值校验器不生成独立副本,也不投影为 JSON Schema。
|
||||
|
||||
存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的合成 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。[前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md) 继续负责缓存版本与身份兼容性。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
|
||||
2026-09-19-projection-cache-listing-identity-and-cached-rows.md: 61b82e5003ba4b634230941f5d3df65e65491e25
|
||||
2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md: ef49d90bc68ca501f7496adbac1d1aa8a5d706a7
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# Agent Note: Projection cache read-only face matches the lifecycle identity; the client store separates cached and sequenced rows
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After a Host process restart, every forked session (`SessionHeader.isSeeded === true`) shows no title in the sidebar session list, `@` reference completion shows only the session id, and list ordering degrades to creation time. Opening the session once restores all of it. On one development machine on 2026-09-19, 44 of 241 projcache records were seeded, all 44 carried a valid `title` row, and the list never read any of them.
|
||||
|
||||
This is neither cache corruption nor a version mismatch. The projcache record's domain version is 7 and its `identity.formatVersion` is 4; those are the cache's own on-disk format generation and the Session log format generation the fold ran under, and both are current. The defect is on the read path.
|
||||
|
||||
### Mechanism
|
||||
|
||||
A cache record (record format and predecessor recovery: [Projection-cache predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md)) is bound to a lifecycle identity: `formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`, matched by `identityMatches` on full equality. Since #3346, `inheritedEventCount` (the length of the event prefix a fork inherits, the cut below) no longer appears in the logical header: the header keeps only the `isSeeded` bit and the exact cut follows the body. Since Session format v2 (#3398) the physical header line no longer stores `seedLength` either; the reader derives the cut from the seq of the `session/end-seed {inherited: true}` marker in the body.
|
||||
|
||||
A header-only read therefore cannot obtain the cut: the JSONL backend's `fromHeaderLine` hard-codes `inheritedEventCount: 0` for header-only reads, and `SessionPersistenceSnapshot` carries only the header, the revision, and an optional eventCount. All three header-only cache consumers grew the same guard:
|
||||
|
||||
| Consumer | Guard | Fallback | Consequence |
|
||||
|---|---|---|---|
|
||||
| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | none | no title, no `sessionListMetadata`; blank falls back to `false`, ordering falls back to `createdAt` |
|
||||
| `packages/context/session-reference/src/index.ts` `projectedLabels` | same | none | `@` completion labels by id; the title cannot be searched |
|
||||
| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | same | `observeSession` reads the body | correct result, one body read per seeded child |
|
||||
|
||||
When the guard landed (#3346, 2026-09-01), `list.ts` still had `probeSmallCold`: on a cache miss it read the body when the log file was at most `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` bytes. Its purpose was blank-session detection; it covered only logs under 1KB and never applied to an ordinary fork. #3400 (2026-09-03) removed it and the list returned to metadata plus cache only. #4320 (2026-09-19) made forking a first-class feature (`packages/core/session/src/fork.ts`, fork at any seq); every fork is `isSeeded: true` with an exact cut, seeded sessions went from a handful to dozens, all of them cold after a restart, and the gap became visible at once.
|
||||
|
||||
### The cache's two uses and where validation lives
|
||||
|
||||
The read and write faces of `SessionProjectionCache` and their callers:
|
||||
|
||||
| Face | What it does with the record | Writes back | Callers |
|
||||
|---|---|---|---|
|
||||
| `write(session)` | writes the checkpoint at three mandatory points plus a throttle | yes | the cache's own listeners |
|
||||
| `hydratePrepared(session, events)` | uses `rows` as the fold starting point and applies events from `row.seq + 1`; the result lands in live cells | the next checkpoint | `session-query/src/observation.ts` |
|
||||
| `coldSnapshot(meta, cut, events)` | fold starting point plus write-back | yes | no production caller |
|
||||
| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`: each row passes `ver` and `stateSchema`, is `view`ed, and returned | no | `list.ts`, `session-reference`, `list-children` |
|
||||
| `cachedPredecessorTitle(meta, cut)` | the same, `title` only, allowing an older `formatVersion` | no | `list.ts` |
|
||||
|
||||
Identity validation lives entirely inside the cache (`recordFor` → `identityMatches`; `viewCheckpoint` checks `ver` and schema per row). `list.ts` validates nothing itself; it merely declines to call when it cannot supply a cut.
|
||||
|
||||
spec.ts and the README describe the purpose of the identity check with one verb: preventing "seed state folded from an unrelated log", "cannot seed the caller". That guards the fold face. The read-only face has never seeded anything.
|
||||
|
||||
### The client store cannot guarantee that connected data replaces hints
|
||||
|
||||
Each session has exactly one `ProjectionValueStore` on the client (`manager.projectionStores`; its merge rules were recorded in [Session observations and projection-owned client state](2026-08-25-session-observations-and-projection-owned-client-state.md)). The block delivered by the list, the history first-page baseline on open, the control baseline, push frames, and rename results all write into that one object, and `useProjection` reads it. All writers are peers under one higher-seq-wins rule: `apply` drops a new value when `seq <= row.seq`, and `seed` clears omitted keys only when `row.seq <= cut`. A list hint whose seq is equal or higher survives untouched after the session is opened. The hint's seq comes from a disk record; after a crash-repair truncation it can be numerically higher than the connected cursor, which is exactly the case where the cache is wrong and the connection is right. Comparing seqs against a hint is the wrong tool.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two read faces; the cut is compared only on the fold face
|
||||
|
||||
| Face | Identity | Who can supply it | Methods |
|
||||
|---|---|---|---|
|
||||
| read-only face | lifecycle identity: `formatVersion + createdAt + cwd + isSeeded`, all from the header | any header-only caller | `cachedSnapshot(header, keys?)`, `cachedPredecessorTitle(header)` |
|
||||
| fold face | lifecycle identity plus `inheritedEventCount` | callers holding the Session or its body | `hydratePrepared`, `write`, `coldSnapshot`, all through `recordFor` |
|
||||
|
||||
The two read-only methods no longer take a cut parameter. `inheritedEventCount` is still written into every record and the fold face still compares it on full equality; the original design expectation, that records folded under different fork cuts never seed each other, is unchanged.
|
||||
|
||||
In the block the read-only face returns, `asOfSeq` is the lowest watermark among the served rows, the stored record's own position. A header can vouch neither for the cut nor for the comparability of that watermark with the current log, so comparability is not expressed by this number: the Session list adds an independent field `kind` to each summary's `projections` block (`SessionProjectionHints`). A block a cold session's row viewed from projcache is `cached`; a block the Host's live registry computed for an attached session is `sequenced`. The two fields are independent facts: `kind` names the sequence space `asOfSeq` belongs to, and `asOfSeq` is the watermark within that space.
|
||||
|
||||
### Why the read-only face may skip the cut
|
||||
|
||||
- Within one `formatVersion`, a log's cut is determined by `(id, createdAt, cwd, isSeeded)`: it is fixed at fork creation, and only a cardinality-changing format migration can alter it, which necessarily bumps `formatVersion`. The cut is a derived fact of the lifecycle, not an independent coordinate.
|
||||
- What the read-only face actually did until now: unseeded callers passed the constant 0 (true by construction) and seeded callers never called. The cut was never compared against any real fact on this face. Removing it changes the rejection set not at all: a record from another lifecycle (any of the four fields differing) is still rejected, unseeded results are identical, and seeded lifecycles go from unaddressable to addressable.
|
||||
- The read-only face never seeds and never writes back; its output is a pure function of the stored record, the cut plays no part in it, and it flows nowhere.
|
||||
- The one new exposure: a record whose four fields all match but whose cut differs. The system cannot produce it; only copying a session directory by hand and editing files can. Even then the list shows that record's values until the session is opened, and nothing enters any fold.
|
||||
|
||||
### Consumers
|
||||
|
||||
| Location | Change |
|
||||
|---|---|
|
||||
| `list.ts` `projectionsFor` | drop the `isSeeded` branch and `SessionLogOffset(0)`; every cold row reads `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
|
||||
| `session-reference` `projectedLabels` | same symptom, same change |
|
||||
| `list-children.ts` `resolveColdIdentity` | follows the signature only; the `!header.isSeeded` guard and the body fallback stay, because it needs the cut to classify a seq as inherited or owned, which is fold semantics |
|
||||
|
||||
### The client store separates cached and sequenced rows
|
||||
|
||||
Rows in `ProjectionValueStore` come in two kinds, named for the property the rule depends on: whether the value carries a seq comparable within this connection.
|
||||
|
||||
| Row | Meaning | Carries |
|
||||
|---|---|---|
|
||||
| `cached` | viewed from the persisted checkpoint without a Session; its seq is not comparable with this connection | `value` only |
|
||||
| `sequenced` | computed by the Host for this Session within this connection; seqs share one space | `value` and `seq` |
|
||||
|
||||
Rules:
|
||||
|
||||
| Write | Against a `cached` row | Against a `sequenced` row |
|
||||
|---|---|---|
|
||||
| `applyCached(values)` | replaces | ignored |
|
||||
| `seed(baseline)` | discards every `cached` row first, then writes the block; omitted keys clear under `seq <= cut` | higher-seq-wins |
|
||||
| `apply(key, value, seq)` | replaces | higher-seq-wins |
|
||||
|
||||
Seq comparison happens only within one Host connection: `handleConnected` clears the whole store, then the list writes cached rows again and opening a session seeds again. From cached to sequenced is an unconditional replacement without any seq comparison.
|
||||
|
||||
Writer classification:
|
||||
|
||||
| Entry | Row kind |
|
||||
|---|---|
|
||||
| the `projections` block of each summary in the `session.list` response (`manager.refreshList`) | by the block's `kind`: `cached` for a cold session, `sequenced` for a live one |
|
||||
| the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` |
|
||||
| the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced |
|
||||
| the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced |
|
||||
| the `session.projections` result of `refreshProjections` (a body observation) | sequenced |
|
||||
| push frames (the `projection` frame handled by `manager`) | sequenced |
|
||||
| the `title` after a successful rename (`session.ts`) | sequenced |
|
||||
|
||||
The `seed` and `apply` signatures are unchanged. The client routes by `kind`: a `sequenced` block goes through `apply` key by key, a `cached` block through `applyCached`, which does not read its `asOfSeq`.
|
||||
|
||||
### What stays untouched
|
||||
|
||||
- The Session format, the physical header line, `fromHeaderLine`, persistence, `SessionPersistenceSnapshot`.
|
||||
- Hydration and the checkpoint write path; `recordFor` keeps full equality on all five fields.
|
||||
- `coldSnapshot` stays; having no production caller is not a reason to delete it here.
|
||||
- Old records (domain v4/v5, lacking `isSeeded` and `inheritedEventCount`) keep missing for seeded sessions until the session is opened and rewritten as a v7 record.
|
||||
- The list still delivers every row with a wire view (`contextBreakdown`, `turnOutline`, and so on); narrowing that is out of scope.
|
||||
|
||||
### Worst case, walked through
|
||||
|
||||
1. The list serves a wrong record; the sidebar shows a wrong title, held in a cached row.
|
||||
2. The session is opened; for the instant before the first-page baseline arrives the wrong value is still shown. Every pre-population has this window.
|
||||
3. The first-page baseline `seed`s: every cached row is discarded unconditionally, then the block is written. Nothing of the wrong value survives, whatever its seq.
|
||||
4. A later list refresh writes cached values that hit sequenced rows and are ignored; the wrong value does not come back.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Write the cut back into the physical header line.** Header-only reads would obtain the cut directly and the read-only face would keep its identity. The cost is another Session format generation right after V4 landed, and reversing #3346's decision that the exact cut does not pose as body-free metadata. Rejected: the header is not changed for this.
|
||||
|
||||
**An index of each session's cut maintained by persistence or session-query.** A new durable index with its own consistency maintenance, serving one display read. Rejected.
|
||||
|
||||
**Restore a bounded body probe.** The `probeSmallCold` idea #3400 removed, or a client-side asynchronous `refreshProjections` for visible seeded sessions. It breaks the list's zero-I/O principle, and the historical 1KB threshold shows it never covered an ordinary fork. Rejected.
|
||||
|
||||
**Only the `asOfSeq: -1` sentinel, without store tiers.** One server-side change makes hints always lose under the seq rule. It rests on a convention: when a hint's seq equals or exceeds the baseline cut (crash-repair truncation), `apply` keeps the old row and the wrong value survives until the next frame. The user requires connected data to replace hints unconditionally, so the rule belongs in the store rather than in a seq convention.
|
||||
|
||||
**Express cached through `asOfSeq: -1` and let the client route on the sentinel.** The read-only face always emits `-1` and the client treats every list block as cached. Rejected: one field would carry two meanings, and the second could only be inferred by convention; a live session's list block carries a real seq comparable within the connection, and treating every block as cached demoted those too. The PR review reproduced the regression: a delayed control baseline at a lower cut overwrote a newer list value. The independent field `kind` replaces the sentinel, and `asOfSeq` keeps each source's own watermark.
|
||||
|
||||
**Remove the cut from the cache identity entirely.** The fold face needs it: `restore` continues applying from the cached row's state, and `schedule`, `subagentCatalog`, `permissions.seeded`, and owned/inherited classification encode the cut; an error would be written back and persisted. Rejected.
|
||||
|
||||
**Naming the row kinds `hint` / `authoritative`.** Those words speak of trust, while the rule depends on seq comparability. Renamed `cached` / `sequenced` so the names state the property the rule uses.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought:
|
||||
|
||||
- After a restart, forked sessions immediately show their title, `sessionListMetadata` (blank, lastPromptAt), and every other cached wire value in the sidebar, ordered by the last prompt time.
|
||||
- `@` completion shows and searches forks by title.
|
||||
- Connected data replaces list hints unconditionally, independent of seq ordering.
|
||||
- The two levels of the cache identity have names: the lifecycle identity answers "is this the same log", the fold identity answers "may this continue a fold".
|
||||
|
||||
Paid:
|
||||
|
||||
- `cachedSnapshot` and `cachedPredecessorTitle` change signature; three callers change with them.
|
||||
- `SessionProjectionHints` gains the required field `kind`; every producer of a list summary and every test fixture that builds one carries it.
|
||||
- A hand-crafted record with the same four fields and a different cut is displayed in the list until the session is opened.
|
||||
- Keys the baseline omits clear together with their hints: when a Host does not mount `schedule`, the schedule mark the list hinted disappears after the session opens. Under "connected data is the truth" this is the correct behavior.
|
||||
- Old records still miss for seeded sessions until an open rewrites them.
|
||||
|
||||
## Testing
|
||||
|
||||
- `session-projection-cache/tests/cache.spec.ts`: a seeded cold header obtains every version-matching row through `cachedSnapshot(header)` at the row's watermark; an unseeded header is refused for the same id's seeded record; `coldSnapshot` continues from the row when the cut matches, refolds the whole log when it differs, and throws for an unseeded call with a nonzero cut; `cachedPredecessorTitle(header)` serves only `title` from a seeded record of an older `formatVersion`; rows with differing watermarks form one block whose `asOfSeq` is the lowest row.
|
||||
- `session-projection-cache/tests/fixtures.spec.ts`: archived v3 to v6 records still expose only the predecessor title; lineage-less archives still miss for seeded callers.
|
||||
- `api/session-controller/tests/session-cold.host.spec.ts`: a seeded cold summary carries `kind: 'cached'`, `title`, and `sessionListMetadata`, `updatedAt` takes `lastPromptAt`, the cache is queried, and the body is not read.
|
||||
- `api/session-controller/tests/session-projections.host.spec.ts`: a real fork made through `sessions.fork` is checkpointed into projcache, the whole Context is disposed, and a Host restarted over the same storage root reads the `kind: 'cached'` `title` and `sessionListMetadata` through `session.list` from the header alone; `inspect` and `open` are never called.
|
||||
- `api/session-controller/tests/projection-store.client.spec.ts`: `applyCached` fills empty keys only and never displaces a sequenced row; every sequenced write (including a frame at cursor `-1` and a baseline at a lower cut) replaces cached rows; a baseline discards all cached rows before clearing omitted keys; face subscribers are notified on cached fills and their discard. Manager path: a `cached` list block at any watermark is replaced by a control baseline at a lower cut, and a later list refresh cannot bring it back; a `sequenced` list block merges under higher-seq-wins, so a delayed baseline at a lower cut neither overwrites nor clears it while a higher-seq frame still advances it.
|
||||
- `api/session-controller/tests/manager.client.spec.ts`: a `cached` `api-session/added` block is replaced by a control baseline at the same cursor; a `cached` list block does not displace an existing sequenced title.
|
||||
- `api/session-controller/tests/inbox-projection.client.spec.ts`: unchanged; a live session's `sequenced` list block still outranks a delayed control baseline at a lower cut.
|
||||
- `context/session-reference/tests/session-reference.spec.ts`: a seeded cold session is labeled and searchable by its cached title, a session without a cache record is still labeled by id, and neither reads a log.
|
||||
- `subagent/subagent/tests/list-children.spec.ts`: unchanged; seeded children still go through body observation.
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# Agent Note: Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-projection-cache-listing-identity-and-cached-rows.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Host 进程重启后,所有 fork 出来的会话(`SessionHeader.isSeeded === true`)在侧边栏列表里没有 title,`@` 引用补全只显示会话 id,列表排序退化到创建时间。打开一次之后恢复。2026-09-19 的一台开发机上,241 条 projcache 记录里有 44 条 seeded,44 条的 `title` 行全部合法,列表一次都没读到。
|
||||
|
||||
这不是缓存损坏,也不是版本失配。projcache 记录的 domain version 是 7,`identity.formatVersion` 是 4,两者分别是缓存自身的磁盘格式代际和 fold 所依据的 Session 日志格式代际,都是当前值。问题在读取路径。
|
||||
|
||||
### 机制
|
||||
|
||||
缓存记录(记录格式与前代恢复见 [投影缓存前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md))绑定一份 lifecycle identity:`formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`,`identityMatches` 做全等匹配。其中 `inheritedEventCount`(fork 继承的事件前缀长度,下称 cut)从 #3346 起不再出现在逻辑 header 里:header 只保留 `isSeeded` 这一位,精确 cut 跟随正文。Session 格式 v2 起(#3398),物理 header 行也不再存 `seedLength`,reader 从正文里 `session/end-seed {inherited: true}` 标记的 seq 推出 cut。
|
||||
|
||||
于是 header-only 的读取拿不到 cut:JSONL 后端 `fromHeaderLine` 对 header-only 读取硬编码 `inheritedEventCount: 0`,`SessionPersistenceSnapshot` 只有 header、revision 和可选的 eventCount。三个只读 header 的缓存消费者都因此加了同一个守卫:
|
||||
|
||||
| 消费者 | 守卫 | 兜底 | 后果 |
|
||||
|---|---|---|---|
|
||||
| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | 无 | 无 title,无 `sessionListMetadata`,blank 判定退到 `false`,排序退到 `createdAt` |
|
||||
| `packages/context/session-reference/src/index.ts` `projectedLabels` | 同 | 无 | `@` 补全按 id 显示,搜不到 title |
|
||||
| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | 同 | `observeSession` 读正文 | 结果正确,每个 seeded 子代一次正文读 |
|
||||
|
||||
守卫存在时(#3346,2026-09-01),`list.ts` 还有 `probeSmallCold`:缓存 miss 且日志文件不超过 `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` 字节时读正文补全。它的目的是识别 blank 会话,只覆盖不到 1KB 的日志,对正常 fork 从未生效。#3400(2026-09-03)删掉它,列表回到纯元数据加缓存。#4320(2026-09-19)把 fork 变成一等功能(`packages/core/session/src/fork.ts`,任意 seq fork),每个 fork 都是 `isSeeded: true` 加精确 cut,seeded 会话数量从个位数变成几十条,重启后全部冷,问题集中显形。
|
||||
|
||||
### 缓存的两种用途与校验位置
|
||||
|
||||
`SessionProjectionCache` 的读写面和调用方:
|
||||
|
||||
| 面 | 对记录做什么 | 写回 | 调用方 |
|
||||
|---|---|---|---|
|
||||
| `write(session)` | 三个强制点加节流写 checkpoint | 有 | 缓存内部监听 |
|
||||
| `hydratePrepared(session, events)` | 把 `rows` 当 fold 起点,从 `row.seq + 1` 继续 apply,结果进 live cell | 后续 checkpoint 写回 | `session-query/src/observation.ts` |
|
||||
| `coldSnapshot(meta, cut, events)` | fold 起点加回写 | 有 | 无生产调用方 |
|
||||
| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`:每行过 `ver` 和 `stateSchema`,`view` 后返回 | 无 | `list.ts`、`session-reference`、`list-children` |
|
||||
| `cachedPredecessorTitle(meta, cut)` | 同上,只看 `title`,允许更旧的 `formatVersion` | 无 | `list.ts` |
|
||||
|
||||
身份校验全部在缓存内部(`recordFor` → `identityMatches`;`viewCheckpoint` 逐行校验 `ver` 与 schema)。`list.ts` 自己不校验,只是给不出 cut 时不调。
|
||||
|
||||
spec.ts 与 README 描述身份检查的目的时用的都是同一个动词:防止 "seed state folded from an unrelated log"、"cannot seed the caller"。它防的是 fold 面。只读面从未做过 seed。
|
||||
|
||||
### 客户端 store 无法保证建连数据覆盖提示
|
||||
|
||||
客户端每个会话只有一个 `ProjectionValueStore`(`manager.projectionStores`;合并规则原记录于 [Session observation 与 projection 所有的客户端状态](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)),列表下发的 block、打开会话的 history 首页基线、control 基线、推送 frame、rename 结果全部写进同一个对象,`useProjection` 读的也是它。所有写入者平等,只有一条 higher-seq-wins 规则:`apply` 在 `seq <= row.seq` 时丢弃新值,`seed` 清理没带的键时只清 `row.seq <= cut` 的行。一个 seq 相等或偏高的列表提示会在会话打开后原样留在格子里。列表提示的 seq 来自磁盘记录,crash-repair 截断日志后它可以数字上高于建连 cursor,而那正是缓存错、建连对的情形。对提示做 seq 比较是用错了工具。
|
||||
|
||||
## Decision
|
||||
|
||||
### 缓存分成两个读面,cut 只在 fold 面比对
|
||||
|
||||
| 面 | 身份 | 谁能提供 | 方法 |
|
||||
|---|---|---|---|
|
||||
| 只读面 | lifecycle 身份:`formatVersion + createdAt + cwd + isSeeded`,全部来自 header | 任何 header-only 调用方 | `cachedSnapshot(header, keys?)`、`cachedPredecessorTitle(header)` |
|
||||
| fold 面 | lifecycle 身份加 `inheritedEventCount` | 握有 Session 或正文的调用方 | `recordFor` 所服务的 `hydratePrepared`、`write`、`coldSnapshot` |
|
||||
|
||||
只读面的两个方法不再接受 cut 参数。`inheritedEventCount` 继续写入记录,fold 面继续全等比对它;"不同 fork cut 的记录不能互相当种子"这条原始设计预期不变。
|
||||
|
||||
只读面返回的 block 里,`asOfSeq` 是所服务各行中最低的水位,也就是存储记录自己的位置。header 既作证不了 cut,也作证不了这个水位与当前日志的可比性,所以可比性不由这个数字表达:Session list 给每条摘要的 `projections` block(`SessionProjectionHints`)加独立字段 `kind`,冷会话从 projcache 看出来的 block 标 `cached`,活会话由 Host live registry 算出的 block 标 `sequenced`。两个字段各管各的:`kind` 说明 `asOfSeq` 属于哪个序列空间,`asOfSeq` 是那个空间里的水位。
|
||||
|
||||
### 只读面为什么可以不比 cut
|
||||
|
||||
- 在同一个 `formatVersion` 下,一条日志的 cut 由 `(id, createdAt, cwd, isSeeded)` 唯一决定:fork 创建时写死,只有改变事件基数的格式迁移能改它,而那种迁移必然改 `formatVersion`。cut 是 lifecycle 的派生事实,不是独立坐标。
|
||||
- 只读面今天实际发生的是:unseeded 传常量 0(构造上恒真),seeded 直接不调。cut 在只读面上从未和任何真实事实比过。拿掉它,拒绝集合一个都不变:另一个 lifecycle 的记录(四字段任一不同)照样被拒;unseeded 结果相同;seeded 从不可寻址变成可寻址。
|
||||
- 只读面没有 seed、没有写回,输出是存储记录的纯函数,cut 不参与计算,也不流向任何地方。
|
||||
- 唯一多出来的暴露面:一份四字段全同、只有 cut 不同的记录。系统自身产生不出它,只有手工复制会话目录再改文件才行。就算出现,后果是列表显示它的值直到会话被打开,仍然不进任何 fold。
|
||||
|
||||
### 消费者
|
||||
|
||||
| 位置 | 改法 |
|
||||
|---|---|
|
||||
| `list.ts` `projectionsFor` | 删 `isSeeded` 分支和 `SessionLogOffset(0)`,冷会话统一 `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
|
||||
| `session-reference` `projectedLabels` | 同一症状,同一改法 |
|
||||
| `list-children.ts` `resolveColdIdentity` | 只跟随签名,`!header.isSeeded` 守卫和正文兜底原样保留;它需要 cut 做 inherited/owned 分类,属于 fold 语义 |
|
||||
|
||||
### 客户端 store 区分 cached 与 sequenced 行
|
||||
|
||||
`ProjectionValueStore` 的行分两类,按"这个值有没有本连接可比的 seq"命名:
|
||||
|
||||
| 行 | 含义 | 携带 |
|
||||
|---|---|---|
|
||||
| `cached` | 从持久化 checkpoint 只读看出来的值,没有 Session 参与,seq 与本连接不可比 | 只有 `value` |
|
||||
| `sequenced` | 本连接内由 Host 对该 Session 算出的值,seq 在同一空间可比 | `value` 加 `seq` |
|
||||
|
||||
规则:
|
||||
|
||||
| 写入 | 遇到 `cached` 行 | 遇到 `sequenced` 行 |
|
||||
|---|---|---|
|
||||
| `applyCached(values)` | 覆盖 | 忽略 |
|
||||
| `seed(baseline)` | 整表丢弃 `cached` 行,再按 block 写入;block 没带的键按 `seq <= cut` 清理 | higher-seq-wins |
|
||||
| `apply(key, value, seq)` | 覆盖 | higher-seq-wins |
|
||||
|
||||
seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `clear()`,再由列表重新写 cached、打开会话重新 seed。从 cached 到 sequenced 是无条件替换,不看 seq。
|
||||
|
||||
写入口归类:
|
||||
|
||||
| 入口 | 行类型 |
|
||||
|---|---|
|
||||
| `session.list` 响应里每条摘要的 `projections` block(`manager.refreshList`) | 按 block 的 `kind`:冷会话 `cached`,活会话 `sequenced` |
|
||||
| `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` |
|
||||
| history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced |
|
||||
| control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced |
|
||||
| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced |
|
||||
| 推送 frame(`manager` 处理 `projection` frame) | sequenced |
|
||||
| rename 成功后的 `title`(`session.ts`) | sequenced |
|
||||
|
||||
`seed` 与 `apply` 签名不变。客户端按 `kind` 分流:`sequenced` block 逐键 `apply`,`cached` block 走 `applyCached`,不读它的 `asOfSeq`。
|
||||
|
||||
### 不动的部分
|
||||
|
||||
- Session 格式、物理 header 行、`fromHeaderLine`、persistence、`SessionPersistenceSnapshot`。
|
||||
- hydration 与 checkpoint 写路径,`recordFor` 的五字段全等。
|
||||
- `coldSnapshot` 保留,不因没有生产调用方而删除。
|
||||
- 老记录(domain v4/v5,缺 `isSeeded` 与 `inheritedEventCount`)对 seeded 会话继续 miss,等会话打开后被 v7 记录改写。
|
||||
- 列表下发全部带 wire 视图的行(`contextBreakdown`、`turnOutline` 等)这一点不在本次范围。
|
||||
|
||||
### 最坏情形推演
|
||||
|
||||
1. 列表用了一份错误记录,侧边栏显示错 title,格子里是 cached 行。
|
||||
2. 点进会话,首页基线到达前一瞬仍是错值。这是任何预填充都有的窗口。
|
||||
3. 首页基线 `seed`:所有 cached 行先被无条件丢弃,再按 block 写入。错值零残留,与 seq 大小无关。
|
||||
4. 之后列表刷新,cached 写入遇到 sequenced 行直接忽略,错值不会被刷回来。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把 cut 写回物理 header 行。** header-only 读取能直接拿到 cut,只读面无需改身份。代价是 Session 格式再升一版,V4 刚落地;并且要推翻 #3346"精确 cut 不伪装成 body-free metadata"的决定。否决:不为此改 header。
|
||||
|
||||
**由 persistence 或 session-query 维护每个会话 cut 的索引。** 新增一套 durable 索引及其一致性维护,只为服务一个展示读取。否决。
|
||||
|
||||
**恢复有界正文探测。** #3400 删掉的 `probeSmallCold` 思路,或由客户端对可见 seeded 会话异步 `refreshProjections`。违背列表零 I/O 原则,且历史阈值 1KB 说明它从未覆盖过正常 fork。否决。
|
||||
|
||||
**只用 `asOfSeq: -1` 哨兵,不给 store 分层。** 服务端一处改动即可让提示在 seq 规则下永远落败。但它靠约定成立:一旦提示 seq 与基线 cut 相等或偏高(crash-repair 截断),`apply` 保留旧行,错值存活到下一帧。用户要求建连数据无条件覆盖提示,规则要写进 store 而不是靠 seq 约定模拟。
|
||||
|
||||
**用 `asOfSeq: -1` 表达 cached,客户端按哨兵分流。** 只读面统一输出 `-1`,客户端把所有列表 block 当 cached。否决:一个字段同时承担两个含义,另一个含义只能靠约定推断;活会话的列表 block 带本连接可比的真实 seq,一律当 cached 就把它们也降级了,PR 评审复现了延迟到达、cut 更低的 control 基线覆盖更新列表值的回归。改为独立字段 `kind`,`asOfSeq` 保持各自来源的水位。
|
||||
|
||||
**把 cut 从缓存身份里彻底删掉。** fold 面需要它:`restore` 从缓存行状态继续 apply,`schedule`、`subagentCatalog`、`permissions.seeded` 与 owned/inherited 归属都编码了 cut,错误会被写回并持久化。否决。
|
||||
|
||||
**行类型命名 `hint` / `authoritative`。** 说的是可信度,而规则依据的是 seq 可比性。改为 `cached` / `sequenced`,名字直接说明规则用到的性质。
|
||||
|
||||
## Consequences
|
||||
|
||||
买到的:
|
||||
|
||||
- 重启后 fork 会话在侧边栏立即有 title、`sessionListMetadata`(blank、lastPromptAt)和其余带 wire 的缓存值,排序按最后一次提问时间。
|
||||
- `@` 补全对 fork 按 title 显示和搜索。
|
||||
- 建连后的数据无条件替换列表提示,不再依赖 seq 大小。
|
||||
- 缓存身份的两级含义有了名字:lifecycle 身份回答"是不是同一份日志",fold 身份回答"能不能当续算起点"。
|
||||
|
||||
付出的:
|
||||
|
||||
- `cachedSnapshot` 与 `cachedPredecessorTitle` 签名变化,三个调用方同步修改。
|
||||
- `SessionProjectionHints` 新增必填字段 `kind`,所有产出列表摘要的地方和构造摘要的测试夹具都要带上。
|
||||
- 手工构造的同四字段、不同 cut 的记录会在列表上显示到会话被打开为止。
|
||||
- 基线没带的键连提示一起清掉:某个 Host 未挂载 `schedule` 时,打开会话后列表提示过的 schedule 标记消失。按"建连数据是真值"这是正确行为。
|
||||
- 老记录对 seeded 会话仍然 miss,直到打开重写。
|
||||
|
||||
## Testing
|
||||
|
||||
- `session-projection-cache/tests/cache.spec.ts`:seeded 冷 header 通过 `cachedSnapshot(header)` 取到全部版本匹配的行,`asOfSeq` 为行水位;unseeded header 对同一 id 的 seeded 记录被拒;`coldSnapshot` 对 cut 一致的记录从行续算、对 cut 不一致的记录整段重新 fold,对 unseeded 且 cut 非 0 的调用抛错;`cachedPredecessorTitle(header)` 对 seeded 的更旧 `formatVersion` 记录只给 `title`;不同 watermark 的多行合成一个 block,`asOfSeq` 取最低行。
|
||||
- `session-projection-cache/tests/fixtures.spec.ts`:归档的 v3 到 v6 记录继续只透出 predecessor title;无 lineage 字段的归档对 seeded 调用方继续 miss。
|
||||
- `api/session-controller/tests/session-cold.host.spec.ts`:seeded 冷会话摘要携带 `kind: 'cached'`、`title` 与 `sessionListMetadata`,`updatedAt` 取 `lastPromptAt`,缓存确实被查询,正文没有被读。
|
||||
- `api/session-controller/tests/session-projections.host.spec.ts`:用 `sessions.fork` 造一个真实 fork 并写入 projcache,销毁整个 Context 后在同一存储根上重启 Host,只凭 header 通过 `session.list` 读到 `kind: 'cached'` 的 `title` 与 `sessionListMetadata`,`inspect` / `open` 一次都没被调用。
|
||||
- `api/session-controller/tests/projection-store.client.spec.ts`:`applyCached` 只填空位、不覆盖 sequenced 行;任何 sequenced 写入(包括 cursor `-1` 的 frame 和 cut 更低的基线)替换 cached 行;基线先丢弃全部 cached 行再清理没带的键;面订阅在 cached 填充与丢弃时都收到通知。manager 路径:`cached` 列表 block 的水位再高,也被 cut 更低的 control 基线替换,之后的列表刷新拿不回来;`sequenced` 列表 block 按 higher-seq-wins,cut 更低的延迟基线既不覆盖也不清掉它,更高 seq 的 frame 仍能推进它。
|
||||
- `api/session-controller/tests/manager.client.spec.ts`:`api-session/added` 的 `cached` block 被同 cursor 的 control 基线替换;`cached` 列表 block 不覆盖已有的 sequenced title。
|
||||
- `api/session-controller/tests/inbox-projection.client.spec.ts`:不变,活会话的 `sequenced` 列表 block 仍压过延迟到达、cut 更低的 control 基线。
|
||||
- `context/session-reference/tests/session-reference.spec.ts`:seeded 冷会话按缓存 title 标注并可按 title 搜到,没有缓存记录的会话仍按 id 标注,两者都不读日志。
|
||||
- `subagent/subagent/tests/list-children.spec.ts`:不变,seeded 子代仍走正文观察。
|
||||
@@ -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-08-17-durable-web-queue-recovery.md
|
||||
2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3
|
||||
2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c
|
||||
@@ -18,7 +18,7 @@ The registry folds committed splices before `Session.append()` returns; each Age
|
||||
|
||||
The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
|
||||
|
||||
A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values.
|
||||
A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all Host-sequenced values obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer history values or a live Session's list values; a cached list block viewed from the projection cache yields to the baseline instead ([listing identity and cached rows](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md)).
|
||||
|
||||
The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We
|
||||
|
||||
通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
|
||||
|
||||
对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。
|
||||
对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有 Host 排序过的值都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 history 值或活 Session 的 list 值;从 projection cache 看出来的 cached list block 则让位于该 baseline([只读面按 lifecycle 身份匹配与 cached 行](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。
|
||||
|
||||
客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md
|
||||
2026-09-15-client-session-blank-reconciliation.md: 7213636e32702c4c51887adba3da019f49b8703c
|
||||
2026-09-15-client-session-blank-reconciliation.zh.md: 4b50340fea668b6d850c1d00787dda3874b61e0c
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Keep accepted Session presentation across list refreshes
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-client-session-blank-reconciliation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A successful prompt response converts the Client's `New Session` row before a turn necessarily starts. A later `session.list` response can still report `blank: true` and undo that conversion. Reconnect triggers the same refresh. Keeping the conversion only inside a Session object also loses it when that object is replaced.
|
||||
|
||||
## Decision
|
||||
|
||||
The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`, with `sessionListMetadata` hints still applied on top. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities.
|
||||
|
||||
Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull.
|
||||
|
||||
The observation survives refresh, reconnect, and object replacement. Removal, drop, and successful list refresh forget it only when no list row, Session object, or child address retains the identity. Refresh evaluates retention after replaying in-flight mutations; failed and superseded pulls do not prune observations. Late acceptance cannot recreate a removed row or a Session object. Manager disposal clears the set and prevents late acceptance and outstanding list responses from publishing.
|
||||
|
||||
Engagement is display memory, not proof that a turn started, work remains queued, or a message reached durable storage. It is not persisted across page reload. Historical `sessionListMetadata.blank` and the Host summary defaults are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Preserve every previously false summary bit.** A previous Host summary is not evidence of local acceptance or running. Making all false values permanent would also suppress later Host corrections without either observation.
|
||||
|
||||
**Introduce a shared Session record.** Combining this memory with projection stores requires constructor and cache-lifetime changes. Those changes are independent of preventing a blank rollback and are excluded.
|
||||
|
||||
**Wait for history before converting the row.** This changes the existing acceptance-time display rule and is a separate UI decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
Accepted or observed-running Sessions retain their existing display conversion, including when accepted input never starts a turn. Drafts, submission-error recovery, sidebar filtering and title rules, creation/reuse, UUIDs, Host protocol, persistence, and projection-cache lifetimes are unchanged. Reverting this Client-only fix needs no data migration.
|
||||
|
||||
The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign.
|
||||
|
||||
For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If its projection store was discarded and that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it.
|
||||
|
||||
## Verification
|
||||
|
||||
Manager tests cover refresh without a turn, running-to-idle updates, object replacement, late acceptance, removal through notifications or list reconciliation, retained instances and child addresses, failed and superseded pulls, and disposal. They check both list and Session state where an instance exists. Session tests retain first-send rejection and later-send behavior. Browser verification uses the recorded first-turn scenario with acceptance held before `turn/start` and a same-page reconnect. A timestamp substituted only in the reconnect list response provides a visible synchronization marker; the Host's blank/running values and durable log remain unchanged.
|
||||
|
||||
## Related decisions
|
||||
|
||||
The [scope and provisioning decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns Session scope, creation/reuse, and the visible blank row. The [Session/Conversation ownership decision](../architecture/2026-08-20-client-session-conversation-ownership.md) owns transport, assembly, and snapshot contents.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: 在列表刷新后保留已受理会话的展示状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-client-session-blank-reconciliation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
提示词请求成功后,客户端会把 `New Session` 行转换为普通会话,但此时轮次不一定已经开始。后续 `session.list` 响应仍可能返回 `blank: true`,从而撤销该转换。重连会触发同样的刷新。若只在 Session 对象内保留转换状态,对象被替换时也会丢失。
|
||||
|
||||
## 决策
|
||||
|
||||
[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`,其上仍叠加 `sessionListMetadata` 提示。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。
|
||||
|
||||
每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。
|
||||
|
||||
该观察跨刷新、重连和对象替换保留。移除、drop 和成功的列表刷新仅在列表行、Session 对象及子会话地址均不再保留该身份时清除它。刷新在重放请求期间的变更后判断保留资格;失败及被替代的请求不会清理观察。迟到的受理不能重建已移除的行或 Session 对象。Manager 销毁会清空集合,并阻止迟到的受理和未完成的列表响应发布。
|
||||
|
||||
受理/运行是展示记忆,不证明轮次已经开始、工作仍在排队,或消息已经写入持久存储。它不会跨页面重载持久化。历史 `sessionListMetadata.blank` 与 Host 摘要默认值保持不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留所有曾为 false 的摘要位。** 先前的 Host 摘要不能证明客户端观察过受理或运行。将所有 false 永久保留,也会在两种观察均不存在时阻止后续 Host 校正。
|
||||
|
||||
**引入共享 Session 记录。** 将这份记忆与投影存储合并,需要修改构造参数及缓存生命周期。这些改动独立于防止 blank 回退,因此不纳入。
|
||||
|
||||
**等待历史后再转换行。** 这会改变现有的受理时展示规则,属于独立的 UI 决策。
|
||||
|
||||
## 后果
|
||||
|
||||
已受理或观察过运行的 Session 保持既有展示转换,包括已受理输入始终没有开始轮次的情况。草稿、发送错误恢复、侧边栏筛选与标题规则、创建/复用、UUID、Host 协议、持久化及投影缓存生命周期保持不变。回退这项纯客户端修复不需要数据迁移。
|
||||
|
||||
Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。
|
||||
|
||||
对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若投影存储已被丢弃,且该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。
|
||||
|
||||
## 验证
|
||||
|
||||
Manager 测试覆盖无轮次刷新、运行转空闲、对象替换、迟到受理、通过通知或列表对账移除、保留实例与子会话地址、失败及被替代的请求,以及销毁。在存在实例的场景中,测试同时检查列表和 Session 状态。Session 测试保留首发拒绝及后续发送行为。浏览器验证使用已有首轮录制场景,在受理后、`turn/start` 前暂停并进行同页重连。测试仅替换重连列表响应中的时间戳,用其显示结果作为同步标记;Host 的 blank/running 值及持久日志保持不变。
|
||||
|
||||
## 相关决策
|
||||
|
||||
[作用域与供给决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md) 拥有 Session 作用域、创建/复用及可见 blank 行。[Session/Conversation 归属决策](../architecture/2026-08-20-client-session-conversation-ownership.zh.md) 拥有传输、装配及快照内容。
|
||||
@@ -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-16-desktop-window-menus.md
|
||||
2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd
|
||||
2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa
|
||||
2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47
|
||||
2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253
|
||||
@@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem
|
||||
|
||||
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
|
||||
|
||||
Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
|
||||
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser
|
||||
|
||||
## Consequences
|
||||
|
||||
macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop.
|
||||
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
|
||||
|
||||
## Testing
|
||||
|
||||
A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`.
|
||||
`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution.
|
||||
@@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
|
||||
|
||||
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
|
||||
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之
|
||||
|
||||
## 影响
|
||||
|
||||
macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。
|
||||
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。
|
||||
`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md
|
||||
2026-09-19-declared-session-attachment-carriers.md: 1619624800dc5e1ee8d6a4f4b64b84a6c80078c2
|
||||
2026-09-19-declared-session-attachment-carriers.zh.md: bd66b1c80469e2adbd7becc1b015d390978becc6
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Declared Session attachment carriers
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-declared-session-attachment-carriers.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session attachment authorization and ZIP export inferred attachment ownership from common payload field names. An unknown ignorable event could therefore authorize an image read or export file bytes. Compaction content under `summary` and `rawOutput` was outside that field-name scan.
|
||||
|
||||
## Decision
|
||||
|
||||
Both consumers select content fields by built-in event type. They inspect completed assistant blocks and direct content blocks, including flat V4 tool-role messages. Other content-block fields and unknown event payloads remain opaque, even when they contain objects resembling image or file references.
|
||||
|
||||
The controller applies this rule before reading image bytes. The exporter applies it before reading image or file bytes and retains the complete logical log. Historical logs reach these readers after persistence converts them to current logical events. Title requests are excluded because V4 requires a single text block. Compaction uses its declared content fields: the basic producer currently emits text, while other valid compaction producers may supply attachments there.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Scan common keys on every event.** This lets a field name grant attachment access without a defined content meaning. It also misses valid content stored under other declared fields.
|
||||
|
||||
**Interpret historical wrappers in attachment readers.** Historical conversion belongs to the adjacent format packages. Current attachment readers use flat V4 content and do not maintain a second interpretation of old blocks.
|
||||
|
||||
## Consequences
|
||||
|
||||
Plugins that store attachments only in custom events cannot use these built-in readers for those references. A plugin needs a supported content occurrence or its own reader. Adding a built-in content carrier requires updating both readers and their acceptance and refusal tests. Historical codec inventories remain version-owned; changing this policy must not change what a released decoder recognizes.
|
||||
|
||||
The change affects attachment lookup and archive membership, not Session encoding or the model-visible message history.
|
||||
|
||||
## Verification
|
||||
|
||||
Controller and archive tests cover declared carriers, flat tool results, opaque plugin payloads, and misleading extra fields. Refusal tests assert that attachment storage is never read; archive tests also compare the exported log text.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Session 声明的附件载体
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-declared-session-attachment-carriers.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Session 附件授权与 ZIP 导出按常见载荷字段名推断附件归属。因此,未知可忽略事件也能授权图片读取或导出文件字节。压缩事件的 `summary` 与 `rawOutput` 内容不在这套字段名扫描范围内。
|
||||
|
||||
## 决策
|
||||
|
||||
两个消费者均按内置事件类型选择内容字段。它们检查已完成的 assistant 块与直接内容块,包括扁平的 V4 tool 角色消息。其他内容块字段与未知事件载荷保持不透明,即使其中含有类似图片或文件引用的对象。
|
||||
|
||||
控制器在读取图片字节前应用此规则。导出器在读取图片或文件字节前应用此规则,并保留完整逻辑日志。历史日志由持久化层转换成当前逻辑事件后交给这些读取器。标题请求不在附件载体之列,因为 V4 要求它只含一个文本块。压缩事件使用其声明的内容字段:基础生产者目前输出文本,其他合法压缩生产者可以在这些位置提供附件。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**扫描所有事件的常见键。** 这使字段名在没有明确内容语义时授予附件访问权,也会遗漏其他已声明字段中的有效内容。
|
||||
|
||||
**在附件读取器中解释历史包装块。** 历史转换由相邻格式包负责。当前附件读取器使用扁平的 V4 内容,不维护第二套旧块解释。
|
||||
|
||||
## 影响
|
||||
|
||||
仅在自定义事件中存储附件的插件不能通过这些内置读取器读取相关引用。插件需要使用受支持的内容位置,或提供自己的读取器。新增内置内容载体时,需要同步更新两个读取器及其接纳和拒绝测试。历史编解码器的清单仍归各版本所有;调整此策略不能改变已发布解码器识别的内容。
|
||||
|
||||
此变更影响附件查找与归档成员,不影响 Session 编码或模型可见消息历史。
|
||||
|
||||
## 验证
|
||||
|
||||
控制器和归档测试覆盖声明的载体、扁平工具结果、不透明插件载荷及误导性额外字段。拒绝测试断言不会读取附件存储;归档测试还比较导出的日志文本。
|
||||
Loaded 100 of 1374 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user