Skip to content

feat(server): add session auto commit scheduling#2772

Open
zhoujh01 wants to merge 1 commit into
mainfrom
session-auto-commit-server
Open

feat(server): add session auto commit scheduling#2772
zhoujh01 wants to merge 1 commit into
mainfrom
session-auto-commit-server

Conversation

@zhoujh01

Copy link
Copy Markdown
Collaborator

Session Auto Commit 服务端自动触发详细方案

1. 背景

当前 session commit 的自动触发能力曾经主要分散在客户端或插件侧,不同接入方的行为模型并不一致。

两个典型例子:

  • openclaw-plugin
    • 事件驱动
    • turn-based
    • 更偏向在对话推进过程中基于 token threshold 触发
  • opencode-memory-plugin
    • 时间驱动
    • scheduler-based
    • 更偏向在后台按时间窗口做自动提交

这种现状带来几个问题:

  • 自动触发逻辑分散,服务端没有统一真相源
  • 不同客户端的触发语义不一致
  • 客户端退出、重启、断连后,自动触发状态容易丢
  • 多端同时操作同一个 session 时,行为不可控
  • 服务端难以统一治理 token/time 两类触发策略

因此,需要把 session auto commit 能力收回到服务端。

2. 目标

本方案的目标是:

  • 让服务端统一支持两类自动 commit 触发
    • 基于 token threshold
    • 基于 idle timeout
  • auto_commit_policy 持久化到 session meta
  • 让重启后自动触发能力能够恢复
  • 让内部调度状态与用户资源目录解耦
  • v1 先用简单、清晰、可控的方式落地

3. 非目标

v1 明确不做下面这些事:

  • 不做复杂的分布式 lease 协议
  • 不持久化 runtime in_flight task 状态
  • 不给 token-only session 建额外调度索引
  • 不做多文件索引分片
  • 不在 v1 引入独立的 update auto_commit_policy 专用 API
  • 不考虑旧路径兼容和历史数据迁移

4. 设计原则

4.1 配置真相源和调度状态分离

auto_commit_policy 保存在 session meta 中,因为它是业务配置真相源。

索引文件不保存完整 policy,只保存“当前需要被 idle scheduler 跟踪的 session membership”。

也就是说:

  • session meta 负责表达“这个 session 想要什么行为”
  • 索引文件负责表达“当前有哪些 session 需要后台调度器关注”

4.2 token 和 idle 走不同路径

这两类触发机制本质不同:

  • token threshold 是消息写入后的即时判断
  • idle timeout 是时间流逝后的后台判断

因此不应强行走同一个调度模型。

4.3 索引只保存活跃待跟踪对象

索引不应该保存所有历史上开启过 auto commit 的 session。

它只应保存:

  • 已开启 auto commit
  • 且配置了 idle timeout
  • 且当前确实还存在未提交内容
  • 且服务端 idle 自动触发功能处于开启状态

这样可以避免索引文件无限膨胀。

4.4 v1 优先简单可解释

在功能边界明确的前提下,优先做:

  • 单文件
  • 明确的写入/删除时机
  • 明确的故障恢复语义
  • 最小去重

而不是一开始就追求复杂分片或强一致机制。

5. 现状梳理

5.1 openclaw-plugin 模式

openclaw-plugin 当前更像:

  • 事件驱动
  • 每个 turn 结束时检查是否需要 commit
  • 更偏向基于 token 大小或 turn 边界做触发

这个模型的特点是:

  • 及时
  • 与交互链路绑定紧
  • 但客户端必须在线
  • 服务端不掌握完整触发状态

5.2 opencode-memory-plugin 模式

opencode-memory-plugin 当前更像:

  • 定时调度
  • 后台周期检查
  • 更偏向基于时间窗口决定是否 commit

这个模型的特点是:

  • 适合 idle 场景
  • 客户端在线与否影响较小
  • 但逻辑仍不在服务端统一收口

5.3 收敛方向

服务端应统一支持:

  • token trigger:消息到达时即时判定
  • idle trigger:后台调度器判定

这样既能保留两类行为的优势,又能统一治理。

6. Session Meta 设计

auto_commit_policy 放入 session .meta.json

建议字段如下:

{
  "auto_commit_policy": {
    "enabled": true,
    "token_threshold": 8000,
    "idle_timeout_seconds": 1800,
    "keep_recent_count": 10
  },
  "last_message_at": "2026-06-20T10:00:00+08:00",
  "auto_commit_last_error": "",
  "auto_commit_last_error_at": ""
}

