Lifecycle-bound message feedback sidecar
绑定生命周期的消息反馈伴随记录
The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, r
English
Problem
The existing /feedback command records an immutable Session-level feedback/record event. That event can release a pending telemetry prefix under FEEDBACK_ONLY, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching model context, or implicitly consenting to telemetry.
A sidecar keyed only by SessionId can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade.
Decision
@deepseek-ai/dsh-message-feedback owns the ctx.messageFeedback service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no feedback/record event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent.
Every usable row is bound to the inspected Session header identity {createdAt, cwd}, not merely its SessionId. A lifecycle mismatch is treated as absence: list returns no items, and put may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it.
put accepts a target only when SessionPersistence.inspect() observes a non-empty, append-origin assistant/message with that MessageId. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold listSnapshots() preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return session-not-found, and the caller retries after retirement materialization.
Before put commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical ctx.sessions.flush checkpoint, then both live and cold paths are physically read from sequence zero through SessionPersistence.readFrom. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
Each message item carries its own opaque version plus Host-assigned createdAt and updatedAt timestamps. put compares the caller's ifVersion only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves createdAt, replaces the version, and keeps updatedAt from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
maxNoteBytes is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to 8192. The package publishes the Host messageFeedback.list, messageFeedback.put, and messageFeedback.delete contract directly through TypertRemoteService and @Remote. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract.
The service performs no fake deletion cascade. session/disposed and host/session-removed describe detach from live ownership, not durable Session deletion, and Session persistence currently has no deletion API. Sidecar rows can therefore remain after out-of-band log removal; a different {createdAt, cwd} prevents such an orphan from becoming feedback for a later Session that reuses the id.
Alternatives considered
Append edits to the Session log and derive a projection. Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing feedback/record would silently couple a message rating to telemetry consent.
Key feedback globally by MessageId, copy it on fork, or use one Session revision. Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts.
Extend KvTable with cross-process compare-and-swap in this change. Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work.
Delete feedback on Session disposal. Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority.
Consequences
Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same {createdAt, cwd} is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics.
中文
问题
现有 /feedback 命令记录不可变的 Session 级 feedback/record 事件。在 FEEDBACK_ONLY 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型上下文,或隐式表示遥测同意。
只按 SessionId 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。
决策
@deepseek-ai/dsh-message-feedback 拥有 ctx.messageFeedback 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 feedback/record 事件,也不执行遥测交接;command-feedback 与 message-feedback 约定保持独立。
每条可用记录都绑定到经检查的 Session header 身份 {createdAt, cwd},而不只是其 SessionId。生命周期不匹配按不存在处理:list 返回空条目,put 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。
put 只接受由 SessionPersistence.inspect() 观测到的非空、append-origin assistant/message,且其 MessageId 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 listSnapshots() 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 session-not-found,调用方在 retirement materialization 后重试。
put 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 ctx.sessions.flush checkpoint,随后 live 与 cold 路径都会通过 SessionPersistence.readFrom 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
每个消息条目都携带自己的 opaque version,以及 Host 分配的 createdAt 和 updatedAt 时间戳。put 只把调用方的 ifVersion 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 createdAt、替换 version,并保证 updatedAt 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 约定。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
maxNoteBytes 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 8192。该包通过 TypertRemoteService 与 @Remote 直接发布 Host messageFeedback.list、messageFeedback.put 与 messageFeedback.delete 约定。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 约定的薄消费者。
服务不伪造删除级联。session/disposed 与 host/session-removed 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 {createdAt, cwd} 可阻止此类遗留记录变成后来复用该 id 的 Session 反馈。
考虑过的替代方案
把编辑追加到 Session 日志并派生投影。 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 feedback/record 会把消息评分与遥测同意静默耦合。
按全局 MessageId 建索引、在 fork 时复制,或使用一个 Session revision。 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。
在本次变更中为 KvTable 扩展跨进程 compare-and-swap。 不予采纳,因为现有 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子约定,属于独立工作。
在 Session disposal 时删除反馈。 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。
后果
消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本约定无法区分保留相同 {createdAt, cwd} 的克隆日志。Host Remote 约定现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。