Skip to content

feat(amsg): hook 契约补任务身份与状态读写口,任务按时区推进,推送订阅改用户级 - #42

Merged
Tosd0 merged 3 commits into
mainfrom
feat/amsg-hook-contract-and-user-subscription
Aug 2, 2026
Merged

feat(amsg): hook 契约补任务身份与状态读写口,任务按时区推进,推送订阅改用户级#42
Tosd0 merged 3 commits into
mainfrom
feat/amsg-hook-contract-and-user-subscription

Conversation

@Tosd0

@Tosd0 Tosd0 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

这一批补的是 amsg 后台主动消息在真实使用中暴露出来的三处契约缺口:hook 手里缺任务身份和状态读写口、push 里缺调度身份、任务的时区与订阅归属没有落到库里。

1. hook 契约补齐

之前 现在
onAfterSend / onStaleSkip 想写 client_state,只能由宿主自己缓存一份 fire 级 ctx 上的写口 两个 hook 的载荷直接带 readState(ns) / writeState(ns, entries)
「这次生成了哪几段正文」这类上下文,要按任务行 id 自建登记表(连带 TTL 清扫和并发隔离)传给 onAfterSend onAfterSend 收到本次 fire 的 scratch,与 onBeforeFire / onLLMOutput 是同一个对象引用
任务身份只能从 sessionId 里切字符串取 onLLMOutput / executeToolCalls 的 ctx 带 taskId / taskUuid / occurrenceMs

宿主自建缓存那条尤其要紧:isolate 冷启动后、本次 tick 里还没有任何 fire 跑过时缓存是空的——服务停摆恢复后的第一波过期跳过一条痕迹都留不下,而那正是 onStaleSkip 存在的意义;缓存下来的闭包还握着上一次 invocation 的数据库绑定。

2. push 带上调度身份,体积算得准

每条从任务发出去的 push 顶层多了 taskId / taskUuid / recurrenceType / occurrenceMs(冻结 prompt 路径和 fire-time hook 路径都算)。客户端据此认领任务、判断它还会不会再来——角色在 fire 里给自己排的任务客户端从没见过,此前只能靠宿主往 metadata 里逐个抄,抄漏一个就会把循环任务误判成一次性的。

体积计量新增导出 PUSH_ENVELOPE_RESERVED_BYTES(384 字节)和 measurePushPayload(payload, { reserveEnvelope: true })。hook 把 payload 交还给库之后,库还会补 messageId / sessionId / timestamp / messageIndex / totalMessages 和上面那四个字段,hook 手里量到的从来不是最终 payload——不留这一截,卡在边界上的消息会「量出来装得下、补完字段就超了」,既没走旁路存储也发不出去。

3. 任务生命周期

  • 任务行支持 IANA tzIddaily / weekly 按该时区的墙钟推进。此前固定 +24h / +7d,纽约的 08:00 每日任务过一次夏令时切换就永久变成 09:00。全程走 Intl,不手搓偏移;不给 tzId 维持 UTC 行为。
  • scheduleTask 撞车返回已存在任务的投影{ created: false, reason: 'duplicate', uuid, task }。此前只有 uuid,重跑那一轮的宿主什么都记不下来。投影走与 GET /messages 同一份白名单,不含任何凭据。
  • 循环任务的过期快进也调 onStaleSkipaction: 'fast_forwarded',带 skippedCount / skippedOccurrences / nextSendAt并写 lastError。此前只有一次性任务的过期会回调,循环任务攒下的那几次是完全静默跳过的。

4. 推送订阅改成用户级

新增 push_subscriptions 表和 PUT / GET / DELETE /push-subscription 三个端点,一个用户一份,落库前用 per-user key 加密。任务不再携带订阅,到点投递时现读那一份。

订阅冻结在每条任务里的话,用户清站点数据、重装 PWA、或推送服务轮换 endpoint 之后,每条老任务都攥着一个已死的订阅,而「刷新订阅」只能按客户端本地已知的任务清单逐行 PUT——它不知道的任务(角色在 fire 里给自己排的那些)就永远刷不到,成了死循环:推不出去 → 状态记不下来 → 客户端不知道这条任务存在 → 更刷不到它。

破坏性变更

