会话持久化

把会话的事件日志落到磁盘,并在进程崩溃后把"半截的最后一个 turn"补成可重放的形状。 稳定契约、通用 Coordinator 与物理 Provider 分属独立程序集;Consumer 不需要知道事件最终写进 JSONL 还是 SQLite。

一个 Coordinator,两个可替换 Provider

Tether.Session.Persistence 只保留稳定接口、header/result DTO、格式版本与异常; Tether.Session.Persistence.Coordinator 统一拥有 live capture、write-behind、repair 与 writer 生命周期; .Jsonl 和 .Sqlite 只实现物理 event store。base/headless/CLI 默认选 JSONL, SQLite patch 只替换 session-store 这一行,不替换 Coordinator 或 Consumer。

三层职责不能互相渗透

层稳定入口拥有的职责
Service DefinitionISessionPersistence、ISessionEventStoreprovider-neutral DTO、格式版本、稳定异常
CoordinatorSessionPersistenceCoordinatorSession event capture、per-session write-behind、flush/final drain、header/seq/prefix 校验、inspect/load/prepare、synthetic closers、writer ownership 与 publication fence
ProviderJsonlSessionEventStore(唯一 first-party 实现)原子 batch、物理 codec、range/list、tail revision fence、进程级 lease 与 durability

Provider 不订阅 Session event,也不构造 Session 或 Agent。Coordinator 不读取文件路径、SQLite transaction 或 zstd frame。这个分界让 composition 只换 store,而所有调用方继续依赖同一个 ISessionPersistence。

Consumer-facing ISessionPersistence

public interface ISessionPersistence : IAsyncDisposable
{
    ValueTask CreateAsync(SessionHeader header, CancellationToken ct = default);
    ValueTask AppendAsync(SessionId id, IReadOnlyList<SessionEvent> events, CancellationToken ct = default);
    ValueTask FlushAsync(SessionId? id = null, CancellationToken ct = default);
    ValueTask AttachAsync(ISession session, CancellationToken ct = default);
    ValueTask AttachAsync(ISession session, SessionHeader header, CancellationToken ct = default);
    ValueTask RetireAsync(ISession session, CancellationToken ct = default);
    ValueTask<SessionInspection> InspectAsync(SessionId id, CancellationToken ct = default);
    ValueTask<SessionInspection> LoadAsync(SessionId id, CancellationToken ct = default);
    ValueTask<SessionPreparation> PrepareAsync(SessionId id, CancellationToken ct = default);
    ValueTask<SessionInspection> ReadFromAsync(SessionId id, long fromSeq, CancellationToken ct = default);
    ValueTask<IReadOnlyList<SessionHeader>> ListAsync(CancellationToken ct = default);
}

ISessionPersistence 是 Consumer 看到的协调面。它不发布 Agent;冷恢复时 PrepareAsync 只返回尚未发布的 SessionPreparation,最终 publication 仍由 Agent 注册表拥有。

不带 header 的 AttachAsync(session) 用于正常 composition:它先读取 durable store,只有确实收到 SessionPersistenceMissingException 才为新 Session 采用生命周期内唯一的 pending header。带完整 header 的 overload 用于 prepare/resume 与显式 identity fence;两条路径都不会把 I/O、corruption、unsupported format 或 writer conflict 当作 missing。

Provider-facing ISessionEventStore

public interface ISessionEventStore : IAsyncDisposable
{
    ValueTask AppendAsync(
        SessionHeader header,
        SessionLogOffset expectedNextOffset,
        IReadOnlyList<SessionEvent> events,
        CancellationToken ct = default);
    ValueTask<SessionStoreReadResult> ReadAsync(
        SessionId id,
        SessionLogOffset fromOffset = default,
        CancellationToken ct = default);
    ValueTask RepairTailAsync(
        SessionId id,
        SessionStoreRevision expectedRevision,
        SessionLogOffset expectedNextOffset,
        CancellationToken ct = default);
    ValueTask<IReadOnlyList<SessionHeader>> ListAsync(CancellationToken ct = default);
    ValueTask<bool> DeleteAsync(SessionId id, CancellationToken ct = default); // 默认 NotSupported
}

