Skip to content

feat(amsg): tick 支持同分组任务串行,新增 fire 收尾 hook,任务详情可读回完整 metadata - #44

Merged
Tosd0 merged 1 commit into
mainfrom
feat/amsg-tick-serialize-and-fire-settled
Aug 2, 2026
Merged

feat(amsg): tick 支持同分组任务串行,新增 fire 收尾 hook,任务详情可读回完整 metadata#44
Tosd0 merged 1 commit into
mainfrom
feat/amsg-tick-serialize-and-fire-settled

Conversation

@Tosd0

@Tosd0 Tosd0 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

起因是下游 SullyOS 排查主动消息「穿帮」时发现的几处,判断下来在库这边解决比在宿主绕更根本。

改了什么

同分组任务不并发跑runScheduledTick 新增可选 serializeBy(task)。同一个角色挂多条定时任务时,两条撞在一起会让用户一口气收到两条互不知情的消息;更麻烦的是宿主在 hook 里维护的台账是读-改-写整份,并发时后写的会盖掉先写的。

危险窗口不只是「同一跳」:一次 fire 含 LLM 生成加工具循环,几十秒到几分钟很常见,上一跳还在跑时下一跳照样捞到同角色的另一条。所以同跳与跨跳都拦,跨跳的判定与占位放在 claimTask 的同一条 UPDATE 里(先查后占中间的空档能让两个 tick 双双进同一组)。

被拦下的任务一个字段都不动,下一跳原样再被捞起来 — 是推迟,不是丢弃。

lease_untilretry_after 拆开 — 此前一列兼职「正在跑」和「等着重试」两件事,一条正在退避、其实闲着的任务会把整组堵住最长 6 分钟。现在退避写 retry_after 并立刻放掉租约。

onFireSettledonAfterSend 只在真发出去那条路触发,skip / skip-push / 抛错都不调,宿主拿不到可靠的「这次结束了」信号。下游因此踩过两次:hook 里建出来的任务漏了记账变成谁也删不掉的幽灵任务;想在 hook 里拿锁则找不到释放点。新 hook 只要 onBeforeFire 被调用过就必调一次,status 区分 sent / skipped / failed / not-handled。onAfterSend 语义不变。

GET /message?id=<uuid> — 列表投影有意不带完整 metadata,但 update-message 对 metadata 是整体替换,两者相加导致「只改 metadata 里的一个子字段」在客户端做不到(拿不回完整的就没法读-改-写)。新端点返回单条详情并带完整 metadata,凭据仍按白名单剔除;列表投影保持不变。

update-messagecontactName — 白名单里有 avatarUrl 没有它,看着是漏了。它不是 key(投影注释里就写着会跨角色重名、宿主靠 charId 过滤),数据库层面也没有独立列。

amsg-sw 空正文兜底 — 宿主发了 body 为空的 content push 时,此前会弹一条只有标题、正文空白的横幅。现在退回 defaults.defaultBody仍然照常弹 — 没有用「不弹」来解决,那会违反 userVisibleOnly 约定(README 里那一段讲的代价)。

Schema

新增三列 lease_until / retry_after / serialize_groupinitSchema 幂等补齐(SQLite 没有 ADD COLUMN IF NOT EXISTS,只吞 duplicate column name,其他错误照抛)。列不在时 getPendingTasks 会直接报错,整跳发不出去 — 部署时别出现「代码已经是新的、表还没迁」的窗口。

测试

npm run ci 全绿,server +28 / sw +4。回归守卫做了反向验证:临时改回旧行为(退避写回 lease_untilclaimTask 不传分组参数、去掉 onFireSettled),确认对应用例会红。

同跳内的预处理改成按 next_send_at 升序顺序执行 — 原本并发解密,谁先解完谁抢到组是随机的,同角色晚到点的消息可能抢在早到点的前面发出去。这也是此前测试偶发抖动的根因,连跑 6 轮已无抖动。

已知边界

  • claimSkippedTasks 计数里,「被分组拦下」与「行被别的 tick 领走」混在一起,要分开得让适配器回报失败原因,不值得。
  • 自定义适配器若实现了 claimTask 但忽略新增的分组参数,串行会静默退化成只在同一跳内生效,库无法探测;interface 与 README 已写明。
  • stale 任务同样占分组名额,一个角色积压 N 条过期任务要 N 跳才清完 — onStaleSkip 里宿主也在做读-改-写,并发同样会互盖。
  • pg / neon 的分组 SQL 在 CI 里只有录 SQL 文本的断言(仓库既有打法),行为正确性由走真 SQLite 的 D1 用例覆盖。真上 Postgres 前建议手验一次 NOT EXISTS 子查询。

https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX

- `runScheduledTick` 新增可选 `serializeBy(task)`:返回同一个键的任务,同一跳只放行
  一条,且该组还有任务正在跑时后来的也不放行。被拦下的任务不动任何字段(next_send_at
  / status / retry_count 全部原样),下一跳照常再被捞起来。跨跳的判定与占位在
  `claimTask` 的同一条 UPDATE 里完成,分组键以 HMAC 派生值落库,不为分组多开明文出口。
- `lease_until` 与 `retry_after` 拆开:前者只表示「正在跑」,投递失败的退避改写后者
  并立刻放掉租约。此前两件事共用一列,一条正在退避、其实闲着的任务会把整组堵住。
- 新增 `onFireSettled(ctx, outcome)`:只要 `onBeforeFire` 被调用过就必调一次,
  `status` 区分 sent / skipped / failed / not-handled,部分失败带 sentCount 与 total。
  宿主用它做「拿了就要放」的收尾(记账、放锁)。`onAfterSend` 语义不变。
- 新增 `GET /message?id=<uuid>`:返回单条任务详情并带完整 metadata(凭据仍剔除),
  列表投影保持不带。客户端对应 `client.getMessage(uuid)`。
- `PUT /update-message` 的可写字段增加 `contactName`,要求非空字符串。
- amsg-sw:通知正文为空时退回 `defaults.defaultBody`,仍照常弹出——不用「不弹」解决,
  那会违反 userVisibleOnly 约定。

新增三列 `lease_until` / `retry_after` / `serialize_group`,`initSchema` 幂等补齐。

Claude-Session: https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX
@Tosd0
Tosd0 merged commit 190453c into main Aug 2, 2026
1 check passed
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.

1 participant