Repository navigation
fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (#5161) - #5191
Merged
Merged
Conversation
…t a failed drain at error (#5161) `status:'queued'` had exactly one consumer — the afterInsert outbox drain that fires during the insert itself (plus, since #5160, the email.send.async job send() publishes). A process that died between the insert and the delivery, or a drain whose delivery threw, left the row at `queued` forever: a state named after a queue with no reader, with the caller already told the message was accepted. - `sweepStrandedOutbox` runs once per boot at kernel:ready, after the queue subscriber and the #5160 boot gate. Queue mode publishes `{ rowId }` through EmailService.enqueuePersistedRow (send()'s own producer, options and `sys_email:<id>` idempotency key, so a row with a pending job collapses onto it); inline mode finalizes the row in place via deliverPersistedRow. - Only rows older than OUTBOX_SWEEP_MIN_AGE_MS (5m) are eligible — a young row is somebody's in-flight work, on this instance or a sibling, and age is the only property that means the same thing on every instance. Service-managed rows and rows carrying a message_id are skipped. Batch bounded at 500, oldest first, truncation reported. - Boot does not await the sweep; it self-catches and reports at error, since a throwing kernel:ready handler is swallowed on LiteKernel (#5170). - Both drain-hook catches now log at error with the consequence (the message was NOT sent, the row stays at `queued`) and the fix, per the AGENTS.md degradation-log-level rule, and deliverPersistedRow joins DURABILITY_CRITICAL_CALLEES so the level cannot regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…l cause class A row whose message cannot be reconstructed is already recorded as `failed` by deliverPersistedRow, so the only way into this catch is the datasource or the queue. Say that instead of sending the operator to inspect the row's columns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
…jectQL's own dispatch (#4550) `check:engine-double-contract` flagged the new fake engine in email-plugin.outbox-sweep.test.ts: its `delete()` filtered on `where.id` directly instead of routing through `assertEngineDeleteDispatch`, which is looser than the engine it stands in for on exactly the case a hand-written mirror drops (`where: { id: { $in: [...] } }` reads as an id and is a multi-row predicate the real engine rejects without `multi`). Same shape as the sibling double in email-plugin.queue-delivery.test.ts (b169f21); `@objectstack/objectql` is already a devDependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
os-zhuang
marked this pull request as ready for review
August 4, 2026 08:41
This was referenced Aug 4, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 6, 2026
…ch,并收编 run-summary 的盲区实例 (objectstack-ai#5197) (objectstack-ai#5630) 同一天三个互不相同任务的 dev agent(3/3)新写假引擎全部踩 `check:engine-double-contract` 判红,错误一模一样 —— 手抄守卫、未路由 `assertEngineDeleteDispatch`(objectstack-ai#5173、objectstack-ai#5191、 objectstack-ai#5192),各花一轮 CI 往返 ≈15 分钟;objectstack-ai#5584 的新测试是第四次同款命中(objectstack-ai#5604)。这不是门禁 漏了,防线工作正常,代价纯粹是「新增测试 + 需要假引擎 + delete 路径」这个高频组合缺一行 提交前的提示。 os-dev 定义与 pm-dispatch 派发词模板各加一行同措辞纪律,把这轮往返省在提交前。两处都点名 手抄守卫**真有洞**这一实测事实,而不只是「风格不推荐」:objectstack-ai#5173 的手抄副本放行了 `where: { id: { $in: [...] } }` —— 它看着像 id,是多行谓词,真引擎无 `multi` 时拒收。 引用的样板是「门禁绿跑时自己列出的 pinned 假引擎」而不是一个会过期的计数。 第三处是 `service-automation/src/run-summary.test.ts` 的盲区实例(objectstack-ai#5197 评论定位):它的 内联假引擎 `async delete() { return false; }` 对谓词删除照单全收,而 `delete_record` 自 objectstack-ai#5393 起转发 `multi: cfg.multi === true`,所以 `{ objectName: 'deal', filter: { stale: true } }` 这一形状真引擎是 reject。该文件既不在 pinned 也不在 DEBT 台账 —— 门禁的形参个数 判据够不到零形参的 delete(另立 objectstack-ai#5629 记录该扫描面缺口及实测口径),所以是检测器盲区,不是 已登记的债。后果不是假设:objectstack-ai#5225 里 showcase 的清扫流从上线起每次 `acted: 0`,单测全绿。 收编后按「补声明」处置而非重写断言:该 fixture 从来就不是契约内合法的,补 `multi: true` 声明其批量意图,于是 sweep 真的到达驱动,驱动报告匹配 0 行,用例原本的主题(计数器读 0) 完整保留。执行器侧的 reject 传播已由 `builtin/crud-bulk-intent.test.ts:153` 钉住,不在此 重复。 断言同时加强,这一步是实测逼出来的而非顺手:反向验证(假引擎已收编、`multi` 撤掉)预期红, 实际**仍然绿** —— 因为 `acted: 0` 既是「删了 0 行」也是「删除被拒」留下的痕迹,原用例唯一 的断言两种情形都满足,是为空而绿。补 `res.success` 与该节点 `runs: 1 / failures: 0` 之后 同一撤销才真的判红(`expected false to be true`),用例才在读它声称在读的那件事。 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: os-zhuang <hr@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 7, 2026
…ts that decided them (objectstack-ai#20757) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the eleventh stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/plugins/plugin-email/src/**` and nothing else. By the seat's census at the claim (`5902547086`), it is the largest package in the lane that no in-flight work holds. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 10 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`). That is **16 sites on 16 lines in 8 files, covering 4 numbers**: - 7 census sites (every census site this package has); - 9 sites in test comments, which the census defers. Three of them carry `objectstack-ai#13190`, a dead number that stands only in test files here, so the census never judged it; it was read on its own (404); - no site the gate's grammar cannot see (the package has none that is dead, see Acceptance notes). Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **4 distinct shas**. No number in this package has an ADR or ruling record of its own (a grep of `docs/adr/` and `scripts/adr-anchors/` finds only ADR-0131 naming `objectstack-ai#11741`, as evidence in its D7, not as the record of that decision; nothing else under `docs/` names the four), so every anchor is a commit, per ruling C's order. No number was dropped. Only comments changed. Every touched source file keeps its line count (16 lines out, 16 in, over 8 files), so no line citation into these files moves. Every one of the 16 changed lines carried a dead citation; there is no reflow line. No code token moves (see the guard below). **No citation number is added.** The added lines carry no tracker number at all. Over the whole diff, added minus removed is negative for the four dead numbers and zero for every other number, and no number is new to the diff. No PR number is the citation on an added line: the two `PR objectstack-ai#8675` spellings became that pull request's squash commit. 10 dead sites are left on purpose, all of them `describe` / `it` titles (see the list below). One more file: a `patch` changeset for `@objectstack/plugin-email`, because the rewritten prose ships (see Changeset below). ## Census: `plugin-email`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/plugins/plugin-email/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | plugin-email sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `97005aed0`, run 2026-09-30T02:00:45Z to 02:04:02Z | enumerated, 186 pages, frontier objectstack-ai#20748 (newest objectstack-ai#20747 before, objectstack-ai#20748 after: a pull request opened at 02:03:20Z, inside the run) | 1,064 | **7** | 7 | 4 | 3 | | after | head `15a7d69a7`, run 02:11:19Z to 02:14:30Z | enumerated, 186 pages, frontier objectstack-ai#20753 (newest objectstack-ai#20753 before and after) | 1,057 | **0** | 0 | 0 | 0 | The before count matches the seat's census and A1 (7 sites: `objectstack-ai#13189` ×4, `objectstack-ai#11741` ×2, `objectstack-ai#8675` ×1). The before run's board moved during the run; its frontier equals the newest number at the run's end, which is A1's criterion (stage 7's precedent). The whole-repo drop is 7, exactly this diff's census sites. The `resolves` tally is 33,029 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. The after run was taken on `15a7d69a7`; the head `23283d394` adds only the changeset. No run was truncated or discarded: both enumerations read 186 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `plugin-email/src` (50 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when that census judged it on this board anywhere (its `--list` extraction, 37,072 rows) and did not report it. The eleven numbers the census never saw, because they stand only in test files or as the second half of a slash pair here, were read one by one on the issues endpoint: `objectstack-ai#13190` answers 404; `objectstack-ai#5169`, `objectstack-ai#5286`, `objectstack-ai#10619`, `objectstack-ai#16506`, `objectstack-ai#20374`, `objectstack-ai#5197` answer 200 as issues, and `objectstack-ai#8348`, `objectstack-ai#5191`, `objectstack-ai#5211`, `objectstack-ai#5232` as pull requests. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `97005aed0` | 360 | **26** | 7 | 9 | 0 | 10 | | after, `15a7d69a7` | 344 | **10** | 0 | 0 | 0 | 10 | Its src-comment column equals the census's 7, which is the control on the second instrument. The 323 live citations are the same in both readings, and the drop of 16 citations is exactly the rewritten sites. 11 extracted tokens are not tracker references at all and are not judged: the HTML entity `&objectstack-ai#39;` (6 sites in the template engine and its tests) and the fixture subjects `Invoice objectstack-ai#42` to `Invoice objectstack-ai#45` (5 sites). A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 371 occurrences and 26 dead before, 355 and 10 after. Beyond the gate's grammar it sees 11 tokens, none dead: the nine second numbers of the `#A/#B` lines (all live), the excused `Prime Directive objectstack-ai#12`, and the CSS colour `#2563eb`. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject, and `git blame` at the base puts every rewritten line in its anchor commit or in a later commit that descends from it (`merge-base --is-ancestor` exit 0 for all 16 line and anchor pairs). | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#13189` | 13/4 | 8/5 | `33fbd3566` (PR objectstack-ai#13375): the SMTP port guard tests integrality (`Number.isInteger`), so a fractional port such as `587.5` is refused at construction, and the generated refusal sentence reads `(expected an integer 1-65535)`, the range still rendered from the constants. Its changeset headline names `objectstack-ai#13189`; its diff writes the integrality docblocks the rewritten lines sit in. New to the sweep | | `objectstack-ai#13190` | 5/1 | 3/2 | `56c5b1dbe` (PR objectstack-ai#13316): `smtpOptionsFromMailSettings` passes a present-but-unreadable `smtp_port` through to the guard instead of omitting it (which had silently fallen back to 587); absent and `''` still mean "not set", and no second refusal was added. Its changeset headline names `objectstack-ai#13190`; its diff writes the `objectstack-ai#13190` comment block itself. New to the sweep | | `objectstack-ai#11741` | 6/3 | 3/3 | `b706af987` (PR objectstack-ai#11839): `SendEmailInput` / `SendTemplateInput` gain an optional `organizationId`, which `plugin-email`'s writer stamps verbatim onto `sys_email.organization_id` (pass-through only, no resolution or fabrication), and `sendTemplate` forwards it as a producer of `send()`. Its message names `objectstack-ai#11741` as the card that commit closed; `git blame` puts all three rewritten lines in it. The `plugin-auth` stage's anchor for the same number | | `objectstack-ai#8675` | 2/2 | 2/0 | `c9f595083`: the squash commit of the pull request that was `objectstack-ai#8675` (its subject ends `(objectstack-ai#7987) (objectstack-ai#8675)`): `sys_account`'s OAuth token columns are declared `internal: true`. Its diff records the trap both lines describe: those columns are `required: false`, so inferring "key missing, therefore the strip ran" broke ordinary sign-in (16 red tests), which is why the readback carries the `absenceProvesStrip` discriminator. New to the sweep | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 4), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 4; control leg: stage 1's landing `422db788a` exit 0; the history is complete, `--is-shallow-repository` false, 15,155 commits). Each of the 4 numbers answers 404 on the issues endpoint, which serves pull requests too. Independently, the package's own shipped `CHANGELOG.md` pairs `b706af9`, `33fbd35` and `56c5b1d` with the same three decisions. ## Wordings to check - **Tag swaps in parentheses.** 「(objectstack-ai#13189)」 became 「(commit 33fbd35)」 at `transports/smtp-port-contract.ts:87` (a section heading), `:134` and `transports/smtp.ts:68`. - **Line openers.** 「objectstack-ai#11741 —」 became 「Commit b706af9 —」 at `email-service.ts:742` and `:1439`; 「objectstack-ai#13190 —」 became 「Commit 56c5b1d —」 at `transports/smtp.test.ts:221`; 「## objectstack-ai#13189 —」 became 「## Commit 33fbd35 —」 at `transports/smtp-port-contract.test.ts:34`. - **`email-service.test.ts:342`**, a section rule: 「── objectstack-ai#11741 —」 became 「── Commit b706af9 —」, and its trailing rule was shortened by 10 characters so the line keeps its width exactly. - **`internal-header-readback.ts:37`.** 「(PR objectstack-ai#8675 hit exactly this on `sys_account`'s optional」 became 「(Commit c9f5950 records exactly this on `sys_account`'s optional」: a commit does not "hit" a trap, it records one, and that commit's own diff is where the 16 red tests are recorded. - **`email-headers-internal.integration.test.ts:251`.** 「The regression PR objectstack-ai#8675 measured on a sibling card」 became 「The regression commit c9f5950 records from a sibling card」, the same reading. - **`transports/smtp-port-contract.test.ts:228`.** 「objectstack-ai#13189 is the card that SPENDS that」 became 「Commit 33fbd35 is the change that SPENDS that」, so the noun matches the anchor. - **`transports/smtp.ts:127`, `transports/smtp.test.ts:272`, `:276`, `:281`, `:283`.** The number became 「commit SHA」 in place (「until commit 33fbd35:」, 「The bucket commit 56c5b1d never had to name」, 「Commit 33fbd35 made the guard test」, 「Commit 56c5b1d's rule is that」, 「commit 33fbd35 changed which numbers」). ## The 10 sites left - **Test strings, 10 sites on 9 lines**, all `describe` / `it` titles, left as stages 1 to 10 left theirs: `email-service.test.ts:349` and `send-template.test.ts:63`, `:88` (`objectstack-ai#11741`); `transports/smtp-port-contract.test.ts:225`, `:309`, `:340` (`objectstack-ai#13189`); `transports/smtp.test.ts:230` (`objectstack-ai#13190`), `:271` (`objectstack-ai#13189`), `:293` (`objectstack-ai#13190` and `objectstack-ai#13189`). - No source string, operator log string, assertion message, quoted maintainer ruling or generated file in this package carries a dead number. - Outside `src`, the package's `CHANGELOG.md` names three of these numbers on 5 lines. It is release-owned and deliberately not edited here (see Acceptance notes). ## Mechanical guard: no code token moves The guard compares the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited), base `97005aed0` against head. String and template literals are therefore read in full. It ran over all 8 touched `.ts` files. - Real run: 7,035 base leaf tokens, **0 files with a token change** (exit 0). - Comment control in `email-service.ts` (「no resolution, no default, no fabrication」 to 「… no default and no fabrication」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `transports/smtp.ts` (`isValidSmtpPort(port)` given `as number`): DIFFER, 587 to 588 leaf tokens (exit 1). - Positive control, one digit changed inside a kept test title (`transports/smtp.test.ts:293`, `objectstack-ai#13189` to `objectstack-ai#13188`): DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`1e99bd5e2bcb`, `46c13267611b`, `da5314910bc4`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/plugin-email` (`.changeset/20596-plugin-email-provenance-anchors.md`) is included. Its body is stage 10's, word for word, with the package name changed. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. After the build, `b706af987` appears twice in each of `dist/index.js` and `dist/index.mjs` (the two inline comments in `email-service.ts`, which the bundle keeps). `c9f595083` appears once in each of `dist/index.d.ts` and `dist/index.d.mts` (the `internal-header-readback.ts` docblock), and so does `33fbd3566` (the docblock on `SmtpTransportOptions.port`). `56c5b1dbe` reaches nothing (test files only). Positive controls, one unchanged line beside each shipped rewrite, land exactly where their neighbours do: 「context, so the input's organization is the one fact it may stamp:」 and 「caller's organization so the sys_email row it persists is stamped.」 once in each JS file; 「token columns: inheriting」 and the unchanged line just above the rewritten one in the `port` docblock once in each declaration file. A never-written negative phrase appears nowhere in `dist`. None of the 4 dead numbers is left in `dist`. ## Gates (head `23283d394`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run found no citation added against `97005aed0` (4 files read; test files are a deferred surface). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `23283d394` derived 61 commands: all 55 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. Each ran with its exit code captured before any pipe, and all 61 exit 0. `--ran`, fed each command with its exit code, reports 61 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/plugin-email test`: 31 files pass and 510 tests pass. `vitest list --filesOnly` names 31 files, all the tracked test files, the 4 touched ones included. - `pnpm --filter @objectstack/plugin-email typecheck` exits 0 (`tsc` on `tsconfig.json`, then `check:test-typecheck` on `tsconfig.test.json`: 0 files and 0 errors in its debt ledger). `tsc --listFiles` holds all 8 touched files in both programs, and the test program holds all 50 files under `src/`. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 8 touched `.ts` files, gives 8 files, 0 errors and 0 warnings. All 8 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 9 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#` (objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word 「option」. In this package: `#N-word` none, `#A/#B` 9 lines, `option #N` none, at the base and at the head, which is the claim's 0 / 9 / 0. Every second number on the 9 slash lines answers 200 (`objectstack-ai#5197` ×2, `objectstack-ai#5191`, `objectstack-ai#5211`, `objectstack-ai#5232` ×2, `objectstack-ai#5177`, `objectstack-ai#4251`, `objectstack-ai#5094`), so nothing there needed rewriting. - **ADR-0131 names `objectstack-ai#11741`.** Its D7 cites `objectstack-ai#11741` as the writer fact that keeps `sys_email` tenant data. That is evidence inside a later record, not the record of what `objectstack-ai#11741` decided, so it is not this stage's anchor, and `docs/adr/**` is a governed Tier H surface outside this card's stages. It joins the ADR-tree residue the seat already carries (ADR-0131's `objectstack-ai#14484`, stage 2). - **`CHANGELOG.md` is left.** `packages/plugins/plugin-email/CHANGELOG.md` names `objectstack-ai#11741`, `objectstack-ai#13189`, `objectstack-ai#13190` and `objectstack-ai#8675` on 5 lines. It is release-owned (AGENTS.md, Documentation Guardrails), a deferred surface of the citation gate, and ⛔ not part of this stage. - **「This card」 phrases are left.** 20 comment lines in 8 files of this package speak of 「this card」, 「the card」 or 「the two cards」. They carry no number and neither instrument sees them. Inside the `objectstack-ai#13189` test block, they still have the kept `(objectstack-ai#13189)` title as their referent; the one rewritten line that said 「the card」 now says 「the change」 (above). The rest are unchanged, as in stages 8 to 10. - **The census instrument did not truncate in this stage.** Both enumerations read 186 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#13189` → `33fbd3566`; `objectstack-ai#13190` → `56c5b1dbe`; `objectstack-ai#8675` → `c9f595083`. `objectstack-ai#11741` → `b706af987` reuses the `plugin-auth` stage's anchor. - **Base.** The branch is on `main` at `97005aed0`. `main` has since moved two commits (`9c8f113c6`, `a6866da0c`). Their 14 files touch nothing under `plugin-email`, nor `scripts/check-issue-citations.mjs`, `.changeset/config.json` or the `doc-authoring-prose-id` baseline, and the three console-injection scripts they change are not among this diff's 61 derived families. So no merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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.
Fixes #5161
基于 #5173 合并后的 main(
9c4f1743c)工作。问题
sys_email的status: 'queued'只有一个消费时机:insert 当时的afterInsertdrain 钩子(以及 #5160 之后send()自己发出的email.send.async作业)。此后没有任何东西再看这一行。进程在 insert 之后、投递完成之前死掉,或者 drain 的投递抛错,这一行就永远停在queued—— 一个以队列命名、却没有读者的状态,而调用方早就被告知"消息已接受"。实现
1. 启动清扫
sweepStrandedOutbox(新文件outbox-sweep.ts)挂在
kernel:ready,位置在email.send.async订阅者注册与 #5160 的 boot 门之后 —— 注册表定型、订阅者已就位,重新入队的行才有地方落。按当前模式分流:EmailService.enqueuePersistedRow发布{ rowId }。这是刻意复用send()自己的生产者:同一个EMAIL_SEND_QUEUE常量、同一份 publish options、同一个sys_email:+ 行 id 的 idempotencyKey。第二个生产者用自己的方式拼载荷,正是队列两半漂移的起点;而共享的幂等键让"还挂着 pending 作业的行"被清扫时收敛到那个作业上,而不是把第二个 worker 推到同一行上。deliverPersistedRow就地把行推进到sent/failed—— 进程没死的话 drain 钩子本来就会做这件事。publish 失败回退内联,与send()自身的判断一致:行已经提交了,把它继续留给"没人"才是本 issue 要修的 bug。2. 捞取判据:只捞"够老"的行(5 分钟)
这是 PR 里最需要说清楚的一条。合格条件是
status='queued'且created_at早于now - 5min,而不是"早于本次 boot"。理由:send()的 insert 与 transport.send 之间,可能是本进程setTimeout(0)延后的 drain 钩子,也可能是多实例部署下另一个实例的同样两者。清扫它就等于把别人手里的活抢过来发第二遍。年龄门之下还有两道兜底(不是许可):本进程
send()正在持有的行(isServiceManaged)不碰;已经带message_id或已不是queued的行跳过 —— 与email.send.async订阅者投递前的幂等守卫同一套语义。诚实交代边界:内联模式没有跨进程协调,本来也从来没有(drain 钩子同样如此),年龄门就是那里的全部保护。多实例的持久投递正是队列模式存在的理由,在那里幂等键使重复发布成为 no-op。
3. 边界与可见性
created_at升序;多读一行判断是否截断,所以"还有更多"是事实而不是"整页=可能还有"的推断,截断时日志明说下次 boot 继续。failed > 0时另有一行 error,带后果与修复。kernel:readyhandler 抛错会被吞(core: kernel:ready 钩子抛错在 ObjectKernel 上失败 boot、在 LiteKernel 上被吞成一条 error 日志 —— 同一钩子两套失败语义 #5170),对一个耐久性清扫来说那等于把它存在的意义本身丢掉。需要确定性的调用方(测试)等EmailServicePlugin.outboxSweepSettled。4. drain 钩子失败升 error
两处 catch 从
warn升到error,并按 AGENTS.md 的 degradation-log-level 标准带上后果(这封信没有发出、行停在queued、本进程不会再重试)与修复(下次重启的清扫会捞;要让失败被重试和进 DLQ 就打开 Settings → Mail → "Durable queue delivery")。同时把deliverPersistedRow加进DURABILITY_CRITICAL_CALLEES,以后再有 catch 把它悄悄降级会被pnpm check:durability-log-level挡住(该 gate 现为 14 个 seam,全绿)。验收对照
{ rowId }作业并由真实DbQueueAdapter的一次 poll 推到sent,内联模式直接推到sent;不可发送的行推到failed而不是继续滞留。send()仍内联投递、app 写入的行仍由 afterInsert 钩子恰好投递一次,清扫在这两种情况下scanned: 0。测试
新增用例:
outbox-sweep.test.ts(14 例,含年龄门、路由、兜底、边界与失败路径)、email-plugin.outbox-sweep.test.ts(10 例,真实DbQueueAdapter端到端 + drain 日志级别 + 默认路径)。影响面
只动
packages/plugins/plugin-email/src/{email-plugin,email-service,index}.ts与新增文件,外加scripts/check-durability-degradation-log-level.mjs的一条词表。未碰 headers/attachments 相关的任何东西(留给 #5177)、未碰packages/spec、未碰content/docs/releases/。用户可见,已带 changeset。🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code