Milestone 1 · Implementation Plan

Kastell — 双历史个人 Agent 应用

本计划梳理并核对 Tutorials 的 01 + 02a → 08a(a-track),合并重复说明,形成可供工程实施参考的基准: 一个 Swift 宿主服务、一个 SwiftData 双历史持久库、多个受控 pi 运行时、一个极简独立的 Swift CLI。 全篇聚焦核心概念、系统不变量与架构取舍,重点说明模块衔接、失败路径和验收条件。它是一份实施计划,不是已完成产品的验证报告。
macOS 26+ · Swift 6 · Vapor 4 · Commander 一个 SwiftData store = journal + timeline 轮询 cursor · 无推送 · 无队列 中途可用点:04a
00

怎么用这份计划

先理解,再动手,最后验收

这份文件同时是「项目说明书」和「施工图」。建议分三种用法:

  • 建立心智模型(一次通读):读 §1–§6。读完你应该能用三句话解释这个项目:一个 store 同时保存观察日志和规范消息;pi 运行时可替换,数据库才是持久状态的权威;任何操作提交成功后才算成功,对不确定的执行结果不作推断。
  • 逐章实现(边写边看):按 §7 的 01 → 02a → … → 08a 顺序推进。每一章给出「产出物 / 核心概念 / 规则与取舍 / 完成判定 / 常见坑」。示例代码不抄进文档,需要时回原教程。
  • 验收与防漂移:§8 是 M1 的验收清单;§9 是永远成立的硬规则;§10/§11 写清楚「不做什么」和「不承诺什么」——这两个清单同样重要,它们防止项目膨胀。
本计划对应的路线 01(共享起始章)→ 02a → 03a → 04a → 05a → 06a → 07a → 08a。 原始 SQLite 线(02–08)保留作为对照,但不跟随;a-track 会标注哪些原章内容可以复用、哪些被替换。你只建一个变体,不是两套并行的持久化系统。
术语约定 英文术语保持原样(journal、timeline、cursor、generation…),因为它们同时也是代码里的标识符。主要术语统一在 §13 解释。正文中的代码标识符不作翻译;附录中的建议不自动成为 M1 的验收要求。
阅读提示 手机端可展开页首目录跳转章节;宽表格与代码图可在各自区域内左右滑动,正文无需横向拖动。验收项支持勾选,状态仅尝试保存在当前浏览器中,不代表项目已经通过验收。浏览器限制本地存储时,勾选仅在本次打开期间有效;不运行脚本的文件预览仍可阅读全文。
01

一页速览

这个项目是什么,M1 到哪里为止

终点长什么样

Terminal A                                Terminal B
kastell-host                              kastell note / prompt / log --follow / timeline
     │                                          │
     ├────────── HTTP/JSON on loopback ─────────┘
     │                (127.0.0.1:8787)
     │
     ├── ONE SwiftData writer ──► persisted journal + canonical message timeline
     │        (@ModelActor, 一个 ModelContext, 一次 save)
     │
     └── AgentManager
           ├── PiRuntime(A) ── Commander ── pi --mode rpc --no-session
           └── PiRuntime(B) ── Commander ── pi --mode rpc --no-session
一个 host、一个数据库、两个终端;CLI 不碰数据库、不启动 pi、不维持 host 存活。

三句话理解整个设计

  1. 一个 store,两个历史。同一个 SwiftData store 里既存 journal(host 收到并成功提交的限额内原始事件),也存 timeline(按固定策略保存的规范消息)。两者由同一个 writer、同一个 save 边界维护,不需要后台投影任务。
  2. pi 运行时可替换,数据库是持久状态的权威。进程崩溃、host 重启都不丢已提交的历史。重启后历史可读,但新进程默认从空白上下文开始——这是明确的产品边界,不是缺陷。
  3. 提交先于成功,证据优于猜测。收到成功回执 = 已提交入库,不等于模型执行成功;发出去没等到响应 = 不确定,绝不盲目重发;解释不了的数据保留原始记录并显式报错,不编造"看起来合理"的消息。

M1 的定义

走完 a-track 全部 8 章之后你得到的东西,就是 M1:

M1 = 07a 结束时的那句话 SwiftData 持久化、两种历史、多个 pi 运行时、一个 Swift host 公共 API、一个最小独立 CLI——并且能通过 §8 的六个日常里程碑。

中途可用点:04a。 到这里 notes 能落 journal、两个历史都能读;timeline 为空是正确状态。05a/06a 才把真实 prompt 和 pi 消息接进来。

初始限额与配置(沿用教程,调整前先评估)

项目M1 取值
host 数量 / 数据库1(单机 lease 防止双开)
pi 运行时数量初始 2–4 个
每个 agent 并发 prompt1 个;busy 就拒绝,不进队列
读取方式按 cursor 轮询(间隔约 250 ms,可按需调整),无 WebSocket/SSE/broker
工具与扩展发现初始关闭;之后开启是显式的 capability 决定
单条存储上限raw / canonical JSON ≤ 1 MiB
单次请求体上限64 KiB(HTTP mutation)
用户输入文本上限32 KiB UTF-8
单次读取响应上限8 MiB(编码后),允许短页
分页参数after ≥ 0limit 1…100,默认 100
存储层单次取候选行最多 4 条,客户端持续拉到空页
02

架构全貌:三层协议与资源归属

谁拥有什么,谁不许做什么

三层协议必须分开

Client ── HTTP requests/responses ──► Host
                                        │
                                        ├── application operations ──► HistoryStore
                                        │
                                        └── pi JSONL ──► Commander ──► child process
  • 公开 API 说应用语言:create agent、start、submit prompt、append note、read log。绝不提供 POST /rpc 之类的"任意 JSON 转发给 pi"路由。
  • pi 的私有协议可以随时变,不该让每个客户端都理解 process ID、扩展对话框、RPC 队列。
  • Commander.writeLine 只证明传输层成功,不证明任何应用层操作成功。host 负责三层之间的翻译。
取舍:为什么不用一个通用 RPC 桥? 直通最短,但把 pi 的私有协议冻结成了公共 API。分开之后,升级 pi 不影响客户端;代价是 host 要为每个应用操作写一次显式翻译——这正是 M1 想要的可解释性。

每个资源只有一个 owner

资源owner生命周期
数据库连接 + host writer leaseHistoryStorehost 全程
网络监听kastell-host / Vaporhost 全程
agent 身份与配置数据库跨 host 重启
活动运行时字典 / slotsAgentManager actorhost 全程
一个 pi 子进程及其 stdout/stderr readerPiRuntime一个 generation
一个已接受的 prompt 任务host 持有的 runtime/controller直到 settled / rejected / interrupted
cursor 与显示格式kastell client一次客户端调用
这条规则来自 01,全项目有效 client 不打开数据库、不启动 pi、不维持 host 存活。关掉 Terminal B 不影响 Terminal A 里运行的 agent。

注意 Swift 的任务语义:Task { } 创建的是非结构化任务,必须明确由谁持有、取消并等待其清理完成;actor 不会自动监督自己创建的任务。资源归属最终要落实到“谁持有、谁取消、谁等待结束”。

模块边界由编译器强制

KastellCLI ────────────────────────────────► KastellProtocol
KastellHost ──► KastellRuntime ──► Commander
     │               │
     └───────────────► KastellStore ────────► KastellProtocol
  • 没有任何 target 依赖 host 可执行文件;CLI 不能意外调用 store,因为没 import 那个模块。
  • KastellProtocol 不 import Vapor / SwiftData / Commander——它是纯 DTO 层。
  • 这是架构约束,不是命名约定。多模块的价值就在于此。
Swift 细节坑 Swift 默认 access 是 internal。public struct 不会自动获得 public 的 memberwise initializer——共享 DTO 要显式写 public init(...)。否则会出现"同一文件能编译、跨 target 不行"的怪问题。
03

身份、顺序与游标

不要用 PID 或时间戳思考

整套系统只信任两类东西:UUID 身份数据库分配的整数顺序。时间戳只用于展示,不是排序依据。

身份表

名称含义生命周期
storeID这个历史数据库的身份换数据库才变;重启不变
hostID本 host 这一次启动每次启动新 UUID
agentID一个命名的逻辑 worker跨 stop/start 存活
generationID一个 pi 进程/上下文的一次化身每次 launch 新 UUID
operationID客户端的一次意图变更重试同一 mutation 时复用
pi RPC id单个协议请求的关联health/abort 每次新;prompt 可用 operationID
eventID / messageID一条不可变事实 / 一条规范消息永不复用
journal sequence全局已提交的 journal 位置单调递增,由 writer 分配
timeline sequencetimeline 流的独立位置也是全局、跨 agent
wireOrdinal某 generation 某条管道上收到的第几条记录每个 stdout/stderr 流各自从 1 开始
两条铁律agent 不是 PID。PID 会被操作系统复用;generationID 是数据库用来比较的 fencing token,不证明进程还活着。
游标本质是 (storeID, stream, sequence)。新数据库里同样的数字不是同一个位置——这个小区别防止重连的客户端静默跳过新历史。

两个计数器,两条流

journal 和 timeline 各自只有一个全局计数器,跨所有 agent;它们顺序不同、互不通用:

Journal:   J1 client.note
           J2 prompt.requested ─────────────► T1 user
           J3 pi.agent_start
           J4 pi.message_end(user) ────────► 链接到 T1,不新增
           J5 pi.message_update
           J6 pi.message_end(assistant) ───► T2 assistant
           J7 pi.agent_settled
           J8 prompt.settled

Timeline:  T1 user        (sourceJournalSequence = 2)
           T2 assistant   (sourceJournalSequence = 6)
  • 每写一条 journal 行 → journalHead + 1;只有产生新的规范消息才 timelineHead + 1
  • sourceJournalSequence:从规范行指向"创建它的那条 journal 事件"。
  • JournalEvent.canonicalMessageID:从 journal 行指向已有或新建的规范消息;多个观察可以链到同一条消息(如 user echo)。
  • mutation receipt 里的 sequence 永远是 journal 位置,即使这次 admission 同时创建了 timeline 行。
  • 计数器在同一次 save 里随行分配,并防 Int64.max 溢出。不许用 fetchCount + 1、不许用 Date、不许用进程内自增(重启会重复)。

客户端游标长这样

HistoryCursor {
  storeID: 1111...,
  stream:  "timeline",     ← 关键:journal 和 timeline 不能共用裸数字
  after:   12,
  agentID: 5555... ?       ← 可选的过滤条件也属于游标身份的一部分
}

它读作"在这个 store 里、对这个过滤条件、timeline 位置 12 之后"。logtimeline 用不同的 cursor 文件(如 /tmp/kastell-journal-cursor.json/tmp/kastell-timeline-cursor.json)。服务端无法猜出一个裸数字来自哪个命令——journal 的 10 可能刚好落在 timeline 范围内,静默跳过消息。所以客户端本地校验 + 服务端范围/store 校验,缺一不可

幂等靠 identity key,不靠文本

对象identity key 形式
journal notenote:<operation UUID>
journal prompt intentprompt:<operation UUID>
journal 收到的 pi 行pi:<generation UUID>:<stdout|stderr>:<ordinal>
timeline 提交的 useruser:<operation UUID>
timeline pi 消息message:<generation UUID>:<stdout message_end ordinal>
  • 重放同一身份:先 fetch 比较(action、agent、原始输入),一致就返回原来的 eventID/sequence/canonical 链,什么都不新建;不一致就报错。
  • 永远不用文本 hash 合并消息——两条合法消息完全可能内容相同。相同文本 + 新 operationID = 真正的新 prompt/note。
  • 重复摄入不重新执行生命周期转换(比如不再次标记 accepted)。
04

两个历史:journal 与 timeline

观察证据 vs 规范消息

这是 a-track 的核心决定:两个历史都真实持久化。journal 保留原始证据,timeline 保存每个逻辑消息的一份规范表示。两者职责不同,但由同一套代码维护映射,并在同一次 save 中提交。

Journal(观察日志)

  • 客户端意图、host 生命周期、pi 的每一条 stdout 事件、stderr 诊断。
  • 一个只追加、不改写的序列(append-only);同一条消息可能在多个事件里重复出现(update、message_end、agent_end…)。
  • 用途:看协议层到底发生了什么、查 provenance、排查失败。

Timeline(规范消息)

  • 每个被接纳的用户意图、每个合格的非 user message_end,各一行。
  • 去重的是"逻辑消息",不是字节;payload 与 journal 有意重复。
  • 用途:日常阅读对话;顺序稳定、所有客户端一致。

投影策略(V1 全部规则)