一次 append 的 expectedNextOffset、header identity 与连续 seq 在同一个原子 commit 中核对:整个 batch durable,或整个 batch 完全不可见。JSONL provider 在持有会话写锁期间为每个会话保留 append 游标(dsh appendLines 按游标写入、不回读):上一次成功提交后 artifact 的路径、exact 文件 identity、字节长度、逻辑下一位置与解码字节数。后续追加只核对尾部——同一文件对象、长度恰为游标记录的长度——再按游标里的 durable header、expectedNextOffset 与解码上限校验并写入一帧,代价与 artifact 大小无关;文件对象被替换,或长度与游标不符(截断、撕裂尾部、追加的外来字节)时退回完整扫描,沿用其失败关闭语义。同长度的原地改写不在追加时检测——与 dsh appendLines 一样,这类损坏留给下一次读取的帧校验和与格式校验发现,属于 owner-only 威胁模型之外。首次写打开、写入失败与 RepairTailAsync 之后都没有游标,下一次追加完整扫描一次。SessionStoreRevision 是 provider-qualified 的 opaque fence;Coordinator 只能原样带回 RepairTailAsync,不能解释其内容。读后若 artifact 或 next seq 已变化,repair 立即以 conflict 失败。

空 batch 是 no-op,不会单独 materialize header。选中的 Provider 一旦失败就向上失败;没有 fallback、mirror 或双写路径。

DeleteAsync 只服务用户显式删除(Web 侧栏批量删除经 Tether 宿主扩展 tetherSessions/delete 调用):provider 在删除期间持有与写路径同一把跨进程会话锁,另一 owner 正在写时以 SessionPersistenceLockedException 拒绝;稳定缺席返回 false;删除不解析 header,任何格式代都能删。未声明删除能力的 provider 走默认实现(NotSupportedException),不会被误当成已删除。provider 不观察 live Session/Agent,所以宿主先检查 activity(与归档同一 workspace/session-activity 询问,运行中的会话拒绝删除),再经 IAgentRegistry.RetireAsync 退役该 id 的 live Agent(委托持有 handle 的 factory,等 teardown 与 final drain 完成),并在物理删除期间占住 registry 的 cold-start flight——并发 resolve/follow 只会等到它以 not-found 失败,删除窗口里不会把会话重新唤醒。根会话连同全部 subagent 后代删除(后代先删);日志删除之后才清理 workspace 归属、置顶/归档集合与检索索引,并经 api-session/removed 让每个已连接浏览器摘掉该行。

会话头是日志之外的身份

public sealed class SessionHeader
{
    public int Version { get; }        // 事件词汇表版本,当前 SessionFormat.Version = 15
    public SessionId Id { get; }
    public long CreatedAt { get; }     // Unix 毫秒
    public string? Cwd { get; }         // 工作区
    public string? Composition { get; } // 组合文档身份
    public string? Profile { get; }
    public string? Preset { get; }
    public SessionLogOffset? InheritedEventCount { get; }
    public SessionSubagentOrigin? SubagentOrigin { get; }
}

