Skip to content

feat(session): occupancy 写入 SQLite,所有权不再读心跳文件 - #1202

Open
LucasIcarus wants to merge 6 commits into
masterfrom
feat/session-occupancy-sqlite
Open

feat(session): occupancy 写入 SQLite,所有权不再读心跳文件#1202
LucasIcarus wants to merge 6 commits into
masterfrom
feat/session-occupancy-sqlite

Conversation

@LucasIcarus

@LucasIcarus LucasIcarus commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 以 origin/master@82054f7b(已含 refactor(session): 删除 daemon 侧 JSON 写路径,落盘改为行级 upsert #1051)为基线,落地 Session 终态 Stage 1:per-bot sessions.db 新增 occupancy 表(scope / owner_pid / boot_id / lease_until)。daemon 在首次 load()BEGIN IMMEDIATE 里写入租约,30s 续期,shutdown / exitboot_id 释放。
  • SQLite 离线写在同一事务内读租约:有效则中止,过期则允许现有离线 apply(不在本进程抢租约)。findOnlineDaemon 不再作为默认所有权,只保留 IPC 发现与 dashboard 展示。
  • 租约行不存在时回落心跳,兼容尚未重启的 refactor(session): 删除 daemon 侧 JSON 写路径,落盘改为行级 upsert #1051 daemon;JSON 升级窗口路径仍走 abortIf。删除该回落的条件与 Stage 0 JSON 读路径相同。本 PR 不做 Stage 2(单一 apply)也不删 JSON 读路径。

影响面

  • 共用路径:session-storesession-offline-write、daemon 启停心跳、CLI close / abandon / prune、whiteboard 离线解绑。
  • 会话类型:话题 / 群 / adopt / restore 走同一套 store 导出;Pty / Tmux、sandbox on/off 不另开所有权协议。
  • 不改 <dataDir>/heartbeats/ busy-count,不把旁路文件并进会话库。

Test plan

  • tsc --noEmit 干净
  • bun run build 通过
  • bunx vitest run test/session-occupancy.test.ts test/session-store-sqlite.test.ts test/session-store.test.ts test/whiteboard-unbind-session.test.ts test/session-delete-cli.test.ts test/daemon-discovery.test.ts(157 条通过)
  • CI 全绿
  • 合入后观察:新 daemon 重启后库内出现 occupancy 行;旧 daemon 未重启时 close / abandon 仍走缺行回落;租约过期后即使心跳文件仍在,离线写应能落盘

Made with Cursor

@LucasIcarus
LucasIcarus force-pushed the feat/session-occupancy-sqlite branch from bd1e669 to cd9706f Compare September 3, 2026 06:21
Comment thread test/brand-template.test.ts Fixed
@LucasIcarus
LucasIcarus force-pushed the feat/session-occupancy-sqlite branch from dfd503f to 996920e Compare September 3, 2026 06:35
@LucasIcarus
LucasIcarus marked this pull request as ready for review September 3, 2026 06:55
@LucasIcarus
LucasIcarus force-pushed the feat/session-occupancy-sqlite branch from 996920e to 966ef8b Compare September 3, 2026 09:38
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR —— 把所有权从旁路心跳文件搬进会话库的同一个 BEGIN IMMEDIATE,是对「探测与写入之间那道缝」的正解。以下是自动评审的初步意见,最终以维护者审阅为准。

结论:0 阻断,1 条非阻断建议

已同步到最新 origin/masterba9bc72ae)rebase 验证:0 冲突(仅 src/cli.ts#1220 产生 1 行偏移)。tsc --noEmit 干净、build 通过、相关 267 条测试全绿(在 rebase 后的树上重跑,因为 CI 的绿是在旧 base 上跑的)。新增的 test/session-occupancy.test.ts 17 个用例全部真实执行。

我们对几处承重逻辑做了变异测试,确认断言都有牙:把「租约有效」改成恒假、摘掉进程存活检查、把「抢占仍存活的前任租约」这个危险方向放开、砍掉心跳回落腿、把 CLI 侧退回改动前的行为 —— 五种变异全部触发红灯。有条件接管(前任还活着就不抢、只记 warn 并在心跳里重试)和关停期「租约持有到最后一刻」这两个设计尤其稳妥。

🟠 非阻断建议:mutateSessionRowOffline 的锁竞争建议收敛成 undefined

openDbForOwnStoretry之外,所以拿不到写锁时异常会直接穿出 mutateSessionRowOffline,而不是像「租约被持有」那样干净地返回 undefined

这一点需要说清楚归因:我们最初怀疑是新增的 CREATE TABLE IF NOT EXISTS occupancy 引入的,于是把「DDL 只在 owning daemon 的 load() 里做」这个修法实现出来实测 —— 窗口并没有消失,抛出点只是从 DDL 挪到了 mutateSessionRowOffline 自己的 BEGIN IMMEDIATE。随后把 session-store.ts 换成 origin/master 版本跑同一个探针,得到逐字相同database is locked

所以这是既有行为,不是本 PR 引入的缺陷。 建议之所以仍然提出来,是因为本 PR 会在部署当刻短暂抬高它的触发概率(见下)。

建议的小改(可选):把 openDbForOwnStore 挪进 try,把锁竞争映射成 undefined —— 与「租约被持有」同语义的干净放弃,CLI 侧不再抛栈。我们不建议改成「offline writer 不建表」,实测那样只是把抛出点挪位,却多引入一个开关和「谁能建表」的分叉。

📌 部署期的一个预期现象(供值班同学心里有数,不是缺陷)

线上实测:57 个 store 目前都还没有 occupancy。也就是说部署那一刻,每个 bot 首次打开自己的库时都要真正执行一次建表写入。CREATE TABLE IF NOT EXISTS 在表已存在时是纯 no-op(实测 1ms、不取锁),但首次是真写:若恰好撞上一个持有写锁超过 busy_timeout(3s) 的写者,daemon 侧会重启一次(自愈 —— 表由第一个成功者建好,重试即 no-op),CLI 侧会报一次 database is locked

概率很低(本库没有长写事务、WAL 下读不阻塞写),且如上所述这个错误形态在改动前同样存在 —— 只是部署当刻是 fleet 范围同时经历一次,值得提前知晓。

其他几点(均无问题,仅作确认)

  • removeDaemonDescriptor 增加 bootInstanceId 归属校验是正确的修复:重启重叠期旧 daemon 不会再删掉后任刚发布的 descriptor。
  • 「daemon 应答即权威」的语义调整经核查是安全的:唯一能放行离线写的状态是 daemon 已死超过 90s;daemon 活着但 IPC 不可达时租约或心跳必然新鲜,仍然 fail-closed。
  • 被 displaced 的 daemon 不会自我降级 —— 实测其读写照常,租约只用于阻挡其它进程的离线写。
  • 心跳回落确实必要:线上 57 个 SQLite store 与 57 份冻结 JSON(56 个 per-bot + 1 个 legacy)并存,其中 10 个 bot 尚未跑过 Stage-0 导入,两条兼容路径都在承重。设计文档把删除条件挂在「fleet 自动重启落地 / 2026-11-26 复核」是保守且正确的。

一点供参考:daemon 侧的 claim / release / 心跳续期 / descriptor 归属校验目前没有单测覆盖,测试集中在 store 与 CLI 层。考虑到 daemon.ts 的体量这可以理解,只是这部分需要合入后靠线上观察。

以上是自动评审的初步意见,最终以维护者审阅为准

@LucasIcarus
LucasIcarus force-pushed the feat/session-occupancy-sqlite branch from 966ef8b to 69fd203 Compare September 3, 2026 11:02
LucasIcarus and others added 6 commits September 3, 2026 20:35
Co-authored-by: Cursor <cursoragent@cursor.com>

#1051 之后 daemon 只写会话行,但所有权仍在 dashboard-daemons 心跳上,探测与落盘不在同一事务。本变更把 per-bot 租约放进同库 occupancy 表,在首次 load 与离线写的 BEGIN IMMEDIATE 里判定,findOnlineDaemon 只保留 IPC 发现。
review 后续。修正租约语义:

- 领取有条件:别的 boot 的租约只有过期、或其 owner_pid 已不存在时才被接管;存活的前任保留所有权,后任记 warn 并在心跳里重试。领取与续期合并为 claimOccupancyLease(held / displaced / unavailable),丢失租约的进程会在对方释放或过期后自动拿回,不再终生无租约运行
- 所有权谓词统一为「有效租约 || 心跳新鲜」:离线写事务内与 CLI 的 isOccupancyHeld 一致,回滚到不写 occupancy 的旧构建时不再被绕过;isOccupancyHeld 永不抛错,库不可读时由心跳决定
- 已应答的 daemon(任何 HTTP 状态)的拒绝恢复为终态;只有连接失败时才用租约区分「daemon 在但不可达」与「descriptor 残留」
- 优雅关停期间租约持有到 process.exit 前才释放,关停开始先续一次;exit handler 兜底。首次 load 之后立即 claim 一次,覆盖 reconcile 提前触发 load 的路径
- load 事务 COMMIT 失败不再被吞;领取失败(如只读库)只记 error、快照照常加载;attachOwnStore 的锁竞争(升级后首次 CREATE TABLE occupancy)透传重试而非钉成 loadFailure
- removeDaemonDescriptor 先比对 bootInstanceId,重叠的旧 daemon 不再删掉新 daemon 的 descriptor
- 删除无调用者的 setOccupancyHolder 与未用的 scope 参数;离线写事务内只读一次租约行;90s 常量收敛到 utils/daemon-heartbeat.ts 供 discovery / registry / session-store 共用
- mojo-isolation-inventory-failclosed 的枚举式 mock 补上 occupancyLeaseIsActive / readOccupancyLease:bun 在链接期校验整张图的具名导出,缺一个整文件 0 执行,这是 CI bun-test 腿红的原因

影响面:session-store(load / 离线写 / 租约)、session-offline-write、cli delete/prune/whiteboard 的 daemon 优先路径、daemon 心跳与关停顺序、descriptor 删除。不涉及 worker、适配器或 IM 路由。

验证:tsc --noEmit 干净;session-occupancy 17/17(新增接管 / 顶替 / 领取失败 / DDL 锁竞争);session-delete-cli 11/11(新增过期租约仍终态、活租约拒绝离线、过期且无心跳才离线);相关 16 个套件 722/722;bun test 该 mock 文件 2 pass。switch:here 后 daemon:restart 实测:新 daemon 持有租约并随 30s 心跳续期。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
本地 macOS 全量单测长期红的几处,CI(ubuntu)看不到:

- brand-template {cwd}:macOS 临时 home 路径含下划线,被 lark_md 转义成 \_;期望值按同一规则转义
- claim-botmux-bin-binary:/var 是 /private/var 的软链,wrapper 存的是解析后路径;期望值改用 realpathSync
- sandbox-shim-compiled-form:prepareDirectSandbox 在非 Linux 直接返回 null,整组 describe 本质 Linux-only,按仓库惯例 skipIf
- mojo 四个文件:等待 fake mojo 首行输出的 vi.waitFor 用默认 1s,单独跑全绿、全量并行下超时;14 处给 15s 预算

验证:8 个文件 vitest 95 通过 / 11 跳过;同批在 bun test 下 0 fail;全量 vitest --project unit 从 12 失败收敛到 1(npm pack 解析,#1207 修)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ization 告警

上一提交在测试里手写了一遍 lark_md 转义正则来生成期望值,CodeQL 的
js/incomplete-sanitization 规则把它当成缺少反斜杠处理的清洗代码报了 high。
测试本就不该复刻被测模块的转义逻辑:改为断言 `~/foo` 的渲染结果与展开后
绝对路径的渲染结果一致,且不以字面量 ~ 开头。用例意图不变,macOS 上依旧绿。

验证:vitest 21/21;bun test 21 pass。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
openDbForOwnStore 与 BEGIN IMMEDIATE 拿不到写锁时,与租约被持有同语义返回 undefined,CLI 不再抛 database is locked。缺引擎 / 坏库仍表面。

Co-authored-by: Cursor <cursoragent@cursor.com>
worker 在 argv-baked 首轮会合法地依次发出 working → idle → working → idle:
中间那个 idle 是启动窗口内的假 idle,worker 自己判定后 re-arm 回 working,
最后才真正落到 idle。

原用例用 waitForScreenUpdates 等到「出现过 idle」就返回,却断言「最后一条
screen_update 是 idle」。两者只有在尾部三条 update 挤进同一个 IPC 投递批次
时才等价:本地这三条落在同一毫秒,25ms 轮询每次都看到完整序列,所以恒过;
CI 上满载、该文件排在最后一批,批次被打散,轮询正好落在 re-arm 的 working
与最后那个 idle 之间,尾部断言就读到 working。

新增 waitForSettledIdle:等「最新一条是 idle 且在静默窗口内保持 idle」,不
再依赖投递批次,且比原来更严格——瞬时 idle 后紧跟的 re-arm working 落在静
默窗口内,无法再结束等待。

覆盖两个同缺陷调用点(都丢弃 helper 返回值后重新全量过滤 messages);另两
处用的是 helper 返回的 idle-only 数组,.at(-1) 恒为 idle,本就无竞态,未改。

不涉及产品代码,worker.ts 与 idle 检测一行未动。

验证:bun test 与 vitest 两条腿各跑一次,均 13/13 通过,expect 数量不变。

Claude-Session: https://claude.ai/code/session_017pqAYFy3pdKDL4fzGwyBge
@LucasIcarus
LucasIcarus force-pushed the feat/session-occupancy-sqlite branch from 69fd203 to a4ff3ca Compare September 3, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants