Skip to content

refactor(core): share the two hook-dispatch flavours + gate the paired kernel pins (#5282) - #6672

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5282-kernel-hook-dispatch-unify
Aug 8, 2026
Merged

refactor(core): share the two hook-dispatch flavours + gate the paired kernel pins (#5282)#6672
os-zhuang merged 2 commits into
mainfrom
claude/issue-5282-kernel-hook-dispatch-unify

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5282

Implements the maintainer-approved ruling on this card (2026-08-06), quoted verbatim:

裁 B + C——钩子分发器抽为模块级自由函数(消掉手写镜像的两份实现)+ 成对测试升格为门(新增钩子不配对即红)。⛔ 不做 A(747 行生产内核继承重构,风险与收益不成比例)。

Premise, re-verified against origin/main (0e043d8)

The issue is from 08-04 and its line numbers had moved, so every claim was re-anchored before any edit:

issue claim 08-04 anchor on origin/main today verdict
ObjectKernel extends nothing kernel.ts:56 kernel.ts:56export class ObjectKernel { holds
ObjectKernelBase has one subclass kernel-base.ts:30 / lite-kernel.ts:23 same lines, LiteKernel only holds
two independent hook stores kernel.ts:59 private hooks / kernel-base.ts:33 protected hooks holds
isolating dispatch written twice kernel.ts private method kernel.ts:736 triggerShutdownHookIsolating vs kernel-base.ts:268 triggerHook — two loops, same log wording holds
dormant ctx.trigger('kernel:shutdown') inconsistency kernel-base.ts:123 kernel-base.ts:123 and kernel.ts:153, both bare propagating loops, still nothing triggers the hook by hand holds

kernel.ts is 825 lines now, not 747 — the only number that had drifted. The premise stands in full; nobody had unified dispatch in the meantime.

PM mechanism assumption, measured (one falsification)

Assumed: the two flavours are the only dispatch shapes in the kernel files. Measured: the two flavours are the only hook dispatch shapes, but the propagating flavour has two sub-shapes that differ by one observable — triggerHookOrThrow emits the Triggering hook: trace line, PluginContext.trigger (both kernels) never has. Preserving semantics exactly therefore required the shared propagating function to take an optional logger, with its absence, not its presence, being the faithful reproduction of context.trigger. That asymmetry is documented at the function and pinned by a test that asserts no debug call is made.

Two loops in kernel.ts deliberately stay outside the extraction because they are not hook dispatch at all: the shutdownHandlers loop (kernel.ts:771, a different collection registered via onShutdown(), logging Shutdown handler error) and the reverse-order plugin destroy() loop. Folding either in would change a log line, which the ruling forbids.

B half — one implementation per flavour

New internal module packages/core/src/hook-dispatch.ts (⛔ not exported from the core barrel):

  • dispatchHookIsolating(name, handlers, logger, args) — a failing handler is logged as Hook handler failed: plus the hook name; the remaining handlers still run.
  • dispatchHookPropagating(name, handlers, logger, args) — the first failure escapes unwrapped, the handlers behind it are skipped; logger optional, and only then is the trace emitted.

Five call sites now reach them, each keeping its exact flavour, log wording and trace behaviour:

call site flavour trace line
ObjectKernelBase.triggerHook isolating yes
ObjectKernel.triggerShutdownHookIsolating isolating yes
ObjectKernelBase.triggerHookOrThrow propagating yes
ObjectKernelBase context.trigger propagating no (unchanged)
ObjectKernel context.trigger propagating no (unchanged)

The handler array is passed by reference, never copied, because both originals iterated the live array off the hooks map — pinned by a test so a defensive copy cannot change dispatch semantics as a tidy-up.

Out of scope by the ruling and untouched: the two hooks maps stay separate, ObjectKernel gains no base class, packages/core/src/index.ts unchanged, packages/core/src/utils/** untouched (#5586 in flight).

C half — the pairing becomes a gate

scripts/check-kernel-hook-pairs.mjs + pnpm check:kernel-hook-pairs, wired into the ESLint job of lint.yml next to its siblings. Every kernel:* hook dispatched in packages/core/src must be named in a test title in both kernel.test.ts and lite-kernel.test.ts; a missing pair fails naming the hook and the side that lacks it. Static AST over five files, no build needed, runs its own --self-test (10 cases) first.

Subscriptions (ctx.hook(...) in a plugin) are deliberately not dispatches — only the kernels decide what a hook means. hooks.get('kernel:x') is in the dispatch vocabulary, and that turned out to be load-bearing: on origin/main the ObjectKernel side of kernel:shutdown had no other statically visible dispatch, so without it the gate would have been blind to exactly the hand-rolled shape it exists to catch.

Measured against main's corpus before being pinned in CI (the gate's audit run over git show origin/main: sources, 42 files):

kernel:bootstrapped  kernel.ts:393 (trigger)   lite-kernel.ts:114 (triggerHookOrThrow)
kernel:listening     kernel.ts:403 (trigger)   lite-kernel.ts:117 (triggerHookOrThrow)
kernel:ready         kernel.ts:381 (trigger)   lite-kernel.ts:108 (triggerHookOrThrow)
kernel:shutdown      kernel.ts:735 (get)       lite-kernel.ts:158 (triggerHook)
PASS on origin/main — 4 hooks, 0 problems (zero false positives)

The dormant inconsistency is pinned, not flipped

On both kernels kernel:shutdown has two dispatch paths with opposite flavours: the kernel's own teardown isolates, a plugin's manual ctx.trigger('kernel:shutdown') propagates. Nothing in the repo triggers it by hand today, so it is dormant. Per the ruling it is now a documented fact with a named test on each side (dispatches kernel:shutdown two ways: ctx.trigger propagates, the kernel teardown isolates (#5282)) rather than a surprise found at teardown. No behaviour was changed; if it should be flipped, that is a separate decision.

Reverse verification — direction written before running

Predictions were recorded before the mutations, and all four matched.

RV-1a — delete the shared isolating dispatcher's error log. Predicted: red on both kernels' files from one edit — the property extraction was ruled to buy, and one the hand-mirrored copies could not have had. Actual: 4 failures across all three files.

x src/lite-kernel.test.ts (19 tests | 1 failed)
   x logs `Hook handler failed: kernel:shutdown` for the handler that threw (#5282)
x src/hook-dispatch.test.ts (10 tests | 1 failed)
   x logs each failure as `Hook handler failed: ...` with the original error
x src/kernel.test.ts (42 tests | 2 failed)
   x names the failing handler and never reports a timeout that did not happen (#5274)
   x dispatches kernel:shutdown two ways: ctx.trigger propagates, the kernel teardown isolates (#5282)
 Test Files  3 failed (3)

RV-1b — re-introduce the divergence by hand (ObjectKernel stops calling the shared function and swallows silently again). Predicted: asymmetric on purpose — kernel.test.ts red, lite-kernel.test.ts and hook-dispatch.test.ts green, i.e. the pre-#5274 shape where one kernel's semantics change with no signal on the other. Actual: exactly that.

x src/kernel.test.ts (42 tests | 2 failed)
 Test Files  1 failed | 2 passed (3)
      Tests  2 failed | 69 passed (71)

Worth noting for the record: under RV-1b the gate stayed green, correctly — the hand-rolled loop still dispatches kernel:shutdown, which is still paired. The gate guards pairing, not implementation sharing; the tests guard the sharing. Neither substitutes for the other, which is why the ruling asked for both.

RV-2a — add a fifth dispatched hook kernel:probe with no pins. Predicted: gate red, naming the hook and both missing sides. Actual:

x kernel:probe — dispatched at packages/core/src/kernel.ts:408 (trigger) — has no named pin in: kernel.test.ts, lite-kernel.test.ts

RV-2b — same, pinned in kernel.test.ts only. Predicted: still red, naming only the missing side. Actual:

x kernel:probe — dispatched at packages/core/src/kernel.ts:408 (trigger) — has no named pin in: lite-kernel.test.ts

Tests and gates

pnpm --filter @objectstack/core test          28 files, 511 tests passed  (hook-dispatch.test.ts: 10 new)
pnpm --filter @objectstack/objectql test     149 files, 2519 tests passed
pnpm --filter @objectstack/runtime test      112 files, 1677 tests passed

Type-check debt: @objectstack/core carries a DEBT entry rather than a typecheck script, so the raw count was measured both ways — 98 on origin/main, 98 on this branch, exactly its ledgered number. The first draft measured 102; the four extra were all in the new/edited test files (one missing .js import extension plus three implicit-any ctx params it cascaded into), fixed at the source rather than by raising the ledger. pnpm check:type-check-debt passes.

Every check: step enumerated from .github/workflows/lint.yml was run locally, one by one — 32 in the ESLint job (including the new check:kernel-hook-pairs) and 25 in the TypeScript job after a full turbo run build — all pass.


Generated by Claude Code

claude added 2 commits August 8, 2026 09:17
…d kernel pins (#5282)

`ObjectKernel` does not extend `ObjectKernelBase`, so lifecycle-hook dispatch
existed twice with no shared code path — the isolating loops printed the same
`Hook handler failed: <name>` line only because it was typed twice. Three
consecutive bugs grew on that seam (#5170 / #5257 / #5274), each "one hook name
means opposite things on the two kernels".

B half: the two flavours move verbatim into `packages/core/src/hook-dispatch.ts`
(`dispatchHookIsolating` / `dispatchHookPropagating`), called by both kernels.
Every call path keeps its flavour, its log wording and its trace line — including
`PluginContext.trigger`, which has never emitted a trace and still does not. The
two `hooks` maps stay separate and `ObjectKernel` gains no base class (both out
of scope by the ruling).

C half: `scripts/check-kernel-hook-pairs.mjs` requires every `kernel:*` hook
dispatched in `packages/core/src` to be named in a test title in BOTH
`kernel.test.ts` and `lite-kernel.test.ts`, failing with the hook and the missing
side. Measured against main's corpus first: 4 hooks, 0 problems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwoubC3jL271FYt9rGXwxb
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 10:10am

Request Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core.

23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/automation/webhooks.mdx (via @objectstack/core)
  • content/docs/concepts/north-star.mdx (via packages/core)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core)
  • content/docs/kernel/services.mdx (via @objectstack/core)
  • content/docs/permissions/authentication.mdx (via @objectstack/core)
  • content/docs/permissions/authorization.mdx (via packages/core)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core)
  • content/docs/plugins/index.mdx (via @objectstack/core)
  • content/docs/plugins/packages.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core)
  • content/docs/releases/implementation-status.mdx (via @objectstack/core)
  • content/docs/releases/v12.mdx (via @objectstack/core)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v17.mdx (via @objectstack/core)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 8, 2026 10:27
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit d6d1a50 Aug 8, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5282-kernel-hook-dispatch-unify branch August 8, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core(finding): ObjectKernel 不继承 ObjectKernelBase —— 钩子分发语义在两处各写一遍,#5170/#5257/#5274 都是同一条结构缝

2 participants