Kastell — 双历史个人 Agent 应用
怎么用这份计划
先理解,再动手,最后验收这份文件同时是「项目说明书」和「施工图」。建议分三种用法:
- 建立心智模型(一次通读):读 §1–§6。读完你应该能用三句话解释这个项目:一个 store 同时保存观察日志和规范消息;pi 运行时可替换,数据库才是持久状态的权威;任何操作提交成功后才算成功,对不确定的执行结果不作推断。
- 逐章实现(边写边看):按 §7 的 01 → 02a → … → 08a 顺序推进。每一章给出「产出物 / 核心概念 / 规则与取舍 / 完成判定 / 常见坑」。示例代码不抄进文档,需要时回原教程。
- 验收与防漂移:§8 是 M1 的验收清单;§9 是永远成立的硬规则;§10/§11 写清楚「不做什么」和「不承诺什么」——这两个清单同样重要,它们防止项目膨胀。
一页速览
这个项目是什么,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
三句话理解整个设计
- 一个 store,两个历史。同一个 SwiftData store 里既存 journal(host 收到并成功提交的限额内原始事件),也存 timeline(按固定策略保存的规范消息)。两者由同一个 writer、同一个 save 边界维护,不需要后台投影任务。
- pi 运行时可替换,数据库是持久状态的权威。进程崩溃、host 重启都不丢已提交的历史。重启后历史可读,但新进程默认从空白上下文开始——这是明确的产品边界,不是缺陷。
- 提交先于成功,证据优于猜测。收到成功回执 = 已提交入库,不等于模型执行成功;发出去没等到响应 = 不确定,绝不盲目重发;解释不了的数据保留原始记录并显式报错,不编造"看起来合理"的消息。
M1 的定义
走完 a-track 全部 8 章之后你得到的东西,就是 M1:
中途可用点:04a。 到这里 notes 能落 journal、两个历史都能读;timeline 为空是正确状态。05a/06a 才把真实 prompt 和 pi 消息接进来。
初始限额与配置(沿用教程,调整前先评估)
| 项目 | M1 取值 |
|---|---|
| host 数量 / 数据库 | 1(单机 lease 防止双开) |
| pi 运行时数量 | 初始 2–4 个 |
| 每个 agent 并发 prompt | 1 个;busy 就拒绝,不进队列 |
| 读取方式 | 按 cursor 轮询(间隔约 250 ms,可按需调整),无 WebSocket/SSE/broker |
| 工具与扩展发现 | 初始关闭;之后开启是显式的 capability 决定 |
| 单条存储上限 | raw / canonical JSON ≤ 1 MiB |
| 单次请求体上限 | 64 KiB(HTTP mutation) |
| 用户输入文本上限 | 32 KiB UTF-8 |
| 单次读取响应上限 | 8 MiB(编码后),允许短页 |
| 分页参数 | after ≥ 0,limit 1…100,默认 100 |
| 存储层单次取候选行 | 最多 4 条,客户端持续拉到空页 |
架构全貌:三层协议与资源归属
谁拥有什么,谁不许做什么三层协议必须分开
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 负责三层之间的翻译。
每个资源只有一个 owner
| 资源 | owner | 生命周期 |
|---|---|---|
| 数据库连接 + host writer lease | HistoryStore | host 全程 |
| 网络监听 | kastell-host / Vapor | host 全程 |
| agent 身份与配置 | 数据库 | 跨 host 重启 |
| 活动运行时字典 / slots | AgentManager actor | host 全程 |
| 一个 pi 子进程及其 stdout/stderr reader | PiRuntime | 一个 generation |
| 一个已接受的 prompt 任务 | host 持有的 runtime/controller | 直到 settled / rejected / interrupted |
| cursor 与显示格式 | kastell client | 一次客户端调用 |
注意 Swift 的任务语义:Task { } 创建的是非结构化任务,必须明确由谁持有、取消并等待其清理完成;actor 不会自动监督自己创建的任务。资源归属最终要落实到“谁持有、谁取消、谁等待结束”。
模块边界由编译器强制
KastellCLI ────────────────────────────────► KastellProtocol
KastellHost ──► KastellRuntime ──► Commander
│ │
└───────────────► KastellStore ────────► KastellProtocol
- 没有任何 target 依赖 host 可执行文件;CLI 不能意外调用 store,因为没 import 那个模块。
KastellProtocol不 import Vapor / SwiftData / Commander——它是纯 DTO 层。- 这是架构约束,不是命名约定。多模块的价值就在于此。
internal。public struct 不会自动获得 public 的 memberwise initializer——共享 DTO 要显式写 public init(...)。否则会出现"同一文件能编译、跨 target 不行"的怪问题。
身份、顺序与游标
不要用 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 sequence | timeline 流的独立位置 | 也是全局、跨 agent |
wireOrdinal | 某 generation 某条管道上收到的第几条记录 | 每个 stdout/stderr 流各自从 1 开始 |
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 之后"。log 与 timeline 用不同的 cursor 文件(如 /tmp/kastell-journal-cursor.json、/tmp/kastell-timeline-cursor.json)。服务端无法猜出一个裸数字来自哪个命令——journal 的 10 可能刚好落在 timeline 范围内,静默跳过消息。所以客户端本地校验 + 服务端范围/store 校验,缺一不可。
幂等靠 identity key,不靠文本
| 对象 | identity key 形式 |
|---|---|
| journal note | note:<operation UUID> |
| journal prompt intent | prompt:<operation UUID> |
| journal 收到的 pi 行 | pi:<generation UUID>:<stdout|stderr>:<ordinal> |
| timeline 提交的 user | user:<operation UUID> |
| timeline pi 消息 | message:<generation UUID>:<stdout message_end ordinal> |
- 重放同一身份:先 fetch 比较(action、agent、原始输入),一致就返回原来的 eventID/sequence/canonical 链,什么都不新建;不一致就报错。
- 永远不用文本 hash 合并消息——两条合法消息完全可能内容相同。相同文本 + 新 operationID = 真正的新 prompt/note。
- 重复摄入不重新执行生命周期转换(比如不再次标记 accepted)。
两个历史: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 全部规则)
| 输入事件 | Journal | Timeline |
|---|---|---|
| client note | append client.note | 无(note 不是模型对话消息) |
| admitted prompt | append 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) |
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_entriesreceipt——那对 checkpoint/import/resume 是合理设计,但不能混用:一个 generation 只允许一种 canonical ingestion 策略。
"canonical" 不是什么(防止自我误解)
- 不是"只有最后一个成功的回答"。
- 不是 provider 认可的当前上下文快照。
- 不是 pi 原生 session 树的精确复制。
- 不是"内容去重后的消息集合"。
pi 自动重试后,两次完成的 assistant 观察都是各自独立的事件、各自的 provenance,都留在 timeline;turn_end / agent_end 里的副本不会多加行;同一个数据库摄入用旧 wire 身份重试不会加行。这是三种不同意义的"retry",设计分别处理。
存储:一个 store、一个 writer、一次 save
原子边界是这套设计的心脏结构
HistoryStore (@ModelActor)
│
ONE ModelContext / ONE save
│
┌───────────────┴────────────────┐
│ │
JournalEvent TimelineMessage
观察到了什么 逻辑消息各一份
└──────── provenance links ──────┘
(操作状态模型 HistoryAgent / HistoryGeneration / HistoryOperation
也在同一个 store 里)
实现纪律(概念层面必须记住的)
- 一个
@ModelActor,一个ModelContext,autosaveEnabled = 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 ≥ 0、limit 1…100、after ≤ 所选流自己的 head;store 身份不符 → 409。 - 页的大小限制按实际编码后字节算(含转义),只包含能装下的前缀。
投影失败:唯一的窄例外
如果一条在限额内的原始记录语义上无法解释(比如消息包无效、违反 V1 策略),流程是:
- 保留 raw journal 记录(无效 JSON 就存一个有界 wrapper:原文 + parse error)。
- 追加 host 事件
history.projection_failed,写明相关 journal sequence、原因、generation、已知 operation。 - 设置
HistoryGeneration.projectionError,标记该 generation/agent stopping,并在同一次 save 里为未决工作持久化"中断"评估。不创建猜测性的 timeline 行。 - 返回
projectionFailure,controller 停止正常操作。
之后该 generation 进入 journal-only draining:继续读、继续存原始记录,但不会再产生 canonical 消息,也不会因为又收到一个 response 就恢复 accepted。这是 fail-closed 的窄路径,不是通用 schema 演进机制。
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 |
HistoryAgent | 是 | profile + 当前调度指针 |
HistoryGeneration | 是 | host 所有权、状态、每条管道的 receive ordinal |
HistoryMetadata | 只有计数器 | 稳定 storeID、两个 sequence head、projection version |
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 意图和可信的运行时摄入路径能创建那些行。
分章实施路线
01 → 02a → … → 08a,每章的产出与判定每章按同一模板展开:目标 / 产出物 / 核心概念 / 规则与取舍 / 完成判定 / 常见坑。已经在前文讲透的概念这里只给结论,不重复论证。
画边界,先建最小有用切片
共享起始章(两轨共用)目标:一个依赖方向清晰的 Swift 包,加上对 "append"、"agent"、"done" 的精确含义。此时还不需要跑 pi。
产出物
MVP/Package.swift:5 个 target ——KastellProtocol、KastellStore、KastellRuntime、KastellHost(executable)、KastellCLI(executable)。- §2 的 owner 表和 §3 的身份表落成文档/注释;五个不变量写在随手可见的地方(见 §9)。
- 第一个 round trip 的设计:
kastell note → POST /v1/notes → 一个 transaction → receipt;kastell 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 注释用。
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。
SwiftData:同时持久化日志与规范消息
a-track 核心目标:一个 SwiftData store、一个 writer、两个持久化历史;journal 覆盖所有限额内收到的 pi stdout/stderr 记录,timeline 每个逻辑消息一行。
产出物
- 复制
Models.swift与Values.swift进包(§6)。 HistoryStore:@ModelActor;open(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——本设计没有这种事务。
Host HTTP API:暴露两个已持久化的历史
a-track目标:同一个 loopback host 现在打开 HistoryStore,暴露两个独立分页的持久集合。路由不做"按需从 raw 事件重建消息"。
产出物 / 接口
| Endpoint | a-track 行为 |
|---|---|
POST /v1/notes | 保存 journal note;不创建对话消息 |
POST /v1/agents/:id/prompts | intent + canonical user + operation + busy 状态一次 save;返回 admission receipt |
| agent create/start/abort/stop、operation review | 应用命令不变;由新的 store 方法完成 save |
GET /v1/log | 读 JournalEvent 快照 → JournalPage |
GET /v1/timeline | 读 TimelineMessage 快照 → TimelinePage(新增) |
GET /v1/messages/:id | 按 UUID 读一条已存规范消息;缺失 404(新增) |
GET /v1/operations/:id | operation 快照,含 userMessageID 与持久 outcome |
GET /v1/health | host/store 身份 + 支持的 history views |
- 没有
POST /v1/timeline。任意调用者不能注入伪造的 assistant 消息:user 走 prompt admission,pi 消息走运行时摄入,同一个 writer。 - receipt 形状不变且
sequence永远是 journal 位置;找规范消息用operation.userMessageID或 journal 行的canonicalMessageID。 - DTO 的
Contentconformance 只写在 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 链接回原消息,不改变其来源。
message_end 时,raw 行和 canonical 行一起durable;紧接着查这条消息不会看到"已确认但还没投影"的状态。但两个独立的 GET 发生在不同时间:两次读取之间 agent 可能写入,两者的行数不必构成同一快照——一个响应更新不是数据损坏。用 provenance ID 追某条消息,而不是比较无关页的长度。
另一个推论:user timeline 行不会在 operation 变成 accepted/rejected/interrupted 时改变。状态属于 operation 资源和 journal;否则已经越过该行 cursor 的客户端会错过更新。
完成判定
- 两终端跑通:host 启动 →
curl /v1/health→curl POST /v1/notes→curl /v1/log?after=0→curl /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。
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 输出不静默截断。
[submitted] + operation ID;kastell operation <id> 才是当前状态。不要轮询 timeline(after:) 然后期待它通知"某条旧行的 operation 完成了"——它是 append cursor,不是所有相关对象的 change-data feed。把可变状态塞进历史消息只会让误解更深。
provenance 的读法
- journal 行有
canonicalMessageID→kastell 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 文件 → 本地拒绝,而不是静默跳消息。
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"重算。
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.lastAssistantMessageID。toolUse的 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。
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 完整。
多 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.createdjournal 行(含 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,然后 saveagent.ready+ running/idle + start outcome。无 timeline 行。- 如果 launch await readiness 期间来了 stop:不能盲目发布 idle;用同一 reserved slot 完成清理。容量计数持续包含 starting/running/stopping,直到 child/readers 真正结束。
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 区分
created与replayed;只有 created 启动 runtime 工作。对外两种都返回同一个原始MutationReceipt。 - shutdown 等待已登记的 admission handoff 完成;commit 成功但执行装不上时必须显式记录 interrupted——不能返回成功却留下没有 owner 的已接纳行。
- 客户端断开只结束"这个客户端的等待",不取消已接纳的 agent 工作。之后的持久重试即使 agent 忙/已停/换 generation,也返回原 receipt。
完整 prompt 生命周期(每步写什么,观察顺序可交错)
| 方法 / 观察 | Journal | Timeline | Operation 变化 |
|---|---|---|---|
admitPrompt | prompt.requested | 原始 user 行 | prepared, busy, user 指针 |
markAttempted | prompt.attempted | 无 | attempted |
| 匹配的成功 response 被摄入 | raw pi.response | 无 | accepted + response provenance |
user message_end 被摄入 | raw 事件链接 user UUID | 无 | echo 绑定 |
非 user message_end 被摄入 | raw 事件链接新 UUID | 完整 canonical 消息 | 适用时记 last assistant |
agent_settled 被摄入 | raw 事件 | 无 | settlement 证据 |
finishPrompt | prompt.settled | 无 | 终局 outcome;条件允许时释放 prompt slot |
| 权威拒绝被摄入 | raw rejection | 无 | rejected;原 user 行保留 |
| interruption / review | host 生命周期/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)。
重启:两个历史都还在,但不自动重演
a-track目标:host 重开同一个 SwiftData store,保留两个历史集合与游标;未完成的工作被表示为 interrupted,不伪造 canonical 消息、不重放 prompt。
原子性让边界更强——但不是所有边界
§5 的"一次 save"保护的是已提交的观察,不是"未观察到的模型/tool 行为"。host 仍可能在收到/保存之前丢失输出,或在发出 prompt 后失去执行确定性。pi 和数据库之间从来不是事务关系。
重启后每种持久状态怎么处理
| 最后的持久状态 | 重开后 journal / timeline | 怎么做 |
|---|---|---|
| 没有 admitted operation | intent 与规范 user 都不存在 | 客户端可以用它的 operation ID 重新提交 |
| prepared admission | intent 和原始规范 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 fault | raw 证据 + fault 标记在;部分 canonical 行故意缺失 | 显示故障;不声称 timeline 完整 |
启动顺序(保守)
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 步)
- 关闭 manager 的 admission barrier。
- join 进行中的 admission handoff,确保没有已提交但无 owner 的 operation。
- 把 generation 标 stopping,同时保持它们对捕获有效。
- 要求 active 工作 abort,带边界等待。
- stop/reap 每个 Commander,同时 stdout/stderr readers 继续。
- join readers 与 operation/control/launch 任务。
- 为未决 operation 保存最终 generation outcome 与 interruption。
- 若存储可用,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_entriescheckpoint 路径不是能直接加在 live timeline 上的第二个 importer——没有稳定的跨来源身份协调,它会插入已存消息的第二份拷贝。一个 generation 只选一种 canonical ingestion 策略。
SwiftData 维护守则
- 用 model/container API;不装自定义 trigger、不背地里改 pragma、不把检查未文档化的表结构当正常行为。检查就通过
log、timeline、message、operation。 - 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"这一步。
设计复盘:让双历史个人应用长期保持简单
收尾章目标:把"两个历史都要"的决定长期维护成简单系统,而不是论证掉其中一个需求。设计任务变成:让它们简单协作。
六条长期原则
- 正确的简化是"一个 store",不是"一个视图"。一个 model actor 保存两种表示;没有要同步的 journal 数据库与对话数据库、没有要监督的后台投影 worker。这个安排可以用很久。少量 payload 重复是保留"证据 + 可读历史"的代价,不是设计坏了。在尺寸真的不便之前,不要优化成共享 blob 引用或内容寻址存储——显式模型更容易学习和检查。
- timeline 策略要小、要写下来。核心规则(§4)值得反复记住。它是一个适度的确定性投影,不是通用语义去重引擎;不要合并相同文本、不要猜两条相似消息哪条"才算数"。限制也清楚:live ordinal 是 host 观察身份,不是 pi 原生 entry ID。若将来"导入/恢复原生历史"成为中心需求,要有意识地重新考虑 canonical ingestion,而不是加第二个来源做模糊匹配。
- SwiftData 应该移除管道工程,而不是隐藏 save 边界。保持显式:service context 关闭 autosave;mutation helper 只 stage 不独立 save;公共领域方法一次 save 并返回快照;rollback 覆盖两个视图与计数器;只有一个 writer。这已经足够,不需要再套 unit-of-work 框架。
- 在脑子里把 runtime 和历史分开。journal 说 host 观察到了什么;timeline 说观察策略把什么变成了逻辑消息;runtime 决定下一步发生什么。两个集合都不证明进程活着,都不应被自动重放为可执行命令;fresh generation 的空上下文不是存储故障。operation 行连接了意图、证据与执行结果——保持小而领域化,不要变成通用 workflow engine。
- 两个视图都要好用,不只是"技术上存在"。日常
timeline可读;调查用log保留细节;都显示 agent 与 generation;J#/T#与 provenance 链接让你不容易混淆两个独立顺序,并能回答"这条消息从哪来"。operation 状态保持在不可变 timeline 行之外:一个标着submitted+ operation ID 的 user 行,比"存在即已执行"的行诚实。展示层可以之后再改进,不用重写存储历史、也不新增事件订阅服务。 - 别让 full capture 变成无尽基础设施。journal 可能很吵。先做显式有界 save + 简单轮询客户端。捕获造成明显延迟时,先看 flush 频率、重复编码、writer 上的读取负担,再谈架构变化。有界批处理是合理的后续优化——前提是"response 在批提交前不算 captured"且源顺序保持——但不要作为第一版。同样,不要因为 timeline 可以说成"物化视图"就加正常启动重放:原子摄入意味着例行恢复只需 reconcile 未完成 operation,而不是重建两个历史。
从今天起的六个日常里程碑(也是 M1 使用验收)
- 保存一条 note,只在 journal 里看到它。
- Admit 一个 prompt,看到一条 canonical user 行。
- 收到 user echo,timeline 计数不增加。
- 收到完成回答,看到 raw 证据 + 一条 canonical 回答(同一次 save 的结果)。
- stop/reopen host,检查同样的行与 provenance。
- 跑两个 agent,看到同样的规则在交错下成立。
这些是应用的普通使用里程碑,不是再建一个验证框架的理由。走顺之后就去用它;重构你真正遇到的重复 flag 或让人困惑的方法边界。
原 08 章仍成立的通用建议
- 显式拥有 children/tasks;不盲重放不确定的 prompt;注意能让多个 tool-capable agent 共享文件的风险。个人 app 的简单工作规则往往够用:一个 workspace 一次只让一个 agent 编辑,或给并发编辑的 agent 独立工作副本/worktree。不要为此造文件系统事务系统,把 workspace ownership 变成显式设计选择即可。
- 成本同理:避免自动 retry/restart 循环在你以为停止后继续产生调用。
- 不要做分布式 scheduler、message broker、多 host writer、插件框架、账号/权限系统。API 留在 loopback;同机的其他程序能访问不代表你要把个人 CLI 变成身份平台。真要暴露到机器之外时再重新考虑安全边界。
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。log与timeline的 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,最终使用验收)
- note 只进 journal。
- prompt → 一条 canonical user 行。
- user echo → timeline 计数不变。
- 完成回答 → raw 证据 + 一条 canonical 回答。
- stop/reopen → 同样的行与 provenance。
- 两个 agent 交错 → 规则依旧成立。
硬规则:不变量集中版
这十条比任何抽象都值钱教程把它们散落在各章;这里集中。实现任何新功能前先问:会不会破坏其中一条?
- Commit before success(提交先于成功)。成功的 note/operation receipt 指一个已提交的数据库事务,不是内存队列里的待办项。
- One journal order(唯一 journal 顺序)。sequence 由数据库 writer 分配,永不来自时间戳或 agent 本地计数器。
- No mutation of facts(事实不可变)。修正与生命周期变化都 append 新事件;操作表可以与事件在同一次 save 里原子更新,但历史行不改不删。
- One active prompt per generation(每个 generation 同时最多一个 prompt)。在任何可能让第二个 prompt 进入的
await之前先 reserve busy 状态。SwiftData 不消除 actor reentrancy。 - No blind resend(不盲发)。尝试写入管道但没有 pi 响应 = 不确定,不等于 pi 什么都没做。绝不自动重发。
- Journal + canonical 同一次 save。没有后台 projector、没有最终一致性窗口;投影失败是显式的窄例外,且那之后只 drain 不投影。
- 只有两条路径能创建 timeline 行:被接纳的 user 意图、可信运行时摄入的非 user
message_end。客户端不能插入任意消息。 - 重放看身份,不看文本。同 identity 返回原结果;相同文本 + 新 operationID = 新动作;绝不 hash 文本合并。
- reader 纪律。只有一个 stdout reader;它绝不 await 只有自己能读的 RPC 响应;response 在 raw 证据保存前不能交给 waiter。
- 关闭有序。停 admission → join handoff → generation 保持可捕获地 stopping → reap child → join readers/tasks → 存最终状态 → 释放 store/lease。重启后 fresh context,不自动重放。
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
诚实清单: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)。
关键取舍一览
每个决定的"换来了什么 / 放弃了什么 / 何时重审"| 决定 | 换来 | 放弃 | 何时重审 |
|---|---|---|---|
| 两个历史都持久化(选择 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 结论稳定) |
术语表
按首次出现的语境精确定义- 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 的双向可追踪链接(
sourceJournalSequence、canonicalMessageID)。 - 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 内容。语义续接是后续显式功能。
资源与文件结构
读什么、复用什么、注意什么教程阅读顺序(a-track)
Tutorials/01-MVPBoundaries → 02a-SwiftDataHistory → 03a-SwiftDataHostAPI → 04a-JournalTimelineCLI → 05a-PiJournalTimeline → 06a-SwiftDataMultiAgent → 07a-SwiftDataRecovery → 08a-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.1 的
rpc.md、session-format.md、settings.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.store;MVP/.gitignore排除.build/、.swiftpm/、.state/;凭据与运行日志不进仓库。
一句话结尾
先做小、做对、做得能解释:一个 host、一个 writer、两个历史、多个 agent、一个诚实的 CLI。剩下的复杂度,等真实使用提出要求再说。
Bonus — 我的审读笔记与实现手册
通读全部章节之后:接缝决定 · 调试剧本 · 故障演习 · 变更准入 · 成长阶梯这一章不是教程内容,也不改变任何设计。它是通读 01 + 02a–08a(并对照原轨道)之后,我认为最值得补上的东西:教程给你的是设计;而落地时最容易出错的,是各章之间的接缝,以及那些“看起来都对、就是不对”的时刻。教程本身仍是权威,下面是我个人的笔记,你可以把它当成一份可修改的 checklist。
B1 · 接缝处的十个决定
这些是两个章交界、契约只被“勾勒”而没有写死的地方。它们不需要现在回答“对错”,但需要你明确选一个,并且写下来——哪怕只是一行注释。我做实现的习惯是给每个决定写三行:背景 / 决定 / 后果。
-
区分运行时归属与持久状态校验。
- 教程事实:runtime 在发送前装好 active operation(05a);store 校验显式传入的关联(02a);manager 负责调度(06a)。
- 我的建议:runtime/controller 持有当前进程与请求的关联;store 依据持久化的 active 指针、generation 归属和 operation 状态,校验调用者传入的
(agent, generation, operation)三元组。两者职责互补:runtime 不能绕过数据库校验,数据库中的 busy 指针也不能证明进程仍在执行。
-
把两套状态机的转换表写下来。
- 分别列出合法转换,而不是串成一条通用状态链。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、完成、拒绝或中断事务更新,不由无关回调修改。
- 分别列出合法转换,而不是串成一条通用状态链。note 可直接为
-
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 断言。
-
markStopping/retireGeneration/reconcileOnStartup是三件事,不是一件事。markStopping:fence 仍有效,readers 仍可写;retireGeneration:readers 已 join、禁止再 append、写最终 generation 状态;reconcileOnStartup:只发生在 boot,处理上一次遗留的孤儿。- 我的做法:让 retire 只有 shutdown 的 join 点能调用(把“已 join 的凭据”作为参数传递)。让“提前 retire”在类型层面就难写出来,比在 code review 里抓住它可靠得多。
-
解析发生在哪里,原始字符串永远原样保留。
- reader 只负责读完整行、分配 ordinal;解析/分类放到 store actor 上(或 reader 侧解析成一个只含提取字段 + 原始字符串的 Sendable enum)。
- 无论选哪种:raw 保留 Commander 交付的原始行文本,canonical 从解析结果确定性编码。这里不承诺字节级管道归档:Commander 会处理行尾,非法 UTF-8 或超长帧可能在交付前被拒绝。不要为格式美化重写 raw,也不必为了生成 canonical 重复解析。
-
把事件路由做成一张二维表。
- 维度:事件类型 × 是否存在匹配的 active operation。格子里填:journal-only / 生命周期证据 / echo 绑定 / 新 canonical 行 / protocol fault。
- 教程的规则分散在 05a/06a 各节;合成一张表贴在 ingestion 代码旁边,新增事件类型时先改表再改代码。特别注明:正常摄入时,无归属的对话 message_end 应保留原始证据并触发投影故障;已进入故障排空模式的 generation 则继续 journal-only——丢掉会让调查者永远不知道发生过什么。
-
时间只有一个来源,而且不参与冲突判定。
recordedAtMS是 host 接收时间;admission 的时间戳在首次提交时固定、重试不重生成;排序一律用 sequence。- 建议集中处理时间获取;确有替换需求时再注入时钟函数或协议,并在比较函数上留注释:“时间戳不用于判断两条记录是否相同”。很多“重试变成了新事件”的 bug 都是这里漏的。
-
溢出与超限的失败姿势:fail closed。
- 分配 sequence 或 ordinal 前检查溢出;raw/canonical 存储 JSON 分别受 1 MiB 上限约束,编码后的读取页受 8 MiB 上限约束。超限、计数器耗尽与语义投影失败要分别报告,不一律归为
projection_failed;存储不可用时转 host 失败路径。 - 不静默回绕、不跳过、不“先存了再说”。这些分支在正常开发中永远不会跑到,正是它们值得在写的时候多想五分钟。
- 分配 sequence 或 ordinal 前检查溢出;raw/canonical 存储 JSON 分别受 1 MiB 上限约束,编码后的读取页受 8 MiB 上限约束。超限、计数器耗尽与语义投影失败要分别报告,不一律归为
-
那些数字是策略,不是语义。
fetchLimit = 4、8 MiB、32 KiB、1 MiB、250ms……放进一个常量文件,每个两行注释写明“为什么是这个数量级”。- 验收脚本不要依赖具体数值(除非你正在验收限额本身)。否则将来调参时,会有一批“测试”替你把好设计锁死。
-
projectionVersion是兼容承诺;同时给未来的 renderer 留一扇可替换的门。- 承诺:同一个 storeID +
stream=timeline+ after,其含义不因投影策略升级而原位漂移。改策略需要新版本与明确的兼容或迁移计划;不支持的版本应显式拒绝,不能静默重解释旧行。 - 建议现在就做的一件事:把 user-echo 校验写成一个纯函数(输入:原始意图文本/结构、收到的 echo、当前策略版本;输出:匹配/不匹配/异常)。将来加入语义续接 renderer 时,这能缩小校验逻辑的改动范围;仍需设计派生请求记录、来源追踪和恢复策略,不能认为只改一个函数就已支持续接。
- 承诺:同一个 storeID +
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 mismatch | cursor 文件跨 store 复用,或数据库被复制/替换 | 游标里保存的 storeID;确认 storeID 随数据库建立一次且不变(§3) |
| 能同时启动两个 host | 路径规范化不一致、锁未取得或过早释放 | 检查 lease 的路径、加锁与持有期;O_CLOEXEC 防的是子进程继承锁,缺失通常导致锁迟迟不释放(02a §6) |
| 发送后 pi 毫无反应,attempted 已写入 | 这往往不是 bug:它正是“不确定”路径。真问题是:有没有人在偷偷重发? | grep 自动 retry/resend;确认 no blind resend(§9 规则 5) |
B3 · 故障注入演习:没有测试框架的测试
教程明确说“终端会话就是正常工作流”。下面七个演习用于复现进程、存储和连接故障,补充正常路径与单元测试。请使用独立的临时 store 和可丢弃的测试进程,不在真实数据目录上演练。
-
在四个提交边界强制终止 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”。 -
只杀 child,不杀 host。 对 pi 子进程
kill -9。
通过判据:等待两个 reader 消费可交付的缓冲记录并结束,显式报告读取错误;generation 与未决 operation 标 interrupted,不伪造 assistant。child/readers 清理完毕后才释放容量;是否可再次 start,仍需检查未解决工作的 review 政策。 -
store 写失败。 在可丢弃环境中使用受控故障注入,让保存步骤确定性地失败,再发 note。仅将已打开的目录改成只读未必能阻止已有文件描述符继续写入;无权限路径通常只能验证打开失败,不能代替 save/rollback 演习。
通过判据:请求以可理解的错误失败;journal、timeline、计数器与操作状态一起提交或一起回滚,不存在“一半提交”;host 走失败路径而不是假装成功。 -
超大记录。 为演习临时把 1 MiB 上限调小到容易复现的值(例如让一条 stdout 行超过几 KB),触发拒绝路径。
通过判据:不落盘完整超限内容;在存储仍可用且评估记录满足限额时保存有界 size/error 事件,否则向 stderr 报告。generation 停止;后续轮询不卡死;错误可见。 -
畸形 JSON 注入。 在 pi stdout 上制造一行非法 JSON(或用测试替身进程)。
通过判据:raw wrapper +history.projection_failed+ generation stopping;之后进入 journal-only drain,后续记录仍在 journal 可见。 -
断线重试。 分别验证两件事:关闭
--follow只停止观察;另在 mutation 请求提交后、回执接收前断开客户端,再使用原 operationID 和原输入重试。
通过判据:host 工作不受影响;返回原来的 receipt;timeline 不新增重复消息;新客户端从头读得到完整历史。 -
双开。 同时启动第二个 host。
通过判据:第二个 host 得到清晰的 lease 错误并退出;第一个 host 不受影响;第二个 host 未打开写入路径、未修改历史数据(lease 文件的检查本身不等于写历史)。
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)
B5 · 调试自检:debug 构建里的八条断言
这些断言可检查计数器、身份与引用的一致性,但完整扫描及关联校验会随数据量增长,不应视为恒定低成本。建议放进一个 debug-only 函数,在 启动 reconcile 之后和 需要时的自检命令里调用——不要让它在生产路径上每次摄入后都跑,更不要变成后台任务。
journalHead == max(JournalEvent.sequence) == journal 行数,并检查 sequence 唯一且连续(空表的 max 按 0 处理;前提是从 1 连续分配且历史从未删除)。timelineHead == max(TimelineMessage.sequence) == timeline 行数,同样检查唯一性、连续性与空表情况。- 每条
TimelineMessage.sourceJournalSequence指向存在的 journal 行,且那条行的canonicalMessageID == messageID。 - 每个非空
canonicalMessageID都指向存在的 TimelineMessage。 - 每个 agent 最多只有一个非终局的 active prompt operation。
HistoryGeneration.stdoutHead/stderrHead等于该流已存身份的最大 ordinal,且1…head无空洞(重放不算新 ordinal)。- timeline 的
agentID/operationID都指向存在的行;operation 绑定的userMessageID存在且 role=user。 - 所有 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. 开启 tools | workspace ownership 规则写下来(一个 workspace 一次一个编辑者,或独立 worktree) | tool 消息如何显示与进入上下文?成本护栏在哪? | 共享文件系统冲突不是数据库事务能解决的;token/费用增长 |
| 5. 历史导入 / resume(get_entries 路线) | 接受“一个 generation 只有一种 canonical 策略” | 基于稳定 entry ID 的 canonicalization 如何设计?与 live 策略如何共存(不同 store?显式迁移?) | 跨来源身份协调;混用会产生第二份拷贝 |
| 6. 第二个客户端 / GUI | API 已就绪(它本来就是为这个留的) | 展示约定(折叠、隐藏 thinking)放哪一层? | 不要为了 GUI 新增服务端状态;保持 timeline 由 host 持久化一次 |
| 7. 多 host / 远程访问 | 认证、真正的协调与 lease 重审 | 这是另一个项目,不是这个项目的一章 | M1 明确不做;别让“顺手加个远程”破坏单 writer 假设 |
② 演习与断言互补。强制终止 host 能暴露正常路径难以覆盖的恢复问题;断言则检查保存下来的状态是否自洽。
③ 成长阶梯上每一项都带着前置条件。先使用 M1;等真实需求出现,再按顺序升级,而不是提前把未来的复杂度预支到今天。
运行风险与 M2 演进建议
补充审阅:并发 · 故障处置 · 阅读体验 · 后续能力前文给出实施路线,Bonus 补充模块衔接和排错方法。本章继续讨论运行时容易忽略的风险,并列出 M2 的候选方向。 这些是补充审阅建议,不是已实现能力,也不自动扩大 M1 范围。涉及新状态、错误码或命令时,应先定义契约并验证,再纳入实现。
E1 · 并发与进程管理的五个风险
Swift 6 的严格并发检查有助于发现隔离与 Sendable 使用问题,但通过编译不等于状态机正确,更不代表底层 I/O、外部进程和不安全接口已得到验证。以下五点需要在实现中单独检查。
-
跨 await 保留未提交的 ModelContext 改动。
风险:actor 方法在await处可能挂起;挂起期间,其他任务可以进入同一 actor。若共享 context 中仍有未提交改动,另一次 save 或 rollback 就可能把不属于本次操作的状态一并提交或撤销。并非每个 await 都实际挂起,但设计必须按“可能重入”处理。
规则:属性修改、关联校验与save()必须处于同一个无 await 的提交边界。异步准备在边界外完成,进入后重新校验状态。 -
管道背压与关闭顺序。
风险:管道容量有限,不能把某个容量数值当作跨系统保证。下游停止读取后,生产方可能阻塞或等待可写;此时只等待 child 退出而不排空输出,可能无法完成关闭。
规则:正常关闭时保留 stdout/stderr 单消费者循环,stop/reap child 后再 join readers。当前 Commander 在直接子进程退出后会做有界尾部捕获并关闭描述符,避免后代进程持有管道导致无限等待;上层应消费其可交付记录并处理终止错误,不能承诺总能等到物理 EOF 或完整捕获所有后代输出。 -
轮询重试错误地推进或重置游标。
风险:M1 是 loopback 上的普通轮询,不是长轮询。即使在本机,请求也可能超时或连接中断;服务端完成响应不代表客户端已经成功展示。凭猜测推进游标会漏读,每次失败都重置为 0 则会重复全量读取。
规则:成功展示非空页后推进到nextAfter,短页也一样推进并继续读取;空页才暂停,follow 模式等待后再查。瞬时 GET 失败从最后已展示的位置有限退避重试;格式错误、顺序异常与 store 变化应显式报错。 -
直接子进程退出,不代表整个进程树已清理。
风险:Commander 只管理直接子进程。开启 tools 或扩展后,其派生进程可能继续运行、持有管道或占用端口;仍在运行的孤儿进程与已退出待回收的僵尸进程不是同一概念。
规则:M1 初始关闭 tools 与扩展发现。异常退出后,先核查残留进程再启动替代实例;锁文件、旧 PID 和 generation UUID 都不足以单独证明进程归属。任何清理命令都需核对身份并明确确认,不能按进程名批量终止。进程组管理属于后续显式设计。 -
绕过 SwiftData 检查活动数据库。
风险:外部写入可能破坏框架维护的状态;长读事务可能阻碍 WAL 回收,但不能简单等同于所有写入都会报 busy。immutable=1声明文件不会变化,不适用于仍在写入的活动 store,也不是通用安全只读开关。
规则:日常检查使用kastell log、timeline、message与operation。需要离线取证时,先关闭 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中,应连同其他字段完整保存在 canonicalmessageJSON;原始事件也保留在 journal。不能只保留 journal 而删去 canonical 中的 thinking。 - 阅读与续接另行决定:CLI 可默认折叠 thinking,并保留查看完整结构的入口。未来 renderer 是否将这些内容送回模型,必须按能力与上下文策略明确决定;M1 不承诺获得模型未公开的内部推理。
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 值得保留的,不是“永不出错”的表述,而是以下三个边界:
- 只有提交成功的意图,才返回持久化回执;
- 只有实际收到并成功保存的观察,才成为持久证据;
- 无法确认的执行结果,明确标为不确定,不补写一个看似成功的结局。
下一步应是实现并验证最小闭环,而不是继续扩展概念。等真实使用暴露问题,再决定要增加什么。
终审意见与收尾建议
保留有效设计,纠正过度承诺,回到可验证的交付延续前两轮审阅的做法,本章记录我的最终判断、这次修订的重点,以及交付前仍需完成的验证。终审的目的不是再加一层架构,而是让全文的承诺、术语和行动建议彼此一致。
F1 · 审核结论与适用范围
§0–§14 是实施主线;Bonus 与 §15 是补充建议;本章说明终审边界。M2 候选能力不计入 M1 验收,也不能因写进报告就视为已经支持。
F2 · 本轮纠偏要点
- 完整存储与简洁展示分开。合格完成消息中的 thinking、工具字段和失败信息保留在 canonical payload;CLI 可折叠,存储不能悄悄删减。完成消息不等于终局成功回答。
- 来源链接不可改写。submitted user 的来源始终是 prompt intent;user echo 只链接回已有消息,不重写其 sourceJournalSequence。
- 分清三类失败。语义投影失败、传输帧错误与数据库保存失败,能保留的证据不同;不能一律承诺“原文已入库并可继续排空”。保存失败也不能提前释放仍有任务持有的 slot。
- 恢复不补写成功。settled 证据已在、最终转换未提交,重启后仍保守标 interrupted。原 admission receipt 与终局 outcome 是不同事实;重试前必须分清。
- 收回未经支持的安全承诺。worktree 不是沙盒,内容摘要不是安全签名,append-only 不是防篡改存储;活动数据库不能用 immutable 声明绕过正常读取约束。
- 把建议写成可执行的条件。修正短页的游标推进、限额层次、故障演习判据与自检成本;删去“绝对完整”“确定性演进”等超出证据的措辞。
F3 · iPhone 阅读与验证边界
- 排版:手机正文采用 16px 基准字号,缩小嵌套卡片留白;长标识符可换行,宽表格和等宽图各自横向滚动。保留页面缩放,并考虑安全区与减少动态效果的系统偏好。
- 导航:窄屏目录可折叠,章节锚点保留;滚动高亮不再把手机页面拉回目录。目录链接、回顶按钮和勾选标签提供便于触控的操作区域。
- 交互:验收项使用原生复选框,支持键盘与辅助技术;浏览器拒绝 localStorage 时仍能临时勾选。为避免修订后状态错配,不迁移旧版按列表序号保存的勾选记录。
- 离线:样式与脚本均内嵌,无外部字体、图片或网络依赖。Safari 与“文件”App 的预览能力不同:不执行 JavaScript 的预览保留正文、表格和原生锚点,目录折叠、进度条及勾选增强需要支持脚本的浏览器。
本轮验证:WebKit 与 Chromium 在 320、375、390、430、844、1440px 视口下通过页面宽度、锚点、目录高亮、回顶、勾选保存和键盘操作检查;另检查本地文件打开、禁用脚本、拒绝本地存储及正文放大情形,未发现脚本异常或整页横向溢出。WebKit 的 iPhone 视口模拟不等于 iPhone 真机 Safari 或“文件”App 实测,仍建议在目标手机上按下列步骤复核。
F4 · 交付前的最后一轮验收
- 先验文档:在 iPhone 竖屏与横屏分别打开,检查页首、05a 长卡片、E2 宽表和本章;左右滑动只影响表格或代码区域,放大文字后正文仍可读。
- 再验交互:展开目录跳到 05a 与本章,测试回顶;勾选一项并重开页面,确认当前打开方式是否支持保存。若使用文件预览,不把脚本缺失误判为正文缺失。
- 最后验产品:执行 §8 的日常闭环与 B3 的相关故障演习,记录 storeID、operationID、J#/T# 和实际结果。未运行的项保持未勾选,失败的项保留失败证据。