变更 使用方要做什么
POST /schedule-messagePUT /update-message 不再收 pushSubscription(带了返回 400 PUSH_SUBSCRIPTION_NOT_ACCEPTED 拿到订阅后调一次 putPushSubscription(),之后每次启动幂等再调一次。原来那套「按任务清单逐行 PUT 刷订阅」整段可以删掉
排程时该用户还没登记订阅 → 409 PUSH_SUBSCRIPTION_MISSING 首次排程前先登记
onStaleSkip 现在循环任务也会调 info.action 分流;原来假设「被调到 = 一次性任务已终态」的分支会把还活着的循环任务当成死的
新增 push_subscriptions 部署后跑一次 POST /init-tenant(或手工执行 schema.sql 里那段)

GET /capabilities 的 features 相应追加九个串,供前端探测部署是否落后:hook-state-accessorsafter-send-scratchfire-task-identitypush-task-identitypush-envelope-reserved-bytesschedule-task-duplicate-rowrecurring-stale-skip-hooktask-timezoneuser-push-subscription

测试

五个包全量 704 通过(client 85 / instant 187 / server 298 / shared 75 / sw 59),build 全绿。新增 43 例 server + 6 例 client,其中当回归守卫用的:

  • 跨夏令时墙钟不变recurrence.test.mjs):纽约 daily 任务跨 2026-03-08,墙钟保持 08:00、真实间隔 23h;同一文件里显式断言旧的 +24h 实现会落到 09:00。另有秋令时 25h、weekly、伦敦 BST、gap 时刻不倒退、非法 tzId 退回 UTC。
  • 循环快进必须留痕run-tick.test.mjs):快进时 onStaleSkip 收到 skippedCount: 4 和四个 skippedOccurrenceslastError.skippedCount 落库——旧行为下一次都不调。
  • onStaleSkip 自带的 writeState 在「本跳零 fire」时真能落库——这是宿主自建缓存方案盖不住的那个洞。
  • 信封余量盖得住webpush-webcrypto.test.mjs):按预留额度组的 payload 补完字段仍在限内,而不留信封的老口径正好溢出。
  • 换订阅后老任务推到新 endpointpush-subscription.test.mjs):这是第 4 节存在的理由;另有落库是密文、无订阅排程 409、带订阅排程 400。

未直接覆盖:pg / neon 适配器的三个订阅方法(这两个适配器本来就没有连库测试,SQL 照 D1 版翻译,D1 那条路径测试全覆盖)、多租户线 /push-subscription 的 GET / DELETE(单用户线三个方法全测了)。

附带

docs: 那个提交是之前审查报告里的文档类小项清扫,一并带过来了。

https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX

Tosd0 and others added 3 commits August 2, 2026 10:38
- RELEASING.md 补 Changesets pre(next)模式章节(进入 / 发布 / 退出);
  根 README 安装一节说明 next tag 与 latest 稳定线的关系
- docs/VERCEL_TEST_DEPLOY.md 把 Vercel 已下线的 `vercel secrets add` + `@secret`
  语法换成 `vercel env add`,标题去掉手写版本号
- standards/active-messaging-api.md 头部去掉五个包的过期版本号与写死的 ^0.2.0
  (版本号只活在 package.json)
- client README 补五个未记载的公开方法:getVapidPublicKey / getCapabilities /
  putClientState / getClientState / clearClientState
- server README 导出清单补全到 25 个全集、加 ./cloudflare 子路径说明;
  删过期的「v2.0.1 变更摘要」小节改指 CHANGELOG
- shared README 修两处与代码矛盾的描述(ReasoningPush 的多段字段、tool-request
  的 pushPayloads),补 bytes / LLM 调用 / Web Push / 协议常量 / agentic 契约导出地图
- instant README CORS 表补 X-Amsg-Request-Encoding,onEvent 从 4 种过期枚举改为
  30+ 事件分家族一览;0.8.0-next.4 迁移文档加历史声明
- vercel.json.example 与 .env.test.example 从 examples/ 归位 tests/ 并更新全部引用,
  前者内容同步去掉死语法
- instant engines 对齐 >=20,instant / sw 对 shared 依赖区间统一 ^0.4.0-next.1
  (附 changeset,lockfile 已刷新)
- .gitignore 删除匹配不到的 packages/*/dist/;ref 明确为 ref/(本地参考资料,非孤儿项)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTqLHc4HsGyPpudVWb4Vnh
- config 级 onAfterSend / onStaleSkip 的载荷带 readState / writeState,onAfterSend
  与 fire 级共享同一个 scratch;onLLMOutput / executeToolCalls 的 ctx 直接带
  taskId / taskUuid / occurrenceMs,不用再从 sessionId 切字符串取任务身份
- 每条 push 顶层带 taskId / taskUuid / recurrenceType / occurrenceMs;新增导出
  PUSH_ENVELOPE_RESERVED_BYTES 与 measurePushPayload 的 reserveEnvelope 口径,
  hook 量到的不再是「补完信封前」的尺寸
- 任务行支持 IANA tzId,daily / weekly 按该时区墙钟推进,全程 Intl 不手搓偏移,
  跨夏令时切换墙钟保持不变
- scheduleTask 撞车返回已存在任务的脱敏投影;循环任务的过期快进也调 onStaleSkip
  (action: 'fast_forwarded',带跳过次数与名义时刻)并写 lastError
- 新增 push_subscriptions 表与 PUT / GET / DELETE /push-subscription 三个端点,
  任务不再携带订阅、投递时现读;schedule-message / update-message 收到
  pushSubscription 返回 400,排程时用户没登记订阅返回 409

Claude-Session: https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX
订阅按 userVisibleOnly: true 建,后台收到 push 却不展示通知的话,Chrome 会替你
弹通用横幅、Firefox 超配额会退掉订阅、iOS 可能撤销推送权限,且掉订阅是静默的。
README 通知策略一节补上这段代价,并写明 when-hidden 这一档安全(规范允许 user
agent 在有可见窗口时免掉展示约束)。前台自绘 Toast 的示例从 show: false 换成
show: "when-hidden"——前台交给页面自绘、后台照弹系统通知。

Claude-Session: https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX
@Tosd0
Tosd0 merged commit defa528 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