Merge remote-tracking branch 'upstream/master' into fix/translation-brief-uncommitted-pair

This commit is contained in:
ZiyaZhang
2026-07-28 08:18:32 -07:00
456 changed files with 9213 additions and 5438 deletions

View File

@@ -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
2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812
2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda
2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a

View File

@@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising:
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 line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## Consequences

View File

@@ -29,7 +29,7 @@ Status: implemented
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
格式版本控制header 携带一个 `version``load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
格式版本控制header 携带一个 `version``load` 拒绝任何非当前版本预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
## 后果

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
2026-07-22-unified-send-and-coalesced-user-messages.md: 6936fbfa04c0fdaf1a8786c0465c193e9c285243
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3af14359fa01e92f63ae3b3e51dced9a97f6419f
2026-07-22-unified-send-and-coalesced-user-messages.md: ed171735cf483938c70291963a6e68dc02d7bde2
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 8b2a3ebabb493954e653255e876255b9c0810c19

View File

@@ -12,21 +12,21 @@ Separately, `context/message` and `user/message` had converged: the surface proj
## Decision
**One primitive, three preset aliases.** The `Agent` interface's `send(input, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its `UserMessageData` input owns the inseparable model-facing `content` and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one input and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller.
**One primitive, three preset aliases.** The `Agent` interface's `send(message, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its complete `UserMessage` owns identity, role, model-facing `content`, and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one message and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller.
**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessageData.source` preserves the caller's explicit provenance.
**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessage.source` preserves the caller's explicit provenance.
**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type.
**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree.
**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`.
**`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing.
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source. Enqueue separately carries the resolved `queued | steering` placement captured by the producer at acceptance time, so observers and reconnect mirrors never reconstruct routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) carry the accepted `UserMessage`. Enqueue and dequeue also carry the resolved `queued | steering` placement captured at acceptance, so observers and reconnect mirrors retire repeated message identities from the correct FIFO without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate.
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. The loop clones and freezes `UserMessageData` before publication, queueing, or immediate append, so later caller or observer mutation cannot change the accepted value. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy.
**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use the identified, frozen `UserMessage` directly. The loop stores that value beside private routing state rather than copying its identity, content, or source into another public shape. A queued message that becomes steering keeps the same message value in the outbox, while injected and tool-produced context each carry their own identified message. The [identified immutable message decision](2026-07-28-identified-immutable-message-values.md) supersedes this note's former `UserMessageData`/`AgentMessage` hierarchy and extends the representation to assistant and tool-result messages.
**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second.
@@ -35,7 +35,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
## Alternatives considered
- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly.
- **A typed discriminant field on `UserMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
- **A typed discriminant field on `UserMessage`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision.
@@ -50,3 +50,4 @@ The delivery surface is now one primitive plus three self-documenting presets, a
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on.
- [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event.
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends.
- [identified immutable message values](2026-07-28-identified-immutable-message-values.md) — the message identity and representation contract that now underlies this routing decision.

View File

@@ -12,21 +12,21 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
## 决策
**一个原语,三个预设别名。** `Agent` 接口的 `send(input, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。 `UserMessageData` 输入持有不可分割的模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup``next-turn`/wakeup`steer``next-step`/wakeup`inject``next-step`/no-wakeup都接收这一项输入并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup入队但不唤醒可以表达只是没有别名也没有当前调用方。
**一个原语,三个预设别名。** `Agent` 接口的 `send(message, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。完整的 `UserMessage` 持有标识、角色、模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup``next-turn`/wakeup`steer``next-step`/wakeup`inject``next-step`/no-wakeup都接收这一条消息并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup入队但不唤醒可以表达只是没有别名也没有当前调用方。
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessageData.source` 会保留调用方显式提供的来源信息。
**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessage.source` 会保留调用方显式提供的来源信息。
**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。
**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。
**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId``send` 此前的返回值是 `void`
**`send` 返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard``cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源。enqueue 还会单独携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像永远不必根据后续状态或会话历史重建路由。注入从不触及 FIFO也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数dequeue 和 discard 永远无法把它压到负数。
**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard``cancel()` 丢弃了待处理项)都会携带已接受的 `UserMessage`。enqueue 和 dequeue 还会携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像可以从正确的 FIFO 中结算重复出现的消息标识,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数dequeue 和 discard 永远无法把它压到负数。
**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。循环会在发布、入队或立即追加前克隆并冻结 `UserMessageData`,因此调用方或观察方后续的修改无法改变已接受的值。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级
**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用带标识且冻结的 `UserMessage`。循环把该值与私有路由状态存放在一起,不会将其标识、内容或来源复制到另一种公开形状中。一条成为 steering 的排队消息会在 outbox 中保留同一个消息值,而注入和工具产生的上下文则各自携带带标识的消息。[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)取代了本记录此前的 `UserMessageData`/`AgentMessage` 层级,并将这一表示扩展到 assistant 消息和工具结果消息
**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。
@@ -35,7 +35,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
## 考虑过的替代方案
- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。
- **在 `UserMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它goal 系统也已经以它为键;第二个判别字段会重复这一事实。
- **在 `UserMessage` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它goal 系统也已经以它为键;第二个判别字段会重复这一事实。
- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。
- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。
@@ -50,3 +50,4 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。
- [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。
- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。
- [带标识的不可变消息值](2026-07-28-identified-immutable-message-values.md)——本路由决策现在所依托的消息标识与表示契约。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md
2026-07-24-separate-context-injection-from-turn-execution.md: d44ef5afc8c376790192998bcf3069ceb651ae82
2026-07-24-separate-context-injection-from-turn-execution.zh.md: ba561b587effb278a67844ce4d876da9cd940e94
2026-07-24-separate-context-injection-from-turn-execution.md: bf3ae2ecbd2205a4c49e8004ffc694f89a2460a3
2026-07-24-separate-context-injection-from-turn-execution.zh.md: a805eb651c5c77f3d37c92dacd116bb41f154ed7

View File

@@ -18,7 +18,7 @@ Idle `inject()` exposed a second mismatch. Injection did not request model execu
`inject()` is the only caller-facing operation for supplementary model-facing input, and a turn means one execution of the model loop.
`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers `UserMessageData` through `inject()` and submits the direct message independently with `send()` or `steer()`.
`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers an identified, frozen `UserMessage` through `inject()` and submits the direct message independently with `send()` or `steer()`.
Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results.
@@ -59,7 +59,7 @@ This decision preserves the caller-owned framing decision from [unwrapped inject
## Verification
- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement.
- `UserMessageData` is the shared shape across prompt interception, tool execution, hook bridges, guards, and context producers.
- `UserMessage` is the shared identified, frozen shape across prompt interception, tool execution, hook bridges, guards, and context producers.
- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
- Idle `inject()` appends one sourced `user/message` without a turn or model call.
- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them.

View File

@@ -18,7 +18,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
`inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。
`SendOptions` 只包含 `target``wakeup`。拥有上下文的调用方通过 `inject()` 交付 `UserMessageData`,再独立使用 `send()``steer()` 提交直接消息。
`SendOptions` 只包含 `target``wakeup`。拥有上下文的调用方通过 `inject()` 交付带标识且冻结的 `UserMessage`,再独立使用 `send()``steer()` 提交直接消息。
提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。
@@ -59,7 +59,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入:
## 验证
- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。
- `UserMessageData` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的形状。
- `UserMessage` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的带标识且冻结的形状。
- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`
- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`
- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。

View File

@@ -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-07-28-identified-immutable-message-values.md
2026-07-28-identified-immutable-message-values.md: cdb0f1aadc4796b5aa0642a3994d3e3e4ab67bd9
2026-07-28-identified-immutable-message-values.zh.md: 3e1732cb5b7f49fb9349b2e1790cf5b3ec1474be

View File

@@ -0,0 +1,50 @@
# Agent Note: Create every message as an identified immutable value
Status: implemented
English | [中文](2026-07-28-identified-immutable-message-values.zh.md)
## Problem
The harness had several message-shaped representations with different identity rules. Agent input acquired an inbox correlation id only when the loop accepted it, while durable user messages, assistant messages, tool results, and model-request messages could have no identity. Prompt admission therefore sat between creation and identity, and equivalent content was copied across live events, durable events, and model requests without one value that named the message throughout its lifetime.
This made identity a routing side effect rather than a message invariant. Producers could not refer to a message before calling the agent, prompt hooks received content and source separately, and later projections had to reconstruct a message while deciding whether an id existed. Immutability also began at different boundaries: some inputs were frozen by the loop, some only by session append, and provider-produced assistant output used a separate provenance-bearing shape.
## Decision
`@deepseek-ai/dsh-llm` owns one `Message` value with required `id`, `role`, `content`, and `source`. `MessageId` is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before routing, prompt admission, durable append, or request projection. The same id survives every representation boundary.
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content and model provenance. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
The `Agent` interface accepts a complete `UserMessage`. `send`, `followup`, `steer`, and `inject` never allocate or return identity; they freeze an imported value whose id the caller already holds. Prompt admission receives that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
Durable message-producing events store complete messages. `user/message` stores its `UserMessage` directly; `assistant/message`, `tool/result`, and `steering/message` wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed.
Any operation that changes only the representation of an existing semantic message preserves its id and returns another frozen value. An operation that creates a new semantic message mints a new id. Compaction content rewrites therefore preserve the rewritten tool-result identity, while a summary checkpoint is a new message.
## Alternatives considered
**Keep ids optional on the base message.** This would minimize fixture migration and allow provider or persistence shapes to remain anonymous. It would also preserve the original ambiguity: every consumer would need to branch on whether identity exists, and no type would prove that admission, logging, or projection retained it.
**Let `Agent.send()` allocate the id.** This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before `send()` returns.
**Let each durable event allocate a new id.** This gives persisted messages identities but deliberately breaks correlation with the live input and makes replayed requests appear to contain different messages. Identity belongs to the semantic value, not to each envelope that carries it.
**Freeze only at agent or session admission.** This avoids a creation helper but leaves an identified mutable interval in which caller code can change the meaning associated with an id. The decision makes “has an id” and “is an immutable snapshot” coincide.
## Consequences
Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use `freezeMessage()` instead of `createMessage()`.
Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Prompt admission and UI attachment cleanup can compare `MessageId` before a turn exists. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established.
The shared representation removes the old `UserMessageData`/`AgentMessage` split and folds provider provenance into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata.
The message and helper unit tests pin immediate identity, detachment, deep immutability, and preservation of an imported id. Agent-loop tests pin identity across admission, inbox lifecycle, durable append, content rewriting, and cancellation; session tests pin frozen derivation and identity-preserving replacement.
## Related
- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](2026-07-22-unified-send-and-coalesced-user-messages.md) — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision.
- [Reconstructable requests](2026-07-05-reconstructable-requests.md) — the session log remains the authority for every model-visible input.

View File

@@ -0,0 +1,50 @@
# Agent Note: 将每条消息创建为带标识的不可变值
Status: implemented
[English](2026-07-28-identified-immutable-message-values.md) | 中文
## 问题
harness 曾存在多种形似消息的表示各自采用不同的标识规则。agent智能体输入只有在 loop 接受后才会取得 inbox 关联 id而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。
这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 loop 冻结,部分直到会话追加时才冻结,提供方产生的 assistant 输出则使用另一种携带溯源信息的形状。
## 决策
`@deepseek-ai/dsh-llm` 持有唯一一种 `Message` 值,其 `id``role``content``source` 均为必填。`MessageId` 是不透明标识由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id早于路由、提示词准入、持久追加或请求投影。同一个 id 会跨越每个表示边界。
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将输入的角色、内容和来源与输入分离,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容和模型溯源信息。所有创建辅助函数的输入都不包含 id因此调用方不会意外地把创建表达为导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与输入分离并深度冻结,不会生成替代标识。
这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整契约只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它使用同一个工具调用 id将工具来源与确切的 user-role 工具结果块耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
`Agent` 接口接收完整的 `UserMessage``send``followup``steer``inject` 绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。提示词准入会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
产生持久消息的事件会存储完整消息。`user/message` 直接存储其 `UserMessage``assistant/message``tool/result``steering/message` 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。
仅改变已有语义消息表示的操作会保留其 id并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此压缩compaction内容改写会保留被改写工具结果的标识而摘要检查点是一条新消息。
## 考虑过的替代方案
**让基础消息的 id 保持可选。** 这能减少 fixture测试前置数据迁移并允许提供方或持久化形状继续保持匿名但也会保留原有歧义每个消费方都必须根据标识是否存在执行分支且没有任何类型能证明准入、记录或投影保留了标识。
**让 `Agent.send()` 分配 id。** 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在 `send()` 返回前提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。
**让每个持久事件分配新 id。** 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。
**只在 agent 或会话准入时冻结。** 这能省去创建辅助函数,却会留下一个带标识但可变的时间区间,调用方代码可以在这段时间内改变该 id 所关联的含义。本决策让「拥有 id」与「是不可变快照」同时成立。
## 后果
每个消息生产方都必须显式选择创建或导入测试也会构造完整值而不是不完整的内容来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 `freezeMessage()`,而不是 `createMessage()`
实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。提示词准入和 UI 附件清理可以在轮次存在之前比较 `MessageId`。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。
共享表示移除了旧的 `UserMessageData`/`AgentMessage` 划分并将提供方溯源信息纳入带类型的消息来源。事件封装仍持有不属于消息语义的事实例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。
消息和辅助函数的单元测试会固定即时标识、输入分离、深度不可变性,以及导入 id 的保留。agent loop 测试会固定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会固定冻结派生和保留标识的替换行为。
## 相关
- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](2026-07-22-unified-send-and-coalesced-user-messages.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。
- [可重建的请求](2026-07-05-reconstructable-requests.md)——会话日志仍是每项模型可见输入的权威来源。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md
2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41
2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3

View File

@@ -0,0 +1,38 @@
# 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 and SQLite sessions still held the immediately preceding shapes: 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-shape 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 backends without weakening validation for unrelated obsolete or malformed events.
## Decision
`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message:<session-id>:<event-seq>` 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.
The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths.
The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction.
## Alternatives considered
**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation.
**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system.
**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 and SQLite sessions resume with their original message content, sources, provider provenance, 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 same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload 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.

View File

@@ -0,0 +1,38 @@
# Agent Note: 加载消息标识机制引入前持久化的会话
Status: implemented
[English](2026-07-28-load-pre-identity-session-messages.md) | 中文
## 问题
带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering中途引导事件直接携带 `content`/`source`assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`
消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。
## 决策
`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId``legacy-message:<session-id>:<event-seq>`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id从而保持当前仅改写内容的不变量。
同一项规范化也用于 `load``inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR热模块替换前缀接管。因此前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。
这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。
## 考虑过的替代方案
**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。
**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。
## 后果
消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外必须在持久化边界提供另一套完整且无歧义的映射当前数据若格式错误系统仍会拒绝而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
## 相关
- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。
- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。

View File

@@ -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
2026-06-29-todo-write-tool.md: 760373d64f462e3717a174d5793e6d47ab76b0b4
2026-06-29-todo-write-tool.zh.md: fd29e6049e6a729c2c7e78860ad7c065422da60e
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-29-todo-write-tool.md
2026-06-29-todo-write-tool.md: 288932f641a37c13ea6beeb069ac360c4a8447c1
2026-06-29-todo-write-tool.zh.md: 03dae6328e5c7f4379694ab01db3434b4469826c

View File

@@ -18,7 +18,7 @@ The model sends the entire list every call; the new list replaces the old (last-
### State on the session log, not a service
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).)
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the standing plan from the latest `todo/write` that is not followed by a later `turn/start` ([plan strip lifetime](2026-07-28-todo-plan-clears-on-next-turn.md)), with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).)
### NOT a surface event

View File

@@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结
### 状态在会话日志上,而非服务
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM大语言模型历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。)
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM大语言模型历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从「其后没有更晚 `turn/start`」的最近一次 `todo/write` 重新推导站立计划([计划条生命周期](2026-07-28-todo-plan-clears-on-next-turn.md),无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。)
### 不是 surface 事件

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md
2026-07-21-cross-session-references.md: a6c7c3b3e89e032d6d807a25a7661a875f9f7bec
2026-07-21-cross-session-references.zh.md: ef78cd7ecee3e2625b446139d7f22e16d0bf1dfa
2026-07-21-cross-session-references.md: 61ea30cabb2abf4d4a9b4391891b5987affb91d0
2026-07-21-cross-session-references.zh.md: 6fba44103942d29428fd820591815743dfb5d96d

View File

@@ -10,7 +10,7 @@ TUI users need to bring relevant work from another conversation into one new mes
## Decision
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional sourced `UserMessageData` snapshot; core agent packages do not parse session URIs or read another log.
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log.
`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.

View File

@@ -10,7 +10,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但
## 决策
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带来源信息`UserMessageData` 快照;核心 agent 包既不解析会话 URI也不读取其他日志。
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结`UserMessage` 快照;核心 agent 包既不解析会话 URI也不读取其他日志。
`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d
2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be
2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d
2026-07-23-web-todo-display.zh.md: ac773a3c587183aa9a152caff2812686bc79f3c7

View File

@@ -14,7 +14,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
### Side-effect channel, converging with window replay
`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without it), which the host answers with the projection or omits it only when the full log holds no `todo/write` — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.
`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins) and clears on `turn/start` ([turn-scoped plan lifetime](2026-07-28-todo-plan-clears-on-next-turn.md)). `rebuildDerivedFromWindow` sweeps the window from an empty plan and restores the tail-page seed only when the window never determined the plan (no `todo/write` and no `turn/start`); otherwise the in-window write/`turn/start` fold wins. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without reseeding), which the host answers with the projection or omits it when no plan stands — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing.
### TodoPanel: the durable list as a persistent strip
@@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`
## Consequences
Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection.
Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel shares the same turn-scoped lifetime (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection.

View File

@@ -14,7 +14,7 @@ Status: implemented
### 副作用通道,与窗口回放收敛
`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——取自尾页 history 携带的全量 log 投影——而任意窗口未必包含最近一次写入,因此往前翻页保留它,只有窗口内或实时的写入才会覆盖`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap``loadOlder` 只往前拼接、不走它),而 host 对尾页请求要么带上投影、要么仅在全量 log 没有任何 `todo/write` 时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),并在 `turn/start` 清空([按 turn 界定的计划生命周期](2026-07-28-todo-plan-clears-on-next-turn.md))。`rebuildDerivedFromWindow` 从空计划扫过窗口,仅当窗口从未判定计划(无 `todo/write` 且无 `turn/start`)时恢复尾页种子;否则以窗口内写入/`turn/start` 折叠为准`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap``loadOlder` 只往前拼接、不再播种),而 host 对尾页请求要么带上投影、要么在无站立计划时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。
### TodoPanel长驻列表作为一条常驻横条
@@ -33,4 +33,4 @@ Status: implemented
## Consequences
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致fx-alpha 第 65 轮的 fixture测试前置数据加 assembled keyless snapshot`apps/web/tests/todo-display.snapshot.ts`在构建产物客户端全图上钉住整条链行摘要与状态、dock 面板内容、折叠往返)。`todos``ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板未受改动(自动化专用的 ACP 桥接刻意不做 todo 呈现Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。
回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致fx-alpha 第 65 轮的 fixture测试前置数据加 assembled keyless snapshot`apps/web/tests/todo-display.snapshot.ts`在构建产物客户端全图上钉住整条链行摘要与状态、dock 面板内容、折叠往返)。`todos``ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按 turn 界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底history 尾页附带 `todos`——全量 log 上的站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍站立且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。

View File

@@ -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/feature/2026-07-28-todo-plan-clears-on-next-turn.md
2026-07-28-todo-plan-clears-on-next-turn.md: 8a2808f5023a0800fbbeb65c5e28b3495e051dbc
2026-07-28-todo-plan-clears-on-next-turn.zh.md: 6ea6b2b4fafe4b2f695ad2d54561f164747e5694

View File

@@ -0,0 +1,31 @@
# Agent Note: Todo plan strip clears on the next turn
Status: implemented
English | [中文](2026-07-28-todo-plan-clears-on-next-turn.zh.md)
## Problem
`todo_write` stores whole-list snapshots on the session log, and interactive hosts render the latest list as a plan strip (web TodoPanel via the `todos` projection, TUI Plan panel). After a turn finished, that strip stayed on screen into the next user turn — a completed or abandoned checklist from the previous task. Readers treat the strip as "what this turn is doing," so a stale list across the turn boundary is the wrong product lifetime. The [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md) notes still own event-sourcing and the two render surfaces; they described the standing plan as lasting for the whole session until the next write.
## Decision
The standing plan is the latest `todo/write` that is not followed by a later `turn/start`. `turn/end` keeps the list visible so the finished checklist remains while the user reads the answer; the next `turn/start` clears it until the model writes again.
### Host projection (web)
`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Carriers (`dsh-host-apiproxy`) serve that value on the history tail `projections` block and push `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots.
### TUI live path
The TUI `renderEvent` switch still clears its local plan panel on `turn/start` and replaces it on `todo/write` (TUI is not yet a projection carrier). The rebuild path resets the panel before replaying the log so cold resume converges on the same rule.
## Alternatives considered
- **Clear on `turn/end`** — hides the checklist while the user is still reading the just-finished answer; the strip's job at that moment is the completed plan, not an empty dock.
- **Clear only when every item is `completed`** — leaves abandoned or partial plans across turns; the strip would still show another task's work.
- **Append an empty `todo/write` on turn start** — mutates the log for a UI lifetime rule and invents a write the model never authored.
## Consequences
The host projection and the TUI panel share one lifetime rule; reopening a session restores a plan only when no later turn has started. Partial supersession of the session-long standing-plan wording in [web todo display](2026-07-23-web-todo-display.md) and [`todo_write` tool](2026-06-29-todo-write-tool.md): event-sourcing, last-write-wins replacement, and the two render surfaces stay there; this note owns turn-boundary clearance. Coverage: tool-todo projection specs for turn/start clear + turn/end keep, fixture push-frame clearance for the assembled web snapshot, plus the TUI snapshot that starts the next turn and pins the strip gone.

View File

@@ -0,0 +1,31 @@
# Agent Note: 下一轮开始时清空 todo 计划条
Status: implemented
[English](2026-07-28-todo-plan-clears-on-next-turn.md) | 中文
## 问题
`todo_write` 在会话日志中存储整表快照交互式宿主把最新列表渲染为计划条web TodoPanel 经 `todos` 投影TUI Plan 面板)。一轮结束后,该条仍留在下一用户轮次的屏幕上——上一任务已完成或已放弃的清单。读者把计划条理解为「本轮正在做什么」,因此跨轮次的陈旧列表是错误的产品生命周期。[web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)笔记仍拥有事件溯源与两个渲染面;它们把站立计划描述为持续整段会话直至下一次写入。
## 决策
站立计划是其后没有更晚 `turn/start` 的最近一次 `todo/write``turn/end` 保留列表可见,以便用户阅读回答时仍能看到刚完成的清单;下一次 `turn/start` 将其清空,直至模型再次写入。
### Host 投影web
`dsh-tool-todo``todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取整表,并在每个 `turn/start` 返回 `null``stateVersion` 2。载体`dsh-host-apiproxy`)在历史尾页的 `projections` 块与 `session/projection` 推送帧上供给该值web dock 经 `useProjection('todos')` 读取。无密钥 fixture测试前置数据镜像同一折叠供组装后的 snapshot 使用。
### TUI 实时路径
TUI 的 `renderEvent` 分支仍在 `turn/start` 清空本地计划面板、在 `todo/write` 替换之TUI 尚非投影载体)。重建路径在回放日志前重置面板,使冷恢复收敛到同一规则。
## 考虑过的替代方案
- **在 `turn/end` 清空**——用户仍在阅读刚完成的回答时就隐藏清单;此时计划条的职责是已完成计划,而非空 dock。
- **仅在全部项为 `completed` 时清空**——会让放弃或部分完成的计划跨轮残留;计划条仍会显示另一任务的工作。
- **在 turn start 追加空的 `todo/write`**——为 UI 生命周期规则改写日志,并捏造模型从未写出的写入。
## 后果
Host 投影与 TUI 面板共用同一生命周期规则;重新打开会话仅在其后没有更晚轮次开始时恢复计划。部分取代 [web todo 展示](2026-07-23-web-todo-display.md)与 [`todo_write` 工具](2026-06-29-todo-write-tool.md)中「会话级站立计划」的表述事件溯源、last-write-wins 替换与两个渲染面仍归那些笔记本笔记拥有轮次边界清空。覆盖tool-todo 投影对 turn/start 清空与 turn/end 保留的规格测试、供组装 web snapshot 的 fixture 推送帧清空,以及启动下一轮并钉住计划条消失的 TUI snapshot。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md
2026-07-24-agent-loop-observable-state-machine.md: 54730de8aa73342b609d423dc478edb076d7844b
2026-07-24-agent-loop-observable-state-machine.zh.md: 206ac701472f14823300df0c812f2cc818f852f5
2026-07-24-agent-loop-observable-state-machine.md: a25657c6a41e2c0989db620046f44ea3254be151
2026-07-24-agent-loop-observable-state-machine.zh.md: 058d89d3cb9e3d30963f95fda1510ef3c5bf281e

View File

@@ -18,7 +18,7 @@ The public contract exposes four orthogonal state dimensions:
- Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`.
- Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval.
- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`, correlated by `AgentMessageId`. The inbox events describe acceptance, claim, and removal rather than turn completion.
- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`. Enqueue and dequeue correlate an occurrence by `MessageId` plus its queued-or-steering placement; same-placement repeats retire in FIFO order. The inbox events describe acceptance, claim, and removal rather than turn completion.
- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`.
The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle.
@@ -47,7 +47,7 @@ Plugins no longer rewrite every phase of the loop. There is no request-only mess
Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and return an explicit retry action. This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary.
The inbox lifecycle complements, rather than replaces, the durable session log. `AgentMessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.
The inbox lifecycle complements, rather than replaces, the durable session log. `MessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.
## Related

View File

@@ -18,10 +18,10 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及
- 注册生命周期是从 `agent/created``agent/disposed` 的区间。dispose资源释放是注册表的终止边界而不是一种 `AgentStatus`
- agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。
- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue``agent/inbox/discard` 二者之一,并通过 `AgentMessageId` 关联。收件箱事件描述接受、领取和移除,而不是轮次完成。
- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue``agent/inbox/discard` 二者之一。enqueue 与 dequeue 通过 `MessageId` 加 queued 或 steering中途引导放置方式关联一次消息出现放置方式相同的重复项按 FIFO 顺序结算。收件箱事件描述接受、领取和移除,而不是轮次完成。
- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`
循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering(中途引导),循环在所有监听器完成后根据这份数据作出决定。
循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering循环在所有监听器完成后根据这份数据作出决定。
是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。
@@ -47,7 +47,7 @@ agent 生命周期、agent 整体活动状态、收件箱条目的进度以及
负责继续执行的插件发布可持久化的 steering而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误并返回显式重试动作。这样每次尝试都会成为完整轮次同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。
收件箱生命周期用于补充持久会话日志,而非取代它。`AgentMessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。
收件箱生命周期用于补充持久会话日志,而非取代它。`MessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。
## 相关内容

View File

@@ -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
2026-06-19-acp-snapshot-tests.md: 430441e633af1e487f19272900360a8ed2f595c9
2026-06-19-acp-snapshot-tests.zh.md: 243e431567b45d69e1fe16dda5d07ec058403b7c
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db
2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9

View File

@@ -53,7 +53,7 @@ Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with
A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct:
1. The **stdout transcript** — the framed ACP JSON-RPC responses and committed-message updates an automation client receives. It catches regressions in the transport contract and is compared against a committed `stdout.expected.jsonl`.
2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar.
2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt and tool bulk are scrubbed; one scenario per header class pins the remaining header sequence. The pin owns readable prompt and tool-schema sidecars by default, or names another pin as either source when the complete sequence is identical, so each distinct sidecar version is committed once. Fixture guards reject duplicate sidecar content, and record/refresh rejects shared claimants that generate different bytes. The original header-pinning rationale is preserved in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar.
The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits.
@@ -76,9 +76,10 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log
- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output.
- **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test.
- **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam.
- **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content.
## Consequences
The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it).
The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it).
This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP.

View File

@@ -53,7 +53,7 @@ Status: implemented
快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的:
1. **stdout transcript**——自动化客户端收到的、经过 framing 的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema sidecar当完整的对应序列相同时也可将另一个 pin 指定为其中任一来源,因此每个不同的 sidecar 版本只提交一次。fixture 保护会拒绝重复的 sidecar 内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)。Override 场景仅从其 sidecar 派生模型行为。
两个表面互补stdout 覆盖精简的自动化线协议JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。
@@ -76,9 +76,10 @@ Status: implemented
- **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock并让它同时充当行为预期输出。
- **字节级 HTTP 录制库Polly/nock/MSW**:否决。与适配器耦合,处理流式 SSEServer-Sent Events时笨拙且层级低于被测对象。
- **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error显式的 `replay.override.json` 伴随文件是更清晰的 seam。
- **在每个类别 pin 旁复制两个请求头 sidecar**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。
## 后果
该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。
该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。
本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117
2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1
2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db

View File

@@ -51,7 +51,7 @@ declare module 'cordis' {
- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
- The package owns `./invariant` (every served key has a live registration).
@@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
## Alternatives considered

View File

@@ -51,7 +51,7 @@ declare module 'cordis' {
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通host 侧单元、协议块、React 钩子)——没有第二张 DTO 表也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
- **host 是投影唯一的计算地点。** 框架正向驱动eager drive每个已注册的单元每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧。这消除了双重实现陷阱plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存persisted projection cache**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题变成一次索引读至多外加一小段尾部回放session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach由活转冷的时刻。两次写入之间崩溃的代价是尾部回放更长一些绝不会是值出错。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存persisted projection cache**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion``seq` = 水位线,`val` = 状态 JSON。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题变成一次索引读至多外加一小段尾部回放session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach由活转冷的时刻。两次写入之间崩溃的代价是尾部回放更长一些绝不会是值出错。
- 领域的输入事件集由领域自己选择todos 只折叠 `todo/write`plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
- 注册是 effectdisposer 随 fiber 走):插件卸载后其 key 从后续响应中消失客户端将其读作能力缺失——HMR热模块替换语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `
2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1在此之前 fixture测试前置数据喂合成帧
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
4. **领域重新对接**(在 1+2 之后):先 todo单元进 `tool-todo`,删掉搭载字段),再 plan双事件单元、RPC 下线、开关改发 `/plan`),最后 goal`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
## Alternatives considered

View File

@@ -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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847

View File

@@ -0,0 +1,57 @@
# Agent Note: Storage root placement and derived-medium recovery
Status: proposed
English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
## Problem
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
## Proposal
Two independent changes, one per gap.
### One global storage root, resolved once
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')``~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
### Declared derived media: reset instead of reject
- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default.
- `KvFacet` gains one primitive: `destroy(descriptor): Promise<void>` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy.
- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.
- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
## Alternatives considered
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
**Rename the damaged medium aside (`<unit>.json.corrupt-<ts>`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec.
**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
## Acceptance criteria
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
- The same damage to `workspace.json` still fails boot loudly.
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
## Risks
- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content.
- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.

View File

@@ -0,0 +1,57 @@
# Agent Note存储根目录落点与派生介质恢复
Status: proposed
[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文
## Problem
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`apps/cli/cordis.yml——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()`packages/storage/storage-json/src/index.ts——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts。净效果会话日志跨启动目录全局共享`workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit``malformed-medium`/`version-mismatch` 失败packages/storage/storage-json/src/format.tsschema 漂移的记录让域 open 以 `invalid-record` 失败packages/storage/storage-domain/src/index.ts拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc"version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
## Proposal
两个独立改动,一个缺口一个。
### 全局唯一存储根,构造时 resolve 一次
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json``root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
### 声明派生介质:损坏时重置而非拒绝
- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'``workspace` 保持默认。
- `KvFacet` 增加一个原语:`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质json删文件sqlitedrop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。
- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')``DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found``facet-unsupported``already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。
- 有了这个,缓存域 spec 的 version 字段才获得其本意bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
## Alternatives considered
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catchfacility 是唯一已经在分类 open 失败的地方。
**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
**把损坏介质改名旁置(`<unit>.json.corrupt-<ts>`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。
**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
## Acceptance criteria
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
## Risks
- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解重置只在三个确定性解析期代码上触发ENOENT 本来就是「空 unit」,一切 I/O 错误EACCES、EIO大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。