说明:

  • auto_commit_policy
    • session 级别自动 commit 配置
    • 是配置真相源
  • last_message_at
    • 最近一次消息进入 session 的时间
    • idle timeout 计算依赖它
  • auto_commit_last_error
    • 最近一次自动 commit 失败原因
  • auto_commit_last_error_at
    • 最近一次失败时间

6.1 为什么不持久化 runtime in-flight 状态

不建议在 meta 中写:

  • auto_commit_pending
  • auto_commit_in_flight_task_id

原因是:

  • 这些状态是 runtime 瞬时状态,不是业务配置
  • 进程崩溃后,这些状态很容易与真实 task 生命周期脱节
  • 恢复逻辑会显著复杂化
  • 而且现有 commit 本身已经有锁和 task 跟踪能力

因此 v1 依赖:

  • Session.commit_async() 自身的锁/no-op 语义
  • TaskTracker.has_running(...)
  • 进程内 claim set

做最小去重即可。

7. 两类触发机制设计

7.1 token threshold 触发

这类触发不需要进索引。

原因:

  • token threshold 的判断只在消息写入时才有意义
  • 没有消息进入时,token 数不会自然增长
  • 因此不需要后台 scheduler 反复扫描

处理方式:

  1. add_message / batch_add_messages
  2. 更新 session meta
  3. 判断 pending_tokens >= token_threshold
  4. 满足则触发自动 commit

7.2 idle timeout 触发

这类触发需要进索引。

原因:

  • idle timeout 本质是“时间过去了多久”
  • 即使没有新消息,也需要服务端在未来某个时间点主动判断
  • 因此需要后台 scheduler

处理方式:

  1. 新消息写入后更新 last_message_at
  2. 计算新的 next_check_at
  3. 把 session 作为 active idle member 写入索引
  4. scheduler 周期检查,到期时触发自动 commit

8. 服务端全局开关

给 idle 自动触发加一个服务端全局配置:

{
  "server": {
    "session_auto_commit": {
      "idle_enabled": true,
      "check_interval_seconds": 60.0
    }
  }
}

约束如下:

  • idle_enabled
    • 默认开启
    • 只影响 idle timeout 触发
    • 不影响 token threshold 即时触发
  • check_interval_seconds
    • scheduler 的轮询间隔
    • 默认 60.0

这样做的好处是:

  • 出问题时可服务端快速兜底关闭 idle 调度
  • 不用改每个 session 的 policy
  • 调度频率可按部署规模调整

9. 索引文件设计

9.1 为什么需要索引

如果没有索引,要实现 idle 触发,理论上可以每轮扫描全部 session。

但这有几个问题:

  • session 数量大时开销高
  • 大部分 session 根本没开启 idle 自动触发
  • 很多 session 没有未提交内容,扫描毫无意义

因此需要一个“活跃 idle 候选集”的索引层。

9.2 为什么 token-only session 不进索引

因为 token-only session 不需要时间驱动扫描。

只要没有新消息写入,就不会触发 token threshold 判断。

把它放进索引会带来纯粹的无效扫描,没有收益。

9.3 单文件 vs 多文件

v1 先选单文件。

原因:

  • 简单
  • 易于解释
  • 易于调试
  • 当前阶段比复杂分片更重要的是把触发语义跑通

后续如果规模增长,再考虑分片。

10. 索引存储位置

索引文件不应放在 viking://resources/ 下。

原因:

  • 它不是用户资源
  • 它是内部控制面状态
  • 放在 resources 下会污染资源视图
  • 也容易和用户侧语义混淆

当前统一放在内部系统路径:

  • /local/_system/session_auto_commit/index.json

这里不再保留额外的 tmp / bak 轮转文件。

11. 索引文件结构

当前结构如下:

{
  "meta": {
    "updated_at": "2026-06-20T10:30:00+08:00"
  },
  "data": {
    "acct_a": {
      "user_b": {
        "chat_123": {}
      }
    }
  }
}

说明:

  • 顶层固定为 metadata
  • data 采用 account -> user -> session 层级
  • session value 目前为空对象 {},只表示 membership
  • next_check_at 不持久化到文件中

11.1 为什么不写扁平 key

比如这种:

{
  "sessions": {
    "acct_a:user_b:chat_123": {}
  }
}

问题在于:

  • 解析时仍要拆回 account/user/session
  • 难以按层级做清理
  • 可读性较差

11.2 为什么不用 _meta 当固定 key

因为如果顶层同时承载业务层级,_meta 这种保留名会引入命名冲突风险。

比如理论上有人账户名就可能叫 _meta

所以更安全的做法是:

  • 顶层固定两个字段
    • meta
    • data

业务数据全部放在 data 之下。

