news 2026/9/11 2:56:09

Onyx Craft 会话保活:Kubernetes 沙箱中 opencode 会话历史持久化的完整实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Onyx Craft 会话保活:Kubernetes 沙箱中 opencode 会话历史持久化的完整实现指南

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,本文以该文档为骨架,并结合仓库源码逐层展开。

设计目标

整个方案围绕以下目标展开:

  1. 跨生命周期保留历史:opencode 会话历史在 Kubernetes 沙箱休眠、恢复和重新供给(reprovision)后依然完整可用。
  2. 存储所有权归属 API Server / FileStore 层:沙箱 Pod 不持有任何租户存储凭据,持久化读写全部由 API Server 侧负责。
  3. 保持普通会话快照聚焦:普通会话快照只负责每个会话的本地文件(outputs/attachments/),不携带 opencode 数据。
  4. Docker 不承担该能力:Docker 工作区快照不携带 opencode 数据,opencode 历史持久化当前是 Kubernetes 专属能力。
  5. 乐观替换策略:当恢复后的数据库里保存的 opencode ID 缺失时,主动铸造一个新 ID 并持久化;后续再通过 follow-up 把已保存的聊天历史回放到新会话中。
  6. 会话删除的语义边界:删除 Onyx BuildSession 时删除产品可见记录,并在沙箱运行期间尽力删除实时 opencode 会话;失败不阻塞 Onyx 删除,也不会清理持久化的历史归档。

存储模型:两套持久化面

方案在持久化上明确区分了两个层面,源码中对应SnapshotManager与 Sidecar 快照端点两条路径。

按会话隔离的工作区快照

普通会话快照只捕获会话本地用户产出:

  • outputs/
  • attachments/(存在且非空时)

这些快照刻意不包含.opencode-data。归档的创建与恢复由沙箱 Sidecar 通过以下 HTTP 端点完成:

  • POST /snapshot/create
  • POST /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-data

opencode 数据根目录为:

/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_presentsqlite3.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_checkok、或路径为符号链接/非文件,见_opencode_db_is_healthy),则清空恢复出的 opencode 数据目录,让opencode serve全新启动,而不是对着已知的坏状态启动。
  • 启动恢复标记mark_opencode_history_restored在 Sidecar 托管的受管状态目录写入标记文件/workspace/managed/.onyx/opencode-history-restoredmode=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 SidecarinitContainers[*].restartPolicy: Always),因此 Craft Helm 部署要求Kubernetes 1.33 或更新版本。这一要求由 Chart 在部署/渲染阶段强制校验(ENABLE_CRAFT=true搭配SANDBOX_BACKEND=kubernetes在旧集群上渲染即失败),并非运行时后端版本检查。相关部署说明可参考 sandbox/README.md。

Provision 与 Restore 流程

当 Kubernetes 沙箱 Pod 启动时,恢复流程严格保证opencode serve不会在恢复完成(或被显式跳过)之前启动。完整时序如下:

  1. Kubernetes 启动防火墙 init 容器(firewall init container)。
  2. Kubernetes 启动可重启的sidecarinit 容器。此时其健康端点(/health)可用,但启动端点(/ready)保持阻塞。
  3. K8s 管理器确保沙箱 Service 存在,并发布 not-ready Pod 地址,使 Sidecar 在 Pod ready 之前即可被访问。
  4. 若不存在持久化 opencode 历史快照,管理器向/opencode-history/mark-restored发送签名请求,显式标记「跳过恢复」。
  5. 若存在持久化历史快照,API Server 从 FileStore 读取归档到临时文件,计算其 SHA-256,然后 POST 到/opencode-history/restore(对应源码 restore_opencode_history_snapshot,其中hashlib.file_digest(..., "sha256")计算摘要后经post_archive上传)。
  6. Sidecar 恢复 opencode 数据目录;若恢复出的当前 opencode DB 损坏,则清空该数据,让 opencode 全新启动。
  7. Sidecar 标记 opencode 历史已恢复。
  8. Sidecar/ready端点成功,释放可重启 init Sidecar 的启动门控。
  9. Kubernetes 启动sandbox应用容器。其 entrypoint 以XDG_DATA_HOME指向/workspace/opencode-data的方式运行opencode serve
  10. K8s 管理器等待 Pod ready 与opencode serveready。

关键不变量是:opencode serve绝不会在恢复完成或被显式跳过之前启动

当 K8s 管理器发现已存在健康的沙箱 Pod 时,直接复用该 Pod,不会重新执行启动历史恢复。

快照创建流程

opencode 历史快照在沙箱休眠之前以及尽力恢复(best-effort recovery)期间创建:

  1. K8s 管理器对/opencode-history/create发送签名请求(request_and_stream_new_snapshot,见 create_opencode_history_snapshot)。
  2. Sidecar 检查 opencode 数据目录是否有内容。
  3. 无内容:Sidecar 返回204,管理器保留任何已存在的持久化历史归档。
  4. 有内容:Sidecar 暂存 opencode 数据目录,用sqlite3.Connection.backup()替换暂存中的 SQLite DB,生成 tar.gz 归档并流式返回。
  5. API Server 把响应流交给SnapshotManager
  6. SnapshotManager存储到稳定的沙箱级 FileStore 键sandbox-snapshots/{tenant_id}/{sandbox_id}/opencode-history.tar.gz

第 3 步的语义很关键:当 Sidecar 因实时存储为空而返回204时,管理器必须保留既有归档。这保护了闲置/恢复路径——一次瞬时的「实时为空/缺失」不应摧毁最后一次已知良好的历史。