Version 只在事件词汇表发生破坏性变更时才递增,并且刻意与 SQLite 的 schema 版本、application id 区分开——三者变更的理由不同,混在一起会导致”改了存储细节却要求所有旧会话不可读”。 版本 9 采用强类型的 SessionSeq、SessionLogOffset 与 SessionSeqCursor 位置词汇,并把 fork cut 明确为 InheritedEventCount;随后版本随 agent-teams 消息词汇、复刻客户端事件面、权限初始化事实与审批最终词汇的收敛递增;版本 14 把 system prompt 迁为 system/message surface 节点并让 request/header 去 system 化;版本 15(issue #332,上游 V4 目标形状对齐)把 user/message/inbox/spliced 的 source 从封闭枚举换成 producer-owned 对象(kind 为非空字符串、plugin 值退役、未知 kind 的全部字段逐字节保留,form ∈ {instructions,catalog,snapshot,notice,relay,recall} 带各自的必填字段),给 turn/end reason 词汇补 forked,让 subagent/catalog 的 version 域拓宽到 {0,1} 并在 v1 引入 mode:'unknown',并新增可选 session/end-seed.inherited 标签。按 pre-release 规则不为旧事件格式增加兼容层。

因此 load/append 对任何其它格式代都 fail closed,而 listing 仍然宽容:一条异代会话不会让整个列表失败,Query 语料也照常带着它的 Version。上游在打开时沿迁移链升级旧格式,所以它的会话列表照常展示旧会话;Tether 没有迁移链,浏览器面(侧栏 session/list、session/search 的可见集、会话引用候选)只露出 Version == SessionFormat.Version 的会话——展示一条点开即报 session format N is unsupported 的会话没有意义。异代日志仍原样留在磁盘上,不会被隐藏动作改写或删除。冷会话 projection cache 缺失时,session/list 对小日志(≤ 64 条事件)做有界只读探测以判定 blank(若无 turn/start 且已至末尾则为 blank),大日志或探测失败保持上游”未知即可见”(blank: false)。

词汇变更按固定分类规则登记(与 dsh persistence-changes 决策同形,但 tether 版本线自有、与 dsh V3 无对应):

分类变更SessionFormat.Version
same-version新增可选 event-body 属性;required→optional;新增普通事件类型不递增
version-bumpoptional→required;新增 required 属性;payload 类型变更;删除或重命名事件;任何 header/envelope 结构变化递增

新事件类型默认 required-on-read:SessionEventType<T> 的 requiredOnRead 缺省 true,旧读者遇到未知 required 事件即拒绝该日志;显式 requiredOnRead: false 的类型以 ignorable envelope 写入、允许旧读者跳过。envelope 的 ignorable 由类型声明派生,SessionEventMetadata.Ignorable 只能确认、不能反言;可选事件不得是 surface 事件。tests/Tether.Bundles.Tests/session-event-vocabulary.txt 快照门禁比对全部 Tether*.dll 声明的描述符,词汇的任何结构差异都必须同步登记快照并在提交中声明分类。

same-version 扩展登记(版本内新增可选字段、不递增 Version):

事件same-version 变更
hook/result2026-09-27:新增可选 evolutionArtifactId、evolutionGeneration、operationalFailure,由可信 Hook 配置与运行器填充。旧事件缺省不归因、不推断故障;主动 deny/block 不等于运行故障。无新事件名,SessionFormat.Version 不递增。
tool/result版本 13 内新增可选顶层 error{name,code,reason?}(对应上游 persistence-changes/2026-09-12-auto-review-error-metadata):无该字段的旧数据照常读取;读取时整个 error 从 typed message 中丢弃、只留在 durable data 里,reason 永不进入模型可见内容。error 只允许与 tether.tool.failure isError 标记同现,写入与读取都校验该标记。
subagent/catalog版本 14 内新增普通事件类型(required-on-read):parent 日志记录 direct-child 发现事实 {version,childId,childCreatedAt,mode,label?}(上游 subagent/catalog v0,label 缺席不写出)。one-shot 在 provider 返回本地 child 后追加,continuable 在初始 prompt 准入后追加;subagentCatalog 投影是唯一读取端。
tool/ptc-dispatch-start / tool/ptc-dispatch版本 14 内新增两个 required-on-read 事件类型(对应上游 tool/ptc-dispatch*):记录 run_code 嵌套 sub-call 的 start 与 settle;两者都是 log 类普通事件,旧读者遇到会拒绝该日志。
user/message版本 14 内新增可选 files[{attachmentId,name,bytes,order}](对应上游 generic file upload,FileAttachmentRef refs-only 形态):无该字段的旧数据照常读取;空数组不写出(files 缺席语义等于无文件)。
inbox/spliced版本 14 内 inserted[] 各项新增可选 files[{attachmentId,name,bytes,order}]:与 user/message.data.files 同形,排队中的同源 user 消息携带相同的 refs-only 文件块;缺席语义同上。
image/offload版本 14 内新增普通事件类型(required-on-read、requiresMessageProjection,对应上游 compaction-image-offload 的 durable 修复事件):{targets:[{seq,imageIndexes[]}]} 把当前 surface 节点上的图片 occurrence 标记为已卸载;事件自身不进 surface,语义由注册的 message projection 解释,无投影时 fold 失败关闭,旧读者必须拒绝以免已卸载图片在回放中复活。
deliverables/presented版本 14 内新增普通事件类型(required-on-read、log-only,对应上游 deliverables/presented):{turn,callId,files[{path,description?}]} 记录 present 工具成功结算后声明的交付文件;只在非错误 outcome 后追加,永不进模型可见内容。
workspace/changes版本 14 内新增普通事件类型(required-on-read、log-only,对应上游 workspace/changes):{turn} 标记一次 turn 工作区变更记录已完成,summary/diff 由 IWorkspaceChanges 服务按事件 seq 读回(非 durable 负载);在 agent/turn-stopping 追加,turn/end 前仍有迟到的 tool result 结算时再追加一次。
user/message版本 14 内 data.source 的 webhook 形态新增可选 {provider,source,deliveryId,ruleId}(对应上游 webhook delivery provenance,source.kind: 'webhook' + InboxMessageProvenance.Webhook):无该字段的旧数据照常读取;四字段为 webhook source 的闭合标识组,缺席语义等于非 webhook 来源。
session/end-seed版本 15 内新增可选 inherited 布尔标签(issue #332):只写出 true,标记该行是 fork 种子构造的 end-seed;无该字段的旧数据照常读取,false 写入被拒。
file-trigger/observed版本 15 内翻转为只读可选(required→optional,issue #329):随仓库发布的树外插件包运行在可回收 ALC 下,事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到未知该事件可跳过,卸载插件后既有会话日志保持可读。
git-checkpoint/disabled / git-checkpoint/created / git-checkpoint/failed / git-checkpoint/dirty-repo / git-checkpoint/rolled-back版本 15 内翻转为只读可选(required→optional,issue #329):git-checkpoint 全部 5 个 log-only 事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到可跳过。
notify/failed / notify/suppressed版本 15 内翻转为只读可选(required→optional,issue #329):notify 全部 2 个 log-only 事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到可跳过。
tool-workflow/run-start / tool-workflow/agent-start / tool-workflow/agent-end / tool-workflow/run-end版本 15 内新增四个普通事件类型(required-on-read,issue #341):workflow 顶层工具调用(exec.parent 缺席)的 durable 记录面——run-start {runId,name}、agent-start {runId,seq,label,phase?,childId}、agent-end {runId,seq,outcome}、run-end {runId,stopReason};旧读者遇到会拒绝该日志。
request/failed版本 15 内新增普通事件类型(optional、log-only,对应上游 assistant/attempt):{turn,step,failure{message,code,offloadImages?}} 结算一次已写出 request/start 却没有提交 assistant/message 的失败模型尝试——流建立前或流中途的非取消失败,重试与终态失败都写,由 Agent 在 request-error waterfall 之前追加,因此总在 llm/retry 之前。上游把 attempt 流内嵌为 stream;Tether 的流是一等 assistant/chunk 行,本尝试的流就是同 (turn, step) 最近一条 request/start 之后的 chunk(非 surface 事件不带 sourceEventSeqs)。取消不是失败,不写本事件。web wire 把它映射成上游 assistant/attempt {turn,step,stream},流末尾补 adapter 形状的 error finish。名字刻意不复用 assistant/attempt:不认识它的旧宿主把可选事件原样透传给浏览器,上游已知名字配上没有 stream 的载荷会让客户端崩溃,Tether 自有名字只会被当作未知可选事件忽略(页脚照旧不披露)。

v0.25 架构与适配器持久化核对结论:v0.25 MEAI adapter 经核对无 session 事件/header/envelope 差异,无需 same-version/version-bump 条目(FunctionCallContent 的 tether.llm.raw-tool-arguments 与 TextReasoningContent 原生复用已存在的 AssistantMessageEventData 与 tool/result 载体,物理存储与读取完全向后兼容)。

CreateAsync 的物理行可以推迟到首次追加才写,因此 ListAsync 不会列出从未追加过的会话:没写过事件的会话在存储层不算存在。

一旦 session 已经 materialize,durable header 的 CreatedAt/version/id/cwd/composition/profile 就是唯一 authority。persistence 插件卸载再重挂时会先读回并复用这六个字段;首次 append 前尚未 materialize 的 exact Session 也由 weak lifetime slot 保住第一次生成的 header。发行 Boot 把绝对根 composition source(与 ICompositionInfo.ConfigSource 同义)和已解析 profile 写入后两项,因此换 composition 或 profile 恢复同一 durable Session 会在 publication 前失败。故意改动 identity 会在写入前失败,不能通过忽略 CreatedAt、放松比较或换 Provider 绕过。

write-behind 与 flush

AttachAsync 把一个 exact live session 接上 Coordinator 的 write-behind:之后提交的事件进入该 session 的有序队列,已经 durable 的 prefix 不会被重写。

这条 lifecycle 不只覆盖 composition root。AgentLoop 创建或恢复 Session 后,必须在 setup、session/created、registry entry 与任何对外 publication 之前 awaited attach;attach 失败属于同一个 publication transaction,保留 primary failure 并回滚 writer、listener、reservation、Agent 与 Session。Coordinator 重激活时先订阅 agent/created 再枚举 registry snapshot,并按 exact Session reference 合并两条路径,因此 snapshot 窗口内发布的 Agent 不会漏接。

FlushAsync 排空写队列——传 id 只排空一个会话,省略则排空全部活会话。需要”确保已落盘”的场合(退出前、快照前)必须显式 flush,因为 write-behind 本身不保证时点。

RetireAsync 是 writer ownership 的最终交接:只有 final drain 成功后才释放 exact session 的 writer;失败则保留 ownership,让 Coordinator disposal 重试,避免 publication rollback 后留下半退休 writer。

Agent teardown、publication rollback 与 HMR disposal 共享同一个 awaited retirement boundary。persistence-only HMR 重挂时,同一 service key 下的新 exact service generation 会在 snapshot attach 后原子替换 stale finalizer,而不会执行旧 callback;same-id replacement 只有在旧 exact Session 的 final drain/retire 完成后才能取得 writer,provider 行重挂也不会把仍在飞行中的 writer 转交给另一个 Session。

语义持久化 checkpoint

write-behind 决定事件何时落盘,但不保证副作用之前落盘。base 组合默认挂载的 session-checkpoint policy(Tether.Session.Checkpoint)补上这道边界:在三个既有 seam 上,先 awaited 复用 Coordinator 的 FlushAsync,完成前不放行下游——

  • agent/pre-step:上一步的响应/结果批次 durable 之后,本 step 才开始装配;
  • llm/stream:request facts(request/header、request/context)durable 之后,下游适配器才被构造、调用、枚举;retry 的每一次 attempt 同样先过这道 fence;
  • 顶层 tools/execute:call 声明 durable 之后 body 才执行;nested dispatch(在 outer body 内发起的重入)复用外层已持久化的上下文,不递归 flush。

阻塞或失败的 checkpoint 只会向上失败:不产生模型请求、不执行工具 body、不伪造成功。checkpoint 完成后立即复查 cancellation,被取消的调用返回 aborted-before-dispatch。插件只是 checkpoint 消费者——事件捕获、写入、flush 调度、finalizer 与 writer ownership 全部留在 Coordinator,不创建第二个 store。

诚实的 crash window:该策略保证的是「意图先于效果 durable」。进程在外部效果已经发生之后、result 落盘之前崩溃,该效果的 outcome 是 unknown——恢复方可以证明调用曾被声明且 durable,但不能证明效果没有发生。这里不承诺通用 exactly-once,也不会自动重试 outcome unknown 的外部效果;此类恢复必须由领域层或人来判定。Goal/Schedule/Teams 各自的领域专用 checkpoint 不受此通用边界代替。

撕裂的尾部:检视与修复是两回事

进程在一个 turn 中途崩溃时,磁盘上的日志末尾是不平衡的——turn 开着、step 可能开着、tool/call 可能没有对应的 tool/result。两个方法处理这种情况,区别很关键:

方法磁盘返回
InspectAsync完全不动。撕裂的尾部留在盘上校验过的逻辑视图,合成闭合事件只存在于内存
LoadAsync持久地截断撕裂尾部并追加合成闭合事件修复后的视图

也就是说 InspectAsync 是只读诊断,LoadAsync 才会改盘。想看看某个会话什么状态而不想改它,用前者。

PrepareAsync 在 LoadAsync 的基础上再往前一步:返回一个尚未发布的修复后会话(SessionPreparation),供冷恢复流程接管。它实现 IAsyncDisposable,释放时归还内部持有的资源;DisposeAsync 用 Interlocked.Exchange 保证只释放一次。

中断 turn 的合成闭合

修复的核心规则是:绝不改写已提交的事件,只追加。InterruptedTurnRepair.Closers 扫一遍日志,算出需要补哪些事件。

崩溃时磁盘上的日志 turn/start step/start assistant/message 含 tool call tool/call 无结果 崩溃点:turn 与 step 都没关闭 修复 追加的合成事件 tool/result TOOL_OUTCOME_UNKNOWN,引用原 call 的 seq step/end turn/end reason.kind = interrupted 括号重新平衡,日志可正常重放 已提交的事件一个字节都不改动——修复只在末尾追加。因此"当时到底发生了什么"始终可以从原始前缀重建, 合成事件只是让日志重新满足 turn/step 的嵌套约束。
不平衡的尾部是崩溃的事实,不是要被抹掉的错误。修复的做法是补齐闭合括号,而不是回滚。

两种”未完成的工具调用”要区别对待

合成的 tool/result 分两种情况,给模型的指引完全不同:

常量情形给模型的指引
TOOL_NOT_STARTED助手声明了调用,但没有 tool/call 落盘工具还没开始执行,仍需要就重试
TOOL_OUTCOME_UNKNOWNtool/call 已落盘,但没有持久结果结果未知,不要盲目重试

第二种的文本明确要求模型按工具语义判断:只有只读或幂等操作才可以重试;可能有副作用的,先核实外部状态或询问用户。

这个区分是正确性问题,不是措辞讲究

tool/call 已落盘意味着工具本体可能已经执行完了——文件可能已经写了,命令可能已经跑了。 把这种情况和"根本没开始"混为一谈,会让恢复后的模型重复执行有副作用的操作。

合成的 tool/result 会通过 sourceEventSeqs 引用原始 tool/call 的 seq(仅在确实已落盘时),因此事后能精确对应到是哪一次调用被中断。

失败分类是稳定契约

异常含义
SessionPersistenceMissingException指定 session 尚未 materialize
SessionPersistenceCorruptionException已提交的前缀读不出来,或 seq 有空洞
SessionFormatUnsupportedExceptionschema 或事件格式版本是本构建不读的
SessionPersistenceConflictExceptionexpected seq、revision、header identity 或 exact writer owner 已变化
SessionPersistenceLockedException另一个进程已持有写者租约
SessionPersistenceIOException已选 Provider 的本地 I/O 失败

这些类别分开是有意的:损坏需要人工介入,版本不支持需要升级或降级,conflict 要重新读取后再决定,租约冲突表示另一个 writer 正在使用同一 authority。任何一类都不会触发自动 Provider fallback。

本地 artifact 的 owner-only 边界

两个 Provider、projection checkpoint、用户设置、凭据和附件都复用 Tether.LocalStorage,不各自维护 permission helper。POSIX 创建 syscall 请求 0700/0600,且不修改进程级 umask;restrictive umask 移除目录权限时,helper 会在创建 descendant、database/log/checkpoint、settings、attachment、credential 或 lease 前,通过稳定 parent 与 no-follow exact-entry handle 把刚创建的同一目录归一为 0700,并复核 owner/type/identity/parent chain/mode。敏感 regular file 在首次写入前通过 exact handle 归一并复核为 0600,因此 permissive 与 restrictive umask 都不会产生公开窗口。Windows 由 helper 创建的 entry 从创建时就是 exact current-user owner 与 protected private DACL,native SQLite sidecar 从继承时就只有 current-user DACL,并立即在原 handle 上归一 owner/DACL。重开时,归当前 owner 但过宽的权限会在读取或写入前收紧;wrong owner、symlink/reparse point、错误 entry type 或不可验证 ACL 直接失败,不能触发 Provider fallback。

范围受保护的本地产物
JSONLroot、project、session directories;session.jsonl.zstd、.session.jsonl.zstd.staging-*、root .writer
SQLitedatabase、-wal、-shm、-journal、.writer
Projection.projcache database 及其 WAL/SHM/journal
Settingsprofile cordis.patch.yml 与 profile 目录 writer lock
Credentials托管 credential 文件及其 private parent
Attachmentobject root、content-addressed objects 与 publication staging

JSONL 首次发布在同一 private session directory 中写 staging,flush 后以 no-overwrite 原子 rename 发布,并在前后复核 parent/entry identity。SQLite 主 store 持有独占 writer lease,所以 database、lease 与已捕获 sidecar generation 都能跨 open 保持 exact fence。Projection cache 允许其它合法 SQLite connection 并发,没有独占 lease;它对主 database 与 parent 保持 exact fence,并在 open 前后分别收编当时存在且通过 owner/DACL/type 验证的 native sidecar generation。

POSIX 以 effective UID 作为 owner-only 安全主体。cleanup 在 stable parent fd 下以 atomic no-replace quarantine rename 线性化 target path 删除:线性化前的 replacement 会 fail closed,线性化后的 target winner 不会被删除,restore 也绝不覆盖 winner。Linux/macOS 的最终 unlinkat 只绑定 parent/name,并非 expected-inode conditional unlink;线性化后恶意 same-euid actor 对随机 quarantine namespace 的篡改不在 #68 威胁模型内。cleanup 前已观察到的 identity 变化仍 fail closed。Windows 则持有 exact child handle 并按 handle 删除,不使用这项 POSIX 收窄。

Windows SQLite 原生创建 WAL/SHM 时,提升权限的 token 可能先把 token default owner 写入 metadata。唯一的窄例外是:sidecar 位于已捕获的 private parent 中,DACL 只给 exact current user 有效 FullControl;helper 随即在同一个 no-follow handle 上把 owner 与 protected DACL 归一。通用文件验证仍只接受 exact current-user owner,不把 Administrators 或其它 principal 当作敏感文件 owner。

JSONL/Zstandard Provider

权威 artifact 路径是:

<root>/<project-key>/<session-id>/session.jsonl.zstd

没有 cwd 的 header 使用 _no-cwd;project key 与 session segment 都经过 bounded encoding,不能通过 ..、分隔符或超长输入逃出 root。Boot 默认把 root 解析为 <home>/<profile>/sessions,也可由 --session-root 显式覆盖。

删除只移除 session.jsonl.zstd:删除前捕获 identity,经 Tether.LocalStorage 的 stable-parent no-replace quarantine rename 线性化,替换一律 fail closed;session.lock 与会话目录保留(锁文件永不删除),列表与读路径以 artifact 缺席判定会话不存在,删除成功后释放本 store 持有的该会话锁句柄。

首个独立 zstd frame 只含 header;每个 durable batch 再占一个独立 frame。完整 frame 就是 commit boundary:只有文件末尾不完整的 frame 属于 torn tail;任何完整 frame 的解压、UTF-8/JSON、shape 或 seq 错误都属于 committed corruption。崩溃撕裂只会留下最后一次写入的前缀,所以被判 torn 的区域里若还能找到完整、可解压且 content checksum 通过的 frame,或任何 block 声明的 Block_Size 超过 128 KiB,一律按 committed corruption fail closed,不得 repair 截断。Provider 可以物理 packing assistant/chunk,但读出时必须逐事件恢复原始 seq、time、turn、step、type 与 payload。

Coordinator 对 logical prefix 的 envelope、sequence、surface 与 source-seq 字段继续逐项严格比较,payload 则按 JSON 结构比较;对象键序与等价字符转义不会把 dsh 式 packed decode 误判成另一条事件。这个语义不参与 provider revision、expected-next-seq 或 repair CAS,也不会把完整 frame/row 的解压、JSON、shape 或 seq 错误降级为可接受输入。

SQLite Session Provider 已下线(#150)

first-party SQLite Session provider 与 session-sqlite composition patch 已按 #150 的 owner 决策(FOLLOW)移除:JSONL 是唯一 first-party ISessionEventStore 实现,ISessionEventStore 与 Coordinator 保持 provider-neutral。out-of-tree provider 仍可通过替换 stable id 为 session-store 的 row 接入,Coordinator 与 projection/resume/title 等 Consumer rows 保持不变;每个 composition 始终恰好一个 ISessionEventStore。

既有 .sqlite session 库不再被打开或迁移:需要保留会话的用户应在升级前用 provider-neutral logical export 导出。Tether.Storage.Sqlite(domain-KV)与 Session Query 的 FTS 观察索引不受影响。

与会话模型的关系

这条 seam存储的就是会话与事件溯源里描述的那套不可变事件。持久化不改变任何投影语义:FoldSurface 与 DeriveMessages 仍然是对日志的纯函数重放,只是日志现在可以来自磁盘而不只是内存。

冷恢复的完整流程(PrepareAsync 之后如何发布 Agent)见 Agent 生命周期的注册表一节。

下一步

在 GitHub 上编辑此页