Onyx Craft 会话保活:Kubernetes 沙箱中 opencode 会话历史持久化的完整实现指南
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
导读
本文深入剖析 Onyx(danswer)开源 AI 平台中 Craft 会话的 opencode 会话历史持久化方案。Craft 会话以opencode serve作为沙箱内的长生命周期 Agent 运行时,而 Kubernetes 沙箱一旦休眠、被驱逐或重建,仅靠 Postgres 中保存的BuildSession.opencode_session_id无法找回 opencode 的会话历史——历史行存放在沙箱文件系统里。本文将带你理解 Onyx 如何通过「沙箱全局 opencode 历史快照 + 签名 Sidecar 端点 + FileStore 持久化 + 启动恢复门控」这套机制,让 Agent 会话在 Pod 休眠、恢复与重建后依然可续接,同时保持 API Server 持有持久化存储的所有权、普通会话快照聚焦于会话本地文件。读完你将掌握其存储模型、完整的 Provision/Restore 与 Snapshot 流程、发送消息时的乐观会话 ID 解析策略,以及重置、删除、闲置休眠等生命周期路径的设计取舍。
背景:为什么opencode_session_id本身不够
在 Onyx Craft 的架构中,opencode serve是沙箱内长期运行的 Agent 运行时,负责驱动每一轮用户消息的 Agent 推理与工具调用。Onyx 会把BuildSession.opencode_session_id持久化到 Postgres,以便后续轮次复用同一个 opencode 会话,而不是每条消息都新建一个。
但仅有这个 ID 是不够的。原因在于 opencode 的会话数据行存储在opencode 自己的数据目录里——即沙箱文件系统的opencoce数据根目录(Kubernetes 下默认OPENCODE_DATA_HOME=/workspace/opencode-data)。当 Kubernetes 沙箱 Pod 休眠(sleep)、被驱逐(evicted)或被重新创建(recreated)时,Pod 本地卷上的数据会丢失;如果沙箱级别的 opencode 数据没有被持久化并在启动时恢复,Postgres 里保存的 ID 就指向了一个不存在的数据——后续轮次将无法续接任何历史上下文。
因此,该实现将 opencode 历史作为沙箱全局状态进行持久化,与普通的「按会话隔离的工作区快照」区分开来。相关设计文档位于 preserve-opencode-sessions.md,本文以该文档为骨架,并结合仓库源码逐层展开。
设计目标
整个方案围绕以下目标展开:
- 跨生命周期保留历史:opencode 会话历史在 Kubernetes 沙箱休眠、恢复和重新供给(reprovision)后依然完整可用。
- 存储所有权归属 API Server / FileStore 层:沙箱 Pod 不持有任何租户存储凭据,持久化读写全部由 API Server 侧负责。
- 保持普通会话快照聚焦:普通会话快照只负责每个会话的本地文件(
outputs/、attachments/),不携带 opencode 数据。 - Docker 不承担该能力:Docker 工作区快照不携带 opencode 数据,opencode 历史持久化当前是 Kubernetes 专属能力。
- 乐观替换策略:当恢复后的数据库里保存的 opencode ID 缺失时,主动铸造一个新 ID 并持久化;后续再通过 follow-up 把已保存的聊天历史回放到新会话中。
- 会话删除的语义边界:删除 Onyx BuildSession 时删除产品可见记录,并在沙箱运行期间尽力删除实时 opencode 会话;失败不阻塞 Onyx 删除,也不会清理持久化的历史归档。
存储模型:两套持久化面
方案在持久化上明确区分了两个层面,源码中对应SnapshotManager与 Sidecar 快照端点两条路径。
按会话隔离的工作区快照
普通会话快照只捕获会话本地用户产出:
outputs/attachments/(存在且非空时)
这些快照刻意不包含.opencode-data。归档的创建与恢复由沙箱 Sidecar 通过以下 HTTP 端点完成:
POST /snapshot/createPOST /snapshot/restore/{session_id}
Sidecar 拥有 Pod 本地文件系统的读写权,而 API Server 通过SnapshotManager把归档流转存进 FileStore。端点常量定义在 contract.py:
SIDECAR_HEALTH_PATH = "/health" SIDECAR_READY_PATH = "/ready" SIDECAR_SNAPSHOT_CREATE_PATH = "/snapshot/create" SIDECAR_SNAPSHOT_RESTORE_ROUTE = f"{SIDECAR_SNAPSHOT_RESTORE_PREFIX}/{{session_id}}" SIDECAR_OPENCODE_HISTORY_CREATE_PATH = "/opencode-history/create" SIDECAR_OPENCODE_HISTORY_RESTORE_PATH = "/opencode-history/restore" SIDECAR_OPENCODE_HISTORY_MARK_RESTORED_PATH = "/opencode-history/mark-restored"沙箱全局的 opencode 历史
opencode 历史由沙箱内所有 BuildSession共享。在 Kubernetes 中,Pod 配置了:
OPENCODE_DATA_HOME=/workspace/opencode-dataopencode 数据根目录为:
/workspace/opencode-data持久化到 FileStore 的对象路径按沙箱确定性生成(见 snapshot_manager.py 的opencode_history_storage_path):
sandbox-snapshots/{tenant_id}/{sandbox_id}/opencode-history.tar.gz归档内部使用稳定的归档根目录:
.opencode-data/这样一来,opencode 持久化与会话工作区彻底分离,同时避免设计一个「按会话定制的 opencode store」——那与 opencode 实际采用的沙箱级数据模型不符。
关键组件拆解
SnapshotManager:统一 FileStore 持久化入口
SnapshotManager 是两类快照共用的 FileStore 持久化层:
- 普通 Sidecar 创建的工作区快照沿用其归档与未压缩大小校验;
- opencode 历史快照使用上述确定性存储路径,且不受到工作区快照大小上限的约束;Kubernetes 仍通过
emptyDir.sizeLimit限制 Pod 本地 opencode 数据卷的大小。
与普通快照(每次生成随机snapshot_id)不同,opencode 历史快照的 FileStore 键是确定性的、按沙箱唯一,写入时使用reject_empty=True拒绝空流(persist_opencode_snapshot_from_stream),并支持has_opencode_history_snapshot查询与delete_opencode_history_snapshot删除(后者幂等,文件缺失不报错)。
Sandbox Sidecar 快照端点
Sidecar HTTP 服务实现在 server.py,它把 Pod 本地文件系统操作暴露为签名 HTTP 端点:Sidecar 不上传 S3、不持有租户存储凭据,所有写路径请求都需要X-Push-Signature(Ed25519)与X-Push-Timestamp校验(_verify_signature校验时间戳漂移不超过 60 秒并对{timestamp}|{path}|{sha256_hex}消息验签)。
与 opencode 历史相关的端点:
GET /ready:只有启动恢复路径完成(或显式跳过)opencode 历史恢复后才返回健康;该端点用作可重启 init Sidecar 的启动门控,而非稳态 Pod 的就绪信号(ready 实现 在未恢复时返回 503)。POST /opencode-history/create:opencode 数据目录无内容时返回204,否则以application/gzip流式返回归档。POST /opencode-history/restore:接收经过签名、SHA-256 哈希校验的归档请求体(X-Bundle-Sha256头,见_spool_verified_archive),在 Pod 本地恢复 opencode 数据目录。POST /opencode-history/mark-restored:当不存在持久化历史快照时,标记一个全新沙箱已就绪。
Opencode 历史归档辅助模块
opencode_history.py 是 opencode 数据归档逻辑的归属模块,与专注于普通会话工作区快照的snapshot.py分离。其关键职责与实现细节:
- 路径安全:
_safe_opencode_data_dir拒绝符号链接、拒绝非目录路径、拒绝逃逸/workspace根目录的路径(L28-L43)。 - 保持数据路径独立:opencode 数据路径位于
/workspace/opencode-data,在/workspace/sessions之外。 - 归档前暂存(staging):
create_opencode_history_archive_file先把数据目录复制到临时暂存根.opencode-data/,再打 tar.gz(gzipcompresslevel=6)。 - SQLite 一致性备份:
_snapshot_sqlite_db_if_present用sqlite3.Connection.backup()把暂存中的opencode/opencode.db替换为一致性副本(PRAGMA busy_timeout=5000,只读源连接),即使opencode serve正在运行,归档也携带连贯的 DB 快照(L72-L83)。 - 安全恢复:恢复时使用 Python 标准库 tar 的
data过滤器(tar.extractall(staging_path, filter="data")),随后用os.replace原子地替换/workspace/opencode-data内容为解压出的.opencode-data/根;忽略归档中.opencode-data/之外无关的顶层条目。 - 损坏 DB 兜底:恢复后若已知的
opencode/opencode.db存在但损坏(非 SQLite magic、PRAGMA quick_check非ok、或路径为符号链接/非文件,见_opencode_db_is_healthy),则清空恢复出的 opencode 数据目录,让opencode serve全新启动,而不是对着已知的坏状态启动。 - 启动恢复标记:
mark_opencode_history_restored在 Sidecar 托管的受管状态目录写入标记文件/workspace/managed/.onyx/opencode-history-restored(mode=0o600);opencode_history_restored据此判断恢复门控是否放行。 - 并发保护:全部归档/恢复操作通过
threading.RLock串行化;恢复在opencode_history_restored()已为真时直接返回(幂等)。
Kubernetes 沙箱管理器
kubernetes_sandbox_manager.py 协调 Pod 生命周期、Sidecar 调用、FileStore 流式传输与启动恢复门控。它是后端中唯一声明该能力的实现(Docker 管理器当前未启用此能力):
supports_opencode_history_persistence = True(声明位于 kubernetes_sandbox_manager.py,基类默认值为False,见 base.py。)
Craft 的 Kubernetes Pod 模板使用原生可重启 init Sidecar(initContainers[*].restartPolicy: Always),因此 Craft Helm 部署要求Kubernetes 1.33 或更新版本。这一要求由 Chart 在部署/渲染阶段强制校验(ENABLE_CRAFT=true搭配SANDBOX_BACKEND=kubernetes在旧集群上渲染即失败),并非运行时后端版本检查。相关部署说明可参考 sandbox/README.md。
Provision 与 Restore 流程
当 Kubernetes 沙箱 Pod 启动时,恢复流程严格保证opencode serve不会在恢复完成(或被显式跳过)之前启动。完整时序如下:
- Kubernetes 启动防火墙 init 容器(firewall init container)。
- Kubernetes 启动可重启的
sidecarinit 容器。此时其健康端点(/health)可用,但启动端点(/ready)保持阻塞。 - K8s 管理器确保沙箱 Service 存在,并发布 not-ready Pod 地址,使 Sidecar 在 Pod ready 之前即可被访问。
- 若不存在持久化 opencode 历史快照,管理器向
/opencode-history/mark-restored发送签名请求,显式标记「跳过恢复」。 - 若存在持久化历史快照,API Server 从 FileStore 读取归档到临时文件,计算其 SHA-256,然后 POST 到
/opencode-history/restore(对应源码 restore_opencode_history_snapshot,其中hashlib.file_digest(..., "sha256")计算摘要后经post_archive上传)。 - Sidecar 恢复 opencode 数据目录;若恢复出的当前 opencode DB 损坏,则清空该数据,让 opencode 全新启动。
- Sidecar 标记 opencode 历史已恢复。
- Sidecar
/ready端点成功,释放可重启 init Sidecar 的启动门控。 - Kubernetes 启动
sandbox应用容器。其 entrypoint 以XDG_DATA_HOME指向/workspace/opencode-data的方式运行opencode serve。 - K8s 管理器等待 Pod ready 与
opencode serveready。
关键不变量是:opencode serve绝不会在恢复完成或被显式跳过之前启动。
当 K8s 管理器发现已存在健康的沙箱 Pod 时,直接复用该 Pod,不会重新执行启动历史恢复。
快照创建流程
opencode 历史快照在沙箱休眠之前以及尽力恢复(best-effort recovery)期间创建:
- K8s 管理器对
/opencode-history/create发送签名请求(request_and_stream_new_snapshot,见 create_opencode_history_snapshot)。 - Sidecar 检查 opencode 数据目录是否有内容。
- 无内容:Sidecar 返回
204,管理器保留任何已存在的持久化历史归档。 - 有内容:Sidecar 暂存 opencode 数据目录,用
sqlite3.Connection.backup()替换暂存中的 SQLite DB,生成 tar.gz 归档并流式返回。 - API Server 把响应流交给
SnapshotManager。 SnapshotManager存储到稳定的沙箱级 FileStore 键sandbox-snapshots/{tenant_id}/{sandbox_id}/opencode-history.tar.gz。
第 3 步的语义很关键:当 Sidecar 因实时存储为空而返回204时,管理器必须保留既有归档。这保护了闲置/恢复路径——一次瞬时的「实时为空/缺失」不应摧毁最后一次已知良好的历史。
发送消息流程:乐观的会话 ID 解析
Prompt 路径刻意采用乐观策略,保证「恢复后的沙箱里找不到已保存 opencode ID」不会让用户轮次失败:
- 会话行携带
BuildSession.opencode_session_id(若已持久化)。 - 每轮之前,
_ensure_opencode_session_id在行内无 ID 时铸造并持久化一个新的 opencode 会话 ID。 yield_sandbox_events以已保存 ID 与on_opencode_session_resolved回调调用sandbox_manager.send_message。_send_message_via_serve调用OpencodeServeClient.ensure_session(serve_client.py:GET /session/{id}预检,404 则POST /session新建)。- 若已保存 ID 存在:opencode 返回
200,复用同一 ID。 - 若 opencode 返回
404:Onyx 创建全新 opencode 会话,并触发回调更新 BuildSession 行(_persist_resolved_id经_persist_opencode_session_id写回新 ID,见 streaming.py)。 - 非 404 的查找错误仍然抛出——运行时故障不应静默铸造替代会话。
- 消息发送到解析后的 opencode 会话,正常事件流式传输继续。
这种设计的取舍在于:恢复后的沙箱缺失 opencode ID 时,用户轮次不会失败,会开启新 opencode 会话并记录新 ID;代价是 opencode 本身不会在新会话里收到先前的聊天历史——「历史回放」明确不在本次变更范围内(见下文「已知后续工作」)。
删除会话流程
删除 BuildSession 会删除 Onyx 的持久化会话记录;当沙箱运行中且行内存在opencode_session_id时,Onyx 还会尽力请求删除该实时 opencode 会话(DELETE /session/{id},见 serve_client.py)。该清理仅是优化:失败仅记录日志,不阻塞 Onyx 行删除。
opencode 历史仍是沙箱全局的实现数据,因此会话删除不会裁剪持久化历史归档。如果 opencode 中仍保留已删除 BuildSession 的行,该行成为孤儿数据,无法再通过 Onyx 触达。具体步骤:
SessionManager.delete_session获取会话 prompt 槽位。- 若沙箱运行中且 BuildSession 有 opencode 会话 ID,管理器尽力删除该实时 opencode 会话。
- 继续普通工作区清理与 Snapshot FileStore 清理。
- 删除 BuildSession DB 行。
对于休眠或未运行的沙箱,删除不会尝试编辑或校验 opencode 历史;Onyx 行被移除后,留在持久化历史归档中的陈旧 opencode 记录就是孤儿实现数据。
闲置休眠流程
沙箱清理任务处理闲置运行中的沙箱:
- 若后端支持 opencode 历史持久化,先尝试创建 opencode 历史快照。
- 若快照失败但沙箱仍通过健康检查,任务让沙箱继续运行——对健康沙箱直接休眠而没有新鲜历史,会冒着丢失最近 Agent 上下文的风险。
- 若 Pod 已不可达,任务记录警告并继续清理——此时实时文件系统不可信、无法为新鲜快照访问。
- 随后任务对每个会话工作区做快照。
- 必需快照完成后沙箱才能休眠。
对应逻辑见 sandbox_lifecycle.py:先create_opencode_history_snapshot,异常时若health_check通过则跳过休眠。
恢复流程(Recovery)
当 Onyx 检测到不健康的运行中沙箱需要终止/恢复时,生命周期代码在终止前尽力创建一次 opencode 历史快照(sandbox_lifecycle.py 的snapshot_opencode_history_best_effort)。此路径是 best-effort 的,因为沙箱可能已经部分死亡;失败会被记录但不会永久阻塞恢复。重新供给时,正常的恢复流程会恢复最后一次持久化的 opencode 历史快照(若存在)。
重置 / 全新开始流程
用户请求的沙箱重置是破坏性的「start fresh」操作:
- 删除持久化 opencode 历史快照(
delete_opencode_history_snapshot,幂等)。 - 终止沙箱资源。
- 在调用方持有的事务中将沙箱 DB 行标记为
TERMINATED。 - 仅在持久化历史删除与沙箱终止都成功后才提交。
注意,持久化 FileStore 删除在 DB 事务之外:如果历史删除成功后终止失败,API 报告重置失败并回滚 DB 状态,但持久化历史对象已不存在——这保留了「下次成功供给时 start fresh」的不变量。
若沙箱行已是TERMINATED,重置没有可保护的实时 Pod 或 DB 状态转换,此时持久化历史删除是 best-effort:失败记录日志,重置仍返回成功;后续重置可在 FileStore 恢复后重试删除。
这一点与闲置休眠刻意不同:休眠保留历史,重置移除历史。
为什么它不是普通会话快照
普通快照循环遍历会话目录、为每个会话存储其工作区——这对 outputs 与 attachments 是正确的模型。但 opencode 历史不同:
- opencode 把所有会话存放在一个沙箱级数据存储中;
- 按会话归档无法安全地表示共享数据存储;
- 多个 BuildSession 可共享同一个 opencode 历史存储;
- 删除一个会话会留下孤儿 opencode 行(Onyx 不编辑 opencode 内部存储);
- 重置必须删除共享归档,而不是合并或保留按会话存储。
因此实现复用了同一套高层快照基础设施(SnapshotManager、FileStore、签名 Sidecar 流式传输),但把 opencode 历史作为沙箱级归档,配有自己的 create/restore 端点与策略。这一决策完整记录在 preserve-opencode-sessions.md 的 "Why This Is Not A Normal Session Snapshot" 章节。
运维不变量清单
- 沙箱 Pod 不持有持久化存储凭据;API Server 拥有 FileStore 读写。
- Sidecar 拥有 Pod 本地文件系统读写。
- 普通会话快照不包含
.opencode-data。 - opencode 历史存放在
/workspace/sessions树之外的沙箱全局卷上。 - opencode 历史快照包含完整的
.opencode-data/归档根。 - 恢复出的损坏
opencode/opencode.db会被丢弃,改用全新 opencode 数据目录。 opencode serve只在启动历史恢复完成后启动。- Craft Helm 部署在 Kubernetes 版本低于 1.33 时快速失败(在开始供给沙箱 Pod 之前)。
- 发送消息时缺失已保存 opencode ID → 铸造新会话并持久化;
404之外的运行时查找错误仍使该轮失败。 - Craft Helm 部署在
ENABLE_CRAFT=true搭配非 Kubernetes 沙箱后端时快速失败。 - 会话删除尽力移除实时 opencode 会话,但清理失败仍删除 BuildSession 行。
- 会话删除不修改持久化 opencode 历史归档。
- 重置在终止沙箱之前删除持久化 opencode 历史,避免「已终止沙箱还带着可恢复的陈旧历史」。
已知后续工作(Follow-Up)
当前实现中,若恢复出的沙箱不包含已保存的 opencode ID,Onyx 会铸造新会话并持久化,避免阻塞用户;但新会话尚未包含先前的聊天历史。
计划中的后续工作是:检测这种「替换会话」场景,并在发送下一条用户 prompt 之前,把已保存的 BuildMessage 历史回放到 opencode 中。该回放逻辑应位于底层快照/恢复路径之上——快照层继续在可能时恢复 DB、保持存储导向的职责边界。
源码阅读清单
若想深入实现,按以下文件顺序阅读:
- opencode_history.py:归档创建/恢复、SQLite 一致性备份、损坏 DB 兜底、恢复标记。
- server.py:Sidecar 签名端点与
/ready启动门控。 - contract.py:全部端点路径与请求模型常量。
- snapshot_manager.py:FileStore 持久化、确定性历史存储路径、幂等删除。
- kubernetes_sandbox_manager.py:Pod 生命周期协调、恢复门控、能力声明。
- serve_client.py:
ensure_session/delete_session/send_message的 HTTP 语义。 - serve_transport.py:消息发送预检与会话 ID 解析回写。
- streaming.py:
_ensure_opencode_session_id与_persist_opencode_session_id。 - manager.py:
delete_session等会话生命周期入口。 - sandbox_lifecycle.py:闲置休眠与恢复前的 best-effort 历史快照。
- sandbox/README.md:部署模式与 Kubernetes 1.33 版本要求的总体说明。
小结
Onyx Craft 的 opencode 会话历史持久化是一个「职责分离 + 乐观容错」的典型案例:API Server 通过SnapshotManager独占 FileStore 所有权,Sidecar 只做签名保护的 Pod 本地文件系统操作,恢复流程以/ready门控保证opencode serve绝不先于数据就绪启动;发送消息时的乐观 ID 解析、休眠/恢复/重置各自的策略,共同保证了用户在 Pod 生命周期抖动下依然能继续对话。这套设计也为后续「把已保存聊天历史回放进替换会话」留下了清晰的演进空间。
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考