Uh oh!
There was an error while loading. Please reload this page.
fix(metadata,runtime,plugins): five more Plugin implementations tear down from destroy(), the hook the kernel calls - #10987
Conversation
… not stop() [#10772] `MetadataPlugin.start()` attaches a real `FileSystemRepository` (armed chokidar watcher + reconciliation sweep), hands it to the `NodeMetadataManager`, and may attach an artifact file watcher on top. The teardown that closed all three was spelled `stop = async (ctx) => …`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and `destroy?()` — and no `stop()` — and `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins in reverse calling `plugin.destroy()`. Nothing in the repo calls `stop()` on a plugin, so every one of those handles was still held after `await kernel.shutdown()` had RESOLVED. The alias is an arrow PROPERTY, which is why a method-only reading of the class missed it and #10371's enumeration came out short by five. Repair is the PR-10375 shape: the body moves into `destroy()`, and `stop()` stays as a delegating alias — public API of an exported class, and an embedder may have learned to call it directly precisely BECAUSE the kernel never did. It stays an arrow property so a detached `const { stop } = plugin` call keeps working, and its parameter becomes optional and ignored: `destroy()` takes no context, so teardown logs through the context captured in `init()`. Baseline: the `MetadataPlugin` line is deleted from `KNOWN_TEARDOWN_UNREACHED` in `scripts/check-plugin-teardown-shape.mjs`, on that gate's own stale-entry verdict rather than by line number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…op() [#10772] `AppPlugin` emits `app:registered` on the kernel bus at start so the control plane's `AppCatalogService` can mirror the app into `sys_app`, and it emitted the matching `app:unregistered` from a teardown spelled `stop = async (ctx) => …`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and `destroy?()` — and no `stop()` — and `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins in reverse calling `plugin.destroy()`. Nothing in the repo calls `stop()` on a plugin, so `app:unregistered` was never emitted on a real shutdown and the catalog row outlived the kernel that registered it. The alias is an arrow PROPERTY, which is why a method-only reading of the class missed it. Repair is the PR-10375 shape: the body moves into `destroy()` and `stop()` stays as a delegating arrow property, so both a direct call and a detached `const { stop } = plugin` keep working. `destroy()` takes no context, so `init()` now captures the one it needs — assigned as init's first statement, ahead of the empty-env early return, so teardown is armed on every path init takes. Baseline: the `AppPlugin` line is deleted from `KNOWN_TEARDOWN_UNREACHED` in `scripts/check-plugin-teardown-shape.mjs`, on that gate's own stale-entry verdict rather than by line number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…oy(), not stop() [#10772] This is the load-bearing member of the family. The plugin arms one `setInterval` per opted-in datasource from `scheduleDriftChecks()` at `kernel:ready` (ADR-0015 §5.2), and the `clearInterval` sweep over them was spelled `stop = (): void => …`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and `destroy?()` — and no `stop()` — and `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins in reverse calling `plugin.destroy()`. The only caller `stop()` had anywhere in the tree was this class's own `scheduleDriftChecks()` re-arming itself, so on kernel shutdown those intervals were never cleared: the #9371 mechanism verbatim, in one of only two `Plugin` implementations in this tree that own `setInterval` at all (`ReportsServicePlugin` is the other, repaired under #10371). The plugin is mounted on the real serve path (`kernel.use(createExternalValidationPlugin())` in `packages/cli/src/commands/serve.ts`), so the leak is not confined to tests; the timers are `unref`'d, which is why it stayed silent in a long-lived host and lands in a vitest worker instead. Repair is the PR-10375 shape: the body moves into `destroy()` and `stop()` stays as a delegating alias. It stays a SYNCHRONOUS arrow property returning `void` — widening a public alias to `Promise<void>` would change what an embedder's non-awaiting call site does. `scheduleDriftChecks()` now re-arms through `destroy()`, the canonical hook, leaving the alias purely for embedders. Baseline: the `ExternalValidationPlugin` line is deleted from `KNOWN_TEARDOWN_UNREACHED` in `scripts/check-plugin-teardown-shape.mjs`, on that gate's own stale-entry verdict rather than by line number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…dispose() [#10772] The plugin arms a live `email_template` bridge at `kernel:ready`: a metadata subscription, a protocol mutation listener, a provenance hook bound to the data engine, and — when SMTP is configured — an open transport. The teardown that released all of them was spelled `dispose()`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and `destroy?()` — and no `dispose()` — and `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins in reverse calling `plugin.destroy()`. Measured repo-wide on this revision: `dispose()` had exactly ONE caller anywhere, a test in this package. The kernel was never one of them, so after `await kernel.shutdown()` had resolved the bridge was still armed and still writing `sys_email_template` rows through an engine the suite had moved on from. This member and `WebhookOutboxPlugin` are the `dispose()` half of the family — the seventh spelling the #10619 gate's roster was widened for before any instance of it was known, already present when the roster was measured. A census looking only for `stop()` misses both. Repair is the PR-10375 shape: the body moves into `destroy()` and `dispose()` stays as a delegating alias with the same signature and return type — public API of an exported class, and it has a live direct caller in this package. Baseline: the `EmailServicePlugin` line is deleted from `KNOWN_TEARDOWN_UNREACHED` in `scripts/check-plugin-teardown-shape.mjs`, on that gate's own stale-entry verdict rather than by line number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…not dispose() [#10772] At `kernel:ready` the plugin binds two hooks onto the data engine and starts an `AutoEnqueuer`, which holds two realtime subscriptions, a crypto-provider listener and a `setInterval` refresh timer. The teardown that released all of it was spelled `dispose()`. `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and `destroy?()` — and no `dispose()` — and `ObjectKernel.performShutdown()` / `LiteKernel.destroy()` walk the plugins in reverse calling `plugin.destroy()`. Measured repo-wide on this revision: `dispose()` had ZERO callers anywhere — not the kernel, not a test, not an example — so this teardown had never run in any process at all. Repair is the PR-10375 shape: the body moves into `destroy()` and `dispose()` stays as a delegating alias with the same signature and return type — public API of an exported class, kept precisely because the zero-caller census is the argument for deleting it and the wrong one to act on. `vitest.config.ts` is new here and required rather than incidental: the pin needs a REAL kernel, which is this package's first VALUE import of `@objectstack/core` (the plugin's own `import type` is erased before resolution). `pnpm check:test-source-alias` reds on an unaliased value import resolving to `core/dist` and names this remedy — alias to source, do not widen the shrink-only `KNOWN_UNALIASED_TEST_IMPORTS`. The entry is an ANCHORED regex so it cannot swallow the published `@objectstack/core/logger` subpath, and `test` is left unset so the package keeps the vitest defaults its bare `vitest run` was relying on. Full suite after: 124/124. Baseline: the last entry — `WebhookOutboxPlugin` — is deleted from `KNOWN_TEARDOWN_UNREACHED`, on that gate's own stale-entry verdict rather than by line number, leaving the ratchet EMPTY. The header, the do-not-widen failure text and the verdict line are updated to match: the verdict now reads its repair card from the surviving entries' own `repair` data instead of hard-coding `#10371`, which is closed, and says "baseline fully burned down" when there are none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Patch, not minor: nothing is removed, no signature narrows, and the `Plugin` interface is untouched. Every old spelling is retained as a delegating alias with an identical or WIDENED signature (`stop(ctx)` -> `stop(_ctx?)`), so no `ADR-0087` conversion-layer marker is owed. The one behavioural difference for direct callers — the two `stop(ctx)` aliases now log/emit through the context captured in `init()` rather than the argument — is named in the changeset body rather than left for a reader to discover. Committed BEFORE the gate union is run on purpose: `check-adr-0087-registration` reads changeset content from GIT, not the working tree, so running it against an uncommitted changeset returns a green that carries no information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…o new fakes Both findings came from the gate's own verdict, not from a guess: x PINNED [delete]/[update]: plugin-shutdown-stops-auto-enqueuer.test.ts declares 1 engine double whose delete()/update() does not route through assertEngineDeleteDispatch/assertEngineUpdateDispatch (line 67). x RETAINED [update]: plugin-shutdown-detaches-template-bridge.test.ts pins 1 engine double that the pinned ledger does not record. The webhook double is fixed by DELETING the two verbs rather than pinning them. Nothing on the path under test writes through `update()` or `delete()`, so they were a double looser than `ObjectQL` serving no call — which is the shape this gate exists to refuse. Removing them takes the double out of the population instead of adding a row to a shrink-only ledger. ⛔ The baseline in `scripts/engine-double-contract.baseline.json` is untouched: that path is maintainer-only and the gate says so. The email double already routes through `assertEngineUpdateDispatch` (it is the same double `email-plugin.template-runtime-write.test.ts` uses); only the RETAINED ledger had to learn about it, via the prescribed `node scripts/check-engine-double-contract.mjs --write`. That regeneration reported "1 added or grown, 0 lost" — new pinned coverage, no pin weakened. After: `check-engine-double-contract: OK — 372 pinned, 133 in the DEBT ledger, 2 exempt.` plugin-webhooks suite 124/124. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 4 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 49e91121dbcf7e565f44ae791103a914bd852a98 && git checkout 49e91121dbcf7e565f44ae791103a914bd852a98
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 368e7a06f4f68b0eccc3c287ff561f8d38bd2566 019b0673810545199df4d8875ba8670c7b11745e && git checkout -B drift-repro 368e7a06f4f68b0eccc3c287ff561f8d38bd2566 && git merge --no-ff 019b0673810545199df4d8875ba8670c7b11745e
node scripts/docs-audit/affected-docs.mjs --json 368e7a06f4f68b0eccc3c287ff561f8d38bd2566
|
os-warren
commented
Aug 22, 2026
PM review — flipped ready, auto-merge armed. Verified by content, not from the report.
The base-currency check is the one that mattered. #10766 nearly turned Two things I want on the record because they are the kind of thing that usually goes unwritten: A3 is the ablation most reviews would not have asked for. Deleting the retained The Also carried correctly: ⛔ Not merged by me and not merged red: auto-merge fires only on green. If CI comes back red, this PR is back in the drive-to-green loop, not waiting on review. Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32547121662 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10772
Five
Pluginimplementations released their teardown from a hook the kernel nevercalls. Each now tears down from
destroy()— the kernel's only teardown hook — andkeeps its old spelling as a delegating alias, because that name is public API of an
exported class.
Plugin(@objectstack/core'stypes.ts) declaresinit(),start?(ctx)anddestroy?().ObjectKernel.performShutdown()(kernel.ts:776) andLiteKernel.destroy()(lite-kernel.ts:171) walk the plugins in reverse callingplugin.destroy(), and nothing anywhere callsstop(),dispose(),close()orshutdown()on a plugin. So what each of these five released was still held afterawait kernel.shutdown()had resolved.@objectstack/metadataMetadataPluginstop(arrow property)manager.dispose(), repository handle@objectstack/runtimeAppPluginstop(arrow property)app:unregisteredcatalog event, never emitted@objectstack/runtimeExternalValidationPluginstop(arrow property)setInterval@objectstack/plugin-emailEmailServicePlugindispose@objectstack/plugin-webhooksWebhookOutboxPlugindisposeThree of the five spell the alias as an arrow property, which is exactly why a
method-only reading of these classes missed them.
Premise re-derived on this branch, not transcribed
origin/main@81845c65a. Each file's onlyteardown-shaped member there is the alias, with no
destroy():plugin.ts:549 stop =,app-plugin.ts:1422 stop =,external-validation-plugin.ts:182 stop =,email-plugin.ts:1255 async dispose(),webhook-outbox-plugin.ts:172 async dispose().ExternalValidationPluginowns exactly one armed-timer site,setInterval(atexternal-validation-plugin.ts:294, arming one timer per opted-in datasource. Thefile's other
setIntervaloccurrences are a type annotation and two prose mentions —a grep hit count is not a fact count. Enumerated over every non-test
implements Pluginsource underpackages/, exactly two callsetIntervalat all:this one and
plugin-reports/src/reports-plugin.ts(repaired under plugin-reports: the timers started by ReportsPlugin are released from stop(), which the kernel never calls #10371). The card'sclaim holds as measured — with one refinement the card does not make: that census is
over each class's OWN text.
WebhookOutboxPluginowns asetIntervaltransitively,through the
AutoEnqueuerit constructs, so a text scan of the plugin class cannot seeit. Its pin test asserts the live timer count directly for exactly that reason.
dispose()callers census, with a positive control on the same corpus.Positive control: 58
.destroy()call sites underpackages/non-test sources, so theexpression finds what exists. Against that,
EmailServicePlugin.dispose()had exactlyone pre-existing caller in the whole repo (
email-plugin.template-runtime-write.test.ts:375)and
WebhookOutboxPlugin.dispose()had zero — its teardown had never run in anyprocess at all.
The baseline is deleted, not retagged
scripts/check-plugin-teardown-shape.mjsis shrink-only by its own words (:225⛔ SHRINK-ONLY,:136"a stale entry is itself a failure"). Repairing a plugin meansdeleting its
KNOWN_TEARDOWN_UNREACHEDentry — a retag would not have satisfied thegate. Proven by re-inserting the five entries on the repaired tree:
The array is now
[]. The gate's own verdict line no longer cites #10371 as the liverepair owner — that card is closed — and instead reports
baseline fully burned downwhen the ratchet is empty. The gate's self-test fixture at
:675(
{ file: 'a/src/p.ts', … }) is untouched: it is a fixture, not an entry, and the realentry count is the one taken with the
packages/prefix.Both directions are pinned, and each direction was proved by ablation
Predictions were written down before each mutation; every restore is byte-identical by
git hash-object.A1 — stale baseline (re-insert the five entries): predicted exit 1 naming all five as
stale. Observed exactly that (quoted above). Restore
5e93066ba…→5e93066ba….A2 — revert the load-bearing member (
external-validation-plugin.tsback to itsorigin/mainbytes,destroygone): predicted a red gate naming the class, and a red pintest on the timer count. Observed:
Restore
9c370df26…→9c370df26….No rebuild sits between the edit and the run, and that is the proof of what resolves.
The pin test imports the plugin as
./external-validation-plugin.js, a relativespecifier vitest resolves into
packages/runtime/src/. No build command was run betweenthe mutation and the observation, and
git hash-object packages/runtime/dist/index.jswas6c9145953…both before and after — identical bytes while the verdict flipped from greento four failures. A suite reading
dist/could not have moved.A3 — the other direction (delete the retained
stopalias, keepdestroy()):predicted the gate stays green and only the alias legs go red. Observed: gate exit 0,
0 known-unreached;TypeError: plugin.stop is not a functionon both alias legs,Tests 3 failed | 3 passed (6). The gate alone cannot see an alias deletion — the aliasassertions are what pin it. Restore
9c370df26…→9c370df26….A4 — the new vitest config is load-bearing, not decoration.
plugin-webhookshad novitest config; its pin test is the package's first value import of
@objectstack/core.Removing the config reds
check-test-source-alias: "NEW unaliased artifact import(s)…@objectstack/core… Alias them in the package's vitest.config.* — widening the registryentry is not the fix." The config aliases exactly that one specifier, anchored
(
/^@objectstack\/core$/) so the published@objectstack/core/loggersubpath is left toexports. No registry entry was widened. Restore1851931a9…→1851931a9….Tests
Each member gets its own commit and its own behavioural assertions: the resource is
released after a real
await kernel.shutdown(); the kernel demonstrably reachesdestroy(); and the retained alias still tears down for a direct caller — including thedetached
const { stop } = pluginshape the arrow properties supported, thestop(ctx)argument the pre-repair signature required, and the synchronousvoidreturnExternalValidationPlugin.stop()had. Every pre-shutdown leg is a positive control, so noassertion can pass vacuously on a plugin that armed nothing.
Local verification — all of it on
019b06738, clean treeThe gate union was derived on the FINAL commit with
node scripts/pm/dispatch-gates.mjsand no path arguments (
change set derived from git — 14 path(s) vs merge base 81845c65a,committed 14, working tree 0, untracked 0). Every exit code below was captured before anypipe. Each line quotes the gate's own verdict, never a bare
$?.Base was brought up to
origin/main@81845c65a(merge019b06738) before measuring —the branch had been cut 67 commits back, and a baseline measured on a stale base is what
nearly turned
mainred on this same file once already.Suites (script names echoed in the output, so none of these is a zero-match
pnpm --filterthat exits 0 having run nothing):
@objectstack/runtimeTest Files 181 passed (181)·Tests 2692 passed (2692)@objectstack/metadataTest Files 32 passed (32)·Tests 615 passed (615)@objectstack/plugin-emailTest Files 27 passed (27)·Tests 425 passed (425)@objectstack/plugin-webhooksTest Files 10 passed (10)·Tests 124 passed (124)typecheckgreen for@objectstack/runtime,@objectstack/plugin-email,@objectstack/plugin-webhooks. Declared narrowing:@objectstack/metadatahas notypecheckscript at all — asking for one would have been a silent zero-match exit 0 — soits type surface is covered here by its
tsupbuild, its 615-test suite, and the DEBTledger re-measure below, which type-checks the package as a whole.
Gates, each with its own verdict line:
check:plugin-teardown-shape(run directly asnode scripts/…, sincepnpm check:plugin-teardown-shapeis not a wired script and would exit 254) — exit 0:✓ 61 Plugin implementation(s) across 4424 source(s) … (0 known-unreached, ⛔ SHRINK-ONLY, baseline fully burned down). Self-test exit 0:47 cases pass.check:engine-double-contract— exit 0:OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.check:test-source-alias— exit 0:OK — 72 packages with tests scanned; 61 registered …check:type-check-debt(--re-measure, the ratchet half, and the one that answers forthe two new test files landing in ledgered packages —
@objectstack/metadatain DEBT,@objectstack/runtimein TEST_DEBT) — exit 0:OK — 33 ledger entr(ies) re-measured in 323.9s, 1908 raw tsc error(s) total, none above its recorded number. It first refusedoutright (
--re-measure cannot run: 1 workspace dependenc(ies) … create-objectstack),which is NOT MEASURED rather than a pass; the closure was completed and it was re-run.
⛔ Its
--lowersurplus note is deliberately not acted on here — that is not this card.check:adr-0087-registration— exit 0:this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen). The changeset is committed, which is what makes thisverdict carry information — this gate reads changesets from git, not the working tree.
check-changeset-no-majorexit 0 ·check-empty-changesetexit 0 (1 declaring changeset(s) added) ·check:changeset-gate-self-testsexit 0 ·check:objectui-changesetexit 0.check:nul-bytes— exit 0:6336 text file(s) … no raw ASCII control bytes.check:route-envelopeexit 0 ·check:dispatcher-error-vocabularyexit 0 ·check:error-code-casingexit 0 (the [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309 family, run explicitly).check:cross-package-test-inputsexit 0 ·check:durability-log-levelexit 0 ·check:entry-guardexit 0 ·check:parse-guardexit 0 ·check:slot-lookupexit 0 ·check:stack-collection-mapsexit 0 ·check:type-source-resolutionexit 0 ·check:query-options-erasureexit 0 ·check:where-matcherexit 0 ·check:i18nexit 0 ·check:type-check-coverageexit 0 ·check-ci-filter-parityexit 0 ·check-affected-docsexit 0.The derivation named
node scripts/check-plugin-teardown-shape.mjsby path andcheck:engine-double-contractunder its convention-triggered set (adds or edits a testfile); both were run explicitly regardless, as the #10309 class requires.
⛔
scripts/check-single-claim-paths.mjswas not run: withoutPR_NUMBERit reportsNOT WIREDand with one it needs a token — both are wiring failures, not verdicts.Not in this PR
Pluginimplementation that declaresstop()(orshutdown()/close()) with nodestroy()— the kernel only ever callsdestroy()#10619 guard criterion is devx-lane work and is not built here. Two live specimensof the "seventh spelling" it predicted (
dispose) are repaired here; the observation isreported back on the card.
packages/specandcontent/docs/releases/**are untouched.Generated by Claude Code