feat(amsg): hook 契约补任务身份与状态读写口,任务按时区推进,推送订阅改用户级 - #42
Merged
Conversation
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
这一批补的是 amsg 后台主动消息在真实使用中暴露出来的三处契约缺口:hook 手里缺任务身份和状态读写口、push 里缺调度身份、任务的时区与订阅归属没有落到库里。
1. hook 契约补齐
onAfterSend/onStaleSkip想写client_state,只能由宿主自己缓存一份 fire 级 ctx 上的写口readState(ns)/writeState(ns, entries)onAfterSendonAfterSend收到本次 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. 任务生命周期
tzId,daily/weekly按该时区的墙钟推进。此前固定 +24h / +7d,纽约的 08:00 每日任务过一次夏令时切换就永久变成 09:00。全程走Intl,不手搓偏移;不给tzId维持 UTC 行为。scheduleTask撞车返回已存在任务的投影:{ created: false, reason: 'duplicate', uuid, task }。此前只有 uuid,重跑那一轮的宿主什么都记不下来。投影走与GET /messages同一份白名单,不含任何凭据。onStaleSkip(action: 'fast_forwarded',带skippedCount/skippedOccurrences/nextSendAt)并写lastError。此前只有一次性任务的过期会回调,循环任务攒下的那几次是完全静默跳过的。4. 推送订阅改成用户级
新增
push_subscriptions表和PUT/GET/DELETE /push-subscription三个端点,一个用户一份,落库前用 per-user key 加密。任务不再携带订阅,到点投递时现读那一份。订阅冻结在每条任务里的话,用户清站点数据、重装 PWA、或推送服务轮换 endpoint 之后,每条老任务都攥着一个已死的订阅,而「刷新订阅」只能按客户端本地已知的任务清单逐行 PUT——它不知道的任务(角色在 fire 里给自己排的那些)就永远刷不到,成了死循环:推不出去 → 状态记不下来 → 客户端不知道这条任务存在 → 更刷不到它。
破坏性变更
POST /schedule-message与PUT /update-message不再收pushSubscription(带了返回400 PUSH_SUBSCRIPTION_NOT_ACCEPTED)putPushSubscription(),之后每次启动幂等再调一次。原来那套「按任务清单逐行 PUT 刷订阅」整段可以删掉409 PUSH_SUBSCRIPTION_MISSINGonStaleSkip现在循环任务也会调info.action分流;原来假设「被调到 = 一次性任务已终态」的分支会把还活着的循环任务当成死的push_subscriptions表POST /init-tenant(或手工执行schema.sql里那段)GET /capabilities的 features 相应追加九个串,供前端探测部署是否落后:hook-state-accessors、after-send-scratch、fire-task-identity、push-task-identity、push-envelope-reserved-bytes、schedule-task-duplicate-row、recurring-stale-skip-hook、task-timezone、user-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和四个skippedOccurrences、lastError.skippedCount落库——旧行为下一次都不调。onStaleSkip自带的writeState在「本跳零 fire」时真能落库——这是宿主自建缓存方案盖不住的那个洞。webpush-webcrypto.test.mjs):按预留额度组的 payload 补完字段仍在限内,而不留信封的老口径正好溢出。push-subscription.test.mjs):这是第 4 节存在的理由;另有落库是密文、无订阅排程 409、带订阅排程 400。未直接覆盖:pg / neon 适配器的三个订阅方法(这两个适配器本来就没有连库测试,SQL 照 D1 版翻译,D1 那条路径测试全覆盖)、多租户线
/push-subscription的 GET / DELETE(单用户线三个方法全测了)。附带
docs:那个提交是之前审查报告里的文档类小项清扫,一并带过来了。https://claude.ai/code/session_01DpV5FADagj4MTUMo4r9UUX