发送消息流程:乐观的会话 ID 解析

Prompt 路径刻意采用乐观策略,保证「恢复后的沙箱里找不到已保存 opencode ID」不会让用户轮次失败:

  1. 会话行携带BuildSession.opencode_session_id(若已持久化)。
  2. 每轮之前,_ensure_opencode_session_id在行内无 ID 时铸造并持久化一个新的 opencode 会话 ID。
  3. yield_sandbox_events以已保存 ID 与on_opencode_session_resolved回调调用sandbox_manager.send_message
  4. _send_message_via_serve调用OpencodeServeClient.ensure_session(serve_client.py:GET /session/{id}预检,404 则POST /session新建)。
  5. 若已保存 ID 存在:opencode 返回200,复用同一 ID。
  6. 若 opencode 返回404:Onyx 创建全新 opencode 会话,并触发回调更新 BuildSession 行(_persist_resolved_id_persist_opencode_session_id写回新 ID,见 streaming.py)。
  7. 非 404 的查找错误仍然抛出——运行时故障不应静默铸造替代会话
  8. 消息发送到解析后的 opencode 会话,正常事件流式传输继续。

这种设计的取舍在于:恢复后的沙箱缺失 opencode ID 时,用户轮次不会失败,会开启新 opencode 会话并记录新 ID;代价是 opencode 本身不会在新会话里收到先前的聊天历史——「历史回放」明确不在本次变更范围内(见下文「已知后续工作」)。

删除会话流程

删除 BuildSession 会删除 Onyx 的持久化会话记录;当沙箱运行中且行内存在opencode_session_id时,Onyx 还会尽力请求删除该实时 opencode 会话(DELETE /session/{id},见 serve_client.py)。该清理仅是优化:失败仅记录日志,不阻塞 Onyx 行删除。

opencode 历史仍是沙箱全局的实现数据,因此会话删除不会裁剪持久化历史归档。如果 opencode 中仍保留已删除 BuildSession 的行,该行成为孤儿数据,无法再通过 Onyx 触达。具体步骤:

  1. SessionManager.delete_session获取会话 prompt 槽位。
  2. 若沙箱运行中且 BuildSession 有 opencode 会话 ID,管理器尽力删除该实时 opencode 会话。
  3. 继续普通工作区清理与 Snapshot FileStore 清理。
  4. 删除 BuildSession DB 行。

对于休眠或未运行的沙箱,删除不会尝试编辑或校验 opencode 历史;Onyx 行被移除后,留在持久化历史归档中的陈旧 opencode 记录就是孤儿实现数据。

闲置休眠流程

沙箱清理任务处理闲置运行中的沙箱:

  1. 若后端支持 opencode 历史持久化,先尝试创建 opencode 历史快照。
  2. 若快照失败但沙箱仍通过健康检查,任务让沙箱继续运行——对健康沙箱直接休眠而没有新鲜历史,会冒着丢失最近 Agent 上下文的风险。
  3. 若 Pod 已不可达,任务记录警告并继续清理——此时实时文件系统不可信、无法为新鲜快照访问。
  4. 随后任务对每个会话工作区做快照。
  5. 必需快照完成后沙箱才能休眠。

对应逻辑见 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」操作:

  1. 删除持久化 opencode 历史快照(delete_opencode_history_snapshot,幂等)。
  2. 终止沙箱资源。
  3. 在调用方持有的事务中将沙箱 DB 行标记为TERMINATED
  4. 仅在持久化历史删除与沙箱终止都成功后才提交。

注意,持久化 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 2:53:49

单链表基础操作详解:从节点定义到逆序与合并

标题写的“单列表”,我猜大概率是“单链表”的笔误。单链表(singly linked list)是数据结构里最基础也最容易被轻视的一节课。它不只是考试题,后面的栈、队列、哈希表的链地址法、图的邻接表、LRU缓存,底层全是链表或者…

作者头像 李华
网站建设 2026/9/11 2:52:45

微信小游戏开发实战:Cocos Creator 2.4 + TypeScript 从0到上线全链路

1. 项目概述:为什么一个“一人工作室”能靠微信小游戏跑通从0到1的闭环? “Vibe Gaming”这个名字听起来像支有十几号人的独立游戏团队,但实际就是我一个人——白天在大厂做前端架构,晚上和周末泡在Cocos Creator编辑器里调粒子、…

作者头像 李华
网站建设 2026/9/11 2:50:54

地面油污水渍检测数据集:VOC转YOLO与YOLOv8训练实践

简介:这是一套面向目标检测研究者和工程师的地面油污水渍检测数据集,聚焦油污水渍的自动识别与定位,可广泛应用于环境监控、公共安全、工业现场巡检等场景。资源包共2000个文件、大小约70.05MB,以VOC格式的XML标注文件为主体&…

作者头像 李华
网站建设 2026/9/11 2:50:05

如何找到SystemInformer的DLL注入入口:一份源码功能定位指南

如何找到SystemInformer的DLL注入入口:一份源码功能定位指南 【免费下载链接】systeminformer A free, powerful, multi-purpose tool that helps you monitor system resources, debug software and detect malware. Brought to you by Winsider Seminars & So…

作者头像 李华
网站建设 2026/9/11 2:49:53

AI驱动的云自动化巡检:从告警风暴到智能根因定位

我们团队在维护一套跨多个可用区的云上业务系统时,被一个问题反复折磨了快半年——云自动化巡检这五个字,听起来是省心,可真正落地起来,体感却是“配置了一堆告警规则,反而被告警淹没了”。白天还好,一到凌…

作者头像 李华