diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml similarity index 66% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 6eebca38d8..1d5dbfcc3e 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 83cfbf65cf27b79c9deef2f58943931f402feb22 -2026-06-18-shared-persistence-write-coordinator.zh.md: a7b69add8be5a96ad7b9f25484d9601200c551f3 +2026-06-18-shared-persistence-write-coordinator.md: 5c324f2c0c2b951f664bcf92725a36c4975fd3a1 +2026-06-18-shared-persistence-write-coordinator.zh.md: 51ef76189cb9276214bf05bbd1dbd8e3755daa35 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md similarity index 57% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md index 83cfbf65cf..5c324f2c0c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -1,6 +1,7 @@ # Agent Note: Shared persistence write coordinator Status: implemented +Archived: 2026-08-31 English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) @@ -24,14 +25,15 @@ The coordinator retires a session from `session/disposed`: it waits for the cont ### The hook interface (`PersistenceBackend`) -Six required durable primitives plus optional format-fusion, seek, empty-materialization, artifact, and lifecycle hooks form the only boundary between the coordinator and storage: +Five required members plus optional empty-materialization and lifecycle hooks form the only boundary between the coordinator and storage: -- `name` labels an aggregate disposal failure; `loadStored(id)` reads one already-current detached prefix; and `readStoredRevision(id)` returns its cheap source identity. The coordinator asserts ids and rejects a stored/live cwd mismatch before repair or state publication. -- `ensureCurrent?(id)` lets a backend publish any supported historical generation's current successor before a current read. `loadCurrentStored?(id)` fuses that operation with prefix decoding over one stable selected-generation snapshot; otherwise the coordinator calls `ensureCurrent` and `loadStored` in order. The same split exists for raw artifacts through `readCurrentRawStored?` and `readRawStored?`. -- `loadStoredFrom?(id, fromSeq)` is an optional seek-capable current suffix read. Sequential backends omit it and reuse the full current prefix. -- `appendBatch(meta, events, isMaterialized)` durably appends a contiguous batch and atomically performs lazy first materialization. `materializeHeader?(meta)` explicitly persists an empty resumable Session for lifecycle frontends such as [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md). -- `commitRepair(meta, tornMarker, closers)` makes crash repair durable by truncating the torn tail when present and appending closers when present. It need not be atomic: JSONL legitimately truncates then appends in two synced steps. Preparation and load commit truncation plus synthetic closers; live adoption commits truncation only. -- `list()` returns one header-only descriptor for every stored artifact; `locate?()` resolves a backend-owned artifact without I/O; and `close?()` releases backend resources after the coordinator's quiescence drain. +- `name` — backend label for the dispose-failure `AggregateError`. +- `loadStored(id)` — read one stored prefix by id across every storage scope. Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. +- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized. Ordinary creation therefore cannot leave an abandoned materialized-but-empty session. +- `materializeHeader?(meta)` — explicitly persist a header-only session for `SessionPersistence.ensureMaterialized(session)`. This is reserved for a lifecycle frontend that treats an empty session itself as a resumable durable resource; [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) are the first consumer. Backends that support that lifecycle implement the hook; lazy creation remains the default. +- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates then appends in two fsync'd steps. Used by `prepare`/`load` (truncate + synthetic closers) and live adoption (truncate only, `closers = []`). +- `list()` — list all stored metadata. +- `close?()` — optional lifecycle teardown for a provider with owned resources; JSONL omits it. The dispose effect awaits it after the quiescence drain so a close failure never masks a drain error. ### The opaque torn marker @@ -39,13 +41,13 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` proves that already-current JSONL `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery; a supported historical inspection first publishes its migrated and repaired current successor beside the unchanged source. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, Session and provider disposal drains, and crash-tail repair through an in-memory reference and JSONL. `persistence.spec.ts`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch retry, and close ordering. JSONL specs retain storage mechanics and the through-coordinator torn-tail case that exercises the opaque-marker branch. +The shared `runPersistenceContract` proves that JSONL `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, Session and provider disposal drains, and crash-tail repair through an in-memory reference and JSONL. `persistence.spec.ts`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch retry, and close ordering. JSONL specs retain storage mechanics and the through-coordinator torn-tail case that exercises the opaque-marker branch. ## Alternatives considered - **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all. -- **Put historical formats in the coordinator or require two physical reads** — rejected because format framing, highest-generation selection, immutable successor publication, and stable-source decoding belong to the backend. Optional fused current reads preserve one coordinator lifecycle while letting JSONL classify or migrate and decode one exact snapshot; the ordinary hooks remain the fallback for other backends. +- **A wider hook API** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration. ## Consequences -The coordinator adds one indirection, an opaque torn marker, detached Session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration for the JSONL provider and future implementations. Session disposal remains an observe-only event, so the Session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes provider teardown the quiescence boundary. Its hooks stay tied to current consumers: identity, adoption, collision checks, preparation, and immutable inspection share the serialized current-prefix path; materialization stays atomic inside `appendBatch`; and listing bypasses stateful orchestration. For already-current input, read models use `inspect` rather than `load`, so observing an open turn does not commit interruption closers; historical input first publishes a separate migrated and repaired successor. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. A new provider implements storage primitives rather than copying the bounded write lifecycle. +The coordinator adds one indirection, an opaque torn marker, detached Session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration for the JSONL provider and future implementations. Session disposal remains an observe-only event, so the Session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes provider teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. A new provider implements storage primitives rather than copy the bounded write lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md similarity index 56% rename from .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md rename to .agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index a7b69add8b..51ef76189c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/archived/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -1,6 +1,7 @@ # Agent Note: 共享持久化写入协调器 Status: implemented +Archived: 2026-08-31 [English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 @@ -24,14 +25,15 @@ JSONL provider 需要在其存储原语周围执行对正确性要求很高的 ### 钩子接口(`PersistenceBackend`) -六个必需的持久原语,加上可选的格式融合、seek、空会话物化、产物与生命周期钩子,构成协调器与存储之间唯一的边界: +五个必需成员加可选的空会话实体化与生命周期钩子,构成协调器与存储之间唯一的边界: -- `name` 标记聚合 dispose 失败;`loadStored(id)` 读取一个已经是当前格式的分离前缀;`readStoredRevision(id)` 返回其低成本来源标识。协调器会断言 id,并在修复或发布状态之前拒绝存储记录与活动会话的 cwd 不匹配。 -- `ensureCurrent?(id)` 让后端在当前格式读取前发布任何受支持历史 generation 的当前后继。`loadCurrentStored?(id)` 把该操作与基于一个稳定选定 generation 快照的前缀解码融合;否则协调器依次调用 `ensureCurrent` 与 `loadStored`。原始产物通过 `readCurrentRawStored?` 与 `readRawStored?` 使用同一分工。 -- `loadStoredFrom?(id, fromSeq)` 是可选的可 seek 当前格式后缀读取。顺序后端省略它并复用完整当前前缀。 -- `appendBatch(meta, events, isMaterialized)` 持久追加一个连续批次,并原子执行惰性首次物化。`materializeHeader?(meta)` 为 [标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)这类生命周期前端显式持久化空的可恢复 Session。 -- `commitRepair(meta, tornMarker, closers)` 在存在 torn tail 时截断它,在存在 closer 时追加它,从而持久化崩溃修复。它不要求原子性:JSONL 合理地分两步同步,先截断再追加。准备与加载提交截断和合成 closer;活动接管只提交截断。 -- `list()` 为每个存储产物返回一个仅 header descriptor;`locate?()` 不经 I/O 解析后端拥有的产物;`close?()` 在协调器排空至完全停稳后释放后端资源。 +- `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话。因此,普通创建不会留下被放弃的已物化空会话。 +- `materializeHeader?(meta)`——为 `SessionPersistence.ensureMaterialized(session)` 显式持久化仅含 header 的会话。它只供把空会话本身视为可恢复持久资源的生命周期前端使用;[标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)是第一个 consumer。支持该生命周期的后端实现此钩子;惰性创建仍是默认行为。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync,先截断再追加。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 +- `list()`——列出所有已存储的元数据。 +- `close?()`——供拥有资源的 provider 使用的可选生命周期清理;JSONL 省略该钩子。dispose effect 在排空至完全停稳后 await 它,因此 close 失败不会掩盖排空错误。 ### 不透明的 torn marker @@ -39,13 +41,13 @@ JSONL provider 需要在其存储原语周围执行对正确性要求很高的 ## 测试 -共享 `runPersistenceContract` 证明已经是当前格式的 JSONL `inspect` 会配平被中断的逻辑视图但不改变存储或 revision,随后由 `prepare` 或 `load` 提交恢复;受支持的历史检查会先在不改变源文件的情况下于其旁边发布迁移并修复后的当前后继。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现与 JSONL 覆盖接管、HMR、碰撞、Session 与 provider dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活 controller 清理、同 id 链尾竞态、失败批次重试与关闭顺序。JSONL spec 保留存储机制,以及覆盖不透明 marker 分支的经由协调器崩溃尾部用例。 +共享 `runPersistenceContract` 证明 JSONL 的 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现与 JSONL 覆盖接管、HMR、碰撞、Session 与 provider dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活控制器清理、同 id 链尾竞态、失败批次重试与关闭顺序。JSONL 规格保留存储机制,以及覆盖不透明 marker 分支的经由协调器崩溃尾部用例。 ## 曾考虑的替代方案 - **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。 -- **把历史格式放入协调器,或要求两次物理读取**——不予采用,因为格式 framing、最高 generation 选择、不可变后继发布与稳定来源解码属于后端。可选的融合当前格式读取保留一个协调器生命周期,同时让 JSONL 对同一个精确快照完成分类或迁移与解码;普通钩子继续作为其他后端的 fallback。 +- **更宽的钩子 API**——每个候选钩子都被折叠掉:没有限定存储范围的存活会话查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 ## 后果 -协调器增加一层间接、一个不透明 torn marker、脱离 Session 生命周期的退役任务,以及有界的已准备 Session 状态,但为 JSONL provider 与未来实现集中管理对正确性要求很高的编排。Session dispose 仍是仅观察事件,因此 Session owner 不等待持久化退役;协调器收容失败、在活动 controller 中保留待处理事件,并以 provider teardown 为完全停稳边界。其钩子只服务当前 consumer:标识、接管、碰撞检查、准备与不可变检查共用串行化当前前缀路径;物化保持在 `appendBatch` 内原子完成;列举绕过有状态编排。对已经是当前格式的输入,读模型使用 `inspect` 而非 `load`,因此观察开放轮次时不会提交中断 closer;历史输入会先发布一个独立的迁移并修复后继。复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新 provider 只需实现存储原语,而无需复制有界写入生命周期。 +协调器增加一层间接、一个不透明 torn marker、脱离 Session 生命周期的退役任务,以及有界的已准备 Session 状态,但为 JSONL provider 与未来实现集中管理对正确性要求很高的编排。Session dispose 仍是仅观察事件,因此 Session owner 不等待持久化退役;协调器收容失败、在存活控制器中保留待处理事件,并以 provider teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新 provider 只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 18173d7d3d..fa3abbea4f 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -10,6 +10,9 @@ "architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02", "architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85", "architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa", + "architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml": "sha256:3c5c22e9e6a63598ba648cad46d783af322cf3afd6021426a2d738f4b026bf65", + "architecture/2026-06-18-shared-persistence-write-coordinator.md": "sha256:d5242c770101086b6f0a0c40eab500d405ef4a98cae07e28d9ec21e89d94f90e", + "architecture/2026-06-18-shared-persistence-write-coordinator.zh.md": "sha256:3dce52e302600a0eea29b4821a2718b6bbc1ebe4c6c2ae372cd0cbe66cb05519", "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4", "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce", "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff", diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index d664537d10..34927aeae7 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md -2026-06-14-session-persistence.md: 989beb6f8cc65c8d033206a61b4408f3aecdbbc7 -2026-06-14-session-persistence.zh.md: 9dcdbffc7df89ce6fcd5341ef77f99e47643aa92 +2026-06-14-session-persistence.md: a7e06af78c4a372be7a68f3e0f6dc18e38cbead1 +2026-06-14-session-persistence.zh.md: 6d458d4f4c31793212d674bb406204c3882a25ed diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 989beb6f8c..a7e06af78c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -14,22 +14,22 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`export`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. Key durable, contested choices: - **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Ordinary writes append; a crashed turn is closed, never truncated.** Flushed current-generation events are never rewritten by normal persistence. A format migration leaves the exact physical source path, bytes, and inode unchanged, then publishes one re-encoded current successor at a previously absent canonical versioned filename after only edge-owned normalization and current crash repair. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable. +- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` opens the session's write handle, reads the stored log, and publishes the prepared Session under the persisted id, continuing its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns the unpublished-Session ownership window. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; current Session and coordinator code accept only `SESSION_FORMAT_VERSION = 1`. JSONL event-body reads compose the static adjacent migration chain before constructing a Session, and the v0-to-v1 edge owns the former narrow import upgrades such as [pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md). V0 remains at suffixless `session.jsonl[.zstd]`, while v1 and later use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no compatibility promise: reads validate current v0 records only, and retired same-version shapes refuse fail-closed ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 9dcdbffc7d..6d458d4f4c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -14,22 +14,22 @@ Status: implemented 持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`export`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`([基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md))。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **普通写入仅追加;崩溃的轮次被关闭,而非截断。** 正常持久化绝不重写已刷入当前 generation 的事件。格式迁移保持精确物理源路径、字节与 inode 不变,再只经过迁移边拥有的归一化与当前崩溃修复,在此前不存在的规范具名版本文件下发布一个重新编码的当前后继。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 打开该会话的写句柄,读取已存储的日志,并以持久化 id 发布准备好的 Session,继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义未发布 Session 的所有权窗口。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;当前 Session 与协调器代码只接受 `SESSION_FORMAT_VERSION = 1`。JSONL 的事件正文读取会在构造 Session 前组合静态相邻迁移链,v0-to-v1 边拥有原有的范围受限导入升级,例如[消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)。V0 保留在无后缀 `session.jsonl[.zstd]`,v1 及后续版本使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的追加与 flush 能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不作兼容承诺:读取只校验当前 v0 记录,已废弃的同版本形态会以 fail-closed 方式拒绝([导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml index 76348b2cb2..61c4344493 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md -2026-07-19-zstandard-jsonl-session-logs.md: a79bc3907c4f6c02851ba1814f733684ce373898 -2026-07-19-zstandard-jsonl-session-logs.zh.md: 178420c126b68689983d5b01f4ffae29b17fe657 +2026-07-19-zstandard-jsonl-session-logs.md: 076e3c0e0e3e147a98033102e0a3a4ff466eeddc +2026-07-19-zstandard-jsonl-session-logs.zh.md: 5746461ffd3557b51d59a413a472408a8bba653f diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md index a79bc3907c..076e3c0e0e 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -32,7 +32,7 @@ A frame-boundary scanner reads the standard magic, variable header fields, block Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs. -EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames. +EOF inside the final frame is a torn tail. The frame belongs to an append that never resolved, so none of its records were acknowledged durable: repair truncates from that frame's starting byte, retains all prior complete frames, and appends the coordinator's synthetic tool, step, and turn closers as one new checksummed frame ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns dropping the earlier partial-plaintext salvage). ### Consumers and verification diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md index 178420c126..5746461ffd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -32,7 +32,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 -最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整且以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。 +最终帧内部遇到 EOF 属于撕裂尾部。该帧属于一次从未完成结算的追加,因此其中没有任何记录被确认为持久:修复从该帧起始字节截断,保留此前全部完整帧,并把协调器生成的工具、步骤与轮次闭合事件作为一个新的带校验和帧追加(对早先部分明文抢救路径的移除由[导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。 ### 消费方与验证 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index a9bc401dd2..e1483287f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md -2026-07-24-project-session-directories.md: 199a2b273b791e1ee566e566fb59e1b83c63cdea -2026-07-24-project-session-directories.zh.md: 8b6327e349c25aa438b2ad409152cd2c8b720822 +2026-07-24-project-session-directories.md: a37f9231167822e409308f8da60f6c1e837c74d5 +2026-07-24-project-session-directories.zh.md: 469567764219d7baabea89bd94aecd81bd0e5ab3 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md index 199a2b273b..a37f923116 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -19,10 +19,9 @@ The JSONL backend stores sessions under a readable project key and gives every s ----/ / session.jsonl.zstd - session.v1.jsonl.zstd ``` -The two files illustrate retained v0 plus current v1; raw mode omits `.zstd`, positive versions use lowercase `.vN`, and Sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits. +Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits. The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected. @@ -30,9 +29,9 @@ Case-insensitive filesystems can also make differently cased project keys refer The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. -The encoded Session id names an ownership directory rather than one transcript. `SessionPersistence.locate(meta)` returns the canonical target for `meta.version`; header-only discovery scans canonical generation names and selects the numerically highest one, while ignoring unrelated entries. The directory can therefore retain prior generations and add other Session-owned artifacts without another layout change. +The encoded session id names an ownership directory rather than the transcript itself. The backend's diagnostics-only `locate` hook resolves the fixed transcript path inside it for format-refusal messages ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) owns removing the consumer-facing path query). Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. -Lazy materialization remains tied to the current generation: `create()` performs no filesystem I/O, and the first append creates the project/Session directories before no-overwrite publication at the current version's canonical name. Empty directories are not listed as Sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; it provides no automatic migration from that obsolete directory layout. +Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration. ## Alternatives considered @@ -48,6 +47,6 @@ Lazy materialization remains tied to the current generation: `create()` performs ## Consequences -Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every Session has a directory for immutable generation names and other future backend-owned artifacts; callers receive either a version-qualified `locate` target or the exact highest path discovered by listing. +Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path. Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index 8b6327e349..4695677642 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -19,10 +19,9 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 ----/ / session.jsonl.zstd - session.v1.jsonl.zstd ``` -这两个文件表示保留的 v0 与当前 v1;raw 模式省略 `.zstd`,正版本使用小写 `.vN`,没有 cwd 的 Session 使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。 +原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。 项目键有意不带哈希后缀。这遵循 coding agent(智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 @@ -30,9 +29,9 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 -编码后的 Session id 用于命名归属目录,而不是某一份 transcript。`SessionPersistence.locate(meta)` 返回 `meta.version` 的规范目标;仅 header 的发现会扫描规范 generation 名并选择数值最高的一项,同时忽略无关条目。因此,该目录可以保留先前 generation,也能添加其他 Session 自有产物,无需再次改变布局。 +编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。后端仅供诊断的 `locate` 钩子在其中解析固定的 transcript 路径,供格式拒绝消息使用(移除面向消费者的路径查询由[导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)负责)。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 -延迟物化仍以当前 generation 为界:`create()` 不执行文件系统 I/O,首次 append 会先创建项目目录和 Session 目录,再在当前版本的规范名称下以不覆盖方式发布。空目录不会被列为 Session。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;它不提供从该废弃目录布局自动迁移数据的能力。 +延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。 ## 考虑过的替代方案 @@ -48,6 +47,6 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 ## 后果 -共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个 Session 都有一个用于不可变 generation 名称与其他未来后端自有产物的目录;调用方会收到版本限定的 `locate` 目标,或列表发现的精确最高路径。 +共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。 项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 3e59e7e702..28ab0da3dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d +2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 69899efcda..200761cc9e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -55,7 +55,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation. - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. -- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. +- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. The memoized InputBar renders its overlay, left, right, and dock child slots after the renderer has bound their standard props; `ConversationRoot` passes only scalar data and callbacks, so an unrelated shell render does not create fresh ReactNode owner props or invalidate the bar. - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. @@ -105,6 +105,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable | | The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed — both sides retired with the textarea itself; Lexical's history owns undo now | | The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package | +| `ConversationRoot` rendering InputBar's child slots into owner props | Fresh React elements defeat the bar's memo boundary; the bar already receives `renderSlot` and owns the exact positions | | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 7e37dd67a2..673d0ee4bd 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -55,7 +55,7 @@ Status: implemented - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。 - 每个实体会话只有一个 `SessionInputShell`(facade),随会话作用域创建和拆除;无会话时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。 -- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 +- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。memoized InputBar 在 renderer 绑定各 child slot 的标准 props 后自行渲染 overlay、left、right 与 dock;`ConversationRoot` 只传标量数据和回调,因此无关 shell render 不会制造新的 ReactNode owner prop 或使 bar 失效。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在提示词成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。 @@ -105,6 +105,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 | draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 | | 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管——两侧都随 textarea 一并退役;undo 现归 Lexical history | | InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 | +| 由 `ConversationRoot` 把 InputBar child slot 渲染为 owner prop | 新 React element 会击穿 bar 的 memo 边界;bar 已收到 `renderSlot`,也拥有这些位置 | | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名 slot 在注册前保持为空;占位件与真实现冲突时是两个真源 | diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 8baf8386d1..2e41d77dd7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6 -2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42 +2026-07-29-projected-token-usage-and-request-context.md: 7c984012b7a4b387f1ff24279567fe15aa34cf77 +2026-07-29-projected-token-usage-and-request-context.zh.md: b34fb7702898f4a51524a24ad27927c48220bfad diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index 75a05e5a0e..7c984012b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence. -The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. +The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer. ## Context occupancy is approximate, and that is the decision @@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. +Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 7cce5989d7..b34fb77028 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。 两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。 -Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。 +Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer。 ## 上下文占用率是近似值,而这正是决策本身 @@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml index faf19fe0c4..e96c1b2e6b 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md -2026-08-05-session-preparation.md: a8adcc577993f749be3658ee952e087c6943f462 -2026-08-05-session-preparation.zh.md: abe805505be351ecb9dc5face3e152c2f820a6be +2026-08-05-session-preparation.md: 040c9f788173a7cedd91be33cbe7ced3ca758a06 +2026-08-05-session-preparation.zh.md: 44b609d488c6f8bb406370f9097eb2a085f7cc2b diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md index a8adcc5779..040c9f7881 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md @@ -6,63 +6,40 @@ English | [中文](2026-08-05-session-preparation.zh.md) ## Problem -Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point. +Fresh creation and persisted resume reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. -Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. +Cold history inspection and Agent resume also independently materialized the same persisted session log, which this note originally answered with a persistence-side prepared-Session cache; that half is superseded below. ## Decision -`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`. +`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume reads the stored log through the session's write handle, appends `interruptedTurnClosers`, and wraps `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })` — the restoration branch that validates and freezes the transferred graphs in place. The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior. This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-contracts.md) without replacing its ownership model. -## Persisted preparation lifecycle +## Superseded: the persistence-side preparation lifecycle -A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence. - -For already-current input, `inspect(id, signal?)` does not mutate storage: synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. A supported historical body read first publishes its migrated and repaired current generation. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release. - -`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation. - -The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md). - -## History and resume reuse - -History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again. - -If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view. - -Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read. +This note originally also gave persistence a `prepare(id)`/`inspect(id)` lifecycle: a coordinator-backed bounded LRU of cold unpublished Sessions with exclusive reservations, revision-checked reuse, and repair committed inside `prepare`/`load`, so history pagination and a later resume shared one cold materialization. The [handle-based persistence seam](2026-08-27-handle-based-session-persistence.md) deletes all of it: persistence exposes handles only, resume reads the log through its write handle and owns repair, and read-only observers (session-query) own their cold-Session cache keyed by the `stat().revision` change token. The read-reuse goal survives in that cache; the exclusive-reservation machinery does not, because the write handle's single-writer ownership is the exclusion resume actually needs. Resume pays one whole-log read through the handle where the prepared cache sometimes served a warm Session — an accepted cost recorded in the handle note. ## Boundaries -- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation nor joins the LRU. Current input synthesizes no logical closers; historical input may first publish its repaired current generation. -- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted. -- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity. -- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject. -- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation. -- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation. +- The preparation is one disposable ownership window, not a cache: disposal is synchronous and idempotent, and publication accepts only the exact prepared Session. +- A fresh create never claims a persisted identity implicitly. Persistence collisions continue to reject (`SessionAlreadyExistsError`, `SessionAlreadyOwnedError`). +- Live Sessions are owned by the existing stores; preparations hold only unpublished ones. ## Verification -The shared persistence contract pins non-mutating balanced cold inspection for current input, historical migration-before-inspection, and later current repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown. +Agent-loop tests pin the common publication pipeline across create, `createAgent`, and resume, including rollback on setup failure, cancellation, and teardown, and that disposal releases the write handle (reopening for write succeeds). Session-store tests pin the restoration branch's validate-and-freeze-in-place transfer. ## Alternatives considered -**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. +**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. This rationale still guards the session-query cold cache: observation never creates an Agent. -**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit. +**Cache only `{ meta, events }`.** Rejected at the time because resume would still reconstruct a Session from the cached values. Under the handle seam this is exactly what the read side does — session-query caches a cold Session per revision for reads only — while resume rebuilds from the handle read, trading the warm-Session reuse for a single write-ownership door. -**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store. - -**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary. - -**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics. +**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading and Session construction are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary; the handle seam kept that split while moving repair to the loop's resume path. ## Consequences -One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities. - -The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction; historical input also pays one durable migration and repair publication first. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization. +Create and resume share one publication protocol without merging Agent and Session responsibilities, and every exit path disposes exactly one preparation. The persistence-side reuse consequences originally recorded here (shared cold materialization, LRU bounds, reservation coordination) now belong to the [handle note](2026-08-27-handle-based-session-persistence.md) and the session-query cache that replaced them. diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md index abe805505b..44b609d488 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md @@ -6,63 +6,40 @@ Status: implemented ## 问题 -冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合。 +新建和持久化恢复通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 -新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 +冷历史检查和 agent(智能体)恢复也曾分别实体化同一份持久会话日志,本 Note 最初以持久化侧的已准备 Session 缓存回答了这一半问题;那一半已在下文中被取代。 ## 决策 -`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象。 +`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复通过该会话的写句柄读取已存储的日志、追加 `interruptedTurnClosers`,再包装 `SessionStore.prepare(id, { seed, meta, seedSource: 'persistence' })`——即就地验证并冻结转移对象图的恢复分支。 agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上对准备对象执行 dispose(资源释放)。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。 该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md)中的发布边界,但不替换其所有权模型。 -## 持久化准备生命周期 +## 已被取代:持久化侧的准备生命周期 -使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与已配平的逻辑事件日志构成读取方借用的 `SessionInspection`,revision 则保留在持久化内部。 - -对于已经是当前格式的输入,`inspect(id, signal?)` 不修改存储:合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。受支持的历史正文读取会先发布迁移并修复后的当前 generation。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。 - -`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。 - -存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.zh.md)所规定的存储与恢复规则。 - -## 历史与恢复复用 - -历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。 - -如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。 - -冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.zh.md)中的生命周期与授权规则,也消除了重复冷读。 +本 Note 最初还赋予持久化一个 `prepare(id)`/`inspect(id)` 生命周期:由协调器支撑的、装有冷未发布 Session 的有界 LRU,带独占预留、按 revision 校验的复用,以及在 `prepare`/`load` 内部提交的修复,使历史分页与后续恢复共享一次冷实体化。[基于句柄的持久化 seam](2026-08-27-handle-based-session-persistence.zh.md) 删除了这一切:持久化只暴露句柄,恢复通过其写句柄读取日志并自行负责修复,只读观察方(session-query)拥有自己的冷 Session 缓存,以 `stat().revision` 变更令牌为键。读取复用的目标在该缓存中得以延续;独占预留机制则没有延续,因为写句柄的单写者所有权正是恢复真正需要的排他手段。在已准备缓存有时能提供温 Session 的场景下,恢复要为通过句柄的一次全日志读取付出代价——这是句柄 Note 中记录的、已被接受的成本。 ## 边界 -- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,也不会进入 LRU。当前输入不会合成逻辑 closer;历史输入可能先发布已修复的当前 generation。 -- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。 -- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。 -- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。 -- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。 -- Revision 校验在复用点和修复提交点建立新鲜度,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。 +- 准备对象是一个可 dispose 的所有权窗口,而不是缓存:dispose 同步且幂等,发布只接受精确的已准备 Session。 +- 新建流程绝不隐式认领持久化身份。持久化冲突仍会被拒绝(`SessionAlreadyExistsError`、`SessionAlreadyOwnedError`)。 +- 实时 Session 由现有存储持有;准备对象只持有未发布的 Session。 ## 验证 -共享持久化约定规定当前格式冷检查不修改存储且须保持配平,并覆盖历史迁移先于检查与后续当前修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和清理期间从检查到恢复的路径。 +agent loop 测试覆盖 create、`createAgent` 与 resume 之间的统一发布流水线,包括设置失败时的回滚、取消与清理,以及 dispose 会释放写句柄(重新以写模式打开可以成功)。Session store 测试覆盖恢复分支的就地验证并冻结的所有权转移。 ## 考虑过的替代方案 -**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。 +**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。该理由仍然守护着 session-query 冷缓存:观察绝不创建 agent。 -**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。 +**只缓存 `{ meta, events }`。** 当时不采用,因为恢复仍需从缓存值重新构造 Session。在句柄 seam 下,这恰好是读取侧的做法——session-query 按 revision 为只读用途缓存一个冷 Session——而恢复则从句柄读取重建,以温 Session 复用换取唯一的写所有权之门。 -**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复。 - -**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。 - -**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。 +**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读与 Session 构造属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界;句柄 seam 保留了这一分工,同时把修复移入循环的恢复路径。 ## 后果 -一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。 - -首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰;历史输入还会先承担一次持久迁移与修复发布。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。 +新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离,且每条退出路径恰好 dispose 一个准备对象。本 Note 最初记录的持久化侧复用后果(共享冷实体化、LRU 上限、预留协调)如今归属于[句柄 Note](2026-08-27-handle-based-session-persistence.zh.md) 以及取代它们的 session-query 缓存。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml index 0ff8938b30..4a93d57022 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md -2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb -2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3 +2026-08-08-bounded-session-persistence-write-batching.md: 6fb44e494fc17bde08eb3132afe42ce73b5a4e47 +2026-08-08-bounded-session-persistence-write-batching.zh.md: 576764583dd8c465ae45866f62ef15773735961e diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md index 20c16991b0..6fb44e494f 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md @@ -20,19 +20,19 @@ The scheduling bound is deterministic. With an immediately resolving sink, the f ## Decision -The JSONL provider exposes `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. The provider resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior. +The fixed window is the JSONL provider's constant `LIVE_WRITE_BATCH_MAX_DELAY_MS` (200 ms), an internal scheduling policy rather than configuration: the backend's own session listeners route live events by id into the active write handle's buffer, so batching never crosses the package boundary ([handle note](2026-08-27-handle-based-session-persistence.md)). -Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it. +Each active write handle owns its buffer directly. A routed event lands in the handle's pending array, and the first event of an idle buffer arms one fixed timer. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, a single-flight drain persists the pending prefix through the handle's mutation chain, which already serializes it against explicit appends. Events admitted during a drain pass coalesce into the next chained batch, in order. -`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA. +The window bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA. -`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. +`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement (`session/disposed`), the handle's close, and backend teardown's close sweep use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects. Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format. -A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. +A failed background drain retains its complete batch in order ahead of newer pending events, reports the failure once, and pauses the automatic timer. The next explicit drain — a `session/flush` barrier, service-level `flush()`, or close — retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary. -This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary. +This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one buffer owner per live Session, retained failed batches, retirement, and quiescent disposal. The coordinator and the separate write-behind controller that first hosted this behavior are deleted; the buffer, timer, and drain live on the provider's handle, and the [handle-based seam](2026-08-27-handle-based-session-persistence.md) owns the storage boundary they write through. ## Alternatives considered @@ -42,11 +42,11 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live **Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait. -**Implement the timer inside JSONL.** Rejected: scheduling, failure retention, flush races, and teardown are provider-neutral lifecycle concerns that belong in `PersistenceCoordinator`; an out-of-tree provider can reuse the same behavior. +**A shared provider-neutral controller component.** Rejected after one iteration shipped it: the handle's mutation chain already serializes writes, so a separate controller duplicated that ordering machinery. Each provider implements the buffer on its own handle, and the shared live-write contract suite pins the equivalent observable behavior for any provider. ## Verification -The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL suite retains storage-format, recovery, and shared persistence-contract coverage. +The shared live-write contract suite (`runLiveWritePathContract`) uses a fake clock to prove the fixed, non-resetting 200 ms window; the `session/flush` barrier and its loud failure surfacing; ordered failure retention with exactly-once recovery; the service-level `flush()` sweep with per-session failure aggregation; and the disposed/close/teardown drains. The JSONL suite retains its storage-format, recovery, and shared persistence-contract coverage. ## Consequences @@ -54,6 +54,6 @@ High-frequency event bursts normally produce fewer durable append operations whi This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule. -An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait. +An admitted event can remain only in memory during the fixed window, and then while scheduling or backend work is outstanding. Explicit durability boundaries remain unchanged and bypass the wait. -The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged. +The handle gives the timer, active drain, pending prefix, retry pause, and barrier one owner; the backend's listeners own routing and lifecycle-driven drains. `SESSION_FORMAT_VERSION` remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md index ac0384f4e2..576764583d 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md @@ -20,19 +20,19 @@ JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fs ## 决策 -JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。provider 在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。 +固定窗口是 JSONL provider 的常量 `LIVE_WRITE_BATCH_MAX_DELAY_MS`(200 ms),它是内部调度策略而非配置:后端自己的会话监听器按 id 把实时事件路由进活跃写句柄的缓冲,因此批处理绝不跨越包边界([句柄 Note](2026-08-27-handle-based-session-persistence.zh.md))。 -每个活跃的会话都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一会话同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入。 +每个活跃写句柄直接拥有自己的缓冲。被路由的事件落入句柄的待处理数组,空闲缓冲收到的第一个事件会启动一个固定计时器。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,一次 single-flight 排空会把待处理前缀经由句柄的修改链持久化,该链本就将其与显式 append 串行化。排空进行期间接纳的事件会按顺序合并进下一个链上的批次。 -`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。 +该窗口只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。 -`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 +`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役(`session/disposed`)、句柄的 close 与后端 teardown 的关闭清扫共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。 每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。 -后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 +后台排空失败后,其完整批次会按顺序保留在所有较新的待处理事件之前,该失败被报告一次,自动计时器随之暂停。下一次显式排空——`session/flush` 屏障、服务级 `flush()` 或 close——会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。 -本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.zh.md)定义。 +本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)中的即时调度节奏。对于每个活跃会话使用一个缓冲所有者、保留失败批次、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。最初承载该行为的协调器与独立的 write-behind 控制器均已删除;缓冲、计时器和排空落在 provider 的句柄上,它们写入所经过的存储边界由[基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md) 定义。 ## 备选方案 @@ -42,11 +42,11 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No **按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。 -**在 JSONL 内实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是 provider 无关的生命周期问题,属于 `PersistenceCoordinator`;仓库外 provider 可以复用同一行为。 +**共享的 provider 无关控制器组件。** 曾在一次迭代中交付,随后不采纳:句柄的修改链本就串行化写入,独立控制器重复了这套排序机制。每个 provider 在自己的句柄上实现该缓冲,共享的实时写入约定测试套件为任何 provider 钉住等价的可观察行为。 ## 验证 -控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及对重叠发生的后台失败进行显式重试。协调器测试会在会话通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。 +共享的实时写入约定测试套件(`runLiveWritePathContract`)使用假时钟证明固定且不会重置的 200 ms 窗口、`session/flush` 屏障及其失败的响亮暴露、有序保留失败批次并恰好恢复一次、带逐会话失败聚合的服务级 `flush()` 清扫,以及 disposed/close/teardown 的排空。JSONL 测试套件继续覆盖存储格式、恢复和共享持久化约定。 ## 后果 @@ -54,6 +54,6 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No 本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。 -接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。 +接纳后的事件在固定窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。显式持久性边界保持不变,并会绕过等待。 -deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。 +句柄统一负责计时器、活跃排空、待处理前缀、重试暂停和屏障;后端的监听器负责路由和生命周期驱动的排空。`SESSION_FORMAT_VERSION` 保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index c60b8ae3ba..23f634a3fe 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520 -2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d +2026-08-09-client-conversation-node-assembly.md: 0aac5056e2cbe22359f8064b1bf4aa0b015a35c8 +2026-08-09-client-conversation-node-assembly.zh.md: b06a92113f91e6297da986866dce097b11bab45f diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 4831c22617..0aac5056e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -125,16 +125,16 @@ The Assembler does not use State reference equality to decide publication or pro | Return value | Behavior | |---|---| | `immediate` | Request a notification and flush in the current microtask | -| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame | +| `animation-frame` | Coalesce high-frequency updates into materialization after three browser animation frames | | `none` | Do not schedule a flush for this Match; retain its State and dirty marker | Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path. -Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost. +Every live delta during the three-frame interval still executes `update()`, while one historical packed run executes one batch `update()`. Location-data publication, `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost. An immediate publication cancels a pending frame interval and flushes the latest State without delay. #### `buildLocationData(context, scope)` -`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready. +`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler passes the preceding publication back to its owner, which returns that exact value when its business data is unchanged. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready. A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key. @@ -315,21 +315,21 @@ The shell synchronously resolves the persisted selection when a Session binding Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it. -[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. -Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. +Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources. For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys. -[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order` and resolves the two stable sources for each key. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key, subscribes only to its Node and Turn-process sources, and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. [`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. -The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. +The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes the Node's stable Turn data store as `hookContext`; the Slot renderer binds `useTurnData(businessKey)` directly to that store. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. -Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside `useTurnData()` returns only the current Node's `turn.data.get(key)`, so selector equality filters unrelated Session publications. +Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. `useTurnData()` subscribes to `turn.data.source(key)`, so another Location-data key or Session snapshot publication does not notify it. -The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data. +The standard `useSession` remains available to every session-scoped slot renderer, although `ChatNodeSeat` needs neither it nor aggregate `useChat`. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data. Assistant streaming to final and Tool running to settled stay in one Seat while updating its data and necessary ordering properties. Settlement therefore does not reset component-local State through a parent move. @@ -390,6 +390,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran **Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates. +**Cache every Definition's Location data by State identity.** Rejected because a Definition may mutate and return the same State object, and its Location data may also depend on Match Locations or values published by another Definition. Each Definition instead decides whether its business value changed and returns the preceding publication unchanged when it did not. + **Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation. **Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts. @@ -412,11 +414,11 @@ Initial tail, older prepend, and live append share one set of Context invariants Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity. -Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State. +Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per three animation frames. The Assistant view reads the same projection that the preceding Step Location phase installed. Turn Process returns its existing open data and Node for continuing Assistant chunks without deriving or encoding them again, and Turn Tail defers its complete-Match scan until `turn/end`. Step or Turn close and final Events immediately publish the latest State. An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates. -Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates. +Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn, and keyed Location sources isolate unrelated updates. Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 37463d0543..b06a92113f 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -125,16 +125,16 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成 | 返回值 | 行为 | |---|---| | `immediate` | 请求当前 microtask 通知与 flush | -| `animation-frame` | 把多条高频更新合并到下一帧 materialize | +| `animation-frame` | 跨过三个浏览器 animation frame 后,把多条高频更新合并为一次 materialization | | `none` | 本 Match 不主动安排 flush,State 和 dirty 标记仍被保留 | 省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`,final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。 -一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 fragment。 +三帧间隔内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;Location-data publication、`buildViewNode()`、View Builder 与 React snapshot 通知会合并执行,不会丢失 fragment。immediate publication 会取消等待中的帧间隔,并立即发布最新 State。 #### `buildLocationData(context, scope)` -`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。 +`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 会把前一次 publication 传回它的 owner;业务数据未变时,owner 原样返回该值。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。 Definition 分别收到 `step` 和 `turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。 @@ -315,21 +315,21 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化 普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 -Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 +Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source。 Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。 -[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`,并为每个 key 解析两份稳定 source。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,只订阅自身的 Node 与 Turn-process source,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 [`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 -`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 +`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 把 Node 所属 Turn 的稳定 data store 作为 `hookContext` 传给 slot;Slot renderer 直接在该 store 上绑定 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 -Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 内部 selector 只返回当前 Node 的 `turn.data.get(key)`,无关 Session publication 会被 selector equality 截断。 +Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 订阅 `turn.data.source(key)`,其他 Location-data key 或 Session snapshot 的发布不会通知它。 -标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。 +标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,但 `ChatNodeSeat` 不再需要它或聚合 `useChat`。`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。 Assistant streaming 到 final、Tool running 到 settled 始终留在同一个 Seat,只更新 data 和必要的排序属性。结算不会因跨 parent 移动而重置组件内部 State。 @@ -390,6 +390,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。 +**按 State identity 缓存每个 Definition 的 Location data。** 拒绝:Definition 可以原地修改并返回同一个 State 对象,其 Location data 也可能依赖 Match Location 或其他 Definition 发布的 value。各 Definition 改为自行判断业务值是否变化;未变化时原样返回前一次 publication。 + **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 **在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。 @@ -412,11 +414,11 @@ Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deep Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。 -State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。 +State update 与 publication cadence 分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每三个 animation frame 最多 materialize 一次。Assistant view 读取前置 Step Location 阶段刚写入的同一 projection。Turn Process 对持续 Assistant chunk 直接返回已有 open data 和 Node,不再重复派生或编码;Turn Tail 到 `turn/end` 才执行完整 Match 扫描。Step/Turn close 与 final Event 会立即发布最新 State。 inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。 -Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。 +Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 keyed Location source 隔离无关更新。 Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml index 3e8040a52c..44c31bc1cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md -2026-08-10-message-feedback-sidecar.md: 94c607e14704a9a41a4ff0beca084c97e982d959 -2026-08-10-message-feedback-sidecar.zh.md: dbebb77febde3a111595d828005f79c552c6799b +2026-08-10-message-feedback-sidecar.md: d047bebf47f844a6d88932c7e19a3952f43d94cc +2026-08-10-message-feedback-sidecar.zh.md: 4ee1b861a8013dacfd5eba40d9e30b23237be2e5 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md index 94c607e147..d047bebf47 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -16,9 +16,9 @@ A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes w Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. -`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it never publishes or resumes an Agent; already-current repair stays in memory, while a supported historical body read may first publish migration and repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. +`put` accepts a target only when the observed log — a live owner's in-memory events, else the durable log through a persistence read handle — contains a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Observation is cold-safe: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `stat()` preflight classifies definite absence; a read failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. -Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically re-read from sequence zero through a fresh persistence read handle. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md index dbebb77feb..4ee1b861a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -16,9 +16,9 @@ Status: implemented 每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 -`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它绝不发布或恢复 Agent;已经是当前格式的修复留在内存中,受支持的历史正文读取则可能先发布迁移与修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 +`put` 只在被观测的日志——live 持有者的内存事件,否则是经由持久化读句柄读取的持久日志——包含非空、append-origin 且 `MessageId` 与目标相同的 `assistant/message` 时才接受该目标。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。观测是 cold-safe 的:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `stat()` 预检明确不存在;已进入目录的 Session 若读取失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 -`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过新开的持久化读句柄从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml index 61a386bc8f..321dd79463 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md -2026-08-11-trajectory-conversation-context-assembly.md: c041d7a8ba9f39f74cf55f8a35513724eaeb4f54 -2026-08-11-trajectory-conversation-context-assembly.zh.md: 06e7b801b67b789ddffe991916aceee86cc903f8 +2026-08-11-trajectory-conversation-context-assembly.md: fd246922b0ecce146da00e212ecb8053a939017d +2026-08-11-trajectory-conversation-context-assembly.zh.md: 2568de889fc3b6c227d9faa51166186b388de18f diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md index c041d7a8ba..fd246922b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md @@ -74,7 +74,7 @@ The Context migration and the following presentation optimizations solve differe | Following Assistant lookup | One reverse pass records the next Assistant for every input position | The former repeated forward lookup falls from worst-case `O(C²)` to `O(C)` | | Group duration | Fixed decimal grouping replaces `toLocaleString('en-US')` for the invariant English numeric shape | Complexity remains linear in Groups, but the Intl formatter leaves the repeated render path | -Display memoization and search indexing stay separate. Search must include off-screen records and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence. +Display memoization and search indexing stay separate. Search includes off-screen records in the current React-visible history window and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence. ## Alternatives considered @@ -88,7 +88,7 @@ Display memoization and search indexing stay separate. Search must include off-s **Replace the Trajectory stages with generic Conversation Nodes.** Rejected: stages organize requests, timing, schemas, and table layout for one view. Making them engine contracts would constrain a future plain Session-log view and return view-specific composition to Client Runtime. -**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete loaded record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers. +**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete React-visible record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers. ## Verification @@ -100,7 +100,7 @@ Trajectory Definition and Builder tests pin Assistant streaming and interruption Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session. -After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. The search index still performs a light linear signature pass when its input layout changes. +After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. Each Trajectory view mount anchors React layout, timeline, and search data to 50 target Nodes at the current tail; live appends extend that window, and the existing earlier-history action extends its prefix before it requests another Session page. If a replacement window no longer contains the previous tail anchor, the same render uses the replacement's latest Node as its bound and adopts that anchor for subsequent appends. Request numbering and cumulative usage remain derived from the complete resident snapshot. The search index still performs a light linear signature pass when its input layout changes. Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity. diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md index 06e7b801b6..2568de889f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md @@ -74,7 +74,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保 | 后继 Assistant 查找 | 一次反向遍历为每个输入位置记录后续 Assistant | 原先重复向前查找的最坏复杂度从 `O(C²)` 降为 `O(C)` | | Group duration | 以固定十进制分组替代固定英文数字形态下的 `toLocaleString('en-US')` | 复杂度仍与 Group 数线性相关,但重复 render 路径不再调用 Intl formatter | -展示 memo 与搜索索引彼此独立。搜索必须覆盖屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。 +展示 memo 与搜索索引彼此独立。搜索覆盖当前 React 可见历史窗口中的屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。 ## 考虑过的替代方案 @@ -88,7 +88,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保 **用通用 Conversation Node 替换 Trajectory stage。** 不予采纳:stage 为单一视图组织 Request、计时、schema 和表格 layout。把它变成引擎约定会限制未来的朴素 Session-log 视图,并把视图专属组合重新放回 Client Runtime。 -**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部已加载 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。 +**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部 React 可见 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。 ## 验证 @@ -100,7 +100,7 @@ Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interrupt Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。 -首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。 +首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。每次挂载 Trajectory 视图时,React layout、timeline 与搜索数据都锚定在当前尾部的 50 个 target Node;实时 append 会扩展该窗口,现有更早历史操作则先扩展其前缀,再请求下一个 Session 页面。如果 replacement window 不再包含先前的尾锚,同一次 render 会以替换窗口的最新 Node 为边界,并把该节点采纳为后续 append 的新锚。请求编号与累计用量仍从完整的驻留 snapshot 派生。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。 Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index f717ba76e2..620239bf08 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f -2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10 +2026-08-18-session-history-and-event-transport.md: 10fdd9b256c27aadada97195c8dc5516b4485a43 +2026-08-18-session-history-and-event-transport.zh.md: bf110b5f1a2bea99f9aa086c66a616eddaa0a50e diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index d35ed79ded..10fdd9b256 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -163,7 +163,7 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup. -`SessionQuery.observeSession()` chooses an attached Session or borrows one prepared source from `SessionPersistence.borrowSession()`. The persistence preparation cache shares concurrent cold reads and pins the exact unpublished Session until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state. +`SessionQuery.observeSession()` chooses an attached Session or serves a cold one from the reader's own prepared cache, filled through a persistence read handle. The cache shares concurrent cold reads and pins an entry until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state. `session.list` never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 6e6ccf53e2..bf110b5f1a 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -163,7 +163,7 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 -`SessionQuery.observeSession()` 选择 attached Session,或从 `SessionPersistence.borrowSession()` 借用 prepared source。Persistence preparation cache 共享并发冷读取,并在所有 observation lease 释放前固定同一个未发布 Session。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。 +`SessionQuery.observeSession()` 选择 attached Session,或从读取方自己的 prepared cache——经由持久化读句柄填充——提供冷 Session。该 cache 共享并发冷读取,并在所有 observation lease 释放前固定同一条目。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。 `session.list` 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml index 7409f997a1..1bcec5b231 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md -2026-08-19-projection-cache-per-session-files.md: 9e102e786a6c06d82d1a0f45cc2f96a50c8abcd8 -2026-08-19-projection-cache-per-session-files.zh.md: d875c3f57800936f66fbf65233637df9bf300e2d +2026-08-19-projection-cache-per-session-files.md: 0fd171649c1d8c7c3be7a8089287d43782b84714 +2026-08-19-projection-cache-per-session-files.zh.md: 47decb598cc70233c287feab2dddff46bd7bcbc9 diff --git a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md index 9e102e786a..0fd171649c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md +++ b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md @@ -20,8 +20,9 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy - Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column. - ACP, headless, SDK, and Web sessions publish cache rows for later consumers. The log-leading durability barrier may flush a covered prefix at the cache cadence and split otherwise coalesced physical JSONL runs; recorded profile snapshots re-pack the logical event stream so cache timing does not define fixture layout. - The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain. -- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The legacy file remains untouched. -- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id. +- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path and the legacy unit name and version match the requested descriptor. A different version remains untouched and the new domain opens empty; storage never relabels its values as the current version. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. +- The `session_projcache` domain uses version 6. Every version-5 record reads as absent, including a healthy one, so a poisoned version-5 record cannot fail domain validation. Session headers and event logs remain in session persistence; an exact read refolds them and writes a version-6 cache record, while zero-I/O listings lack that projection until the cache returns. +- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd, isSeeded, inheritedEventCount}` identity guards against a recreated id or a mismatched inherited prefix. ## Alternatives considered @@ -29,3 +30,4 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy - **Cache-owned per-session files** (`//projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent. - **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout. - **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded. +- **Copy legacy values across unit versions.** Rejected: the json backend does not know a domain's record schema and cannot derive session-lineage fields. Copying raw values under the requested version relabels data without migrating it. A domain that requires compatibility owns an explicit migration; the projection cache instead discards old records and rebuilds them from session logs. diff --git a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md index d875c3f578..47decb598c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md @@ -20,8 +20,9 @@ Status: implemented - 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。 - ACP、headless、SDK 与 Web 会话都会发布缓存行,供后续消费方使用。确保日志领先的持久性屏障可能按缓存节奏 flush 已覆盖的前缀,并拆分原本会合并的物理 JSONL 行;各 profile 的录制快照会重新 pack 逻辑事件流,因此缓存时序不会决定 fixture 布局。 - per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。 -- json 后端仅在枚举时没有发现任何新布局文档路径,才从旧整单元缓存引导 per-record 目录树。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。旧文件保持不变。 -- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。 +- json 后端仅在枚举时没有发现任何新布局文档路径,且旧单元名称和版本与请求的 descriptor 相同时,才从旧整单元缓存引导 per-record 目录树。版本不同时,旧文件保持不变,新域为空;存储不会把旧值改标为当前版本。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。 +- `session_projcache` 域使用版本 6。所有版本 5 记录都读作缺失,包括健康记录,因此被污染的版本 5 记录不能再使域校验失败。会话 header 和事件日志仍保存在会话持久化中;精确读取会重折叠这些数据并写入版本 6 缓存,而零 I/O 列表在缓存恢复前缺少对应投影。 +- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd, isSeeded, inheritedEventCount}` 身份防止被重建的 id 或不匹配的继承前缀误导。 ## Alternatives considered @@ -29,3 +30,4 @@ Status: implemented - **缓存自持的每会话文件**(`//projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。 - **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。 - **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。 +- **跨单元版本复制旧值。** 未采用:json 后端不知道域的记录 schema,也无法推导会话谱系字段。按请求版本复制原始值只会修改数据标签,不会迁移数据。需要兼容性的域负责显式迁移;投影缓存改为丢弃旧记录,并从会话日志重建。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index a1245a8831..6de3fe99ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77 -2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef +2026-08-20-client-session-conversation-ownership.md: 34848b3d9be4046993f11290a96f963d9cc78bba +2026-08-20-client-session-conversation-ownership.zh.md: 48557a2fc81a71858cc38c96fea630c0aa190c89 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 12e137209b..34848b3d9b 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -298,7 +298,7 @@ Draft and input state belong to Conversation UI and do not enter the Session sna `client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration. -It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. +It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatView` uses `useChat` for aggregate order, navigation, and timeline reads; each `ChatNodeSeat` receives identity-stable Node and Turn-process sources from that snapshot and does not subscribe to the aggregate source. Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index f0d9861eea..48557a2fc8 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -298,7 +298,7 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue `client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。 -它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 +它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatView` 使用 `useChat` 读取聚合 order、navigation 与 timeline;每个 `ChatNodeSeat` 从该 snapshot 接收身份稳定的 Node 与 Turn-process source,不订阅聚合 source。 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index d8e1977615..df7268de5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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: 492640385215b059761b17a057328cc5c6d24bff -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0b892a9cae2c999b4472dd46f19068e2b4139e60 +2026-08-25-session-observations-and-projection-owned-client-state.md: 554d003da1e1767b8955be7a067cbea716f130ef +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: a63b0414f8b0250e4cea95ac39a39aa2c62cd958 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 4926403852..554d003da1 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -32,8 +32,8 @@ flowchart LR Cache -->|"small miss"| Observe Observe --> Source{"live or cold"} Source --> Live["attached Session cut"] - Source --> Borrow["borrowSession"] - Borrow --> Prepared["SessionPreparations.borrow"] + Source --> Borrow["persistence read handle"] + Borrow --> Prepared["reader's prepared cache"] Live --> Mode{"all or none"} Prepared --> Mode Mode --> Snapshot["SessionObservation"] @@ -45,7 +45,7 @@ flowchart LR ### Observation is the point-read unit -`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise `SessionPersistence.borrowSession()` and `SessionPreparations.borrow()` share and pin one prepared Session, including an in-flight cold load. +`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load. Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 0b892a9cae..a63b0414f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -32,8 +32,8 @@ flowchart LR Cache -->|"small miss"| Observe Observe --> Source{"live or cold"} Source --> Live["attached Session cut"] - Source --> Borrow["borrowSession"] - Borrow --> Prepared["SessionPreparations.borrow"] + Source --> Borrow["persistence read handle"] + Borrow --> Prepared["reader's prepared cache"] Live --> Mode{"all or none"} Prepared --> Mode Mode --> Snapshot["SessionObservation"] @@ -45,7 +45,7 @@ flowchart LR ### Observation 是 point read 单元 -`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则 `SessionPersistence.borrowSession()` 与 `SessionPreparations.borrow()` 共享并固定一份 prepared Session,包括尚未完成的冷加载。 +`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。 每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml index 35876cd934..2c0b6abd41 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml @@ -1,6 +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-07-28-load-pre-identity-session-messages.md -2026-07-28-load-pre-identity-session-messages.md: 003331cd66ce0f299f1ef7c03684dbd0bee148b8 -2026-07-28-load-pre-identity-session-messages.zh.md: cf15b5434b66708ae70620326fe3e975738e68d1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md +2026-08-27-handle-based-session-persistence.md: ced7a78178d3036fd5fa9a09ca49f8c751c3a169 +2026-08-27-handle-based-session-persistence.zh.md: e13b5b0e9e7286411b6d38f3b9dc0e86fa742a3d diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md new file mode 100644 index 0000000000..ced7a78178 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md @@ -0,0 +1,42 @@ +# Agent Note: Handle-based session persistence + +Status: implemented + +English | [中文](2026-08-27-handle-based-session-persistence.zh.md) + +## Problem + +The previous persistence seam owned far more than storage. A shared coordinator subscribed to `session/created`/`session/event`/`session/flush`/`session/disposed` and adopted any published session (ownerless claims, HMR re-seeding, stored-prefix adoption); a bounded prepared-Session LRU with exclusive reservations served resume and read-only observation from one cache; committing crash repair lived inside `load`/`prepare`; and optimistic revision read/check/read loops stood in for ownership, so a continuous external writer could livelock a read and nothing excluded a second writer. The service surface (twelve methods) mixed storage with Session construction and lifecycle. Cross-process write ownership — the next step — has no honest home in that shape: ownership belongs to an explicit per-session channel with an owner, not to a global listener. + +## Decision + +**The seam is five service methods returning or serving per-session handles.** `create(header)` stores a new session and returns its owned write handle; `open(id, 'read' | 'write')` opens an existing one; `stat(id)`/`list()` observe snapshots (`header`, opaque `revision`, optional `eventCount`/`sizeBytes` hints — the JSONL backend supplies `sizeBytes`) without reading logs; service-level `flush()` is one backend-wide durability barrier that drains and flushes every active write handle, aggregating per-session failures without abandoning the sweep. The seam carries no raw-artifact export: the WebUI ZIP download serializes the logical log (header line + events) from a read handle in `dsh-session-log-export`, so every backend exports identically and the JSONL-only 501 path is gone. A `SessionHandle` carries `read(offset?, length?)` (validated contiguous prefix slices, never a torn tail, monotonic per handle), `append` (contiguous; persistence is best-effort on resolution, and the shipped JSONL backend happens to persist each batch immediately), `flush` (the durability barrier, which also materializes an empty session), and idempotent uncancellable `close`. One handle type serves both accesses — a mutation on a read handle is a runtime `SessionReadOnlyError`, the deliberate convention of this codebase's other seams rather than a typed split. Single-writer ownership is enforced in-process by a registry (`SessionAlreadyOwnedError`); the durable cross-process lease is the planned next layer on the same shape. + +**The agent lifecycle owns handle acquisition; the backend owns the event-driven flow.** agent-loop — the sole production publication point for sessions — acquires the handle before publication (`create` for fresh sessions, appending any constructor seed through it; `open(id, 'write')` for resume) and closes it in the same memoized teardown that drains the loop. Because persistence already enforces one active write handle per session id, the backend installs the session listeners once and routes by id: `session/event` into the owning handle's bounded write-behind window (an internal scheduling policy, not configuration), `session/flush` as the durability and error-observation barrier, `session/disposed` as final drain and close. Nothing about the write path crosses the package boundary — no writer component, no batching configuration, no drain registry. Root-fiber disposal runs every fiber's disposers concurrently, so `close()` itself drains the routed buffer through the still-open storage; backend teardown's close sweep keeps application shutdown lossless regardless of which fiber unwinds first. The drain guarantees only buffered already-emitted events; a root dispose mid-turn still loses the turn's unemitted remainder by design — the next resume's `interruptedTurnClosers` repairs that tail durably. Sessions published outside the lifecycle no longer persist implicitly; nothing in production does that. + +**Semantic crash repair moved out of persistence.** Resume reads the physically valid log through its write handle, computes `interruptedTurnClosers`, and appends them (plus the constructor's `session/end-seed` marker) through the same handle as ordinary batches — repair is not a special storage entry point. Read-only observers (session-query) balance an interrupted cold log in memory only, and own their cold-Session cache keyed by `stat().revision`; the persistence-side prepared cache and revision convergence loops are deleted. + +**Visibility and freshness are explicit.** A created session is observable in-process from `create`; physical materialization may be deferred (a pure optimization) until the first append or flush, other processes see only materialized sessions, and a crash before materialization means the session never existed. Once an append or flush resolves, reads started afterwards on the same backend instance observe at least that prefix — the guarantee `message-feedback`'s durable-target check rides on. + +**Revision simplifies to a per-instance change token.** Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and `sizeBytes` from one `fs.stat`; a backend whose medium can count events cheaply may supply the `eventCount` hint instead. The session-list cold blank probe returns on this metadata (`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`), restoring the capability removed with the path query. + +## Alternatives considered + +**Typed read/write handle classes (or overloads).** Rejected as the seam style: this codebase's seams prefer one access-tagged type with runtime refusal, and the split would double every consumer-facing type for one compile-time check. + +**Keeping the coordinator's adoption/HMR write path beside handles.** Rejected: adoption exists to guess ownership after the fact; with the lifecycle handing the handle over explicitly, a reloaded backend that cannot serve old handles fails the writer loudly instead of silently re-claiming logs, and a session with no handle is a composition bug surfaced by absent persistence rather than masked by adoption. + +**A service-level `append(id, events)` beside handles.** Rejected: an id-addressed write path bypasses ownership; every write flows through the owning handle so the future lease check has exactly one door. + +**Persistence-owned batching configuration.** Rejected: the batching window is internal write-path scheduling, not a deployment-varying choice, so it is a provider constant and no configuration knob exists anywhere. + +## Consequences + +Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0. + +## Related + +- [Session persistence as an abstract service](2026-06-14-session-persistence.md) — the seam this reshapes; its interface list reflects the handle API. +- [Persistence export() and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the blank probe this note's metadata restores. +- [Retain ignorable external session events](2026-08-30-retain-ignorable-external-session-events.md) — the read-side refusal contract, now shared through `storage-contract` helpers. +- [Bounded session-persistence write batching](2026-08-08-bounded-session-persistence-write-batching.md) — the batching semantics the routed write path preserves as internal scheduling policy. diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md new file mode 100644 index 0000000000..e13b5b0e9e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 基于句柄的会话持久化 + +Status: implemented + +[English](2026-08-27-handle-based-session-persistence.md) | 中文 + +## 问题 + +先前的持久化 seam 承担的远不止存储。一个共享协调器订阅 `session/created`/`session/event`/`session/flush`/`session/disposed`,并接管任何已发布的会话(无主认领、HMR 重新播种、已存储前缀接管);一个带独占预留的有界已准备 Session LRU 用同一个缓存服务恢复与只读观察;提交式崩溃修复内嵌在 `load`/`prepare` 中;乐观的 revision 读取/复核/再读取循环充当所有权的替身,因此持续的外部写入方可能使读取活锁,而且没有任何机制排除第二个写入方。服务表面(十二个方法)把存储与 Session 构造和生命周期混在一起。跨进程写所有权——下一步——在那种形态里没有诚实的归宿:所有权属于一条有明确持有者的逐会话通道,而不属于一个全局监听器。 + +## 决策 + +**该 seam 是五个返回或供给逐会话句柄的服务方法。**`create(header)` 存储一个新会话并返回其持有的写句柄;`open(id, 'read' | 'write')` 打开一个已有会话;`stat(id)`/`list()` 观察快照(`header`、不透明 `revision`、可选的 `eventCount`/`sizeBytes` 提示——JSONL 后端提供 `sizeBytes`),而不读取日志;服务级 `flush()` 是一道后端范围的持久性屏障,排空并 flush 每一个活跃写句柄,逐会话聚合失败而不中途放弃清扫。该 seam 不承载原始工件导出:WebUI 的 ZIP 下载在 dsh-session-log-export 中从读句柄序列化逻辑日志(header 行 + 事件),因此每个后端的导出完全一致,仅 JSONL 可用的 501 路径也随之消失。`SessionHandle` 承载 `read(offset?, length?)`(经过验证的连续前缀切片,绝不返回撕裂尾部,逐句柄单调)、`append`(连续;完成时的持久化是尽力而为的,交付的 JSONL 后端恰好会立即持久化每个批次)、`flush`(持久性屏障,同时把空会话实体化)以及幂等且不可取消的 `close`。一种句柄类型同时服务两种访问——在读句柄上执行修改是运行时的 `SessionReadOnlyError`,这是本代码库其他 seam 的既定惯例,而非类型层面的拆分。单写者所有权由注册表在进程内强制(`SessionAlreadyOwnedError`);持久的跨进程租约是计划在同一形态上叠加的下一层。 + +**agent 生命周期负责获取句柄;后端负责事件驱动的流程。**agent-loop——会话在生产环境中唯一的发布点——在发布之前获取句柄(新建会话用 `create`,并通过它追加构造 seed;恢复用 `open(id, 'write')`),并在与排空循环相同的记忆化 teardown 中关闭它。由于持久化已保证每个会话 id 只有一个活跃写句柄,后端一次性安装会话监听器并按 id 路由:`session/event` 进入持有句柄的有界 write-behind 窗口(内部调度策略,而非配置),`session/flush` 作为持久性与错误观察屏障,`session/disposed` 作为最终排空并关闭。写路径没有任何部分跨越包边界——没有写入器组件,没有批处理配置,没有排空注册表。根 fiber 的 dispose 会并发运行每个 fiber 的 disposer,因此 `close()` 本身会经由仍然打开的存储排空已路由的缓冲;后端 teardown 的关闭清扫使应用关闭无论哪个 fiber 先解退都不丢数据。该排空只保证已发出并缓冲的事件;turn 中途的根 dispose 仍会按设计丢失该 turn 尚未发出的剩余部分——下一次恢复的 `interruptedTurnClosers` 会持久地修复这段尾部。在生命周期之外发布的会话不再隐式持久化;生产环境中没有任何地方那样做。 + +**语义崩溃修复移出了持久化。**恢复通过其写句柄读取物理上有效的日志,计算 `interruptedTurnClosers`,并把它们(连同构造器的 `session/end-seed` 标记)作为普通批次通过同一句柄追加——修复不是特殊的存储入口。只读观察方(session-query)仅在内存中配平被中断的冷日志,并拥有以 `stat().revision` 为键的冷 Session 缓存;持久化侧的已准备缓存与 revision 收敛循环被删除。 + +**可见性与新鲜度是显式的。**已创建的会话自 `create` 起即可在进程内被观察到;物理实体化(纯粹的优化)可以推迟到第一次 append 或 flush,其他进程只能看到已实体化的会话,实体化之前崩溃意味着该会话从未存在。一旦某次 append 或 flush 完成,其后在同一后端实例上开始的读取至少能观察到该前缀——这正是 `message-feedback` 持久目标检查所依赖的保证。 + +**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 `fs.stat` 派生尽力而为的令牌与 `sizeBytes`;存储介质能够廉价统计事件数的后端可以改为提供 `eventCount` 提示。会话列表的冷空白探测回归到这些元数据之上(`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),恢复了随路径查询一起移除的能力。 + +## 考虑过的替代方案 + +**类型化的读/写句柄类(或重载)。**不作为该 seam 的风格采纳:本代码库的 seam 偏好带访问标记的单一类型加运行时拒绝,而拆分会为一个编译期检查让每个面向消费方的类型翻倍。 + +**在句柄旁保留协调器的接管/HMR 写路径。**不采纳:接管的存在是为了事后猜测所有权;当生命周期显式移交句柄后,无法服务旧句柄的重载后端会向写入器响亮地失败,而不是静默地重新认领日志,而没有句柄的会话是一个由持久化缺席暴露、而非被接管掩盖的组合缺陷。 + +**在句柄旁提供服务级 `append(id, events)`。**不采纳:按 id 寻址的写路径绕过所有权;每次写入都流经持有句柄,使未来的租约检查恰好只有一扇门。 + +**由持久化持有批处理配置。**不采纳:批处理窗口是写路径内部的调度策略,而非随部署变化的选择,因此它是 provider 常量,任何地方都不存在配置旋钮。 + +## 后果 + +恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0。 + +## 相关 + +- [作为抽象服务的会话持久化](2026-06-14-session-persistence.zh.md)——本 Note 重塑的 seam;其接口列表已反映句柄 API。 +- [持久化 export() 与预发布读取路径精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)——预备性的移除,包括本 Note 的元数据所恢复的空白探测。 +- [保留可忽略的外部会话事件](2026-08-30-retain-ignorable-external-session-events.zh.md)——读取侧的拒绝约定,现经由 `storage-contract` 辅助函数共享。 +- [为会话持久化写入批处理设定上界](2026-08-08-bounded-session-persistence-write-batching.zh.md)——被路由写路径作为内部调度策略保留的批处理语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml index 33188729f5..c33ab7845a 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md -2026-08-30-retain-ignorable-external-session-events.md: 60d78d452e854f9c82e6edb739bdd1060b6376d3 -2026-08-30-retain-ignorable-external-session-events.zh.md: cf845ef1e6fad20ee6cc9059436d6cf7a1d75ed7 +2026-08-30-retain-ignorable-external-session-events.md: 1fe3a6d99a16daa6ad88f6717baa18f18e8c7355 +2026-08-30-retain-ignorable-external-session-events.zh.md: 632b7b418252c2b299162f3e00a4d41169169509 diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md index 60d78d452e..1fe3a6d99a 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md @@ -12,7 +12,7 @@ That producer inventory did not cover a third-party plugin that currently depend ## Decision -The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. +The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. The persistence seam's stored-event validation (`validateStoredEvents`) continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read. The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy. diff --git a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md index cf845ef1e6..632b7b4182 100644 --- a/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 +标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。持久化 seam 的已存事件校验(`validateStoredEvents`)继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。 只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index 68f6bb7c3f..b459f02998 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 9ea008cf9edf06378ef39d4f14701247bbce0c93 -2026-08-31-released-session-format-migrations.zh.md: 378eebd8c8de5ceb3aa75b0d90ae3ac0c4adfa16 +2026-08-31-released-session-format-migrations.md: 65d89b680e827b29a3a0e5d485a53f7a6eb49f63 +2026-08-31-released-session-format-migrations.zh.md: 6089f6dc4f231aaeec31b89600485ef2a06fb9b0 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 9ea008cf9e..65d89b680e 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -6,7 +6,7 @@ English | [中文](2026-08-31-released-session-format-migrations.zh.md) ## Problem -Session format v0 shipped in an alpha release, so a structural writer change can no longer treat existing JSONL as disposable pre-release state. The runtime also has several ways to read event bodies besides explicit resume: inspect, query, export, fork, continuation, suffix reads, and raw-artifact export. Migrating only one entry path would let callers observe different logical generations or fail only when a later writer reaches the old file. +Session format v0 shipped in an alpha release, so a structural writer change can no longer treat existing JSONL as disposable pre-release state. Stored event bodies reach consumers through read or write `SessionHandle` instances used by resume, query, export, fork, and continuation paths. Migrating only one consumer would let callers observe different logical generations or fail only when a later writer reaches the old file. Migration must retain the exact source path, bytes, and inode, including a torn physical tail, while giving every published format one unambiguous canonical filename. Plain JSONL and Zstandard are encoding choices for the same logical format and must not create parallel migration implementations. @@ -16,15 +16,15 @@ Migration must retain the exact source path, bytes, and inode, including a torn Each edge freezes strict source and target semantics, while its target physical codec remains vocabulary-neutral so ordinary event growth can stay within one format version. The catalog restores the final generation through the installed peer `@deepseek-ai/dsh-session` and its current `KNOWN_SESSION_EVENT_TYPES`, preventing a frozen historical edge from becoming the current vocabulary owner. -Every event-body operation crosses the persistence coordinator's per-Session serialization chain and completes provider-owned ensure-current work before current values escape. JSONL fuses highest-generation resolution, classification or migration, and current decoding over one physical snapshot; fallback backends retain separate `ensureCurrent` and current-read hooks. The six public reads are prepare, load, inspect, borrowSession, readFrom, and readRaw; cold append adoption uses the same body path. Header-only list and listSnapshots never migrate: they rescan each Session directory and return one descriptor for its numerically highest canonical generation, so a future, unsupported, or malformed highest file remains visible instead of silently falling back. When the backend can recover the Session id from its location without trusting the header, the descriptor carries that `storageId`; explicit identity reservations therefore reject unreadable artifacts that already occupy the id. +The JSONL provider completes ensure-current work before `open` returns a handle for a stored Session. It selects the highest canonical generation, migrates a supported historical body, and decodes the current result from one physical snapshot; the public `SessionPersistence` and `SessionHandle` interfaces contain no migration operations. Header-only `stat` and `list` rescan Session directories, translate supported historical headers in memory, and never publish a successor. `create` checks canonical filenames independently of header readability, so every existing generation reserves its Session id. -For `prepare`, `inspect`, and `borrowSession`, cancellation belongs to the observing call rather than shared preparation or migration. A cancelled observer stops waiting, while already-started work may finish for another inspector or later resume; durable publication is never rolled back to satisfy observer cancellation. Detached `readFrom` and `readRaw` operations instead pass cancellation into their serialized backend read. +Cancellation belongs to the `open`, `stat`, or `list` call that supplied it. Discovery, stable reads, decoding, and pre-publication checks observe that signal; once an immutable successor is published and its directory entry is synced, later cancellation does not delete the committed generation. -The configured JSONL encoding owns one full suffix, `.jsonl` or `.jsonl.zstd`. Migration reads a stable exact source, decodes the recoverable logical prefix, composes every required edge in memory, applies current interrupted-turn repair, validates and syncs a same-directory temporary stage for only the final target, rechecks the source fingerprint, publishes that previously absent target without overwrite, syncs the namespace, and reopens it through the ordinary current reader before a Session is constructed. The source never moves or changes; only disposable temporary stages may be moved, linked, or removed. +The configured JSONL encoding owns one full suffix, `.jsonl` or `.jsonl.zstd`. Migration reads a stable exact source, decodes the recoverable logical prefix, composes every required edge in memory, validates and syncs a same-directory temporary stage for only the final target, rechecks the source fingerprint, publishes that previously absent target without overwrite, syncs the namespace, and reopens it through current validation before returning a handle. The source never moves or changes; only disposable temporary stages may be moved, linked, or removed. Migration does not synthesize interrupted-turn events: agent-loop appends those repairs through the write handle, while read-only query paths balance them in memory. Canonical filenames encode the physical format generation: v0 is `session.jsonl` or `session.jsonl.zstd`; every positive generation is lowercase `session.vN.jsonl` or `session.vN.jsonl.zstd`. Publication never renames, replaces, or deletes a committed generation path. If the target already exists, it is accepted only as a regular current-format file with exactly the expected bytes; any other target refuses. Lower generations remain for operator inspection or explicit copying, but normal runtime operations select the numerically highest canonical name and never use retained predecessors as automatic fallback, restore, or downgrade support. -The current-format fast path classifies the header from one stable source snapshot, invokes no historical converter or generation write, and passes that snapshot to current decoding without another file read. A validated current selection is cached for later opens in the same backend instance under the one-writer assumption, while listing deliberately rescans; if the selected file disappears, the backend invalidates the cache and resolves the directory again. Multiple edges leave the original generation unchanged and publish only the final target; intermediate versions exist only in memory. Same-process operations are serialized, and a source fingerprint recheck restarts the complete attempt when content changes. Cross-process writer fencing remains outside this guarantee. +The current-format fast path classifies the header from one stable source snapshot, invokes no historical converter or generation write, and passes that snapshot to current decoding without another file read. The decoded log enters the existing bounded revision-keyed memo for an immediate observe-to-resume handoff, while `stat` and `list` deliberately rescan. Multiple edges leave the original generation unchanged and publish only the final target; intermediate versions exist only in memory. A source fingerprint recheck restarts migration when content changes, and exclusive target publication accepts a racing winner only when its bytes match exactly. Cross-process append fencing remains outside this guarantee. The first edge, `@deepseek-ai/dsh-session-format-v0-to-v1`, is intentionally identity-shaped: aside from the version and bounded historical normalizations already accepted by v0, it preserves logical headers, events, sequence numbers, references, timestamps, payloads, and the configured compression choice. The exact `session.jsonl[.zstd]` source remains byte- and inode-identical, while the current writer encodes the new `session.v1.jsonl[.zstd]` successor. This exercises the complete publication lifecycle before a cardinality-changing format needs it. @@ -42,9 +42,9 @@ This note supersedes the continue-only persistence rule and the deferred-chain s Release verification ran the committed Session-format corpus gate over 152 versioned persisted-or-projected `session*.jsonl` fixtures under `snapshots/`, `packages/`, and `scripts/snapshots/python-sdk-single-exe/`. Fixture-only omitted envelopes and request-header tokens are materialized before the real static catalog; 150 fixtures reached the current v1 view through current restoration or historical migration. Released-v0 replay inputs remain suffixless, while fresh v1 writer outputs use `session.v1.jsonl` for a parent and `session..v1.jsonl` for children. Record and refresh preserve older generations for every role still produced and remove all generations of a child role that the new run no longer produces. The two exact alpha refusals were `snapshots/session/agent-instructions/session.jsonl`, whose projected compaction checkpoint has no matching start, and `snapshots/web/schedule-catalog/session.jsonl`, whose title source contradicts its citations. The continuing gate discovers the corpus dynamically and fails any refusal outside that closed manifest; separate assembled JSONL tests own exact physical-byte migration. -Current-head performance used three independent runs, each with 100 warmups and 600 alternating samples per case; a pooled 1,800-sample marginal estimator compared the immutable resolver with the same-commit dispatch-disabled baseline. Hot median/p95 deltas were raw small `-1.864%/-1.109%`, raw 100-turn `-0.711%/-0.445%`, Zstandard small `-0.880%/-5.025%`, and Zstandard 100-turn `-0.301%/-2.090%`, all within the five-percent regression ceiling. Cold enabled-path median/p95 costs were raw small `220.125/294.708 µs`, raw 100-turn `580.625/730.834 µs`, Zstandard small `248.042/960.083 µs`, and Zstandard 100-turn `636.291/1421.208 µs`. Repeated hot body reads performed zero directory scans; two listing calls performed two scans. +Handle-integration verification runs the pure format, catalog, persistence-seam, and JSONL provider suites together: 420 tests cover both encodings, immutable publication races, header-only observation, read and write handles, migration refusal, append after migration, cancellation, and crash-tail behavior with per-file 100% statement, branch, function, and line coverage. Repository typecheck and lint, 113 keyless recorded-session replays with two declared skips, and 28 owner-local expected-output cases also pass on the merged master checkpoint. -The assembled headless profile test stages `session.jsonl`, resumes it through the shipped composition, observes v1 before Session construction, verifies that the exact v0 bytes and inode remain while `session.v1.jsonl` appears, and proves the next append targets v1. JSONL contract tests exercise raw and Zstandard exclusive publication, torn-tail preservation, source changes, target collisions, future-highest refusal, current-selection caching, listing rescans, temporary cleanup, committed reopen, and current-format bypass. +The assembled headless profile test stages `session.jsonl`, resumes it through the shipped composition, observes v1 before Session construction, verifies that the exact v0 bytes and inode remain while `session.v1.jsonl` appears, and proves the next append targets v1. JSONL contract tests exercise raw and Zstandard exclusive publication, torn-tail preservation, source changes, target collisions, future-highest refusal, revision-keyed parsed-log reuse, listing rescans, temporary cleanup, committed reopen, and current-format bypass. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 378eebd8c8..6089f6dc4f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Session 格式 v0 已随 alpha 版本发布,因此结构化 writer 变更不能再把已有 JSONL 当作可丢弃的预发布状态。除显式恢复外,runtime 还有多个读取事件正文的入口:检查、查询、导出、分叉、继续、后缀读取和原始产物导出。只迁移一个入口会让调用方看到不同的逻辑 generation,或只在后续 writer 到达旧文件时失败。 +Session 格式 v0 已随 alpha 版本发布,因此结构化 writer 变更不能再把已有 JSONL 当作可丢弃的预发布状态。已存储事件正文通过读或写 `SessionHandle` 到达恢复、查询、导出、分叉与继续路径。只迁移一个消费方会让调用方看到不同的逻辑 generation,或只在后续 writer 到达旧文件时失败。 迁移必须保留精确源路径、字节与 inode,包括撕裂的物理尾部,同时为每个已发布格式提供一个无歧义的规范文件名。普通 JSONL 与 Zstandard 是同一逻辑格式的编码选择,不能产生两套并行迁移实现。 @@ -16,15 +16,15 @@ Session 格式 v0 已随 alpha 版本发布,因此结构化 writer 变更不 每条迁移边都会冻结严格的源与目标语义,其目标物理 codec 则保持词汇中立,使普通事件增长可以留在同一格式版本内。目录通过已安装的 peer `@deepseek-ai/dsh-session` 及其当前 `KNOWN_SESSION_EVENT_TYPES` 还原最终代,避免冻结的历史迁移边反过来成为当前词汇 owner。 -每个事件正文操作都经过持久化协调器的逐 Session 串行链,并在当前值离开前完成 provider 拥有的 ensure-current 工作。JSONL 会基于同一个物理快照融合最高 generation 解析、分类或迁移与当前格式解码;fallback 后端保留分离的 `ensureCurrent` 与当前读取钩子。六个公开读取是 prepare、load、inspect、borrowSession、readFrom 与 readRaw;冷 append 接管使用同一正文路径。仅 header 的 list 与 listSnapshots 从不迁移:它们重新扫描每个 Session 目录,并为数值最高的规范 generation 返回一个 descriptor,因此未来、不支持或 malformed 的最高文件会保持可见,而不会静默 fallback。当后端无需信任 header 就能从 location 恢复 Session id 时,descriptor 会携带该 `storageId`;因此显式 identity 预留会拒绝已经占用该 id 的不可读产物。 +JSONL provider 在 `open` 为已存储 Session 返回句柄前完成 ensure-current 工作。它选择最高规范 generation、迁移受支持的历史正文,并从同一物理快照解码当前结果;公开 `SessionPersistence` 与 `SessionHandle` 接口不包含迁移操作。仅 header 的 `stat` 与 `list` 会重新扫描 Session 目录,在内存中转换受支持的历史 header,且绝不发布后继。`create` 独立于 header 可读性检查规范文件名,因此每个现有 generation 都会占用其 Session id。 -对于 `prepare`、`inspect` 与 `borrowSession`,取消属于观察调用,而不属于共享准备或迁移。被取消的观察者会停止等待,而已经开始的工作可以为另一检查者或后续恢复继续完成;持久发布绝不会为了满足观察者取消而回滚。分离的 `readFrom` 与 `readRaw` 操作则会把取消传入其串行化后端读取。 +取消属于提供信号的 `open`、`stat` 或 `list` 调用。发现、稳定读取、解码与发布前检查都会观察该信号;不可变后继一旦发布且其目录项已经同步,后续取消不会删除已提交 generation。 -配置的 JSONL 编码拥有一个完整后缀:`.jsonl` 或 `.jsonl.zstd`。迁移读取稳定的精确源,解码可恢复逻辑前缀,在内存中组合全部必需迁移边,应用当前的中断轮次修复,只为最终目标校验并同步同目录临时 stage,重新检查源 fingerprint,以不覆盖方式发布此前不存在的目标,同步 namespace,并在构造 Session 前通过普通当前 reader 重新打开。源永不移动或改变;只有可丢弃临时 stage 可以被移动、链接或移除。 +配置的 JSONL 编码拥有一个完整后缀:`.jsonl` 或 `.jsonl.zstd`。迁移读取稳定的精确源,解码可恢复逻辑前缀,在内存中组合全部必需迁移边,只为最终目标校验并同步同目录临时 stage,重新检查源 fingerprint,以不覆盖方式发布此前不存在的目标,同步 namespace,并在返回句柄前通过当前格式校验重新打开。源永不移动或改变;只有可丢弃临时 stage 可以被移动、链接或移除。迁移不会合成中断轮次事件:agent-loop 通过写句柄追加这些修复,而只读查询路径在内存中补齐它们。 规范文件名编码物理格式 generation:v0 是 `session.jsonl` 或 `session.jsonl.zstd`;每个正 generation 都是小写 `session.vN.jsonl` 或 `session.vN.jsonl.zstd`。发布绝不重命名、替换或删除已提交 generation 路径。目标已经存在时,只有它是普通当前格式文件且字节与预期完全相同时才接受;其他目标都会拒绝。低 generation 为 operator 检查或显式复制而保留,但普通 runtime 操作选择数值最高的规范名称,绝不把保留的前任当作自动 fallback、restore 或 downgrade 支持。 -当前格式快速路径从一个稳定源快照分类 header,不调用历史 converter,不写 generation,并把该快照交给当前格式解码,而不再次读取文件。后端会在单 writer 假设下缓存已校验的当前选择,供同一实例后续打开使用,而列表会有意重新扫描;如果选定文件消失,后端会让缓存失效并重新解析目录。多条迁移边保持原 generation 不变,并只发布最终目标;中间版本只存在于内存。同一进程内的操作会串行化,源 fingerprint 重新检查会在内容变化时重启完整尝试。跨进程 writer 隔离不在此保证内。 +当前格式快速路径从一个稳定源快照分类 header,不调用历史 converter,不写 generation,并把该快照交给当前格式解码,而不再次读取文件。解码日志进入现有按 revision 为键的有界 memo,供紧接的观察到恢复交接复用,而 `stat` 与 `list` 会有意重新扫描。多条迁移边保持原 generation 不变,并只发布最终目标;中间版本只存在于内存。源 fingerprint 重新检查会在内容变化时重启迁移,排他目标发布只在竞争胜者字节完全相同时接受它。跨进程 append 隔离不在此保证内。 第一条迁移边 `@deepseek-ai/dsh-session-format-v0-to-v1` 有意保持恒等形态:除版本和 v0 已接纳的有限历史归一化外,它保留逻辑 header、事件、序号、引用、时间戳、payload 与已配置的压缩选择。精确的 `session.jsonl[.zstd]` 源保持字节与 inode 相同,当前 writer 则编码新的 `session.v1.jsonl[.zstd]` 后继。这样可在出现改变基数的格式前先验证完整发布生命周期。 @@ -42,9 +42,9 @@ JSONL 发布在 POSIX 上使用硬链接创建与目录同步,在 Windows 上 发布验证针对 `snapshots/`、`packages/` 与 `scripts/snapshots/python-sdk-single-exe/` 下 152 个带版本、来自持久化或投影的 `session*.jsonl` fixture 运行了已提交 Session 格式语料门禁。fixture 专用的缺失信封与 request-header token 会先被实体化,再进入真实静态 catalog;其中 150 个通过当前格式 restore 或历史迁移得到当前 v1 视图。Released-v0 replay 输入保持无后缀,而新鲜 v1 writer 输出对 parent 使用 `session.v1.jsonl`、对 child 使用 `session..v1.jsonl`。Record 与 refresh 会为仍由新运行产生的每个 role 保留旧 generation,并删除新运行不再产生的 child role 的全部 generation。两个精确 alpha 拒绝分别是 `snapshots/session/agent-instructions/session.jsonl`(投影出的 compaction checkpoint 没有匹配 start)与 `snapshots/web/schedule-catalog/session.jsonl`(title 来源与其 citation 矛盾)。持续运行的门禁会动态发现语料,并拒绝封闭 manifest 之外的任何失败;独立组装式 JSONL 测试负责精确物理字节迁移。 -当前 head 性能证据来自 3 次独立运行,每个 case 含 100 次 warmup 与 600 个 alternating sample;pooled 1,800-sample marginal estimator 把不可变 resolver 与同一 commit 下禁用 dispatch 的 baseline 比较。Hot median/p95 delta 分别为 raw small `-1.864%/-1.109%`、raw 100-turn `-0.711%/-0.445%`、Zstandard small `-0.880%/-5.025%`、Zstandard 100-turn `-0.301%/-2.090%`,全部满足 5% regression ceiling。Cold enabled-path median/p95 cost 分别为 raw small `220.125/294.708 µs`、raw 100-turn `580.625/730.834 µs`、Zstandard small `248.042/960.083 µs`、Zstandard 100-turn `636.291/1421.208 µs`。重复 hot body read 执行 0 次目录扫描;两次 listing 调用执行 2 次扫描。 +句柄集成验证会一起运行纯格式、catalog、持久化 seam 与 JSONL provider 测试套件:420 个测试覆盖两种编码、不可变发布竞态、仅 header 观察、读写句柄、迁移拒绝、迁移后 append、取消与崩溃尾部行为,并达到逐文件 100% statement、branch、function 与 line coverage。仓库 typecheck 与 lint、含两个已声明 skip 的 113 个无密钥 recorded-session replay,以及 28 个 owner-local expected-output case 也都在合并 master 的 checkpoint 上通过。 -组装后的 headless profile 测试会暂存 `session.jsonl`,通过随附组合恢复它,在构造 Session 前观察到 v1,验证精确 v0 字节与 inode 保持不变而 `session.v1.jsonl` 出现,并证明下一次 append 以 v1 为目标。JSONL 约定测试覆盖 raw 与 Zstandard 排他发布、撕裂尾部保留、源变化、目标冲突、最高未来版本拒绝、当前选择 cache、列表重新扫描、临时文件清理、已提交重开与当前格式直通。 +组装后的 headless profile 测试会暂存 `session.jsonl`,通过随附组合恢复它,在构造 Session 前观察到 v1,验证精确 v0 字节与 inode 保持不变而 `session.v1.jsonl` 出现,并证明下一次 append 以 v1 为目标。JSONL 约定测试覆盖 raw 与 Zstandard 排他发布、撕裂尾部保留、源变化、目标冲突、最高未来版本拒绝、按 revision 复用已解析日志、列表重新扫描、临时文件清理、已提交重开与当前格式直通。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml new file mode 100644 index 0000000000..aa070add66 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml @@ -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-01-streamed-tool-call-identity.md +2026-09-01-streamed-tool-call-identity.md: c52f39b735199270d65ed30a388333a217003237 +2026-09-01-streamed-tool-call-identity.zh.md: 9a7ffb6343870a08e06507408e007ad94fbad178 diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md new file mode 100644 index 0000000000..c52f39b735 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md @@ -0,0 +1,33 @@ +# Agent Note: Streamed tool-call identity survives empty continuation deltas + +Status: implemented + +English | [中文](2026-09-01-streamed-tool-call-identity.zh.md) + +## Problem + +The DeepSeek SSE translator assigned `id` and `name` on every tool-call delta that carried the field, so a continuation delta repeating either as an empty string erased the identity established by the call's first delta. The assembled block reached the loop with an empty name, which the tool registry refuses as `unknown tool ""`, leaving the affected models unable to run any tool. Gateways that fill those fields with `null` erased the identity the same way, and `WireToolCallDelta` declared both as `string | undefined`, keeping the observed `null` out of the compiler's reach. + +The empty identity outlived the turn. `appendToolCall` and `appendToolResult` write the block's id verbatim and no write path validates it, while `adoptSessionEvent` refuses a `tool/result` whose `callId` is empty, so the persistence coordinator wrapped that refusal in `SessionPersistenceCorruptionError`. A session that recorded one such call was writable and no longer loadable. + +## Decision + +`acceptIdentity` accepts only a non-empty string for a tool call's `id` and `name`; `undefined`, `null`, `''`, and any non-string leave the established value in place. The assignment set only narrows, so no input reaches a worse outcome than before. `WireToolCallDelta` widens `id`, `function.name`, and `function.arguments` to admit `null`, putting the values gateways actually send into the type system and making the runtime guard load-bearing rather than speculative. + +## Alternatives considered + +**Concatenate `id` and `name` across deltas.** Rejected: they are identity, not accumulation. Concatenation produces `Globnull` against a gateway that sends `null`, and a doubled name against one that repeats a non-empty value. + +**Refuse a conflicting non-empty identity mid-stream.** Deferred: a gateway that fragments a long tool name would be refused for it, and no observed provider re-sends a different non-empty identity within one call index. + +**Refuse a response whose tool call never receives an identity.** Deferred. It requires a new failure code, a change to the default retryable set, and a `[DONE]` gate that must not override the finish reason a provider already sent — cost and risk that the reported defect does not carry. The lenient wire it guards against is hypothetical: no report describes a stream that omits identity entirely. + +**Relax the session reader's empty-`callId` refusal.** Rejected: an empty `callId` cannot be paired back to the provider on the next request, so accepting it moves the failure into the model request. That refusal is the durable-boundary gate; the producer was the defect. + +## Consequences + +A continuation delta repeating identity empty or null is inert, so a call keeps the identity its first delta established, and the reported path to `unknown tool ""` and an unreadable session is closed. A stream that never carries identity at all still assembles an empty one, exactly as before; that path and the recovery of sessions already holding an empty `callId` are outside this change. + +## Testing + +`translate.spec.ts` covers empty and null continuation deltas, a repeated identical identity, and parallel calls holding separate identities under empty continuations. The existing cases for a wire that omits identity entirely keep their recorded empty-identity output. diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md new file mode 100644 index 0000000000..9a7ffb6343 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md @@ -0,0 +1,33 @@ +# Agent Note:流式工具调用身份不被空续传分片抹除 + +Status: implemented + +[English](2026-09-01-streamed-tool-call-identity.md) | 中文 + +## 问题 + +DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋值 `id` 与 `name`,因此续传分片把其中任一字段重复发送为空串时,会抹掉该调用首个分片已建立的身份。组装出的块带着空名字进入循环,工具注册表以 `unknown tool ""` 拒绝它,受影响的模型上任何工具都跑不起来。把这些字段填成 `null` 的网关会造成同样的抹除,而 `WireToolCallDelta` 把两者都声明为 `string | undefined`,让实际观察到的 `null` 落在编译器视野之外。 + +空身份还会活过本轮。`appendToolCall` 与 `appendToolResult` 原样写入块的 id 且没有任何写入路径校验它,而 `adoptSessionEvent` 拒绝 `callId` 为空的 `tool/result`,持久化协调器于是把该拒绝包装成 `SessionPersistenceCorruptionError`。记录过一次这种调用的会话可写但不再可读。 + +## 决定 + +`acceptIdentity` 对工具调用的 `id` 与 `name` 只接受非空字符串;`undefined`、`null`、`''` 以及任何非字符串都保留已建立的值。会触发赋值的输入集合只减不增,因此没有任何输入会比改动前更差。`WireToolCallDelta` 把 `id`、`function.name` 与 `function.arguments` 放宽到允许 `null`,使网关实际发送的值进入类型系统,运行时守卫因此是承重的而非臆测的。 + +## 考虑过的替代方案 + +**跨分片拼接 `id` 与 `name`。** 否决:它们是身份而非累积。面对发送 `null` 的网关,拼接产生 `Globnull`;面对重复发送非空值的网关,产生重复的名字。 + +**流中途拒绝冲突的非空身份。** 推迟:分片发送长工具名的网关会因此被拒,且没有观察到任何提供方在同一个调用 index 内改发不同的非空身份。 + +**拒绝始终未获得身份的响应。** 推迟。它需要新增失败 code、改动默认可重试集,还需要一个不得覆盖提供方已给出终止原因的 `[DONE]` 闸门——这些代价与风险,已报告的缺陷并不需要承担。它所防的宽松线上格式是假想的:没有任何报告描述过完全不发送身份的流。 + +**放宽会话读取端对空 `callId` 的拒绝。** 否决:空 `callId` 无法在下一次请求中与提供方配对,接受它只是把失败推进模型请求。该拒绝是持久化边界的闸门;缺陷在生产方。 + +## 后果 + +重复发送空或 null 身份的续传分片不产生作用,调用因此保有其首个分片建立的身份,通往 `unknown tool ""` 与不可读会话的已报告路径就此切断。完全不携带身份的流仍会组装出空身份,与改动前一致;该路径以及已经写入空 `callId` 的会话恢复都不在本次改动范围内。 + +## 测试 + +`translate.spec.ts` 覆盖空与 null 续传分片、重复的相同身份,以及空续传下并行调用各自保有身份。原有那些描述完全不发送身份的线上格式的用例,保留其记录的空身份输出。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md deleted file mode 100644 index 003331cd66..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Load sessions persisted before message identity - -Status: implemented - -English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) - -## Problem - -The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL Sessions still held the immediately preceding forms: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-form validation rejected them before resume could construct a live `Session`. - -Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party provider without weakening validation for unrelated obsolete or malformed events. - -## Decision - -The frozen `@deepseek-ai/dsh-session-format-v0-to-v1` edge normalizes the four exact pre-identity message payloads after v0 decoding and before v1 validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message::` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant. - -Every event-body operation runs the same edge through the build-static catalog before current Session construction. `load`, `inspect`, ownerless-state adoption, HMR prefix adoption, query, export, fork, and suffix reads therefore see one normalized current generation. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their refusal paths. - -JSONL migration leaves the exact suffixless v0 artifact path, bytes, and inode unchanged and exclusively publishes `session.v1.jsonl[.zstd]` beside it. Deterministic identities make repeated restoration reproduce the same message ids, and subsequent appends target only v1. - -## Alternatives considered - -**Reject the released logs.** This strands real first-party sessions even though every old field maps unambiguously to the current message representation. - -**Keep a same-version importer inside the coordinator.** This avoids a format edge but leaves historical payloads in current Session code and provides neither immutable source/successor naming nor independently testable publication. The released adjacent migration system owns canonical publication instead. - -**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. - -## Consequences - -Pre-identity JSONL Sessions resume with their original message content, sources, assistant provider/model fields, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. - -This is one explicit released-v0 normalization, not a permissive compatibility layer. Adding another normalization requires another complete, unambiguous mapping in the frozen edge; malformed current data continues to fail rather than being guessed into validity. Edge and JSONL generation tests exercise deterministic restoration and tool-result replacement identity. - -## Related - -- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract. -- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md deleted file mode 100644 index cf15b5434b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 加载消息标识机制引入前持久化的会话 - -Status: implemented - -[English](2026-07-28-load-pre-identity-session-messages.md) | 中文 - -## 问题 - -带标识的不可变消息变更将四种持久化事件载荷替换为完整消息值。现有 v0 JSONL Session 仍保留紧邻该变更之前的表示:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些 Session 的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前表示验证会拒绝它们,导致恢复流程无法构造 live `Session`。 - -消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的 first-party provider 所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 - -## 决策 - -冻结的 `@deepseek-ai/dsh-session-format-v0-to-v1` 迁移边会在 v0 解码之后、v1 验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息结构,并为其分配确定性的导入用 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 - -每项事件正文操作都会在构造当前 Session 前,通过构建期静态目录运行同一条迁移边。因此,`load`、`inspect`、无 owner 状态接管、HMR(热模块替换)前缀接管、查询、导出、fork 与后缀读取都会看到同一份规范化当前代际。看似当前结构、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 - -JSONL 迁移会保持精确的无后缀 v0 产物路径、字节与 inode 不变,并在其旁边排他发布 `session.v1.jsonl[.zstd]`。确定性标识使重复恢复能够复现相同的消息 id,后续 append 只以 v1 为目标。 - -## 考虑过的替代方案 - -**拒绝这些已发布日志。** 即使每个旧字段都能明确映射到当前消息表示,这也会导致真实的第一方会话无法恢复。 - -**在协调器中保留同版本导入器。** 这可以避免迁移边,但会把历史 payload 留在当前 Session 代码中,而且既没有不可变源/后继命名,也没有可独立测试的发布。已发布相邻迁移系统负责规范发布。 - -**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 - -## 后果 - -消息标识机制引入前的 JSONL Session 可以恢复,并保留原始消息内容、来源、assistant 的 provider/model 字段、工具调用关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 - -这是一项显式的已发布 v0 规范化,而非宽松的兼容层。若要增加另一项规范化,必须在冻结迁移边中提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。迁移边与 JSONL 代际测试会验证恢复的确定性,以及工具结果替换时的标识继承。 - -## 相关 - -- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.zh.md):该记录负责当前的消息标识与不可变性约定。 -- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.zh.md):该记录负责仅追加后端与恢复边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index a1a69bb1af..a5e97e8e8e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: e4809575e03bbd74522b26a8a170ac558d6eee41 -2026-07-31-resume-selector-batch-projection.zh.md: 04646d266c87b96b7c28692663540ffe808d0082 +2026-07-31-resume-selector-batch-projection.md: aa99ecf323b44432e360402f072d89436b2778bd +2026-07-31-resume-selector-batch-projection.zh.md: ebc9d43677edc23004c32736db989d39e59ef152 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index e4809575e0..aa99ecf323 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -26,7 +26,7 @@ No session-query or session-persistence surface changed. The shipped TUI composi **Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications. -**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. +**Surface a last-modified time through `list()`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, provider, and query record type for what the TUI can already derive from the stored log's file metadata. Reintroduce if a second consumer needs metadata activity times. **A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 04646d266c..ebc9d43677 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -26,7 +26,7 @@ session-query 与 session-persistence 的任何接口都未改变。随附的 TU **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 +**通过 `list()`/`SessionRecord` 暴露最后修改时间。** 从 seam 角度最干净,但要触碰持久化约定、provider 和查询记录类型,而 TUI 已能从已存日志的文件元数据得到同样的信息。若出现第二个需要元数据活动时间的消费方再引入。 **专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效约定(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md deleted file mode 100644 index beda9c9984..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md +++ /dev/null @@ -1,40 +0,0 @@ -# Agent Note: Load sessions from the pre-react-loop format - -Status: implemented - -English | [中文](2026-08-04-load-pre-react-loop-sessions.zh.md) - -## Problem - -The react-loop simplification changed durable events while retaining `SESSION_FORMAT_VERSION` 0. Stored sessions from the change's base contain `steering/message` and `turn/start.trigger`; their terminal reasons also use coarse `aborted`, separate `disposed`, and two older error payloads. Current surface and turn invariants cannot replay those records directly. - -The new durable inbox is not part of this compatibility problem. The base emitted process-local inbox notifications but no `agent/inbox/*` session events, so replaying old history as pending work would resurrect already claimed or discarded prompts. - -## Decision - -The frozen `@deepseek-ai/dsh-session-format-v0-to-v1` edge recognizes the exact pre-react-loop shapes after v0 decoding and projects them into v1. It removes the obsolete `turn/start.trigger`, converts `steering/message` to the same identified `user/message`, maps old failure facts into the current structured error, folds `disposed` into an aborted turn with the `disposed` cause, and represents coarse aborted records with the persistence-only `{ kind: 'legacy' }` cause because their caller is unavailable. - -Every persistence event-body entry point first migrates the complete detached artifact through the build-static catalog. `load`, `inspect`, adoption, HMR prefix comparison, and `readFrom` therefore receive the same validated v1 view; suffix reading happens only after immutable successor publication and current restoration. - -The edge does not synthesize inbox splices. A resumed pre-react-loop agent begins with empty pending lists, matching the base runtime's inability to persist pending inbox work. Migration leaves the exact suffixless v0 generation unchanged and publishes one `session.v1.jsonl[.zstd]` successor before later events append. - -## Alternatives considered - -**Treat the released records as unsupported.** This strands sessions produced by the supported first-party writer even though the removed steering content and terminal facts have complete mappings. - -**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work. - -**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would invent a caller that the old record did not name. A dedicated `legacy` cause keeps the stop classification without making a false audit claim. - -**Keep a generic same-version importer in the coordinator.** This lets current Session code accumulate historical forms and gives no immutable physical generation naming or independently testable adjacent edge. The released migration lifecycle owns the conversion instead. - -## Consequences - -Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The frozen edge and JSONL generation contract cover `load`/`inspect`/`readFrom`; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. - -This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity. - -## Related - -- [Load sessions persisted before message identity](2026-07-28-load-pre-identity-session-messages.md) — owns deterministic identities for another released v0 normalization in the same edge. -- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns append-only backend storage and resume. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md deleted file mode 100644 index ace424476a..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md +++ /dev/null @@ -1,40 +0,0 @@ -# Agent Note: 加载 react-loop 重构前格式的会话 - -Status: implemented - -[English](2026-08-04-load-pre-react-loop-sessions.md) | 中文 - -## 问题 - -react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持久事件。该变更基线所存储的会话包含 steering(中途引导)事件 `steering/message`,以及 `turn/start.trigger` 字段;其终止原因还使用粗粒度 `aborted`、独立的 `disposed` 和两种旧版错误载荷。当前表层和轮次不变量无法直接回放这些记录。 - -新的持久 inbox 不属于此兼容性问题。该基线会发出进程本地 inbox 通知,但不会产生 `agent/inbox/*` 会话事件,因此将旧历史回放为待处理工作会让已经领取或丢弃的提示词再次执行。 - -## 决策 - -冻结的 `@deepseek-ai/dsh-session-format-v0-to-v1` 迁移边会在 v0 解码后识别 react-loop 重构前的确切结构,并将其投影为 v1。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。 - -每个持久化事件正文入口都会先通过构建期静态目录迁移完整且分离的产物。因此,`load`、`inspect`、接管、HMR(热模块替换)前缀比较和 `readFrom` 会收到同一份经过校验的 v1 视图;系统只会在不可变后继发布和当前格式恢复后读取后缀。 - -该迁移边不会合成 inbox splice。恢复后的 react-loop 重构前 agent(智能体)从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。迁移会保持精确的无后缀 v0 generation 不变,并在后续事件 append 前发布一个 `session.v1.jsonl[.zstd]` 后继。 - -## 考虑过的替代方案 - -**将已发布记录视为不受支持。** 这会使受支持的第一方 writer 所产生的 Session 无法恢复,尽管已移除的 steering 内容和终止事实都有完整映射。 - -**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。 - -**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会凭空指定旧记录未注明的调用方。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。 - -**在协调器中保留通用同版本导入器。** 这会让当前 Session 代码不断积累历史结构,而且没有不可变物理 generation 命名或可独立测试的相邻迁移边。已发布迁移生命周期负责该转换。 - -## 后果 - -以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。冻结迁移边与 JSONL 代际约定覆盖 `load`/`inspect`/`readFrom`;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 - -此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。 - -## 相关资料 - -- [加载消息标识机制引入前持久化的会话](2026-07-28-load-pre-identity-session-messages.zh.md):负责同一迁移边中另一项已发布 v0 规范化的确定性标识。 -- [以抽象服务实现会话持久化](../architecture/2026-06-14-session-persistence.zh.md):负责仅追加后端存储和恢复。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 94a7268a28..51a7388005 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: ab2f3b5a0534a98e02a3e2494ca2fff1223efe81 -2026-08-13-bounded-cold-blank-verification.zh.md: 5dc4b62f7ac43ebd3c4a8cef58f5da9af367520a +2026-08-13-bounded-cold-blank-verification.md: 8244b93641cfe576cd2f5b0c618ae69fbb215dd5 +2026-08-13-bounded-cold-blank-verification.zh.md: 2c7f392fa8fbd3fed0641fe2b95e717f76cd78fb diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index ab2f3b5a05..8244b93641 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -14,15 +14,15 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess `dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. -A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. +A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank and are served `blank: false`, keeping the Session visible. The earlier physical-size probe — a `locate()` path plus a `coldBlankProbeMaxBytes` eligibility threshold gating an exact `readFrom(id, 0)` fold — is removed with the seam's path query ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)); persistence snapshot metadata (`eventCount`/`sizeBytes` on `stat()`/`list()`) is the reintroduction path for exact cold verification. -`updatedAt` is the later of `createdAt` and `lastPromptAt`. An eligible artifact read supplies exact `lastPromptAt` at no additional I/O cost; other cache misses or stale checkpoints order the Session too old rather than promoting it from an unrelated file write. After each asynchronous cold read, the gateway checks the live store again and replaces the cold result with an attached summary when another request resumed that Session meanwhile. +`updatedAt` is the later of `createdAt` and `lastPromptAt`. A cache miss or stale checkpoint orders the Session too old rather than promoting it from an unrelated file write. ## Alternatives considered **Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session. -**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size eligibility check targets small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. It intentionally does not add a persistence operation solely to make the threshold atomic with the read: concurrent growth may increase one probe's read cost, but the additional events can only preserve visibility or change a blank result to non-blank. +**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes; unverified cold entries degrade toward visibility instead. **Store blankness and recency in an authoritative persistence index.** Deferred because the shipped JSONL provider has an immutable first line and would require a second durable artifact with ordered updates. An out-of-tree provider may use its own index only with defined update atomicity, versioning, and recovery. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). @@ -30,8 +30,6 @@ A cold summary trusts cached `blank: false`, because a checkpoint prefix contain ## Consequences -Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose observed physical size is within the configured threshold when its cache does not already prove non-blank. The default threshold compares compressed bytes for the shipped Zstandard JSONL backend. +A stale cache cannot hide a stored `turn/start`, and a cold list performs no artifact I/O: cold rows are served from cached projections only. Blank cold Sessions without a cached non-blank projection remain visible, and missing or delayed recency cache entries fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened. -Blank artifacts above the threshold and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries for artifacts that are not read fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened. - -The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold eligibility, stale-true rejection, monotonic false reuse, exact small-log recency, live-attachment races, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it. +The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins stale-true rejection, monotonic false reuse, cache-miss visibility, human-prompt recency, and fiber disposal. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 5dc4b62f7a..2c7f392fa8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -14,15 +14,15 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N `dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 -冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 +冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空,因而按 `blank: false` 提供,让 Session 保持可见。早先的物理大小探测——`locate()` 路径加上门控一次精确 `readFrom(id, 0)` 折叠的 `coldBlankProbeMaxBytes` 资格阈值——随该 seam 的路径查询一并移除([导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md));persistence 快照元数据(`stat()`/`list()` 上的 `eventCount`/`sizeBytes`)是重新引入精确冷验证的路径。 -`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。符合资格的工件读取无需额外 I/O 即可提供精确 `lastPromptAt`;其他 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。每次异步冷读取后,网关都会再次检查实时 store;若另一请求期间已恢复该 Session,则用已附加摘要替换冷结果。 +`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。 ## Alternatives considered **信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。 -**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空。 +**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长;未经核验的冷条目转而向保持可见降级。 **把空白状态与最近时间存入权威 persistence index。** 暂缓,因为交付的 JSONL provider 首行不可变,需要增加带有顺序写入要求的第二份持久工件。仓库外 provider 只有定义更新原子性、版本与恢复语义后才可使用自己的索引。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)负责。 @@ -30,8 +30,6 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N ## Consequences -既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且观测物理大小在配置阈值内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该阈值比较压缩后的字节数。 +陈旧 cache 无法隐藏已存的 `turn/start`,且冷列表不做任何工件 I/O:冷行只从缓存投影提供。没有缓存非空投影的空白冷 Session 保持可见,缺失或延迟的最近时间 cache 条目回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。 -超过阈值的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。对于未被读取的工件,缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。 - -网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小资格、拒绝陈旧 true、复用单调 false、小日志精确最近时间、实时附加竞态、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。 +网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了拒绝陈旧 true、复用单调 false、cache miss 保持可见、真人 prompt 最近时间和 fiber 销毁。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml similarity index 56% rename from .agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml index bd53685b34..f113c39c96 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml @@ -1,6 +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-08-04-load-pre-react-loop-sessions.md -2026-08-04-load-pre-react-loop-sessions.md: beda9c9984f37a5ee6fa4e1a5bbff07d3b1e363c -2026-08-04-load-pre-react-loop-sessions.zh.md: ace424476a96c4abc46fc8afeb3920d564bb65ae +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md +2026-09-01-host-goal-pause-aborts-turn.md: 8a6acc1403ea9d8f1dab241e7888bb98aadcde31 +2026-09-01-host-goal-pause-aborts-turn.zh.md: 60170cd1029ce210fa1002ab407b4083af36d72e diff --git a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md new file mode 100644 index 0000000000..8a6acc1403 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md @@ -0,0 +1,29 @@ +# Agent Note: Host-initiated goal pause aborts the live turn + +Status: implemented + +English | [中文](2026-09-01-host-goal-pause-aborts-turn.zh.md) + +## Problem + +Clicking "pause goal" in the Web UI moved the goal to `paused` and disarmed automatic continuation, but the model turn already running kept going. The model could keep acting and call `update_goal resume` inside that same turn, immediately undoing the pause, so a manual pause had no real control over goal execution. + +## Decision + +The goal round driver now reads the `change` on every `goal/changed` event. When `operation === 'pause'` and the pause was not initiated by the agent's own turn, the driver aborts the live turn with `agent.cancel({ kind: 'user' }, { keepInbox: true })`. The Web button runs outside any agent initiator boundary, while a model's `update_goal pause` runs with the agent as the current initiator; the driver distinguishes them with `ctx.agents.currentInitiator() !== agent`. The abort is intentionally broad — it stops any live turn, not just a goal round — because a manual pause is a strong "stop now" signal and disarming alone stops future rounds but not the execution already under way. + +`keepInbox` preserves pending work. A queued goal round already fails the existing pre-step reservation check once the goal is disarmed, so it cannot run after the pause. + +The idle handler that pauses a cancelled goal is fenced to the dropped attempt's exact `{ goalId, revision }`. A resume bumps the revision, so a pause followed by an immediate resume — before the aborted turn converges to idle — is preserved instead of being re-paused by the stale cancelled attempt. + +## Alternatives considered + +**Cancel on every pause, including the model's own.** Rejected: a model that pauses in response to a direct human request should finish its turn and report; aborting mid-tool-call cuts off that acknowledgment without adding control. + +**Put the cancellation in the goal service's `pause`.** Rejected: `pause` is one shared entry point for host and model callers, so the service would still need the same initiator test. Keeping control handling in the round driver leaves the goal service a durable state and event owner. + +**Scope the abort to a turn actually running a goal round.** Rejected: the live turn is the execution the user asked to stop, and the extra attempt-state check adds a subtle path without changing the outcome the issue asks for. + +## Consequences + +A Web "pause goal" now aborts the running turn, so the model cannot keep acting or resume the just-paused goal in that turn. A pause followed by an immediate resume keeps the resumed goal running. Model-initiated pauses are unchanged. The change is confined to the round driver and its tests; the goal domain, tool authority, and durable formats are unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md new file mode 100644 index 0000000000..60170cd102 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 宿主发起的 goal 暂停中止当前轮次 + +Status: implemented + +[English](2026-09-01-host-goal-pause-aborts-turn.md) | 中文 + +## 问题 + +在 Web UI 点击「暂停目标」会把 goal 改成 `paused` 并解除自动续跑的武装(disarmed),但已经在跑的模型轮次不会停止。模型还能继续行动,并在同一个轮次里调用 `update_goal resume`,立刻撤销这次暂停,因此人工暂停对 goal 执行没有真正的控制力。 + +## 决策 + +goal round driver 现在会读取每个 `goal/changed` 事件里的 `change`。当 `operation === 'pause'` 且暂停不是由 agent 自己的轮次发起时,driver 用 `agent.cancel({ kind: 'user' }, { keepInbox: true })` 中止当前轮次。Web 按钮运行在任何 agent initiator 边界之外,而模型调用 `update_goal pause` 时当前 initiator 就是该 agent;driver 用 `ctx.agents.currentInitiator() !== agent` 来区分两者。中止是有意放宽的——它会停掉任何正在运行的轮次,而不只是 goal round——因为人工暂停是强烈的「现在停止」信号,仅 disarmed 只能阻止后续轮次,停不掉正在进行的执行。 + +`keepInbox` 会保留待处理工作。一旦 goal 被 disarmed,已排队的 goal round 就会在既有的 pre-step reservation 校验里失败,因此暂停后不会再运行。 + +暂停被取消 goal 的 idle 处理器被栅栏限定到被丢弃 attempt 的精确 `{ goalId, revision }`。resume 会推进 revision,因此在被中止轮次收敛到 idle 之前「暂停后立即 resume」会被保留,而不会被过期的 cancelled attempt 再次暂停。 + +## 考虑过的替代方案 + +**对每次暂停都中止轮次,包括模型自己发起的。** 否决:响应人类直接请求而暂停的模型应当完成本轮并给出回复;在工具调用中途中止只会截断这层确认,却换不来更多控制力。 + +**把中止逻辑放进 goal 服务的 `pause`。** 否决:`pause` 是宿主与模型共用的唯一入口,服务里同样需要这个 initiator 判断。把控制处理留在 round driver,可以让 goal 服务保持为持久状态与事件的拥有者。 + +**把中止限定到真正在跑 goal round 的轮次。** 否决:正在运行的轮次正是用户要求停止的执行,额外的 attempt 状态检查只会增加一条微妙路径,却不改变本 issue 要求的结果。 + +## 后果 + +现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。暂停后立即 resume 会保留被恢复的 goal 继续运行。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index ed42409d93..255067c0f0 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-ptc.md -2026-06-15-ptc.md: 5b3971c33df4be46c8a466e564ac86ba6454663a -2026-06-15-ptc.zh.md: fe44d8a0e97913998fc001c92f632f493888c720 +2026-06-15-ptc.md: c0a0b3ad54716761a74eed8b54c11480e9ca41bc +2026-06-15-ptc.zh.md: b774fc001509f30d41d1c519f344f67d797c2cbf diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index 5b3971c33d..c0a0b3ad54 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -10,7 +10,7 @@ In the registry's native presentation, the agent loop advertises every visible c For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. -Cloudflare's [PTC mode](https://blog.cloudflare.com/ptc/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. +Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md). The execution substrate is also part of the foundation rather than a placeholder: Node `worker_threads` provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture). diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index fe44d8a0e9..b774fc0015 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -10,7 +10,7 @@ Status: implemented 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 -Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 +Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 50b5c20bf4..9f4c455c9c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md -2026-07-10-agent-session-identity-and-log-location.md: 1bd16fa4123aa8a44719aa0e8c40c4e662f7cb3b -2026-07-10-agent-session-identity-and-log-location.zh.md: 1b54949fb34a0593eaa255e8ff548c203c8023c8 +2026-07-10-agent-session-identity-and-log-location.md: c849ffb882a3334c380b6736a8c8fd8d07a9da40 +2026-07-10-agent-session-identity-and-log-location.zh.md: 07826993839257dd893ef2a56059170ef9d0db31 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index 1bd16fa412..c849ffb882 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -1,4 +1,4 @@ -# Agent Note: Expose agent session identity and JSONL location to tools and hooks +# Agent Note: Expose agent session identity to tools and hooks Status: implemented @@ -6,29 +6,12 @@ English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md) ## Problem -An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. +An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call. Resume, forks, and concurrent parent/child agents make any ambient guess unreliable, while future plugins may need to expose other harness-owned environment facts to shell commands. The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs. ## Decision -Extend the [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query: - -```ts -import type { SessionHeader } from '@deepseek-ai/dsh-session' - -interface SessionLocation { - readonly kind: string - readonly path: string -} - -interface SessionPersistence { - locate(meta: SessionHeader): SessionLocation | undefined -} -``` - -`path` is an absolute local path to the provider's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. An out-of-tree provider without an honest local per-Session artifact returns `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. - The model-facing bash package owns a `ctx.shellEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment API enumerable for diagnostics and future prompt/UI consumers. The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`: @@ -36,15 +19,16 @@ The registry rebuilds a trusted overlay for every foreground and background bash - `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. - `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness. - `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`. -- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`. -Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. +A transcript-location fact is deliberately absent. An earlier form of this decision also extended the persistence seam with a `locate()` path query feeding a `DSH_SESSION_JSONL` variable and the hook bridges' `transcript_path`; the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns removing that half — the paths were only readable with compression disabled, and the seam no longer exposes artifact locations. + +Plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `ShellExecRequest.dshEnv` / `ShellExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. -The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session. +The [Claude Code and Codex hook bridges](2026-06-30-hook-bridges.md) keep `transcript_path` in their wire payloads for protocol shape but always send `''` (Claude Code) / `null` (Codex); the [persistence export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) note owns that degradation. ## Peer product findings @@ -52,36 +36,30 @@ Peer products separate stable identity from physical storage. Codex injects stab ## Lifecycle and persistence semantics -A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee. - -Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe. +A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID`. Resume reuses the loaded header and therefore the same id. Fork and spawn create new session ids. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. The registry is effect-scoped and HMR-safe. `dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home-paths` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. ## Testing -Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL and no-artifact locator contract tests plus both hook bridge suites pin available and unavailable transcript dialects. +Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, ignored model `env`, and parent/child isolation. Both hook bridge suites pin the constant degraded transcript dialects. -A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. +A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. ## Alternatives considered **Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions. -**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity. - **Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values. -**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale. - **A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable. -**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks. +**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. **A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query API; the registry generalizes to future environment facts without one tool per fact. ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. -The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. +The namespace is discoverable but not secret. `DSH_HOME` can reveal a configured root, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 1b54949fb3..0782699383 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 向工具与钩子公开 agent 会话标识和 JSONL 位置 +# Agent Note: 向工具与钩子公开 agent 会话标识 Status: implemented @@ -6,29 +6,12 @@ Status: implemented ## 问题 -agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 +agent(智能体)可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话。恢复、fork 以及并发运行的父子 agent 会让任何来自环境的猜测都不可靠,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。 这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。 ## 决策 -在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.zh.md) seam 上增加同步、无副作用的位置查询: - -```ts -import type { SessionHeader } from '@deepseek-ai/dsh-session' - -interface SessionLocation { - readonly kind: string - readonly path: string -} - -interface SessionPersistence { - locate(meta: SessionHeader): SessionLocation | undefined -} -``` - -`path` 是 provider 为 `meta` 保留的专用日志本地绝对路径;`kind` 标识其表示。JSONL 使用解析后的 root 与路径 helper 返回 `{ kind: 'jsonl', path }`。无法诚实提供逐 Session 本地产物的仓库外 provider 返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告按需创建的目标路径。 - 面向模型的 bash 包拥有一个 `ctx.shellEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境 API 可供诊断工具和未来的提示词/UI 消费方枚举。 注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层: @@ -36,15 +19,16 @@ interface SessionPersistence { - `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-home-paths`](../../../../packages/util/home-paths/README.zh.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。 - `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。 - 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。 -- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。 -会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 +transcript(文本记录)位置事实被有意省略。本决策的早期形式还在持久化 seam 上增加了 `locate()` 路径查询,为 `DSH_SESSION_JSONL` 变量和钩子桥接层的 `transcript_path` 提供来源;[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责移除这一半——这些路径只有在禁用压缩时才可读,该 seam 也不再公开产物位置。 + +需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `ShellExecRequest.dshEnv`/`ShellExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 -[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。 +[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.zh.md)为保持协议格式,仍在线上 payload 中保留 `transcript_path` 字段,但始终发送 `''`(Claude Code)/`null`(Codex);这项降级由[持久化导出与预发布精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md) Note 负责。 ## 同类产品调研 @@ -52,36 +36,30 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 生命周期与持久化语义 -新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。 - -恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 +新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID`。恢复操作复用已加载的 header,因此 id 不变。fork 和 spawn 会创建新的会话 id。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。注册表受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。 `dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-home-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。 ## 测试 -单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL 与无产物定位器约定测试、两套钩子桥接测试均固定 transcript 可用和不可用两种方言。 +单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、忽略模型 `env`,以及父子隔离。两套钩子桥接测试均锁定恒定的降级 transcript 方言。 -一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 +一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id 和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在,并最终检查持久化 header。快照测试会固定录制请求 header 中的通用 bash 说明。该约定属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。 ## 考虑过的替代方案 **只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。 -**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。 - **使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。 -**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。 - **使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。 -**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。 +**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。 **增加面向模型的 `session_info` 工具。** bash 已经提供查询 API,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 -该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 +该命名空间可被发现,但并非秘密。`DSH_HOME` 可能泄露配置的根目录,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 5a5c329383..e8023fd069 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: ddc093e1ce5e646f6b96f1517b2a26c9e10fe423 -2026-07-31-web-workspace-file-links.zh.md: f463de969bab7a47145abbbd46363b8807bcc673 +2026-07-31-web-workspace-file-links.md: 944d7bef9b83eb34895c5f74e511600a713d786b +2026-07-31-web-workspace-file-links.zh.md: 71f0051204e0cb8b65995fc88a9edb230db8f5be diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index ddc093e1ce..944d7bef9b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Decision -**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The single-line lane measures its chips and localized remainder, then shows the largest prefix that fits (up to six) plus `+ N files`. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. +**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. CSS container-width bands select a prefix of up to six paths and its matching `+ N files` label, while flexbox shrinks and ellipsizes the visible basenames; the component performs no JavaScript layout observation ([decision](../simplification/2026-09-01-css-produced-file-layout.md)). One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. @@ -28,9 +28,9 @@ Two distinct defects sat behind that. The transcript never said what a turn had - **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable). - **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `