Blocked-by: #5160 (同文件 email-plugin.ts,严格串行;且 #5160 落地后本单的正确修法才定形 —— 清扫出的行在队列模式下应交给队列,而不是自己直接投)
PM 在回答维护者「邮件有没有队列」时读码发现,维护者已确认要修。
现状
sys_email 的 status: 'queued' 行只有一个消费时机:insert 当时 的 afterInsert drain 钩子(email-plugin.ts ~390,setTimeout(0) 异步自投)。全仓 grep 过:没有任何定时任务、启动清扫或 worker 会再看一眼 queued 行。
两个后果:
崩溃窗口 :进程在 insert 之后、投递完成之前死掉,该行永远停在 queued。一个叫 queued 的状态没有队列在消费它 —— declared ≠ delivered 的又一实例,只是这次声明的是状态词。
失败静默 :drain 钩子的两层 catch 都只 logger.warn 就吞掉(outbox drain failed for … / outbox drain hook error)。按 AGENTS.md degradation-log-level 标准,这是「投递没有发生」级别的失败,应为 error 且带后果与修复;warn 会被启动日志淹没。
建议
kernel:ready 时做一次启动清扫:捞 status='queued' 且非本进程 managed 的行 → 队列模式下 publish 给 email.send.async(plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160 的通道),内联模式下 deliverPersistedRow;清扫结果(捞了几行、成败各几)打 info 一行。
是否加周期性清扫由实现判断 —— 若 plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160 的队列模式已覆盖新写入的行,周期清扫可能只对内联模式有意义,不要为覆盖率造第二套调度。
drain 钩子的失败升 error,消息含后果(这封信没有发出,行停在 queued)与修复(重试入口/清扫会捞)。
验收
人为构造「insert 后进程死亡」的行(直接插 queued 行不触发钩子即可模拟),重启后该行被推进到 sent/failed,不再永久滞留;
drain 失败的日志级别与文案有用例钉住;
默认路径(正常 insert→投递)行为不变。
Blocked-by: #5160(同文件
email-plugin.ts,严格串行;且 #5160 落地后本单的正确修法才定形 —— 清扫出的行在队列模式下应交给队列,而不是自己直接投)PM 在回答维护者「邮件有没有队列」时读码发现,维护者已确认要修。
现状
sys_email的status: 'queued'行只有一个消费时机:insert 当时的afterInsertdrain 钩子(email-plugin.ts~390,setTimeout(0)异步自投)。全仓 grep 过:没有任何定时任务、启动清扫或 worker 会再看一眼queued行。两个后果:
queued。一个叫 queued 的状态没有队列在消费它 —— declared ≠ delivered 的又一实例,只是这次声明的是状态词。logger.warn就吞掉(outbox drain failed for …/outbox drain hook error)。按 AGENTS.md degradation-log-level 标准,这是「投递没有发生」级别的失败,应为 error 且带后果与修复;warn 会被启动日志淹没。建议
kernel:ready时做一次启动清扫:捞status='queued'且非本进程 managed 的行 → 队列模式下publish给email.send.async(plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160 的通道),内联模式下deliverPersistedRow;清扫结果(捞了几行、成败各几)打 info 一行。验收