11.3 为什么 value 里不重复写 account/user/session

因为这些信息已经由层级路径表达了。

value 不需要重复写:

  • account_id
  • user_id
  • session_id

这样能减少冗余。

12. next_check_at 的设计

next_check_at 仍然存在,但只保存在进程内 runtime cache,不持久化到 index.json

12.1 为什么不落盘

当前实现优先考虑:

  • 降低每次写消息时的索引写放大
  • 简化索引格式
  • 让磁盘索引只表达 membership

因此:

  • 磁盘:只存 membership
  • 内存:存 session_key -> next_check_at

12.2 next_check_at 何时更新

它在以下场景更新:

  1. 新消息写入后
    • last_message_at 变化
    • 需要重算并刷新 runtime cache
  2. policy 更新后
    • 如果 idle_timeout_seconds 变化
    • 需要重算并刷新 runtime cache
  3. 自动 commit 完成后
    • 如果仍需继续跟踪,则重算 runtime
    • 如果已不需要跟踪,则删索引并移除 runtime
  4. scheduler 每轮同步 membership 时
    • 对 runtime cache 中缺失的 session
    • 按需读取 session meta 计算并补齐

12.3 为什么这样仍能减少无效扫描

当前 scheduler 每轮不是扫描全部 session,而是:

  1. 先读一次 index.json
  2. 得到 active idle membership
  3. 对 runtime cache 中缺失的 session 按需加载 meta 计算 next_check_at
  4. 只对 runtime 中到期的项继续处理

这样比“扫描全部 session”轻很多。

13. 索引项写入条件

一个 session 只有同时满足以下条件,才进入索引:

  • auto_commit_policy.enabled == true
  • idle_timeout_seconds 有效
  • 服务端 idle_enabled == true
  • 当前存在未提交内容

只有开启 idle 自动触发的 session 才会进入这个文件。

14. 索引项删除条件

索引文件里只保留当前需要后台调度器关注的 session。

因此以下情况应删除索引项:

  • policy 被关闭
  • idle_timeout_seconds 被移除
  • 服务端全局 idle_enabled 被关闭
  • session 被删除
  • commit 后已经没有未提交内容
  • scheduler 同步时发现该 session 已不存在或已不再满足 idle 条件

14.1 为什么自动 commit 成功后可以直接删

当自动 commit 成功后,如果当前 session 已无未提交内容,就没必要继续保留索引。

虽然 “policy 还开着” 这件事仍然存在,但这个信息保留在 session meta 里。

当未来再次 add_message 时:

  • 新消息进入
  • 服务端重新读取 meta 中的 policy
  • 重新计算 runtime 并按需把 session 写回索引

这样做的好处是:

  • 索引不会随着历史 session 无限膨胀
  • 索引始终只表示“活跃待跟踪对象”

15. 持久化策略

15.1 何时持久化

v1 采用“membership 变更立即持久化”。

也就是说:

  • 新增 idle session membership 时立即写盘
  • 删除 idle session membership 时立即写盘

15.2 当前写法

当前实现简化成:

  1. 先从磁盘读取当前 index.json
  2. 在内存中修改 membership
  3. 直接写回 index.json

这样做的原因是:

  • 逻辑更简单
  • 更容易理解和调试
  • 当前单进程假设下已经足够

15.3 runtime 与持久化的边界

只有 membership 需要落盘。

以下状态只保存在进程内:

  • next_check_at
  • claim set
  • in-flight 去重状态

16. 启动与重启恢复

16.1 启动正常流程

服务启动时:

  1. 根据配置判断是否启用 idle scheduler
  2. 如果启用,则初始化索引对象
  3. 启动 scheduler loop

这里不会在启动时全量扫描所有 session。

16.2 当前恢复思路

当前恢复机制不是“启动时全量重建”,而是“每轮 check 时懒恢复 runtime”。

具体做法:

  1. scheduler 每轮先读取 index.json
  2. 用磁盘 membership 和 runtime cache 做同步
  3. 对 runtime 中缺失但仍在 index 里的 session
    • 按需加载 session
    • 读取 meta
    • 计算 next_check_at
    • 回填到 runtime cache
  4. 对已失效 membership 懒清理并回写索引

16.3 重启后会不会丢

分两类看:

  • token trigger
    • 本来就不依赖后台索引
    • 只在后续有新消息进入时重新判断
  • idle trigger
    • 只要 session membership 已写入 index.json
    • 重启后 scheduler 下一轮会重新读到并恢复 runtime

因此当前语义是:

  • 已经进入索引的 idle session,重启后可恢复
  • 不在索引里的 token-only session,不需要恢复 idle 调度