输入事件JournalTimeline
client noteappend client.note无(note 不是模型对话消息)
admitted promptappend prompt.requested(含原始输入)立刻 append 原始 user 消息
pi user message_end(echo)完整保留链接到该 operation 已有的 user 消息;不新增行
pi 非 user message_end完整保留append 完整消息一次
message_start / message_update保留无(预览不是完成消息)
turn_end / agent_end保留(含重复的 messages 数组)无(那些副本不算新消息)
tool 进度、stderr、health 响应保留
error / abort 的 assistant message_end保留也 append——完成 ≠ 成功
compaction / 生命周期 / 未知类型保留无(除非是带有效 message 的 message_end
"canonical" 的确切含义 canonical = 一条逻辑消息一行不是全局只有一份字节。不要为了省那点重复去做 blob 去重或内容寻址存储——在应用尚未跑通时,这通常只会增加实现和维护成本。

user 消息的三条性质

  • 规范 user 消息代表已提交的意图:即使 pi 后来拒绝、或 host 丢失,它也留在 timeline。
  • 用户行存在 ≠ 执行成功。它的内容永远不变,也不会被改成 accepted/rejected/interrupted——那些状态属于 operation 资源和 journal。
  • timeline 不是"可发给模型的上下文列表"。是否送历史、送多少,是将来显式渲染器的决定。

为什么用 message_end,而不是 get_entries?

  • pi 文档把 message_end.message 定义为权威的完整消息;单次同步投影最简单,两个视图立即对齐。
  • a-track 的 live 摄入没有稳定的 pi session-entry ID 来协调 get_messages/get_entries/agent_end.messages 多来源;也不能用文本匹配去重(合法消息可能相同)。
  • SessionArchive 用的是稳定 get_entries receipt——那对 checkpoint/import/resume 是合理设计,但不能混用:一个 generation 只允许一种 canonical ingestion 策略。

"canonical" 不是什么(防止自我误解)

  • 不是"只有最后一个成功的回答"。
  • 不是 provider 认可的当前上下文快照。
  • 不是 pi 原生 session 树的精确复制。
  • 不是"内容去重后的消息集合"。

pi 自动重试后,两次完成的 assistant 观察都是各自独立的事件、各自的 provenance,都留在 timeline;turn_end / agent_end 里的副本不会多加行;同一个数据库摄入用旧 wire 身份重试不会加行。这是三种不同意义的"retry",设计分别处理。

05

存储:一个 store、一个 writer、一次 save

原子边界是这套设计的心脏

结构

                 HistoryStore (@ModelActor)
                           │
               ONE ModelContext / ONE save
                           │
           ┌───────────────┴────────────────┐
           │                                │
     JournalEvent                     TimelineMessage
     观察到了什么                      逻辑消息各一份
           └──────── provenance links ──────┘
        (操作状态模型 HistoryAgent / HistoryGeneration / HistoryOperation
          也在同一个 store 里)
一次 save 覆盖什么 正常摄入时以下全部属于同一个 commit:raw journal 行 + canonical timeline 行 + 两个计数器 + 身份/provenance 链接 + 相关 operation 证据。读取只能看到"全有"或"全无"。没有后台 projector、没有 checkpoint watermark、没有最终一致性窗口。

实现纪律(概念层面必须记住的)

  • 一个 @ModelActor,一个 ModelContextautosaveEnabled = false
  • commit 模式body() → save() → 成功返回 / 失败 rollback 并 rethrow。body 内不允许 await,不允许夹带无关的 pending 改动。
  • 辅助函数(insertJournal / insertTimeline 之类)绝不自己 save,否则原子边界被拆散。
  • rollback 会一起撤销 journal、timeline、计数器和操作状态;回滚后要重新 fetch,不要沿用旧的内存假设。
  • 不要写成 "save raw event → await → save canonical message"。
  • HostLease:本地单 host 租约。第二个 host 应得到友好的 "store already open" 错误——防止两个终端误开;这不是分布式锁、也不是多用户系统。
  • 模型 internal,只对外返回 Sendable 的 DTO 快照;不要编码活的 @Model 直接进 HTTP。SwiftData 的 context/container 不是安全边界,协作代码必须自觉只有一个 writer。
  • 不要用 @Attribute(.unique) 表达"拒绝重复事件":先 fetch 身份、比较、返回原结果或抛错。永远不要插入冲突的新模型然后指望 SwiftData 帮你保住 append-only 语义。
  • 引用用普通 UUID/sequence 属性,不用复杂 relationship 图;SwiftData 不校验外键,由 store 检查关联是否属于同一 agent/generation/operation/message。

限额与读取规则

  • 限额(§1 表)在摄入时就检查;无法满足上限的 raw/canonical 行宁可拒绝存储也不要存成让轮询永远卡住的数据。
  • 读取用 keyset 分页:sequence > after + 排序 + fetchLimit;agent 过滤就是再加一个 predicate。绝不 fetch 整个 journal 再在读取时推导 timeline。
  • 短页(少于 limit)不是历史结束;客户端持续请求直到空页。空页的含义是"以当前视角暂时没有更多",不是"流永远结束"。
  • nextAfter = 本页最后一条被包含的行;空页则等于传入的 after。永远不跳过一条行去塞更小的行。
  • cursor 校验:after ≥ 0limit 1…100after ≤ 所选流自己的 head;store 身份不符 → 409。
  • 页的大小限制按实际编码后字节算(含转义),只包含能装下的前缀。

投影失败:唯一的窄例外

如果一条在限额内的原始记录语义上无法解释(比如消息包无效、违反 V1 策略),流程是:

  1. 保留 raw journal 记录(无效 JSON 就存一个有界 wrapper:原文 + parse error)。
  2. 追加 host 事件 history.projection_failed,写明相关 journal sequence、原因、generation、已知 operation。
  3. 设置 HistoryGeneration.projectionError,标记该 generation/agent stopping,并在同一次 save 里为未决工作持久化"中断"评估。不创建猜测性的 timeline 行。
  4. 返回 projectionFailure,controller 停止正常操作。

之后该 generation 进入 journal-only draining:继续读、继续存原始记录,但不会再产生 canonical 消息,也不会因为又收到一个 response 就恢复 accepted。这是 fail-closed 的窄路径,不是通用 schema 演进机制。

诚实条款 如果记录超过 1 MiB,无法承诺完整保留——只在可能时记录一个有界的 size/error 评估并停止。如果 save 本身失败(比如磁盘满),整个 pending save 回滚;向 stderr 报告并停止生产方——没有保证能把磁盘错误记录在同一块磁盘上
06

V1 数据模型

六个模型,两个计数器;冻结版本

02a 提供两份可直接使用的源码:Models.swift(6 个模型 + V1 schema + migration plan)和 Values.swift(公共 DTO,全部 Codable, Sendable,带显式 public init)。把前者复制进 MVP/Sources/KastellStore/,后者复制进 MVP/Sources/KastellProtocol/HistoryValues.swift;它们替代 01 里的 LogRow/NoteInput 等草图,不要两套并存。

模型提交后是否可变职责
JournalEvent完整事件、source、身份、journal sequence、可选的 canonical 链接
TimelineMessage规范 payload、timeline sequence、sourceJournalSequence
HistoryOperation持久的意图身份、receipt、phase、消息绑定与 outcome
HistoryAgentprofile + 当前调度指针
HistoryGenerationhost 所有权、状态、每条管道的 receive ordinal
HistoryMetadata只有计数器稳定 storeID、两个 sequence head、projection version
@Model 用 var ≠ 允许改历史 属性是 var 只是 SwiftData 的要求;"append-only" 是应用契约。edit 历史行、删除行、提供 delete/update 路由都不在 M1 里。

V1 的冻结规则

  • 提供的 V1 声明是新 schema:不是从原 SQL journal 或 SessionArchive 的迁移;不要修改已发布的 V1 定义,要变就引入真正的下一个 schema/migration。
  • 不要操作 SwiftData 底层的 SQLite 表、不要改 pragma、不要单独拷贝 WAL/main 文件作为备份策略。
  • 需要转换真实数据时:写显式 import(保留 provenance)并给新的 store 身份,而不是重命名文件。
  • 数据变得重要之前,用关闭状态的 store 目录做一致备份(包含所有 sidecar 文件)。

02a 结束时这个 actor 要提供的领域方法

open(url:) / identity()
appendNote(NoteInput) -> MutationReceipt
journal(after:limit:agentID:expectedStoreID:) -> JournalPage
timeline(after:limit:agentID:expectedStoreID:) -> TimelinePage
message(UUID) -> TimelineRow?          # 持久 provenance 查询
operation(UUID) -> OperationSnapshot?

createAgent / beginGeneration / markReady
admitPrompt / markAttempted
ingestPiRecord                          # journal + canonical 选择 + 证据
finishPrompt / interruptGeneration / abandon
requestAbort / requestStop / retireGeneration

这些是后续章节逐步补全的方法清单;02a 本身只要求先实现 notes 和 reads。方法名的要点是:没有公共的"任意插入"接口。客户端不能往 timeline 塞伪造的 assistant 消息;只有被接纳的 user 意图和可信的运行时摄入路径能创建那些行。

07

分章实施路线

01 → 02a → … → 08a,每章的产出与判定

每章按同一模板展开:目标 / 产出物 / 核心概念 / 规则与取舍 / 完成判定 / 常见坑。已经在前文讲透的概念这里只给结论,不重复论证。

01

画边界,先建最小有用切片

共享起始章(两轨共用)

目标:一个依赖方向清晰的 Swift 包,加上对 "append"、"agent"、"done" 的精确含义。此时还不需要跑 pi。

产出物

  • MVP/Package.swift:5 个 target —— KastellProtocolKastellStoreKastellRuntimeKastellHost(executable)、KastellCLI(executable)。
  • §2 的 owner 表和 §3 的身份表落成文档/注释;五个不变量写在随手可见的地方(见 §9)。
  • 第一个 round trip 的设计:kastell note → POST /v1/notes → 一个 transaction → receiptkastell log --after 0 → GET /v1/log

核心概念

  • owner 思维,不是 class 思维。第一个直觉往往是把进程句柄、消息、HTTP 路由、持久化全塞进一个 Agent 类;要问的问题是"进程死了、客户端断了,哪部分应该活下来"。
  • log envelope(01 的 LogRow 草图):不可变信封,payloadJSON 故意存 JSON 文本而不是 [String: Any]——可以保留未知的 pi 字段,又不必让 Foundation 的非 Sendable 动态对象跨 actor。HTTP 上就是一个"内含 JSON 的 JSON string",那点转义对第一个 CLI 可以接受。
  • pi stdout 的 record 规则:kind = "pi." + type,payload 是完整记录;stderr 编码成 {"text":"..."};note 也是 {"text":"..."}任意文本必须编码,不能假设它是合法 JSON。
  • 第一件要跑通的事是 note,不是假造 agent 回答:note 是真实支持的操作,练到持久化/网络/CLI 全路径,之后还能当 operator 注释用。
取舍Vapor 4(host)和 Foundation URLSession(client),而不是手写 HTTP 解析;用一个外部 server 依赖换掉大块学习负担。数据库放 MVP/.state/sessions/ 或任何 agent 会编辑的目录;.build/.swiftpm/.state/ 进 .gitignore。

完成判定

  • swift build --package-path MVP 通过(每个 target 至少一个源文件;runtime 暂时放一个 RuntimePlaceholder)。
  • note 经 HTTP 写入、receipt 返回、log 能按 sequence 读回——全程没有 pi、没有凭据、没有模型调用。
  • public struct 的 memberwise init 不是 public,需要显式 public init
  • 本机 macOS SDK 上 import SQLite3 是系统 C 模块(原轨道才用);a-track 完全不需要。
  • SwiftData 宏需要完整 Xcode:export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
  • 保留生成的 MVP/Package.resolved
02a

SwiftData:同时持久化日志与规范消息

a-track 核心

目标:一个 SwiftData store、一个 writer、两个持久化历史;journal 覆盖所有限额内收到的 pi stdout/stderr 记录,timeline 每个逻辑消息一行。

产出物

  • 复制 Models.swiftValues.swift 进包(§6)。
  • HistoryStore@ModelActoropen(url:)Task.detached 里做 join 式初始化(不是常驻 writer 队列),避免把存储工作意外绑到 MainActor;initializeIfNeeded 用一次普通 save 建 metadata 行、只持久化一次 storeID
  • HostLease(单 host 租约)+ HistoryError
  • 先实现 notes 与两种 reads;timeline 保持空是正确的。

核心概念(本章新增的部分)

  • §4 的投影策略、§5 的 commit 纪律、§3 的两个计数器,都从这一章开始生效。
  • 身份键命名(§3 表)在这里定型;UUID 字符串统一一种规范表示。
  • keyset 读取:predicate + sort + fetchLimit;agent 过滤用第二个 descriptor,形态是 agentID == X && sequence > after
  • 读取映射成 DTO 后按实际编码大小裁剪页;不能返回的行在摄入时就拒绝。
取舍 直接复用现有 SessionArchive 的 SwiftData 模式(@ModelActor、autosave off、显式计数器、WriterLease 含 O_CLOEXEC),但把它当第二个 writer、也不改它的 schema。archive.preparePrompt() 之后接 journal.append() 仍是两次 save——哪怕都用 SwiftData。本章是在应用自己的 KastellStore 里另起一个 HistoryStore

完成判定

  • note 只出现在 journal;timeline 计数仍为 0。
  • 相同 note 重试 → 返回原 receipt,不新增行;不同内容同 operationID → 冲突报错。
  • 同样的操作在错误/回滚路径下两个历史、计数器、操作状态一起回滚。
  • 第二个 host 打开同一 store → 得到 "store already open" 友好错误。
  • 不要重复定义 DTO(01 的 LogRow 等被替换掉)。
  • 操作重试比较的是 action/agent/输入,不是重新生成的观测时间戳。
  • 不要在 actor 外缓存可变的 @Model 实例;只传 Sendable 快照。
  • 配置不能跨两个 ModelContainer 或两个独立 save 的 actor——本设计没有这种事务。
03a

Host HTTP API:暴露两个已持久化的历史

a-track

目标:同一个 loopback host 现在打开 HistoryStore,暴露两个独立分页的持久集合。路由不做"按需从 raw 事件重建消息"。

产出物 / 接口

Endpointa-track 行为
POST /v1/notes保存 journal note;不创建对话消息
POST /v1/agents/:id/promptsintent + canonical user + operation + busy 状态一次 save;返回 admission receipt
agent create/start/abort/stop、operation review应用命令不变;由新的 store 方法完成 save
GET /v1/logJournalEvent 快照 → JournalPage
GET /v1/timelineTimelineMessage 快照 → TimelinePage(新增)
GET /v1/messages/:id按 UUID 读一条已存规范消息;缺失 404(新增)
GET /v1/operations/:idoperation 快照,含 userMessageID 与持久 outcome
GET /v1/healthhost/store 身份 + 支持的 history views
  • 没有 POST /v1/timeline任意调用者不能注入伪造的 assistant 消息:user 走 prompt admission,pi 消息走运行时摄入,同一个 writer。
  • receipt 形状不变且 sequence 永远是 journal 位置;找规范消息用 operation.userMessageID 或 journal 行的 canonicalMessageID
  • DTO 的 Content conformance 只写在 host target;CLI 的依赖保持 Foundation + KastellProtocol,不 import SwiftData。host 不直接把活的 @Model 编码进 HTTP 响应。
  • 路由是薄的:校验 query → 调 store 方法。没有 "fetch journal → 解码所有消息 → 过滤" 的 handler。
  • health 增加 historyViews: ["journal","timeline"]projectionVersion: 1,方便客户端识别 a-track host。

新增的读取 DTO 要点

  • JournalPage/TimelinePage{storeID, stream, items, nextAfter};stream 是 "journal"|"timeline"
  • JournalRow = 原 wire 属性 + canonicalMessageID(可选链接,不代表新增消息)+ projectionVersion
  • TimelineRow = sequence, messageID, sourceJournalSequence, recordedAtMS, agentID, generationID, operationID, origin("submitted"|"pi"), role, messageJSON, projectionVersion。origin 为 submitted 时,sourceJournalSequence 始终指向创建该行的 prompt intent;稍后的 user echo 只从 journal 链接回原消息,不改变其来源。
原子性给客户端什么、不给什么 提交 assistant message_end 时,raw 行和 canonical 行一起durable;紧接着查这条消息不会看到"已确认但还没投影"的状态。
两个独立的 GET 发生在不同时间:两次读取之间 agent 可能写入,两者的行数不必构成同一快照——一个响应更新不是数据损坏。用 provenance ID 追某条消息,而不是比较无关页的长度。

另一个推论:user timeline 行不会在 operation 变成 accepted/rejected/interrupted 时改变。状态属于 operation 资源和 journal;否则已经越过该行 cursor 的客户端会错过更新。

完成判定

  • 两终端跑通:host 启动 → curl /v1/healthcurl POST /v1/notescurl /v1/log?after=0curl /v1/timeline?after=0
  • note 只出现在 journal;timeline 空但合法。不要为了填满屏幕添加假 assistant 行。
  • 错误:负/格式错/超前 cursor、非法 limit → 400;store 不符 → 409;消息不存在 → 404;错误体保持 {code, message},不泄漏框架内部。
  • 不要把 SQL 课的 journal.sqlite 或 SessionArchive 文件喂给新 schema 然后指望 SwiftData 导入。
  • 03a 不在任何已有 store 旁边再加第二个 store。
  • 空过滤结果不证明 agent 不存在——存在性用 agent endpoint。
04a

CLI:分别检查原始日志与阅读对话

第一个有用终点

目标:一个客户端同时提供 raw 检查(journal)和日常阅读(timeline),背后是同一个 store 的两个持久集合。客户端不引入 SwiftData 依赖。

命令面

kastell log      [--after N] [--agent UUID] [--follow] [--cursor-file PATH] [--json]
kastell timeline [--after N] [--agent UUID] [--follow] [--cursor-file PATH] [--json]
kastell message  UUID
kastell note / prompt / agent / operation / ...   ← 沿用原章节的请求形状
  • 日常对话用 timeline;解释回答、缺答、进程失败用 log + operation
  • timeline 不是 CLI 自己的一次性格式化缓存——它跨重启存活。
  • 不需要"通用插入消息"命令。

规则

  • 两个命令绝不共用裸 cursor(§3):分别使用 cursor 文件;保存的 cursor 带 stream + agent;command 与 cursor 的 stream/filter 不匹配就报错,缺少 stream 的旧文件要求用户重置,不许猜。冲突的 --after--cursor-file 直接拒绝。
  • 解码后先检查 page.stream 与期望一致;空页才结束;先打印行、后保存 cursor——崩溃可能重复输出,但绝不能跳过。
  • 瞬时 GET 失败从"最后渲染的位置"重试,带小的有上限延迟;解码错误、store 变化、顺序异常不是瞬时网络错误。
  • 过滤后的 sequence 可以跳号;不要求相邻。Ctrl+C 只停止观察,abort host 里的 agent。
  • 渲染用不同前缀区分两条流(J# / T#),短 ID 只用于显示,JSON 输出和 API 请求保留完整 UUID。
  • timeline 行解码的是 messageJSON(不是 payloadJSON.message);user content 可能是 string 或 text-block 数组;assistant 可能含 text/thinking/tool calls——保留结构,预览不替代存储;未知 role/content 以 opaque/raw 方式显示,不允许"消失";终端控制字符要转义。
  • --json 每行输出一个完整编码的 DTO + LF(真正 JSONL,不 pretty-print)。人类预览可以缩短,存储 payload 和 JSON 输出不静默截断。
不要拿 timeline 当操作状态订阅 user 行只是 [submitted] + operation ID;kastell operation <id> 才是当前状态。不要轮询 timeline(after:) 然后期待它通知"某条旧行的 operation 完成了"——它是 append cursor,不是所有相关对象的 change-data feed。把可变状态塞进历史消息只会让误解更深。

provenance 的读法

  • journal 行有 canonicalMessageIDkastell message <UUID> 拿已存规范消息;如果是 user echo,拿到的是原始提交消息,不是第二条 user 行。
  • 想知道 timeline 行的来源 → 从 sourceJournalSequence - 1 读 journal 并定位该 sequence;来源可能是 prompt intent 而非 pi 事件。
  • 客户端从不自己用 agent_end.messages 对账/去重:timeline 由 host 持久化一次,所有客户端因此天然一致。

完成判定

  • kastell note 后:log 能看到,timeline 仍空(正确)。
  • timeline --follow --cursor-file …log 可以并排使用;Ctrl+C 后 host 里无副作用。
  • 故意用错 cursor 文件 → 本地拒绝,而不是静默跳消息。
05a

pi 运行时摄入:顺序读取,双历史原子提交

a-track 核心

目标:限额内且成功提交的 pi 记录保存在 journal;合格的非 user message_end同一次 SwiftData save 中生成 canonical 行;user echo 链接到已提交的意图,不新增用户消息。重启后,原始证据与可读历史仍可相互追溯。

受控的实时摄入范围

  • 每个 generation 同时只有一个普通 prompt;无队列、steering、follow-up。
  • host 控制 profile,初始关闭 tools 与 resource discovery。
  • 进程运行期间不做 session 切换、branching、扩展注入 user 消息、历史消息导入。
  • 将收到的限额内 stdout/stderr 记录写入 journal;只从 message_end 创建 canonical pi 消息,协议其他位置出现的消息副本不再投影。
  • 同一进程中的后续 prompt 可使用 pi 内存中保留的上下文;新 generation 从空白开始。持久化两个历史不会自动重建上下文。

发送顺序(必须严格遵守)

reserve prompt slot
  → store.admitPrompt      (intent + canonical user + operation/busy 一次 save)
  → install response/settlement observation
  → store.markAttempted    (attempted 生命周期 + phase 一次 save)
  → Commander.writeLine(text: encoded single-record prompt)
  • 任何 prompt 字节发出之前:规范 user 行已存在、响应 waiter 与 settlement 观察已安装、prompt 的 pi RPC ID 就是 operationID。
  • pi 事件可能先于 acceptance 响应到达;它们的归属靠"已 admitted/attempted 的 active operation",不能依赖"已经见到 accepted"。这个关联要保持到最终完成处理或 reader drain 结束,而不是响应 waiter 返回就结束。
  • JSONEncoder 编码单条记录;不要往 JSON 里插字符串、不要在 writeLine(text:) 前自己加 LF。尺寸在 markAttempted 前校验。发送仍可能部分失败——attempted 了没响应 = 不确定

收到的记录只有一个持久身份

  • stdout 和 stderr 各自一个 ordinal;读完一整行、调用 store 前分配。
  • 新记录要求 ordinal == persistedHead + 1,与 journal 行一起保存新 head——检测应用层跳号/乱序。
  • 重试 append 时复用同一个 ordinal、原始行和稳定捕获元数据。身份已存在时:比较(generation/agent/source/ordinal + 原样 payload)→ 要求 ownership hint 一致 → 返回原 eventID/sequence/canonical 链 → 不重新应用生命周期转换
  • 先检查"已有相同 receipt"再要求 generation 还活着:读取已提交结果无害,追加过期新记录有害。不一致是错误,永远不是 upsert。
  • 不要把新生成的 Date() 与原始观测时间比较后当成内容冲突;重放复用第一次提交的元数据;operation 关联也不按"当前谁 active"重算。
这个身份解决不了什么 live 的 message_end envelope 通常没有 session-entry ID。ordinal 标识的是 host 的一次观察,不是全局可识别的 pi 消息。它能挡住"重试同一次 store 摄入"和已知协议 envelope 重复,无法识别以全新 ordinal 到达的任意重传。两条文本相同的 message_end 可能是两条合法消息——保留它们,不要 hash 文本合并。

store 返回捕获结果,不返回活模型

IngestResult {
  journalSequence, eventID,
  canonicalMessageID?, timelineSequence?,
  wasReplay, projectionFailure?
}
  • user echo 时 timelineSequence 指向已有的 user 行,不代表新增。
  • wasReplay 表示"已经保存过的同一原始观察",与正常 user echo 不同。
  • projectionFailure = raw 证据已提交但按本策略无法解释。
  • 这与公开 HTTP 的 MutationReceipt 分开:一个确认客户端动作,一个是 host/runtime 的私有契约。

一次同步的"分类 + 保存"算法

ingestPiRecord(agent, generation, stream, ordinal, raw, ownership hint):
  1 查已有身份 → 若已保存:比较后返回 replay
  2 校验新 generation 的 ownership 与允许的捕获状态
  3 校验下一个 stream ordinal 与 payload 限额
  4 解析路由字段,同时完整保留 raw payload
  5 用 host 持有的 runtime/request registry 校验 operation 关联
  6 分类:journal-only / user echo / 新 canonical 消息 / 协议故障
  7 commit(内部无 await):
      J = 下一个 journal sequence;插入 raw JournalEvent
      若为新 canonical:分配 T 与 message UUID;插入 TimelineMessage
        (sourceJournalSequence = J);journal.canonicalMessageID 指向它
      若为 user echo:journal.canonicalMessageID 指到 operation.userMessageID;
        记录 userEchoJournalSequence;不插 TimelineMessage、不加 timelineHead
      把已识别的 response/settlement 证据应用到 operation 字段
      推进该 generation 的 stream head
      一次 save
  8 返回不可变 IngestResult
  • 可捕获状态包括 starting / running / stopping(只要 readers 仍被拥有并在 drain);启动时的 get_state 响应必须在 generation ready 之前就可 journal。retired generation 不能追加新记录。
  • 不是每条记录都必须属于某个 prompt:启动 health 响应、空闲诊断可以 operationID=nil 或关联到明确对应的 start/control 动作。但完成的对话消息必须属于本 generation 的 active 普通 prompt——不能因为"现在忙"就把迟到的响应挂到别的 operation。

三条分类结果的细则

journal-only

  • 插 raw、推进 journal 计数器与 pipe head、save。
  • 推进 timeline 计数器。
  • 未知但合法的事件类型保持 journal-only,除非将来显式加新的 projection policy。
  • stderr 编码成 {"text":…},永不当模型消息解析。两条 OS 管道之间没有精确实时顺序:journal sequence 记录的是 writer 合并观察顺序。

user echo

  • 要求:generation 与 active operation 匹配且状态为 attempted/accepted;规范 user 消息存在;尚未绑定过别的 user-end 观察;echo 内容等于原始提交文本(string 或纯 text blocks;无意外图片/非空附件;不 trim 掉有意义的空白)。
  • 时间戳与 pi envelope 差异不构成第二条 user 消息——细节留在 journal,canonical 链接原消息。
  • 同一 operation 的第二条不同 user message_end 是协议策略故障,即使文本相同(不同于同 ordinal 的重复摄入,那种情况在分类前就 return 了)。排队/提示扩展不能悄悄变成第二条 user 行。

新的非 user 消息

  • 要求:owned active prompt + 有效完成 envelope;正常流程要求 user echo 已先绑定,顺序相反是 profile/协议异常,要检查而不是猜。assistant 的 stopReason=pending 不算完成 payload。
  • identity:message:<generation UUID>:<stdout message_end ordinal>;origin=pi;role=message.role。
  • canonical 对象用确定性 JSON 编码;raw journal 字符串保留收到的记录。不丢弃 thinking、tool 参数、错误消息、usage、附件、未知字段。canonical 尺寸也要校验。
  • assistant 消息同时设 operation.lastAssistantMessageIDtoolUse 的 assistant 是完成消息但不是终局 outcome,后面可能还有 assistant;失败的尝试和自动重试都按 timeline 顺序保留。

生命周期证据与它来源的同一次 save

  • 成功的 prompt response → raw response journal 行 + operation.acceptedJournalSequence + state=accepted,一次 save。
  • 属于 active operation 的 agent_settled → raw settled 行 + operation.settledJournalSequence,一次 save。不要求先 accepted:异常快序可能让 settlement 信号在 controller 处理 acceptance 之前就到。先各自 latch 证据,再决定完成。
  • 重复但不同的 settlement 事件保留,但不能完成下一个 operation、也不能凭空造 timeline 行。
  • 匹配的 success:false:journal +(在没有矛盾的已观察执行证据时)同 save 记录权威拒绝。若已有 user echo 或 assistant 输出却声称"接受前拒绝",这是异常——保留证据并 interrupt,而不是干净地标成 rejected。
  • controller 不再重复调用 markAccepted——摄入已经存了权威响应和 phase;它只需更新本地 latch,然后在持久前提满足时调用一次 finishPrompt
finishPrompt 的前置条件 校验 accepted 证据 + settled 证据 + user 绑定 + 最新 assistant 的终局 outcome,然后一次 save:append prompt.settled、更新 operation outcome 与 agent 可用性。timeline 行保持不可变。settlement 之后没有终局 assistant = interrupted,不是伪造成功;已知的 error/aborted assistant 按该 outcome 完成,不算正常成功。
原始 settlement 标记和最终 host 完成事务仍可能是两次 save——恢复时可能看到"证据多于最终状态",07a 对此诚实处理。但raw message 与它的 canonical 行在正常摄入路径中永不拆开

reader 纪律与关闭

  • 只有一个 stdout reader。它收到一行 → await store.ingestPiRecord(...) → 有效则路由 response/latch 事件 → 故障则通知 controller 并转 journal-only 继续 drain → 读下一行。
  • reader 绝不能 await 一个只有它能读的 abort/health/checkpoint RPC 响应;settlement 或故障时通知一个单独持有的 controller task,然后 return。
  • response channel 有界、写前安装、在 response/timeout/EOF 时恰好完成一次。inbox 不能在 raw 证据保存前把成功响应交给 waiter;已保存的重复摄入不要当作新观察重新投递。
  • 关闭:停止 admission → 保留 active generation/operation 关联 → stop/reap Commander → store 保持打开时 join 两个 reader(正常停止期间缓冲的最终 message_end 仍可创建 canonical 行)→ 之后才 retire generation、把未决 operation 标 interrupted。

完成判定

  • 真实 prompt 走完:admission → attempted → response accepted → user echo(timeline 计数不变)→ assistant canonical(与 raw 同 save)→ settled → finish。
  • 重复摄入同一条 raw 记录 → replay,无新行、无重复状态转换。
  • 构造一条语义无效记录 → raw + history.projection_failed + generation stopping;之后只 drain,不再投影,也不假装 timeline 完整。
06a

多 agent:一个 writer,两个全局历史

a-track

目标:多个 pi runtime 共享一个 HistoryStore。各自有独立的 live operation,但 journal 与 canonical 消息由同一对持久计数器排序。

结构

Host / AgentManager
  slots[A] → PiRuntime A ─┐
  slots[B] → PiRuntime B ─┼──► ONE HistoryStore model actor
  slots[C] → PiRuntime C ─┘      ├── journalHead / timelineHead
                                 ├── JournalEvent / TimelineMessage
                                 └── agent / generation / operation records
  • 绝不为每个 agent 建 ModelContext。那会让计数器、唯一性和原子双写复杂化,在这个规模没有收益。pi 调用并发;短的 store 方法只把持久化工作串行化。
  • 每个 runtime 和路由用同一个 store handle。manager 拥有进程 slot/reservation,runtime 拥有当前 operation 与子任务,store 拥有合法的持久转换。inbox 只观察协议事实,不自行启动下一个 prompt。

create / start / prompt 的持久边界

  • createAgent(一次 save):HistoryAgent + create operation/receipt + agent.created journal 行(含 profile 快照)。无 timeline 行;agent 初始为 stopped。幂等重试要在"今天 host 是否还定义原 profile"之前先解决——第一次提交的配置与 receipt 是权威。
  • beginGeneration:新 generation + agent generation 计数 + start operation/receipt + agent.start_requested。manager 在 await 这个 save 之前先占容量 slot,返回 202 前 retain 好 launch task。
  • markReady:校验观察到的 state 响应、generation ownership、无 pending stop/fault,然后 save agent.ready + running/idle + start outcome。无 timeline 行。
  • 如果 launch await readiness 期间来了 stop:不能盲目发布 idle;用同一 reserved slot 完成清理。容量计数持续包含 starting/running/stopping,直到 child/readers 真正结束。
中心事务:admitPrompt 在发送任何 RPC 字节之前,把原始 user 输入先存好:
operation(prepared, userMessageID=M, receipt=J) + journal J (prompt.requested, canonicalMessageID=M) + timeline T (user, origin=submitted, sourceJournalSequence=J, messageID=M, 完整原始消息) + agent busy/activeOperationID —— 同一次 save
重复的 operationID 比较 action+agent+文本后返回原 receipt 与消息 handle,不再调度。规范 user envelope 里固定的 admission 时间戳在客户端重试时不重新生成

为什么 SwiftData 不能省掉 reservation

manager 看到 idle
await store.admitPrompt(...)     ← 挂起期间其他请求/关闭可以进来
manager 标记 busy                 ← 已经太晚了
  • actor reentrancy 依然存在。流程:① 登记 reservation(含 operation 身份)→ ② await 那一个 store admission → ③ 重新校验 reservation/generation → ④ 对 created 安装持有的 operation task → ⑤ 重复调用者用同一 receipt 解决、绝不再发 → ⑥ 失败时只释放仍是当前的 reservation,不动新 slot。
  • 用内部 enum 区分 createdreplayed;只有 created 启动 runtime 工作。对外两种都返回同一个原始 MutationReceipt
  • shutdown 等待已登记的 admission handoff 完成;commit 成功但执行装不上时必须显式记录 interrupted——不能返回成功却留下没有 owner 的已接纳行
  • 客户端断开只结束"这个客户端的等待",不取消已接纳的 agent 工作。之后的持久重试即使 agent 忙/已停/换 generation,也返回原 receipt。

完整 prompt 生命周期(每步写什么,观察顺序可交错)

方法 / 观察JournalTimelineOperation 变化
admitPromptprompt.requested原始 user 行prepared, busy, user 指针
markAttemptedprompt.attemptedattempted
匹配的成功 response 被摄入raw pi.responseaccepted + response provenance
user message_end 被摄入raw 事件链接 user UUIDecho 绑定
非 user message_end 被摄入raw 事件链接新 UUID完整 canonical 消息适用时记 last assistant
agent_settled 被摄入raw 事件settlement 证据
finishPromptprompt.settled终局 outcome;条件允许时释放 prompt slot
权威拒绝被摄入raw rejectionrejected;原 user 行保留
interruption / reviewhost 生命周期/review 事件interrupted / abandoned;原行保留

receipt 只关于 admission;acceptance 和 settlement 是各自独立的持久事实;完成的消息不一定是 operation 的最后一条。

finishPrompt 的完整性(一次同步 commit 内)

  • 要求:同一个 active agent/generation/operation、无 projection fault;matching accepted + settled journal 引用;原始 user 行及其 pi echo 绑定;属于本 operation 且 stop reason 终局(stop/length/error/aborted,不是 toolUse/pending)的 last assistant。
  • Append prompt.settled(含 outcome 与终局 message ID);写 operation outcome、清 active prompt 指针;仅当 generation 仍在 running 且没有未决 abort/stop reservation 时才暴露 idle。
  • 同一个已完成结果幂等:第二次完成回调返回已有 outcome,不追加生命周期事件、不清掉更新的 operation;冲突结果是要检查的错误。
  • 两历史不可变:不会把旧消息改成 "completed";改变的是 operation 状态,journal 记录该变化。length 保持可见为受长度限制的完成,不要偷偷标成正常完整回答。

abort / stop 是普通的持久控制

  • abort 目标是 admission 时 active 的 prompt(存在 HistoryOperation.targetOperationID),不能误伤后续 prompt;控制 reservation 保持到响应/完成处理结束。若 pinned prompt 已结束,记录 no-op,不要对着新工作发 abort。
  • stop 阻止新 admission、标记 stopping、保留 generation 的捕获 fence 直到缓冲输出 drain 完。正常 stop 时最终 message_end 仍可生成 canonical 行;projection fault 的 generation 已在 journal-only 模式,不能恢复投影。
  • review/abandon 用各自的 operation ID + target operation ID 记录,保留所有历史消息。它们的含义是"显式选择不重放不确定的工作",不是撤销外部副作用。
  • 控制类操作不产生 timeline 消息:timeline 是对话,生命周期控制属于 journal 与 operation 快照。

完成判定

  • 两个 agent 各自 prompt、交错运行。示例交错:
J#20 A prompt.requested        → T#1 A user
J#21 B prompt.requested        → T#2 B user
J#22 A pi.message_end(user)    → 链接 T#1,无新行
J#23 B pi.message_end(user)    → 链接 T#2,无新行
J#24 A pi.message_update       → 无 timeline 行
J#25 B pi.message_end(assistant)→ T#3 B assistant
J#26 A pi.message_end(assistant)→ T#4 A assistant

两条流都不按 agent 分组、也不按 provider 时间排序——它们是各自流里 writer 观察到的顺序;关系由 provenance 链接解释,而不是比较两个无关的 sequence。

  • 把 06 原 controller 里额外的 markAccepted 调用留下来(05a 已把 acceptance 证据并入摄入 save)。
  • "commit 成功但 task 安装失败"的窗口没有显式处理。
  • 把 settlement 当成"必须有成功 assistant"→ 缺终局 assistant 时伪造回答(应标 interrupted)。
07a

重启:两个历史都还在,但不自动重演

a-track

目标:host 重开同一个 SwiftData store,保留两个历史集合与游标;未完成的工作被表示为 interrupted,不伪造 canonical 消息、不重放 prompt。

原子性让边界更强——但不是所有边界

§5 的"一次 save"保护的是已提交的观察,不是"未观察到的模型/tool 行为"。host 仍可能在收到/保存之前丢失输出,或在发出 prompt 后失去执行确定性。pi 和数据库之间从来不是事务关系。

重启后每种持久状态怎么处理

最后的持久状态重开后 journal / timeline怎么做
没有 admitted operationintent 与规范 user 都不存在客户端可以用它的 operation ID 重新提交
prepared admissionintent 原始规范 user 都已存在标记未完成工作 interrupted;不自动发送
attempted,无 acceptance同上 user + 已捕获证据投递不确定;绝不推断为拒绝
accepted 但只有 deltas证据在;还没有 completed assistant 行deltas 留在 journal;不编造最终回答
有效 message_end save 已提交raw 事件 + canonical 消息都在即使整个 run 被中断,两者都保留
settled 已观察,最终 host 转换缺失消息/证据在;operation 可能仍非终局保守标 interrupted,保留证据
最终生命周期/outcome 已保存两历史与终局状态一致重试返回已有 receipt/outcome
显式 projection faultraw 证据 + fault 标记在;部分 canonical 行故意缺失显示故障;不声称 timeline 完整
记住 被中断的工作仍可能包含有用的完成消息——不要因为无法证明整个 operation 完成就删掉它们。

启动顺序(保守)

open history.store,取得单 host lease
加载持久 storeID 与 journal/timeline heads
生成新的 hostID
reconcile 旧的非终局 generations/operations
append host.started
创建空的 live runtime registry
打开 client admission
  • 对每个旧的非终局 generation,一次 save 里:有界 agent.interrupted 生命周期事件 + 其未决 operations 的 interruption 事件/outcome + retired/interrupted generation 状态 + 清空 active 调度指针。
  • 不碰JournalEvent/TimelineMessage 行;不重置任何 sequence head、不重新生成 canonical message UUID。同一历史重开时 storeID 不变,只有 boot 的 hostID 变。
  • 旧的 projection fault 保持可见;被中断的 prompt 即使 active 指针清了也可从 HistoryOperation 找到。beginGeneration 要按个人 review 政策检查未解决的 interrupted 工作,而不只是看 active 指针是否 nil。
  • 不把 raw journal 重放给 pi;也在每次启动重放进 timeline:正常投影已原子提交,盲目重放只会重复历史或改变身份。

review / abandon:保持简单

  • kastell operation abandon <interrupted-operation-id> --reason 'Reviewed; start fresh':review 有自己的 mutation UUID,目标是那个 interrupted prompt;一次 save 记录 review 事件/receipt 并把目标标为 abandoned。
  • 删除规范 user 行、移除 assistant 输出、声称撤销了外部影响。目的是让你的决定可见并防止意外重放——不是行政审批系统。
  • 不干净退出后,先 reconcile 可能残留的进程再启动替代 host。SwiftData 的 generation flag 杀不掉也收养不了旧 child。
  • 将来若把 review UX 简化成一条"start fresh, don't replay"命令,底层仍保留同一个显式持久决定;不要因为客户端重试了旧 prompt 请求就让这个选择变得隐式。

shutdown 顺序(8 步)

  1. 关闭 manager 的 admission barrier。
  2. join 进行中的 admission handoff,确保没有已提交但无 owner 的 operation。
  3. 把 generation 标 stopping,同时保持它们对捕获有效
  4. 要求 active 工作 abort,带边界等待。
  5. stop/reap 每个 Commander,同时 stdout/stderr readers 继续。
  6. join readers 与 operation/control/launch 任务。
  7. 为未决 operation 保存最终 generation outcome 与 interruption。
  8. 若存储可用,append host.stopped,然后释放 store 与 lease。
  • 正常停止时迟到的缓冲完成消息仍写入两个视图;projection fault 之后按 05a 保持 journal-only。两种情况下都不能让 wait() 赢得竞争就跳过未读管道缓冲、提前 retire fence。
  • reader task 还可能调用时不要关 context/container;不要依赖 deinit 做异步进程清理;用唯一的 host/Vapor 生命周期协调器,而不是互相竞争的 exit handler。
  • 共享 store 失败 → 停止 admission 并驱动生产者清理;单个模型响应失败通常只影响它自己的 operation,不影响所有 agent。

retry 与 failure 之后的 "canonical"

timeline 的内容 = 每个 admitted operation 一条原始 user 意图 + 每个合格非 user message_end 观察一条完整 canonical 行。§4 已说明它不是什么。补充这条重试语义:

  • pi 在失败 assistant 尝试后自动重试 → 两次完成的 assistant 观察可以有各自的行与 provenance。
  • turn_end/agent_end 的副本不增加行。
  • 数据库 ingest 用旧 wire 身份重试 → 不加任何行。

客户端日常视图可以隐藏 thinking、折叠失败尝试——那是展示。结构化 payload 与全部 canonical 行的可访问性必须保留;operation 状态和终局 stop reason 独立于"消息存在"。

需要 timeline rebuild 命令吗?——M1 不需要

两个集合一起保存;现在加入通用重放引擎会引入比它解决的问题更多的代码。projectionVersion=1 记录的是哪版 canonicalization policy 创建了行。将来真的因为 bug 或策略变化要重建,那是停写的显式离线修复/迁移,不是启动逻辑。真要动之前先面对这些问题:

  • prompt intent 行上记着原始 user ID/链接;raw message_end 行带 canonical ID 与 generation/operation 归属。
  • 必须复现原来的 sequence 分配与策略,或有意识地做版本化;简单生成新 ID/计数器会让已保存的 cursor 失效。
  • faulted generation 可能含 journal-only 尾巴(当时没有被接受为 canonical 消息),需要 review 而不是自动导入。
  • 改变投影需要显式兼容计划:stream=timeline + 旧 store 身份不能悄悄指向一个重排过的历史。
  • 现有操作行与 receipt 也是持久权威——本课程承诺整个数据库能从 raw 事件重建。一个有 provenance 的事务型 store 已经足够有用,不需要自称完整 event-sourcing 框架。

历史可见 ≠ 上下文恢复

  • 重启后两个历史可读;fresh pi generation 仍是空的。这是第一个应用可接受的清晰边界。
  • 将来若要加语义文本续接:用显式 renderer 遍历该 agent 的合格 canonical 消息,不是拼接 raw journal 行;把原始 user 意图与"任何派生出的历史前缀"分开保存。
  • 当前 live user-echo 校验期待原始文本。未来 renderer 必须记录 expected wire-text digest / renderer version,并对派生请求校验 echo,同时链回原始 user UUID——否则 bootstrap 文本要么过不了校验,要么递归变成新的 canonical 历史。这是显式的后续变更,context=fresh 并不已支持。
  • 也不要假设只读 canonical 表就足以安全恢复:compaction 和其他影响上下文的事件可能只在 journal 里。保守的第一版 renderer 对不支持的 tools/images/compaction/未知上下文变化与不确定 operation 直接拒绝,而不是悄悄忽略。这些限制针对 resume,不影响消息能否被结构化存储。
  • SessionArchive 是这个问题上有价值的参考(original-intent binding、稳定 checkpoint receipt、受限语义渲染),但它的 get_entries checkpoint 路径不是能直接加在 live timeline 上的第二个 importer——没有稳定的跨来源身份协调,它会插入已存消息的第二份拷贝。一个 generation 只选一种 canonical ingestion 策略。

SwiftData 维护守则

  • 用 model/container API;不装自定义 trigger、不背地里改 pragma、不把检查未文档化的表结构当正常行为。检查就通过 logtimelinemessageoperation
  • schema 是全新 V1,不是从 SQL journal 或 SessionArchive 的迁移;学习期间把那些文件分开。真要转换,写显式 import(保留 provenance)+ 新 store 身份。
  • 一旦 schema 里有了你在意的数据:冻结版本,变更走新的 version/migration。迁移或手工修复前,对关闭状态的 store 目录做一致备份(含 sidecar 文件)——不要只拷贝活着的 SQLite 主文件。
  • append-only 是应用契约;SwiftData 的 var 和 unique attribute 不是防篡改存储。单协作 host + 私有 mutation API + 一个 writer + 显式 save/rollback + 没有 delete/update 路由,是合理的边界。

最终文件布局(可合并小文件,职责边界比文件数重要)

MVP/Sources/
  KastellProtocol/
    HistoryValues.swift      # 提供的双历史 DTO
    Requests.swift           # agent/prompt/control 输入
    Responses.swift          # operation/agent/problem/health DTO
  KastellStore/
    Models.swift             # 提供的 SwiftData V1 schema
    HostLease.swift          # 单本地 host 租约
    HistoryStore.swift       # open、save/rollback、identity、有界读取
    HistoryMutations.swift   # notes、admission、生命周期转换
    PiIngestion.swift        # raw 捕获 + canonical 选择,一次 save
  KastellRuntime/
    PiRuntime.swift          # child/readers/当前 operation 所有权
    RPCInbox.swift           # 私有路由/waiter helper
    AgentManager.swift       # reservations、slots、shutdown barrier
  KastellHost/
    HostMain.swift           # composition root + lifecycle
    HostConfiguration.swift  # host-only 路径/端口/profile 配置
    Routes.swift             # journal/timeline 端点 + 命令
  KastellCLI/
    ClientMain.swift / HostClient.swift
    HistoryCommands.swift    # 独立的 typed cursors 与 renderers

完成判定

  • 重启后 log --after 0 / timeline --after 0 / operation <id> 三视角对得上;timeline 行能顺着 provenance 追到 journal,并通过 operation 解释执行结果。
  • 重启前未完成的工作显示 interrupted,无自动重发;abandon 记录决定且不删内容。
  • 全流程没有"启动时重放 journal"这一步。
08a

设计复盘:让双历史个人应用长期保持简单

收尾章

目标:把"两个历史都要"的决定长期维护成简单系统,而不是论证掉其中一个需求。设计任务变成:让它们简单协作。

六条长期原则

  1. 正确的简化是"一个 store",不是"一个视图"。一个 model actor 保存两种表示;没有要同步的 journal 数据库与对话数据库、没有要监督的后台投影 worker。这个安排可以用很久。少量 payload 重复是保留"证据 + 可读历史"的代价,不是设计坏了。在尺寸真的不便之前,不要优化成共享 blob 引用或内容寻址存储——显式模型更容易学习和检查。
  2. timeline 策略要小、要写下来。核心规则(§4)值得反复记住。它是一个适度的确定性投影,不是通用语义去重引擎;不要合并相同文本、不要猜两条相似消息哪条"才算数"。限制也清楚:live ordinal 是 host 观察身份,不是 pi 原生 entry ID。若将来"导入/恢复原生历史"成为中心需求,要有意识地重新考虑 canonical ingestion,而不是加第二个来源做模糊匹配。
  3. SwiftData 应该移除管道工程,而不是隐藏 save 边界。保持显式:service context 关闭 autosave;mutation helper 只 stage 不独立 save;公共领域方法一次 save 并返回快照;rollback 覆盖两个视图与计数器;只有一个 writer。这已经足够,不需要再套 unit-of-work 框架。
  4. 在脑子里把 runtime 和历史分开。journal 说 host 观察到了什么;timeline 说观察策略把什么变成了逻辑消息;runtime 决定下一步发生什么。两个集合都不证明进程活着,都不应被自动重放为可执行命令;fresh generation 的空上下文不是存储故障。operation 行连接了意图、证据与执行结果——保持小而领域化,不要变成通用 workflow engine。
  5. 两个视图都要好用,不只是"技术上存在"。日常 timeline 可读;调查用 log 保留细节;都显示 agent 与 generation;J#/T# 与 provenance 链接让你不容易混淆两个独立顺序,并能回答"这条消息从哪来"。operation 状态保持在不可变 timeline 行之外:一个标着 submitted + operation ID 的 user 行,比"存在即已执行"的行诚实。展示层可以之后再改进,不用重写存储历史、也不新增事件订阅服务。
  6. 别让 full capture 变成无尽基础设施。journal 可能很吵。先做显式有界 save + 简单轮询客户端。捕获造成明显延迟时,先看 flush 频率、重复编码、writer 上的读取负担,再谈架构变化。有界批处理是合理的后续优化——前提是"response 在批提交前不算 captured"且源顺序保持——但不要作为第一版。同样,不要因为 timeline 可以说成"物化视图"就加正常启动重放:原子摄入意味着例行恢复只需 reconcile 未完成 operation,而不是重建两个历史。

从今天起的六个日常里程碑(也是 M1 使用验收)

  1. 保存一条 note,只在 journal 里看到它。
  2. Admit 一个 prompt,看到一条 canonical user 行。
  3. 收到 user echo,timeline 计数不增加。
  4. 收到完成回答,看到 raw 证据 + 一条 canonical 回答(同一次 save 的结果)。
  5. stop/reopen host,检查同样的行与 provenance。
  6. 跑两个 agent,看到同样的规则在交错下成立。

这些是应用的普通使用里程碑,不是再建一个验证框架的理由。走顺之后就去用它;重构你真正遇到的重复 flag 或让人困惑的方法边界。

目标设计一句话 一个个人 host、一个 SwiftData writer、两个互补的持久历史、一个不读完整代码库就能解释行为的 runtime 边界。

原 08 章仍成立的通用建议

  • 显式拥有 children/tasks;不盲重放不确定的 prompt;注意能让多个 tool-capable agent 共享文件的风险。个人 app 的简单工作规则往往够用:一个 workspace 一次只让一个 agent 编辑,或给并发编辑的 agent 独立工作副本/worktree。不要为此造文件系统事务系统,把 workspace ownership 变成显式设计选择即可。
  • 成本同理:避免自动 retry/restart 循环在你以为停止后继续产生调用。
  • 不要做分布式 scheduler、message broker、多 host writer、插件框架、账号/权限系统。API 留在 loopback;同机的其他程序能访问不代表你要把个人 CLI 变成身份平台。真要暴露到机器之外时再重新考虑安全边界。
08

M1 验收清单

按场景验证,保留可复查的结果

教程不另设测试规划路线,而以终端会话验证正常工作流。请实际执行以下场景,并保留命令、关键输出和相关 ID;自动化测试可作为补充。A 组在 04a 结束时可完成,B、C 组需要 05a–07a,D 组用于最终使用验收。

A. 边界与首个往返(01–04a)

  • swift build --package-path MVP 通过;模块依赖符合 §2;CLI 仅依赖 Protocol 与 Foundation,不依赖 Store 或 Runtime。
  • 同时只有一个 host 能打开 store;第二个得到友好错误。
  • note:只出现在 journal;timeline 空;同 operationID 重试返回原 receipt;不同内容同 ID 冲突。
  • GET /v1/log / GET /v1/timeline 各自校验自己的 head;storeID 不符 409;畸形 cursor 400。
  • logtimeline 的 cursor 文件互不干扰;错误 cursor 被本地拒绝。
  • 短页不会让 CLI 提前停止;Ctrl+C 不影响 host 中工作。

B. 单 agent 摄入(05a)

  • admission 后立刻有一条 canonical user 行,operation 为 prepared→attempted。
  • user echo:journal 增加、timeline 计数不变、链到原 user UUID。
  • assistant message_end:raw 行与 canonical 行一起 commit(同一 save 的结果);未完整/未知消息不会被猜进 timeline。
  • 重复摄入同一 raw 记录 → replay,无新行、无重复状态转换。
  • 语义无效记录 → raw + history.projection_failed + generation stopping;此后只有 journal-only drain。
  • accepted 和 settled 的证据各自独立落盘;settlement 无终局 assistant → interrupted,而不是假回答。

C. 多 agent 与恢复(06a–07a)

  • 两个 agent 交错运行;journal 与 timeline 顺序不按 agent 分组,provenance 能解释对应关系。
  • busy agent 的 prompt 被拒绝(明确错误),不进队列。
  • 客户端断开不取消已接纳工作;同一 operation 重试返回原 receipt。
  • abort 只作用于 admission 时 pinned 的 prompt,不误伤后续工作。
  • 正常 stop 后重启:两个历史可读、游标继续、storeID 不变、hostID 变。
  • 未完成工作显示 interrupted;abandon 记录决定、保留所有消息、不重放。
  • 启动过程没有 journal 重放,也没有 timeline 重建。

D. 六个日常里程碑(08a,最终使用验收)

  1. note 只进 journal。
  2. prompt → 一条 canonical user 行。
  3. user echo → timeline 计数不变。
  4. 完成回答 → raw 证据 + 一条 canonical 回答。
  5. stop/reopen → 同样的行与 provenance。
  6. 两个 agent 交错 → 规则依旧成立。
09

硬规则:不变量集中版

这十条比任何抽象都值钱

教程把它们散落在各章;这里集中。实现任何新功能前先问:会不会破坏其中一条?

  1. Commit before success(提交先于成功)。成功的 note/operation receipt 指一个已提交的数据库事务,不是内存队列里的待办项。
  2. One journal order(唯一 journal 顺序)。sequence 由数据库 writer 分配,永不来自时间戳或 agent 本地计数器。
  3. No mutation of facts(事实不可变)。修正与生命周期变化都 append 新事件;操作表可以与事件在同一次 save 里原子更新,但历史行不改不删。
  4. One active prompt per generation(每个 generation 同时最多一个 prompt)。任何可能让第二个 prompt 进入的 await 之前先 reserve busy 状态。SwiftData 不消除 actor reentrancy。
  5. No blind resend(不盲发)。尝试写入管道但没有 pi 响应 = 不确定,不等于 pi 什么都没做。绝不自动重发。
  6. Journal + canonical 同一次 save。没有后台 projector、没有最终一致性窗口;投影失败是显式的窄例外,且那之后只 drain 不投影。
  7. 只有两条路径能创建 timeline 行:被接纳的 user 意图、可信运行时摄入的非 user message_end。客户端不能插入任意消息。
  8. 重放看身份,不看文本。同 identity 返回原结果;相同文本 + 新 operationID = 新动作;绝不 hash 文本合并。
  9. reader 纪律。只有一个 stdout reader;它绝不 await 只有自己能读的 RPC 响应;response 在 raw 证据保存前不能交给 waiter。
  10. 关闭有序。停 admission → join handoff → generation 保持可捕获地 stopping → reap child → join readers/tasks → 存最终状态 → 释放 store/lease。重启后 fresh context,不自动重放。
来自 08 的补充判据 "如果增加一个生命周期事件需要你在好几个对象里更新同样的 flags,就先简化所有权,再加回调。" 以及:不要为了"每个类都有接口"而加 protocol——只有一个实现、没有真实替换需求时,具体类型就好。
10

M1 明确不做的事

边界清单,防止项目膨胀

这些不是"以后做"的路线图,而是 M1 故意不做的清单。每一条都可以在需要时单独重新决策。

产品面

  • 无 GUI(两个终端 + CLI 就够)
  • 无远程访问/账号/权限系统(loopback only)
  • 无分布式 scheduler、无 message broker、无多 host writer
  • 无插件系统、无自动任务委派、无 branching
  • 无自动 replay、无自动恢复对话上下文(semantic-text-v1 保留但初始拒绝)

数据与协议面

  • 无 WebSocket / SSE / live subscriber fan-out(轮询 cursor)
  • 无 background projection service、无 checkpoint watermark
  • 无 blob 去重 / 内容寻址存储、无通用 event bus 抽象
  • 无 timeline 重建命令、无历史导入(get_entries 路径不混入)
  • 初始无 tools 与 extension discovery;是否开启是之后的显式 capability 决定
  • 无任意 pi RPC 字段透传;prompt 不接受图片、steering、follow-up 队列
  • 无每 client 的 message queue
11

诚实清单:M1 不承诺什么

写下来,免得日后误以为它们该成立
  • "All messages" 有精确定义:host 收到并成功提交的、限额内的完整记录(含 tool 与 error 消息)。不是"模型说过的一切"。
  • 没有持久确认协议:RAM-only 的 pi 进程在崩溃时可能丢弃未被接收/未提交的尾部;M1 暴露这个限制,不承诺无损捕获、不承诺 exactly-once 执行。
  • live ordinal 是 host 观察身份,不是 pi 原生 entry ID:无法识别以全新 ordinal 到达的任意重传;也不能把 live 流与 get_entries 历史按内容做身份协调。
  • timeline 不是 pi session 树的精确副本;faulted generation 存在 journal-only 尾巴——那条 timeline 不能被声称完整。
  • 两个独立 GET 不构成同一快照;user 行不会随 operation 状态变化而更新。
  • 客户端崩溃可能重复输出已渲染的行(先打印后存 cursor 的代价),但不应跳过任何行。
  • 单 host lease 不是分布式锁,不提供多用户保护。
  • append-only 是应用契约,不是防篡改存储:SwiftData 的 var 属性、unique attribute、底层表都可被写入;靠"一个协作 writer + 私有 mutation API + 无 delete/update 路由"约束。
  • save 失败无法被记录在同一块盘上(如磁盘满);此时回滚全部并走 host 失败路径。
  • 超限记录无法完整保留(>1 MiB):只在可能时记录有界的 size/error 评估。
  • 历史可见 ≠ 新进程记得:fresh generation 上下文为空;语义续接是显式后续功能,且需要新的 echo 校验设计。
  • 流程重试 ≠ store 摄入重试 ≠ pi 自动重试——三种 retry 语义不同,设计分别处理(见 §7/07a)。
12

关键取舍一览

每个决定的"换来了什么 / 放弃了什么 / 何时重审"
决定换来放弃何时重审
两个历史都持久化(选择 08a 而非原 08 的"选一个")证据完整 + 阅读自然;两种用途各自最优一定存储重复;需要写清楚投影策略存储尺寸真的不便时,先优化编码/flush,不砍历史
一个 store、一个 writer、一次 save无同步/无后台 projector;journal→timeline 无一致性窗口每次摄入一次同步 save;写吞吐受单 writer 限制每记录 commit 变贵时考虑有界批处理(保持"batch 提交前不算 captured")
只从 message_end 做 live canonical 投影策略小、可解释、立即一致没有稳定 pi entry ID;不做历史导入联合若"导入/恢复原生历史"成为中心需求,重新设计 ingestion
轮询 cursor,不推送简单、断线天然恢复、无 per-client 队列延迟(几百 ms)、每次 GET 有开销延迟真成为问题时,先定位延迟在哪一段(provider/pipe/DB/渲染)
busy 就拒绝,不进队列无 scheduler、行为可预测、一个 generation 一个 prompt并发提交要客户端自己重试出现真实的排队需求且能解释清楚再做
分层限额(1 MiB / 64 KiB / 32 KiB / 8 MiB)内存与响应有界、轮询不会卡死超限内容无法保留尺寸成为实际阻碍时调数值,而不是删掉限额
SwiftData(a-track)而非手写 SQLite模型/读取易读,与现有 workspace 一致透明的 SQL 检查、直接控制表结构原 SQL 线保留对照;不要同时维护两套
重启后 fresh context边界干净、不盲重放、不伪造上下文新进程不记得旧对话真的需要续接时,用显式 renderer + 新 echo 校验(07a 已给出设计要点)
客户端不自己做去重/投影所有客户端对 timeline 天然一致客户端不能改变规范消息的身份与存储投影;仍可自定义展示展示层的折叠/隐藏可以,改变存储顺序不行
Vapor 4 处理 HTTP专注应用层语义,不手写 HTTP 解析一个外部 server 依赖不需要重审(对个人 app 结论稳定)
13

术语表

按首次出现的语境精确定义
Journal
观察日志。客户端意图、host 生命周期、host 从 pi 收到并成功提交的限额内 stdout/stderr 记录的 append-only 全局序列;允许同一逻辑消息出现多次。
Timeline
规范消息集合。每个 admitted user 意图、每个合格的非 user message_end 各一行;跨 agent 的独立全局序列。
Canonical
"逻辑上一条消息一行",不是"全局一份字节"。timeline 行是物化的规范表示;payload 与 journal 有意重复。
Projection(投影)
从 journal 观察决定"是否产生 canonical 行"的固定策略(§4 表格)。原子发生在每次摄入的同一个 save 内,不是后台任务。
Provenance(来源链)
timeline 行 ↔ journal 事件 ↔ operation 的双向可追踪链接(sourceJournalSequencecanonicalMessageID)。
Admission(接纳)
prompt 正式进入系统的持久化时刻:intent + canonical user + operation + busy 一次 save,发生在任何 RPC 字节发出之前。
Attempted / Accepted / Settled
prompt 的三个持久里程碑:已尝试写入(不确定的前沿)、pi 已确认接受、会话级 settlement 已观察。三者在不同 save 中独立落盘。
Receipt(回执)
{storeID, operationID, sequence};确认客户端动作已提交。sequence 永远是 journal 位置。重试返回原回执。
Operation
一次客户端意图的持久记录(note/prompt/abort/review…),带 phase、消息绑定、outcome;连接意图、运行时归属与执行结果。
Generation
一次 pi 进程/上下文化身。它不证明进程活着,是数据库用来比较的 fencing token;每个 generation 有自己的 stdout/stderr ordinal 头。
Agent
命名的逻辑 worker,跨 stop/start 存活;不是 PID。
Cursor(游标)
"在这个 store、这条 stream、这个过滤条件下,位置 N 之后":{storeID, stream, after, agentID}。两条流不能共用裸数字。
Wire ordinal
某 generation 某条 OS 管道上收到的第几条完整记录,从 1 开始;作为收到的原始行的持久身份的一部分。
Keyset pagination
sequence > after + 排序 + fetchLimit 取下一页,而不是 offset;配合"直到空页"的客户端循环。
Owner(所有者)
每个资源恰好一个;负责 retain、cancel、await 清理。Swift 的 actor 不会自动成为所创建 task 的 supervisor。
Reservation(预留)
在 await 之前抢先登记的临时占用(如 prompt slot),防止 actor reentrancy 让第二个请求进入;提交后只有"仍是当前"的 reservation 才被释放。
HostLease
单机单 host 的租约文件;防止两个终端误开同一个 store。不是分布式锁。
Journal-only draining
projection fault 之后的模式:继续读存原始记录,但不再产生 canonical 行、不恢复投影;故障显式可见。
Fresh context
重启后显式启动空白 pi 上下文;历史可读但不自动成为 prompt 内容。语义续接是后续显式功能。
14

资源与文件结构

读什么、复用什么、注意什么

教程阅读顺序(a-track)

Tutorials/01-MVPBoundaries02a-SwiftDataHistory03a-SwiftDataHostAPI04a-JournalTimelineCLI05a-PiJournalTimeline06a-SwiftDataMultiAgent07a-SwiftDataRecovery08a-DualHistoryDesignReview

原 SQL 线(02–08)保留作对照;03-HostAPI/API.md 的写接口形状在 a-track 继续有效。

工作区里可复用的资产

已有内容关系
Sources/Commander启动、写入、排空、停止 pi 复用;保持协议无关。注意:当前 API 需要 workingDirectory:、用 writeLine(text:)(不是旧文档里的无标签写法)、当前实现会追加 LF 且不拒绝内嵌 LF;SIGTERM 走 Foundation terminate()
Sources/SessionArchive已解决 canonical 逻辑对话、checkpoint 去重、受限语义 resume 的参考;不是 raw event journal、不是 supervisor,也不是能直接并联的第二写入方。
Tutorials/ProcessLab管道、Swift concurrency、进程清理的深入前置;VERIFIED.md 记录了实测注意事项。
Tutorials/SwiftDataLab存储与"从数据库派生 pi 上下文"的深入前置。
examples/piAgentActor小型 batch 示例,不是服务;它的无界 stream bridge 不是多 agent 摄入队列。
scripts/pi-rpc现有 session-file launcher;本课程改为显式 --no-session 启动 pi。

pi 协议注意事项(以本地安装包为准)

  • 参考版本:本地 pi 0.85.1rpc.mdsession-format.mdsettings.md;若上游 tag/新版本不同,以你安装包的 docs/ 为权威
  • 本课程用 agent_settled(会话级完成边界),不是 agent_end
  • message_end.message = 权威的完整消息;message_end 不是持久 pi entry 身份,也不保证 tools 成功。
  • 发送 prompt 用 JSONEncoder 输出单条记录;绝不把用户文本插进 JSON 字符串,绝不自己拼 LF。

环境

  • macOS 26+、Swift 6 language mode、Swift 6.0 tools;SwiftData 宏与示例需要完整 Xcode:export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
  • 数据库固定在 MVP/.state/history.storeMVP/.gitignore 排除 .build/.swiftpm/.state/;凭据与运行日志不进仓库。

一句话结尾

先做小、做对、做得能解释:一个 host、一个 writer、两个历史、多个 agent、一个诚实的 CLI。剩下的复杂度,等真实使用提出要求再说。

↑ 回到开头

Bonus — 我的审读笔记与实现手册

通读全部章节之后:接缝决定 · 调试剧本 · 故障演习 · 变更准入 · 成长阶梯

这一章不是教程内容,也不改变任何设计。它是通读 01 + 02a–08a(并对照原轨道)之后,我认为最值得补上的东西:教程给你的是设计;而落地时最容易出错的,是各章之间的接缝,以及那些“看起来都对、就是不对”的时刻。教程本身仍是权威,下面是我个人的笔记,你可以把它当成一份可修改的 checklist。

B1 · 接缝处的十个决定

这些是两个章交界、契约只被“勾勒”而没有写死的地方。它们不需要现在回答“对错”,但需要你明确选一个,并且写下来——哪怕只是一行注释。我做实现的习惯是给每个决定写三行:背景 / 决定 / 后果。

  1. 区分运行时归属与持久状态校验。
    • 教程事实:runtime 在发送前装好 active operation(05a);store 校验显式传入的关联(02a);manager 负责调度(06a)。
    • 我的建议:runtime/controller 持有当前进程与请求的关联;store 依据持久化的 active 指针、generation 归属和 operation 状态,校验调用者传入的 (agent, generation, operation) 三元组。两者职责互补:runtime 不能绕过数据库校验,数据库中的 busy 指针也不能证明进程仍在执行。
  2. 把两套状态机的转换表写下来。
    • 分别列出合法转换,而不是串成一条通用状态链。note 可直接为 recorded;prompt 的主要路径是 prepared → attempted → accepted → settled,另有拒绝与中断分支;interrupted → abandoned 需要显式 review。agent 的 idle/busy 是调度状态,应与 generation 的 starting/running/stopping 分开说明。
    • 写一张“事件 × 当前状态 → 新状态 / 是否允许 / 同时写什么”的表,作为唯一参考;代码里做一个 isAllowed(transition) 之类的校验。特别写死:settled 只表示“结束”不表示“成功”;settled 之后不接受 accepted 回退;interrupted 优先于 idle;busy 指针只能由已定义的 admission、完成、拒绝或中断事务更新,不由无关回调修改。
  3. reservation 复验清单要精确到字段。
    • 06a 的六步里,“revalidate”最容易被写成“大概没事”。我的复验项:(a) operation 身份与登记时一致;(b) generation 仍是当前 generation,且状态允许当前动作(新 prompt 要求 running;starting/stopping 可捕获记录,但不因此允许新 prompt);(c) 没有未决的 control/abort/stop reservation;(d) 没有 projection fault;(e) 容量 slot 仍属于这个 reservation;(f) manager 还没有关闭 admission barrier。
    • 释放规则同样要精确:失败时只释放仍是已登记那一个的 reservation(按身份比较,不按 slot 序号),绝不能释放后来者。这个函数值得配注释和一个 debug 断言。
  4. markStopping / retireGeneration / reconcileOnStartup 是三件事,不是一件事。
    • markStopping:fence 仍有效,readers 仍可写;retireGeneration:readers 已 join、禁止再 append、写最终 generation 状态;reconcileOnStartup:只发生在 boot,处理上一次遗留的孤儿。
    • 我的做法:让 retire 只有 shutdown 的 join 点能调用(把“已 join 的凭据”作为参数传递)。让“提前 retire”在类型层面就难写出来,比在 code review 里抓住它可靠得多。
  5. 解析发生在哪里,原始字符串永远原样保留。
    • reader 只负责读完整行、分配 ordinal;解析/分类放到 store actor 上(或 reader 侧解析成一个只含提取字段 + 原始字符串的 Sendable enum)。
    • 无论选哪种:raw 保留 Commander 交付的原始行文本,canonical 从解析结果确定性编码。这里不承诺字节级管道归档:Commander 会处理行尾,非法 UTF-8 或超长帧可能在交付前被拒绝。不要为格式美化重写 raw,也不必为了生成 canonical 重复解析。
  6. 把事件路由做成一张二维表。
    • 维度:事件类型 × 是否存在匹配的 active operation。格子里填:journal-only / 生命周期证据 / echo 绑定 / 新 canonical 行 / protocol fault。
    • 教程的规则分散在 05a/06a 各节;合成一张表贴在 ingestion 代码旁边,新增事件类型时先改表再改代码。特别注明:正常摄入时,无归属的对话 message_end 应保留原始证据并触发投影故障;已进入故障排空模式的 generation 则继续 journal-only——丢掉会让调查者永远不知道发生过什么。
  7. 时间只有一个来源,而且不参与冲突判定。
    • recordedAtMS 是 host 接收时间;admission 的时间戳在首次提交时固定、重试不重生成;排序一律用 sequence。
    • 建议集中处理时间获取;确有替换需求时再注入时钟函数或协议,并在比较函数上留注释:“时间戳不用于判断两条记录是否相同”。很多“重试变成了新事件”的 bug 都是这里漏的。
  8. 溢出与超限的失败姿势:fail closed。
    • 分配 sequence 或 ordinal 前检查溢出;raw/canonical 存储 JSON 分别受 1 MiB 上限约束,编码后的读取页受 8 MiB 上限约束。超限、计数器耗尽与语义投影失败要分别报告,不一律归为 projection_failed;存储不可用时转 host 失败路径。
    • 不静默回绕、不跳过、不“先存了再说”。这些分支在正常开发中永远不会跑到,正是它们值得在写的时候多想五分钟。
  9. 那些数字是策略,不是语义。
    • fetchLimit = 4、8 MiB、32 KiB、1 MiB、250ms……放进一个常量文件,每个两行注释写明“为什么是这个数量级”。
    • 验收脚本不要依赖具体数值(除非你正在验收限额本身)。否则将来调参时,会有一批“测试”替你把好设计锁死。
  10. projectionVersion 是兼容承诺;同时给未来的 renderer 留一扇可替换的门。
    • 承诺:同一个 storeID + stream=timeline + after,其含义不因投影策略升级而原位漂移。改策略需要新版本与明确的兼容或迁移计划;不支持的版本应显式拒绝,不能静默重解释旧行。
    • 建议现在就做的一件事:把 user-echo 校验写成一个纯函数(输入:原始意图文本/结构、收到的 echo、当前策略版本;输出:匹配/不匹配/异常)。将来加入语义续接 renderer 时,这能缩小校验逻辑的改动范围;仍需设计派生请求记录、来源追踪和恢复策略,不能认为只改一个函数就已支持续接。

B2 · 调试剧本:症状 → 最可能的违约 → 先查哪里

这张表是把 §9 的硬规则反过来用:看到症状,先假设某条不变量被破坏了,而不是先怀疑 pi 或系统。

症状最可能的违约先查什么
user echo 之后 timeline 计数 +1,多出一条 user 行echo 被分类成“新 canonical 消息”,没走绑定分支identity key 是否是 message:<gen>:<ordinal>;echo 校验/绑定逻辑(05a §5)
一条回答在 timeline 里出现两行update/turn_end/agent_end 建了行,或存在第二个 importer投影策略表(02a §2);是否有第二处创建 TimelineMessage 的代码
重启后消息全部翻倍启动时重放了 journal,或并接了 SessionArchive 导入启动流程是否只做 reconcile(07a §2);grep 全仓有没有“import/replay”
客户端少显示了消息,却没有报错共用了裸 cursor,或缺少 stream 校验cursor 文件是否分开、HistoryCursor.stream 是否被检查(04a §2)
--follow 轮询卡住或永远不前进存了超出 8 MiB 无法返回的行,或 nextAfter 没有推进摄入时的限额检查(02a §8);页裁剪是否“绝不跳过行”
agent 永远是 busy,任何 prompt 都被拒reservation 泄漏;或 finishPrompt 没清 active 指针失败路径只释放“仍是当前”的 reservation;finish 的前置条件与清理(06a §5/§7)
abort 杀掉了下一个 prompt没有 pin targetOperationID,按“当前 active”发送abort 的 pinning 规则(06a §8);已结束 prompt 应记录 no-op
重启后旧 pi 进程还在跑wait() 竞争跳过了 drain,或依赖 deinit 清理shutdown 八步(07a §4);是否有 boot reconcile 残留进程
waiter 超时,但消息已存在消息存在不代表匹配的 response 已到达;也可能是保存或路由延迟、RPC ID 不匹配先查 response journal 行与关联 ID,再查 waiter 安装、超时和提交后投递的顺序(05a §8)
出过一次投影故障后,timeline 静默地不再更新fault 后没有显示,或以为已经恢复投影history.projection_failed 是否可见;generation 是否在 journal-only drain(05a §7)
客户端频繁收到 409 store mismatchcursor 文件跨 store 复用,或数据库被复制/替换游标里保存的 storeID;确认 storeID 随数据库建立一次且不变(§3)
能同时启动两个 host路径规范化不一致、锁未取得或过早释放检查 lease 的路径、加锁与持有期;O_CLOEXEC 防的是子进程继承锁,缺失通常导致锁迟迟不释放(02a §6)
发送后 pi 毫无反应,attempted 已写入这往往不是 bug:它正是“不确定”路径。真问题是:有没有人在偷偷重发?grep 自动 retry/resend;确认 no blind resend(§9 规则 5)

B3 · 故障注入演习:没有测试框架的测试

教程明确说“终端会话就是正常工作流”。下面七个演习用于复现进程、存储和连接故障,补充正常路径与单元测试。请使用独立的临时 store 和可丢弃的测试进程,不在真实数据目录上演练。

  1. 在四个提交边界强制终止 host。 在四个时刻分别 SIGKILL:① admit 后、attempted 前;② attempted 后、acceptance 前;③ message_end 提交后、settled 前;④ settled 后、finish 前。
    通过判据:重开后逐个对照 07a 表——①/② 用户行存在、operation interrupted、无伪造回答;③ 至少一条 canonical assistant 在,operation 可能仍非终局但被保守标 interrupted;④ 若最终 host 转换尚未提交,仍保守标为 interrupted,重试返回原 admission receipt,并通过 operation 查询中断结果;只有最终转换已提交时才保留原终局 outcome。全程不允许出现“有 receipt 却无 intent”,也不允许“timeline 有答案但 journal 无 raw”。
  2. 只杀 child,不杀 host。 对 pi 子进程 kill -9
    通过判据:等待两个 reader 消费可交付的缓冲记录并结束,显式报告读取错误;generation 与未决 operation 标 interrupted,不伪造 assistant。child/readers 清理完毕后才释放容量;是否可再次 start,仍需检查未解决工作的 review 政策。
  3. store 写失败。 在可丢弃环境中使用受控故障注入,让保存步骤确定性地失败,再发 note。仅将已打开的目录改成只读未必能阻止已有文件描述符继续写入;无权限路径通常只能验证打开失败,不能代替 save/rollback 演习。
    通过判据:请求以可理解的错误失败;journal、timeline、计数器与操作状态一起提交或一起回滚,不存在“一半提交”;host 走失败路径而不是假装成功。
  4. 超大记录。 为演习临时把 1 MiB 上限调小到容易复现的值(例如让一条 stdout 行超过几 KB),触发拒绝路径。
    通过判据:不落盘完整超限内容;在存储仍可用且评估记录满足限额时保存有界 size/error 事件,否则向 stderr 报告。generation 停止;后续轮询不卡死;错误可见。
  5. 畸形 JSON 注入。 在 pi stdout 上制造一行非法 JSON(或用测试替身进程)。
    通过判据:raw wrapper + history.projection_failed + generation stopping;之后进入 journal-only drain,后续记录仍在 journal 可见。
  6. 断线重试。 分别验证两件事:关闭 --follow 只停止观察;另在 mutation 请求提交后、回执接收前断开客户端,再使用原 operationID 和原输入重试。
    通过判据:host 工作不受影响;返回原来的 receipt;timeline 不新增重复消息;新客户端从头读得到完整历史。
  7. 双开。 同时启动第二个 host。
    通过判据:第二个 host 得到清晰的 lease 错误并退出;第一个 host 不受影响;第二个 host 未打开写入路径、未修改历史数据(lease 文件的检查本身不等于写历史)。
演习的使用方式 不必每次都跑全。每当你改动以下任一处,至少跑相关的那个:admission/finish(演习 1、6)、reader/关闭(2、5)、store 写入路径(3、4)、lease/启动(7)。演习的价值在于:它们检查的是世界状态,而不是函数返回值。

B4 · 变更准入测试:加任何东西之前的十个问题

任何新功能、新事件类型、新命令,先检查下面十项风险。每项均确认“不存在该风险”后再勾选;若答案为“是”或仍不确定,先补充设计说明,不把勾选当作批准。

  • 它是否引入第二个 writer、第二个 ModelContext 或第二个 store?(§5)
  • 它是否会在某个 helper 内部悄悄 save()?(§5)
  • 它是否在两处合法路径(admission、可信摄入)之外创建 timeline 行?(§9 规则 7)
  • 它是否让 client 或 runtime 自己推导 canonical/去重策略?(04a)
  • 它是否引入了不被 reservation 覆盖的 await 窗口?(§9 规则 4)
  • 它是否依赖 wall-clock 排序,或让时间戳参与“是否同一条”的判定?(§3)
  • 它是否会自动重放、重发或“帮忙”完成不确定的工作?(§9 规则 5、07a)
  • 它是否为存储引入新的无上限 payload 类型或字段?(§5 限额)
  • 它是否让恢复“更聪明”:启动时重建 timeline、自动补完 interrupted operation、自动继续对话?(07a)
  • 它是否把可变状态塞进不可变历史行,或让旧行随 operation 悄悄改变?(03a、06a)
附加题(来自 08 的判据) 加了这一个事件之后,同一个 flag 需要在几个对象里更新?如果答案大于“一个决定 + 一个校验”,先简化所有权,再加回调。

B5 · 调试自检:debug 构建里的八条断言

这些断言可检查计数器、身份与引用的一致性,但完整扫描及关联校验会随数据量增长,不应视为恒定低成本。建议放进一个 debug-only 函数,在 启动 reconcile 之后需要时的自检命令里调用——不要让它在生产路径上每次摄入后都跑,更不要变成后台任务。

  1. journalHead == max(JournalEvent.sequence) == journal 行数,并检查 sequence 唯一且连续(空表的 max 按 0 处理;前提是从 1 连续分配且历史从未删除)。
  2. timelineHead == max(TimelineMessage.sequence) == timeline 行数,同样检查唯一性、连续性与空表情况。
  3. 每条 TimelineMessage.sourceJournalSequence 指向存在的 journal 行,且那条行的 canonicalMessageID == messageID
  4. 每个非空 canonicalMessageID 都指向存在的 TimelineMessage。
  5. 每个 agent 最多只有一个非终局的 active prompt operation。
  6. HistoryGeneration.stdoutHead/stderrHead 等于该流已存身份的最大 ordinal,且 1…head 无空洞(重放不算新 ordinal)。
  7. timeline 的 agentID/operationID 都指向存在的行;operation 绑定的 userMessageID 存在且 role=user。
  8. 所有 operation 的 state 在允许集合内;settled 的必须带 outcome;interrupted 的不携带伪造的终局 assistant 指针。

这些检查不能替代 B3 的故障演习——断言证明“账本自洽”,演习证明“世界变化时账本仍然诚实”。两者互补。

B6 · M1 之后的成长阶梯

下面的顺序是我个人的建议。核心原则:每一项都要连同前置条件一起批准。不满足前置条件的实现,不管多小,都只是在给未来的自己增加 B2 表格里的条目。

阶梯前置条件首要设计问题压力点
0. 先用它§8 全绿真实使用中哪些操作最烦?——(这一格才是最重要的)
1. 自检工具(B5)断言放 debug-only 还是显式命令?低风险、高回报,随时可做
2. 有界批处理per-record save 真的造成可感知延迟“captured”的新定义:批提交前不算 captured;顺序如何保持;waiter 何时释放原子边界与延迟的权衡;不要用批量破坏“证据先于投递”
3. 语义续接 renderer(semantic-text-v1)echo 校验已抽成纯函数;renderer version/digest 的存放位置已定哪些 canonical 消息有资格进入上下文?遇到 tools/images/compaction/未知事件怎么办?原始意图与派生前缀如何分离?echo 校验(否则 bootstrap 文本会失败或递归成新历史);上下文忠实度;保守拒绝优于静默忽略
4. 开启 toolsworkspace ownership 规则写下来(一个 workspace 一次一个编辑者,或独立 worktree)tool 消息如何显示与进入上下文?成本护栏在哪?共享文件系统冲突不是数据库事务能解决的;token/费用增长
5. 历史导入 / resume(get_entries 路线)接受“一个 generation 只有一种 canonical 策略”基于稳定 entry ID 的 canonicalization 如何设计?与 live 策略如何共存(不同 store?显式迁移?)跨来源身份协调;混用会产生第二份拷贝
6. 第二个客户端 / GUIAPI 已就绪(它本来就是为这个留的)展示约定(折叠、隐藏 thinking)放哪一层?不要为了 GUI 新增服务端状态;保持 timeline 由 host 持久化一次
7. 多 host / 远程访问认证、真正的协调与 lease 重审这是另一个项目,不是这个项目的一章M1 明确不做;别让“顺手加个远程”破坏单 writer 假设
如果只从 Bonus 带走三句话把衔接规则写下来。状态机、reservation 复验、路由表、retire 时机,都需要落实为明确的实现契约。
演习与断言互补。强制终止 host 能暴露正常路径难以覆盖的恢复问题;断言则检查保存下来的状态是否自洽。
成长阶梯上每一项都带着前置条件。先使用 M1;等真实需求出现,再按顺序升级,而不是提前把未来的复杂度预支到今天。

↑ 回到开头

15

运行风险与 M2 演进建议

补充审阅:并发 · 故障处置 · 阅读体验 · 后续能力

前文给出实施路线,Bonus 补充模块衔接和排错方法。本章继续讨论运行时容易忽略的风险,并列出 M2 的候选方向。 这些是补充审阅建议,不是已实现能力,也不自动扩大 M1 范围。涉及新状态、错误码或命令时,应先定义契约并验证,再纳入实现。

E1 · 并发与进程管理的五个风险

Swift 6 的严格并发检查有助于发现隔离与 Sendable 使用问题,但通过编译不等于状态机正确,更不代表底层 I/O、外部进程和不安全接口已得到验证。以下五点需要在实现中单独检查。

  1. 跨 await 保留未提交的 ModelContext 改动。
    风险:actor 方法在 await 处可能挂起;挂起期间,其他任务可以进入同一 actor。若共享 context 中仍有未提交改动,另一次 save 或 rollback 就可能把不属于本次操作的状态一并提交或撤销。并非每个 await 都实际挂起,但设计必须按“可能重入”处理。
    规则:属性修改、关联校验与 save() 必须处于同一个无 await 的提交边界。异步准备在边界外完成,进入后重新校验状态。
  2. 管道背压与关闭顺序。
    风险:管道容量有限,不能把某个容量数值当作跨系统保证。下游停止读取后,生产方可能阻塞或等待可写;此时只等待 child 退出而不排空输出,可能无法完成关闭。
    规则:正常关闭时保留 stdout/stderr 单消费者循环,stop/reap child 后再 join readers。当前 Commander 在直接子进程退出后会做有界尾部捕获并关闭描述符,避免后代进程持有管道导致无限等待;上层应消费其可交付记录并处理终止错误,不能承诺总能等到物理 EOF 或完整捕获所有后代输出。
  3. 轮询重试错误地推进或重置游标。
    风险:M1 是 loopback 上的普通轮询,不是长轮询。即使在本机,请求也可能超时或连接中断;服务端完成响应不代表客户端已经成功展示。凭猜测推进游标会漏读,每次失败都重置为 0 则会重复全量读取。
    规则:成功展示非空页后推进到 nextAfter短页也一样推进并继续读取;空页才暂停,follow 模式等待后再查。瞬时 GET 失败从最后已展示的位置有限退避重试;格式错误、顺序异常与 store 变化应显式报错。
  4. 直接子进程退出,不代表整个进程树已清理。
    风险:Commander 只管理直接子进程。开启 tools 或扩展后,其派生进程可能继续运行、持有管道或占用端口;仍在运行的孤儿进程与已退出待回收的僵尸进程不是同一概念。
    规则:M1 初始关闭 tools 与扩展发现。异常退出后,先核查残留进程再启动替代实例;锁文件、旧 PID 和 generation UUID 都不足以单独证明进程归属。任何清理命令都需核对身份并明确确认,不能按进程名批量终止。进程组管理属于后续显式设计。
  5. 绕过 SwiftData 检查活动数据库。
    风险:外部写入可能破坏框架维护的状态;长读事务可能阻碍 WAL 回收,但不能简单等同于所有写入都会报 busy。immutable=1 声明文件不会变化,不适用于仍在写入的活动 store,也不是通用安全只读开关。
    规则:日常检查使用 kastell logtimelinemessageoperation。需要离线取证时,先关闭 host,备份完整 store 目录及 sidecar 文件,再在副本上分析;不把未公开的底层表结构当作应用接口。

E2 · 故障处置矩阵

故障时的原则是:停止扩大影响,保留能够确认的证据,不伪造成功。下表沿用 M1 的失败路径;超时阈值、诊断事件名和 HTTP 状态码仍需在实现时明确,不在这里追加一套未经验证的降级协议。

故障场景 触发判据 即刻动作(Immediate Action) 绝对禁令(Never Do) 留存证据与恢复(Recovery)
磁盘空间耗尽
(ENOSPC)
HistoryStore.save() 抛出底层的 POSIX I/O 错误或 SQLite 写入失败 ① 立即中断当前 admission/ingestion 流程;
② 调用 context.rollback() 清空脏数据;
③ 关闭 admission,通知 controller 停止生产方并协调清理;
④ 保留任务与 reservation 的所有权,直到 handoff、child 与 readers 清理完成;不因 save 失败就提前暴露 idle。
严禁在内存队列中无限缓冲脏数据;
严禁假装保存成功;
严禁把“错误必须写回数据库”作为清理能够结束的前提。
证据:向 stderr 报告失败阶段、相关 ID 与错误摘要;诊断本身也不保证持久保存。
恢复:处理磁盘问题后重启,按 07a 对账。仅未接纳的操作可用原 ID 重新提交;已接纳工作返回原回执,未完成者标为 interrupted,不能盲目重发。
pi 进程无响应
(Freeze / Hang)
已登记的 RPC 等待超过配置时限;这是超时证据,不足以证明 pi 完全没有执行 ① 对当前 generation 发起 RPC abort 并等待短宽限期;
② 若仍需停止,调用 Commander.stop(gracePeriod: .seconds(5))(5 秒仅为示例),同时保留 readers;
③ reap child 并 join readers/tasks,保存最终 generation 状态与未决工作的 interruption;
④ 清理完成后才释放容量;不得仅因 timeout 就宣告停止成功。
严禁在未确认直接子进程退出前盲目拉起新进程;
严禁猜测性地向 timeline 写入半截伪造的 assistant 消息。
证据:存储可用时,记录超时评估、控制请求与实际退出结果;具体事件名沿用实现契约。
恢复:客户端查看 operation 与 generation 的持久状态,完成必要 review 后再决定是否 start;超时不等于权威拒绝。
输出帧超限
(传输层与存储层分别检查)
单行 stdout 字节数突破 Commander 的 8 MiB 硬上限,非阻塞读抛出帧超限错误 ① 将 reader 抛出的帧错误通知 controller,停止该 generation 的正常工作;
② 若存储可用,保存 API 实际提供的错误信息;当前错误不保证携带原文、完整大小或头部预览;
③ 停止并回收 child,join readers;已终止的流不能继续 drain,另一条可读管道仍应处理;
④ 持久化未决工作的 interrupted 评估。不要把传输错误伪装成一次已交付完整 raw 的投影失败。
严禁为了容忍超大行而在内存中动态扩大缓冲区;
严禁把超大垃圾数据塞入 Timeline 规范流导致轮询卡死。
证据:尽力保留有界错误评估;不能承诺保存超限原文。Commander 的 8 MiB 帧上限、存储 JSON 的 1 MiB 上限与读取页的 8 MiB 上限是不同边界。
恢复:检查输出来源与限额配置,完成 review 后再启动;不假定所有超限都是模型重复输出。
记录格式不合法
(Malformed JSON)
已交付的限额内 stdout 行不是合法 JSON,或其消息结构违反 V1 策略;未知但合法的事件类型不自动算故障 ① 保留 raw journal 记录(存入 raw wrapper:原始文本 + parse error 摘要);
② 记录 host 事件 history.projection_failed
③ 同一次 save 设置 HistoryGeneration.projectionError、标记 stopping,并记录未决工作的 interruption 评估;
④ 返回 projectionFailure,通知 controller 停止;仍可读取的后续记录只进 journal,不再投影。
严禁使用正则表达式脑补或猜测破损的 JSON 结构;
严禁静默忽略解析错误导致历史静默丢失。
证据:在 wrapper 编码后仍满足限额且 save 成功的前提下,保留原始行文本与有界解析错误摘要,不承诺完整堆栈。
恢复:通过 journal 排查版本或协议问题;旧 timeline 保留,但明确标示该 generation 的历史不完整。
数据库物理损坏
(DB Corruption)
SwiftData 在打开或读取时报告存储损坏等不可恢复错误;具体包装错误取决于系统与框架版本 ① 启动时发现则拒绝开放 admission;运行中发现则停止接纳并协调清理;
② 不再尝试应用层写入或自动修复;
③ 输出错误与人工处置指引;
④ 清理后以非零状态退出。
严禁系统在未获人工授权前尝试自动运行危险的修复指令;
严禁在同名路径下直接静默创建新库覆盖坏库。
证据:关闭所有使用者后保留整个 store 目录及 sidecar 文件;不要只移动主文件或覆盖原件。
恢复:从一致备份恢复,或显式建立新 store。恢复旧备份会使历史回退,不能直接沿用备份之后的 cursor;需冻结旧游标并制定身份或迁移方案,避免相同序号指向不同历史。

E3 · 展示降噪与完整存储

日常阅读不需要暴露每个协议细节,但展示简洁不能以删减持久证据为代价。应把“保存哪些消息”和“默认显示哪些内容”作为两个独立问题处理。

默认视图聚焦对话

  • Timeline 用于读对话:保存已接纳的 user 意图与合格的非 user 完成消息,包括失败尝试、tool 消息及非终局 assistant;不是只保留最后一次成功回答。流式 message_update 不创建 timeline 行。
  • Journal 用于查过程:协议事件、增量、stderr 与生命周期证据留在这里。客户端可提供简洁状态提示,但不得把“消息存在”当作 operation 已成功。

Thinking:完整保留,按需展示

  • 存储规则与 §4/05a 一致:运行时实际提供的 thinking 若包含在合格 message_end.message 中,应连同其他字段完整保存在 canonical messageJSON;原始事件也保留在 journal。不能只保留 journal 而删去 canonical 中的 thinking。
  • 阅读与续接另行决定:CLI 可默认折叠 thinking,并保留查看完整结构的入口。未来 renderer 是否将这些内容送回模型,必须按能力与上下文策略明确决定;M1 不承诺获得模型未公开的内部推理。
显式意图,明确控制边界 M1 的模型工作由客户端显式提交的 prompt 发起,不引入自动任务委派或无限重启循环。note、start、stop、abort、review 各有自己的操作身份,并非都源于 prompt。host 负责可解释的调度与记录,但不承诺模型输出或外部副作用具有确定性。

E4 · M2 候选能力与前置条件

先完成 M1 的实际验收,再根据使用问题选择后续能力。下列方案不是承诺交付的路线图;它们需要新的设计评审,尤其要明确哪些 M1 契约继续成立,哪些需要版本化变更

演进模块准入前置条件核心设计方案严守的 M1 边界
阶段 A:确定性语义续接渲染器
(Semantic Resume Renderer v1)
M1 的 07a 重启与恢复已在多终端验证稳定;echo 校验已实现为纯函数 ① 实现纯函数渲染器 TimelineRendererV1
② 从指定 agent 的合格消息中选择预算内上下文,输出时恢复正向 sequence 顺序,并检查 journal 中影响上下文的事件;
③ 明确 tokenizer、预算、裁剪单位和模板版本,不任意截断消息结构;
④ 保存 renderer version、所选来源与派生请求摘要;摘要用于一致性检查,不是无碰撞身份或安全签名;
⑤ 原始 user 意图与派生 wire 文本分开保存,echo 按派生请求校验,再链接回原始 user UUID。
绝不使用不透明的 session 文件做隐式重演;
不支持的 tools/images/compaction、未知上下文变化及不确定 operation 应显式拒绝。保留可追踪的渲染记录,但不把 append-only 应用契约称为防篡改保证。
阶段 B:受限工具能力与独立 Worktree 单个 Agent 的生命周期与中断处理已无悬挂 bug;已建立代码仓库级版本控制 ① 当开启 pi 的 tools(如 bash / edit / read)时,Host 为每个 Agent 在 .kastell/worktrees/<agentID> 下创建独立的 Git worktree;
② 设置独立工作目录以减少并发编辑冲突;worktree 不是安全沙盒,bash 或绝对路径仍可访问目录外资源,凭据与权限边界需另行设计;
③ 合格的工具结果 message_end 按同一投影策略进入 Timeline;
④ 人工审查分支改动后合并。可考虑新增合并命令,但 kastell merge 目前只是候选接口,不是已有能力。
Git 用于版本管理与合并,不隔离任意文件写入、网络访问或工具副作用。真正的沙盒需要操作系统级限制;不要把工作目录约定写成安全承诺。
阶段 C:可重建的外部搜索索引
(可选)
Timeline 规范流读取接口已就绪;分页游标逻辑已通过所有压力演习 ① 先确认实际搜索需求,不直接修改 SwiftData 的底层表或安装数据库插件;
② 如确有需要,再实现独立的 kastell-indexer(候选组件);
③ 该进程以普通客户端身份通过 GET /v1/timeline?after=N 轮询消费规范消息;
④ 在自己的独立 SQLite 库中维护向量嵌入(Embeddings)与 BM25 全文索引;
⑤ 按 storeID + messageID 幂等索引,提交索引后才保存游标;处理 store 变化,展示索引进度与滞后,先提供最小本地查询接口。
核心历史仍由一个 writer 维护;索引是可删除、可重建且可能滞后的派生数据,不是第二套历史权威。独立索引扩大了 M1 的范围,需单独批准,并限制轮询与计算负载,不能承诺完全没有性能影响。

E5 · 致实现者:让承诺与证据相称

这一轮审阅的收束

简单不是忽略故障,而是让每种故障都有明确的归属、可解释的状态和可执行的处理步骤。Kastell 值得保留的,不是“永不出错”的表述,而是以下三个边界:

  • 只有提交成功的意图,才返回持久化回执;
  • 只有实际收到并成功保存的观察,才成为持久证据;
  • 无法确认的执行结果,明确标为不确定,不补写一个看似成功的结局。

下一步应是实现并验证最小闭环,而不是继续扩展概念。等真实使用暴露问题,再决定要增加什么。

↑ 回到开头

16

终审意见与收尾建议

保留有效设计,纠正过度承诺,回到可验证的交付

延续前两轮审阅的做法,本章记录我的最终判断、这次修订的重点,以及交付前仍需完成的验证。终审的目的不是再加一层架构,而是让全文的承诺、术语和行动建议彼此一致。

F1 · 审核结论与适用范围

结论:可作为 M1 实施基准,不等同于产品验收通过 核心方案值得保留:一个 host、一个 writer、两个互补的持久历史;正常投影在一次 save 内提交,运行时输出与操作结果分别记录。本轮通读全文,并重点对照 02a 的模型与存储契约、07a 的恢复规则及当前 Commander 的读取与停止实现。报告中的领域方法多数仍是待实现的契约;页面审核不能替代 host、CLI 与数据库的端到端验证。

§0–§14 是实施主线;Bonus 与 §15 是补充建议;本章说明终审边界。M2 候选能力不计入 M1 验收,也不能因写进报告就视为已经支持。

F2 · 本轮纠偏要点

  1. 完整存储与简洁展示分开。合格完成消息中的 thinking、工具字段和失败信息保留在 canonical payload;CLI 可折叠,存储不能悄悄删减。完成消息不等于终局成功回答。
  2. 来源链接不可改写。submitted user 的来源始终是 prompt intent;user echo 只链接回已有消息,不重写其 sourceJournalSequence。
  3. 分清三类失败。语义投影失败、传输帧错误与数据库保存失败,能保留的证据不同;不能一律承诺“原文已入库并可继续排空”。保存失败也不能提前释放仍有任务持有的 slot。
  4. 恢复不补写成功。settled 证据已在、最终转换未提交,重启后仍保守标 interrupted。原 admission receipt 与终局 outcome 是不同事实;重试前必须分清。
  5. 收回未经支持的安全承诺。worktree 不是沙盒,内容摘要不是安全签名,append-only 不是防篡改存储;活动数据库不能用 immutable 声明绕过正常读取约束。
  6. 把建议写成可执行的条件。修正短页的游标推进、限额层次、故障演习判据与自检成本;删去“绝对完整”“确定性演进”等超出证据的措辞。

F3 · iPhone 阅读与验证边界

  • 排版:手机正文采用 16px 基准字号,缩小嵌套卡片留白;长标识符可换行,宽表格和等宽图各自横向滚动。保留页面缩放,并考虑安全区与减少动态效果的系统偏好。
  • 导航:窄屏目录可折叠,章节锚点保留;滚动高亮不再把手机页面拉回目录。目录链接、回顶按钮和勾选标签提供便于触控的操作区域。
  • 交互:验收项使用原生复选框,支持键盘与辅助技术;浏览器拒绝 localStorage 时仍能临时勾选。为避免修订后状态错配,不迁移旧版按列表序号保存的勾选记录。
  • 离线:样式与脚本均内嵌,无外部字体、图片或网络依赖。Safari 与“文件”App 的预览能力不同:不执行 JavaScript 的预览保留正文、表格和原生锚点,目录折叠、进度条及勾选增强需要支持脚本的浏览器。

本轮验证:WebKit 与 Chromium 在 320、375、390、430、844、1440px 视口下通过页面宽度、锚点、目录高亮、回顶、勾选保存和键盘操作检查;另检查本地文件打开、禁用脚本、拒绝本地存储及正文放大情形,未发现脚本异常或整页横向溢出。WebKit 的 iPhone 视口模拟不等于 iPhone 真机 Safari 或“文件”App 实测,仍建议在目标手机上按下列步骤复核。

F4 · 交付前的最后一轮验收

  1. 先验文档:在 iPhone 竖屏与横屏分别打开,检查页首、05a 长卡片、E2 宽表和本章;左右滑动只影响表格或代码区域,放大文字后正文仍可读。
  2. 再验交互:展开目录跳到 05a 与本章,测试回顶;勾选一项并重开页面,确认当前打开方式是否支持保存。若使用文件预览,不把脚本缺失误判为正文缺失。
  3. 最后验产品:执行 §8 的日常闭环与 B3 的相关故障演习,记录 storeID、operationID、J#/T# 和实际结果。未运行的项保持未勾选,失败的项保留失败证据。
我的收尾意见 这份计划最有价值的部分,是明确区分“意图已保存”“运行时已接受”“观察已提交”和“操作已完成”。请把这四件事落实到代码、CLI 与验收记录中。下一步不必再扩写计划,先让最小闭环真正运行起来。

↑ 回到开头