维护者已拍板要队列(2026-08-04,PM 会话内决策)。本单是**接线**,不是造基建 —— 三块部件都已存在,缺的是把它们连起来并给出显式开关。 ## 现状(#5087 落地后) 投递有三层,前两层同步、第三层无人使用: 1. `SmtpTransport.send()` 同步跑完 SMTP 会话(超时默认 20s×3 段),调用方 await 里完成; 2. `EmailService.deliverNormalized` 进程内重试:`retries + 1` 次、指数退避**封顶 2000ms**、全在同一个 await 里,进程一死重试即消失;`sys_email.status` 的 `queued` 只是发送前一瞬的预备状态,`failed` 是终态、无人再捞; 3. `EmailServicePlugin` 已订阅 `email.send.async`(handler 里 `send()` 返回 failed 即抛、交队列重试/DLQ),`service-queue` 的 DB adapter + `sys_job_queue` 有完整测试(退避重试、maxAttempts 耗尽转 `dlq`、`listFailed`)——**但仓内没有任何生产者 `publish` 到这个主题**,直接调 `IEmailService.send()` 永远走内联。 ## 目标 默认行为不变(内联投递);新增**队列投递模式**,开启后 `send()` 的语义变为: 1. 先落 `sys_email` 行(`status: 'queued'`)—— 与现在相同; 2. `publish('email.send.async', { rowId }, { maxAttempts, backoff })`,**引用已落的行**; 3. 立即返回 `{ id, status: 'queued' }` —— `'queued'` 已在 `EmailDeliveryStatus` 枚举里,**因此不需要碰 `packages/spec`**; 4. 队列 worker 用 `deliverPersistedRow(row)` 投递,把**同一行**推进到 `sent` / `failed`;`maxAttempts` 耗尽由队列转 DLQ。 ### 必须一并修的既有缺陷:现订阅者的重复插行 现在的 `email.send.async` handler 是 `svc.send(msg.data)` —— 每次队列重试都会**插一条新的 `sys_email` 行**。改为按 `rowId` → `deliverPersistedRow`,一信一行,`attempt_count` 累计在同一行上。(兼容:老消息若带的是 sendInput 而非 rowId,按旧路径处理一个迁移窗口,由实现判断。) ## 配置门(与 #5087 三门同构) - 构造期:`EmailServicePluginOptions.queueDelivery?: boolean`(命名可议,与现有选项风格对齐); - 设置页:`mail` 命名空间新增 toggle(保存热生效,走现有 `applyMailSettings` 路径);注意 #5151 已强制 select options,新增字段照 manifest 规范写; - CLI:`OS_EMAIL_QUEUE`(`OS_{DOMAIN}_{FEATURE}` 形状)是否值得加由实现判断,加了就要有测试。 ## 边界语义(PM 裁定,可反驳) - **`mail/test` 永远同步内联真发**,不入队 —— 测试按钮必须当场回答 SMTP 服务器的真实答复(535 等),回「已入队」等于回到 #5087 修掉的那种假信号。 - **队列模式被显式开启、但 `queue` 服务不存在**:分两路,与本线既有判例一致 —— - 设置页门:error 级日志(后果:投递不具持久化重试;修复:挂 queue capability 或关掉开关)+ **内联回退继续发信** —— 降的是持久性,不是投递,一次保存不该断邮件; - 构造/CLI 门:**抛错,boot 失败**(#5132 判例:显式声明的意图无法兑现就响亮失败)。若你认为这条过硬(毕竟内联仍能发信),照实反驳。 - 队列模式下 `send()` 返回的 `status:'queued'` 是真话 —— 行在库里、任务在 `sys_job_queue` 里,进程死了 worker 还会捞起来。这正是与现状 `queued` 一瞬即逝的本质区别。 ## 不在本单 - `sys_email` 存量 `queued` 行的启动清扫 / drain 钩子吞错 —— 另单(同文件,串行在本单之后)。 - ⛔ `packages/spec`(`'queued'` 已在枚举,无需动;车道另有四单在飞)、⛔ `content/docs/releases/`。 ## 验收 - 开启队列模式:`send()` 立返 `queued`,worker 异步推进同一行到 `sent`(含 `message_id`);SMTP 535 时队列按 backoff 重试、耗尽转 DLQ,`sys_email` 行终态 `failed` 且 error 在案; - 默认(未开启):行为与今天逐字节一致,现有 236+ 用例不改一条断言; - 设置页开关热生效;队列服务缺失的两路语义各有用例; - 现订阅者重复插行的缺陷有回归测试钉死(同一消息重试 N 次 = 1 行,attempt_count 累计)。
Activity
os-zhuang commentedon Aug 4, 2026
认领:PM 循环第 5 轮
会话:
session_017MCKJaEomEqg4tvz4SzdNd分支:
claude/issue-5160-email-queue-deliveryWorktree:
objectstack-issue-5160域:
domain:services文件面:
packages/plugins/plugin-email/src/(email-plugin.ts、email-service.ts、测试)、packages/services/service-settings/src/manifests/mail.manifest.ts+ translations、必要时packages/cli/src/commands/serve.ts的 email capability 段(越界即停,报告说明)维护者拍板依据:会话内直接答复「我要的 email 队列」。#5161(queued 行清扫)同文件严格串行,
Blocked-by本单,下一轮派。在飞撞车检查:plugin-email / service-settings 当前无其它单在飞(#5153 刚合入);#5091/#5092 在 runtime/hono 端点面,不相交。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
复核结论:ACCEPT —— PR #5173,转 ready 并入合并队列。修复 head
b169f217上 7 个工作流全绿。中途一次门禁红,处置记录
首个 head 上
check:engine-double-contract红:新测试的假引擎delete()手抄了守卫而没有路由过assertEngineDeleteDispatch。让原 dev 带上下文修复(b169f217):按门禁第一条路改造,与其余 13 个 pinned fake 同形,没有走 baseline 豁免。值得记的是手抄守卫确实有真洞——where: { id: { $in: [...] } }看着像单行 id、实为多行谓词,真引擎在无multi时拒绝,手抄版放行;门禁抓的不是形式主义,#4434 的教训在这里又一次兑现。注意最初事件报的是「ESLint 失败」——那个 job 串跑多个门禁,不取完整日志就会去修根本没坏的 lint。实现复核(对 diff 独立核过,非采信报告)
queueDelivery/ CLIOS_EMAIL_QUEUE_ENABLED(PD#9 的_ENABLED形状,比派发词里的裸OS_EMAIL_QUEUE更对)/ 设置页 toggle 热生效,四语翻译齐。maxAttempts = retries + 1、行内循环钉死 1 次尝试,两层结构上不可能相乘;publish同时填maxAttempts与 legacyretries两个字段(MemoryQueueAdapter只认后者)。init()挪到kernel:ready(注册表定型后再裁决);「有没有 queue」改为「是不是持久化的」——内核的内存 fallback 自挂__serviceInfo.status === 'degraded'牌子,resolveDurableQueue读牌子把它当作没有,否则send()会对一条谁也捞不回来的消息回答queued;顺带修掉订阅者每次重试插新行的既有缺陷(一信一行,attempt_count累计)。sent/message_id直接返回,租约过期/双 worker 不发两遍);被删行不烧重试预算、直接 error 说明后果与修复。DbQueueAdapter于同持sys_email+sys_job_queue的假引擎,断言 535 → 3 次退避重试 → DLQ、全程 1 行、attempt 1→2→3;既有测试文件零触碰,默认路径逐字节一致的验收成立。衍生单(均已归位)
finding持有)、core: kernel:ready 钩子抛错在 ObjectKernel 上失败 boot、在 LiteKernel 上被吞成一条 error 日志 —— 同一钩子两套失败语义 #5170(needs-user-decision:两内核kernel:ready失败语义相反,等维护者)。Generated by Claude Code
feat(plugin-email): 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_…
fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 …
fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (obje…
fix(plugin-audit): exclude sys_job_queue from audit/activity writes (o…
feat(plugin-email,platform-objects): sys_email 加 headers_json + 有界 at…
feat(spec): EmailServiceConfigSchema 补齐 CLI 实读的 queueDelivery / appNa…