17. scheduler 设计

17.1 扫描范围

scheduler 不扫描全部 session。

它只扫描索引文件里登记的 idle session membership。

也就是说:

  • token-only session 不在调度范围内
  • 未开启 idle policy 的 session 不在调度范围内
  • 已没有未提交内容、已从索引删除的 session 不在调度范围内

17.2 单轮逻辑

每轮 scheduler:

  1. 读取 index.json
  2. 同步 runtime cache
    • 清掉磁盘中已不存在的 runtime key
    • 对 runtime 中缺失的 membership,按需 load session 计算 next_check_at
    • 对已失效 session 懒清理索引
  3. 找出 runtime 中 next_check_at <= now 的项
  4. 对这些项逐个加载 session 再次校验
    • policy 是否还开启
    • idle timeout 是否仍有效
    • 是否还有未提交内容
  5. 满足则触发自动 commit
  6. commit 后更新 runtime 或删除索引

17.3 为什么需要二次校验

因为索引不是配置真相源,runtime cache 也不是配置真相源。

例如:

  • policy 已经关闭
  • session 已被手动 commit 清空
  • idle_enabled 已被服务端关闭

所以到期后不能直接 commit,必须再读 session 状态做最终判断。

18. 去重与并发控制

自动 commit 存在重复触发风险,例如:

  • token trigger 和 idle trigger 同时命中
  • scheduler 多轮扫到同一个 session
  • 进程内多个协程同时试图触发
  • 重试与手动 commit 叠加

v1 使用三层最小去重:

  1. Session.commit_async() 自身锁与 no-op 语义
  2. TaskTracker.has_running(...)
  3. 进程内 claim set

18.1 为什么不再额外持久化 task 状态

因为现有 commit 链路本身已经具备:

  • 任务跟踪
  • 锁保护
  • no-op 防重

再单独持久化 auto_commit_pendingin_flight_task_id

  • 收益有限
  • 恢复逻辑复杂
  • 还会引入 stale 状态问题

v1 没必要。

19. 关键流程

19.1 add message

  1. 写入 message
  2. 更新 last_message_at
  3. 如果请求带 auto_commit_policy,则持久化到 session meta
  4. 如果 keep_recent_count 变化,则同步更新 session.meta.keep_recent_count
  5. 重新计算 pending_tokens
  6. 如果存在 idle policy 且服务端 idle_enabled == true
    • 重算 runtime next_check_at
    • 按需写入 idle 索引 membership
  7. 如果存在 token threshold
    • 即时判断是否要自动 commit

19.2 batch add messages

  1. 一次请求批量写入同一个 session 的多条消息
  2. auto_commit_policy 只允许在 batch 顶层传一次
  3. messages[*].auto_commit_policy 不允许
  4. 写入完成后统一做一次 policy 持久化、runtime 刷新和 auto commit 判断

19.3 token 自动触发

  1. 在消息写入后检查 pending_tokens
  2. 若达到阈值,则发起自动 commit
  3. commit 完成后根据最新状态处理索引

19.4 idle 自动触发

  1. scheduler 扫到到期项
  2. 加载 session 做二次校验
  3. 满足条件则发起自动 commit
  4. commit 完成后更新或删除索引

19.5 自动 commit 成功

  1. 清理最近错误状态
  2. 如果已无未提交内容,则删除索引项
  3. 如果仍需继续跟踪,则重算 runtime next_check_at

19.6 自动 commit 失败

  1. 记录 auto_commit_last_error
  2. 记录 auto_commit_last_error_at
  3. 保留未来再次重试的机会

20. API 语义

在消息写入接口上支持:

{
  "auto_commit_policy": {
    "enabled": true,
    "token_threshold": 8000,
    "idle_timeout_seconds": 1800,
    "keep_recent_count": 10
  }
}

这里的语义应明确:

  • 这是 session 级别 policy
  • 一旦设置,会持久化到 session meta
  • 后续触发完全以服务端持久化配置为准

如果未来需要单独更新 policy,也可以再补一个专用 API,但 v1 不强依赖。

21. 风险点与取舍

21.1 scheduler 每轮仍需读取 membership 并按需补 runtime

当前索引只存 membership,不存 next_check_at

这意味着 scheduler 每轮至少要:

  • 读一次 index.json
  • 对 runtime 缺失的 session 读一次 meta

这个成本比“把全部调度状态都持久化到索引”更高,但换来:

  • 写消息时更少的索引写入
  • 更简单的索引格式

21.2 单文件热点

单文件索引在高并发下会有写热点。

但 v1 的目标不是承载极端规模,而是先把语义跑通、恢复语义跑通,因此可以接受。

21.3 当前不考虑多进程强并发

目前实现以单进程语义为主,对 index.json 的操作通过进程内锁串行化。

暂不引入跨进程分布式锁或 lease。

21.4 故障窗口仍然存在

即便配置真相源在 meta、membership 在索引,也不能消灭所有故障窗口。

本方案的关键不是“绝对无窗口”,而是:

  • 配置真相源在 meta
  • membership 在索引
  • runtime 可按需恢复

只要这三点成立,语义就是可解释、可补偿的。

22. v1 明确边界

v1 明确采用以下约束:

  • 单文件索引
  • 索引只让 idle session 入内
  • token-only session 不入索引
  • 服务端 idle_enabled 默认开启
  • check_interval_seconds 默认 60.0
  • 索引文件不放在 resources
  • 索引文件只存 membership,不存 next_check_at
  • scheduler 不做启动时全量 session 发现
  • 每轮 check 先读 index.json,再按需补 runtime
  • 不持久化 in-flight task 状态
  • 配置真相源始终是 session meta
  • commit 后如果已无继续跟踪价值,直接删索引项

23. 当前实现对照

当前代码中已经落地的内容包括:

  • session meta 中已增加自动 commit 相关字段
    • auto_commit_policy
    • last_message_at
    • auto_commit_last_error
    • auto_commit_last_error_at
  • 服务端配置中已增加 idle 自动触发全局开关和调度周期
    • server.session_auto_commit.idle_enabled
    • server.session_auto_commit.check_interval_seconds
  • add_message / batch_add_messages 已支持接收并持久化 auto_commit_policy
  • auto_commit_policy.keep_recent_count 已同步写入 session.meta.keep_recent_count
  • keep_recent_count 变化时,会重建 pending_tokens
  • token threshold 已在消息写入后尝试即时触发
  • idle session 已进入单文件索引
  • 索引文件已放到内部控制路径,而不是 resources
    • /local/_system/session_auto_commit/index.json
  • 索引读写已简化为现读现写
  • scheduler 已能周期扫描索引并尝试触发 idle commit
  • 删除 session 时已同步清理索引项
  • 自动 commit 路径中已接入最小去重能力
    • TaskTracker.has_running(...)
    • 进程内 claim set
    • Session.commit_async() 自身锁/no-op 语义
  • batch message 接口现在只允许顶层传一次 auto_commit_policy
  • messages[*].auto_commit_policy 已从 batch schema 中移除
  • Session.load() 会用 live messages.jsonl 回填 meta.message_count
  • Session.load() 会重建 pending_tokens

24. 测试覆盖

当前已补的验证包括:

  • 单测
    • idle index 持久化到全局 _system 路径
    • 已存在 membership 时不重复写索引
    • Session.load() 可从 live messages 恢复 message_count
    • session_auto_commit 配置默认值与覆盖值校验
    • idle_enabled == false 时 scheduler 不启动
  • e2e
    • token trigger
    • idle trigger
    • batch 顶层 policy
    • batch 拒绝 per-message policy
    • index persistence
    • restart recovery
    • mixed token + idle
    • no duplicate tasks
    • error and cleanup

25. 结论

这个方案的核心思路可以概括为:

  • auto_commit_policy 放在 session meta,作为真相源
  • token threshold 走消息写入后的即时触发
  • idle timeout 走后台 scheduler
  • scheduler 依赖一个“只记录活跃 idle session membership”的单文件索引
  • 索引放在 _system,不放在 resources
  • index.json 只存 membership,不存 next_check_at
  • next_check_at 只保存在进程内 runtime cache
  • scheduler 每轮先读 index.json,再按需从 session meta 补齐 runtime

这样可以在实现复杂度可控的前提下,把自动 commit 的核心能力从插件侧收回到服务端,并保证后续有继续增强的空间。

@zhoujh01 zhoujh01 force-pushed the session-auto-commit-server branch from bd10b0e to 798b0be Compare June 22, 2026 14:56
@zhoujh01 zhoujh01 marked this pull request as ready for review June 22, 2026 14:56
feat(server): add session auto commit scheduling

fix: harden session auto commit e2e coverage

refactor: shorten session auto commit index path

docs: sync session auto commit design and api docs

feat: refine session auto commit idle indexing

feat: expose session auto commit in sdk and cli

fix: pin setuptools-scm for editable builds

fix: harden session auto commit index sync
@zhoujh01 zhoujh01 force-pushed the session-auto-commit-server branch from ba331c1 to eca01a9 Compare June 23, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant