






















@@ -3,13 +3,25 @@
33 *
44 * Singleton hook runner that's initialized when plugins are loaded
55 * and can be called from anywhere in the codebase.
6+ *
7+ * The runner is created once and resolves hooks live on every dispatch from a
8+ * composed view of the registries that are currently live: the most recently
9+ * initialized registry, the active registry, and the pinned channel/http-route
10+ * surfaces. Freezing one registry caused scoped mid-run activations (harness
11+ * and memory ensures) to rebind the runner to a narrow registry and silently
12+ * drop other plugins' tool-call hooks (#91918). Composing live also preserves
13+ * the older contract that hooks pushed into a registry after initialization
14+ * (e.g. the SDK `addTestHook` helper) dispatch immediately.
615 */
716817import { createSubsystemLogger } from "../logging/subsystem.js";
918import { resolveGlobalSingleton } from "../shared/global-singleton.js";
1019import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
1120import type { PluginHookGatewayContext, PluginHookGatewayStopEvent } from "./hook-types.js";
1221import { createHookRunner, type HookRunner } from "./hooks.js";
22+import { isPluginRegistryRetired } from "./registry-lifecycle.js";
23+import type { PluginRegistry } from "./registry-types.js";
24+import { collectLivePluginRegistries } from "./runtime.js";
13251426type HookRunnerGlobalState = {
1527hookRunner: HookRunner | null;
@@ -25,27 +37,153 @@ const getState = () =>
25372638const getLog = () => createSubsystemLogger("plugins");
273940+function collectHookRegistrySources(
41+lastInitialized: GlobalHookRunnerRegistry | null,
42+): GlobalHookRunnerRegistry[] {
43+const ordered: GlobalHookRunnerRegistry[] = [];
44+const seen = new Set<GlobalHookRunnerRegistry>();
45+const add = (registry: GlobalHookRunnerRegistry | null) => {
46+if (!registry || seen.has(registry)) {
47+return;
48+}
49+// Retired registries were superseded by a newer activation; dispatching
50+// their hooks would resurrect stale config closures. Only lastInitialized
51+// can be retired here (the live registries below are active/pinned, never
52+// retired); SDK-supplied registries are not PluginRegistry and never match.
53+if (isPluginRegistryRetired(registry as PluginRegistry)) {
54+return;
55+}
56+seen.add(registry);
57+ordered.push(registry);
58+};
59+// Precedence: the explicitly initialized registry wins so an SDK caller that
60+// initializes an isolated registry stays authoritative; in the gateway it is
61+// the same object as the active registry, so this just dedupes.
62+add(lastInitialized);
63+for (const registry of collectLivePluginRegistries()) {
64+add(registry);
65+}
66+return ordered;
67+}
68+69+function composeLiveHookRegistry(
70+lastInitialized: GlobalHookRunnerRegistry | null,
71+): GlobalHookRunnerRegistry {
72+const sources = collectHookRegistrySources(lastInitialized);
73+// One source registry owns a plugin's entire contribution (status + hooks),
74+// so handlers never double-fire across registries and a plugin's hooks stay
75+// paired with the status the inbound-claim path reads.
76+const ownerSourceIndexByPluginId = new Map<string, number>();
77+const claimOwner = (pluginId: string, index: number) => {
78+if (!ownerSourceIndexByPluginId.has(pluginId)) {
79+ownerSourceIndexByPluginId.set(pluginId, index);
80+}
81+};
82+// pluginIds each source actually contributes a hook for, so ownership can
83+// prefer a source that carries the plugin's hooks over a same-plugin record
84+// that loaded without any (e.g. a setup-runtime channel load registers the
85+// channel but not the plugin's api.on(...) hooks).
86+const hookPluginIdsBySource = sources.map((registry) => {
87+const ids = new Set<string>();
88+for (const hook of registry.typedHooks) {
89+ids.add(hook.pluginId);
90+}
91+for (const hook of registry.hooks) {
92+ids.add(hook.pluginId);
93+}
94+return ids;
95+});
96+// Prefer the highest-precedence source where the plugin loaded AND actually
97+// contributes a hook, so a loaded-but-hookless record (failed/disabled scoped
98+// reload, or a setup-runtime channel load) cannot shadow a lower-precedence
99+// registration that still carries a fail-closed tool-call gate.
100+sources.forEach((registry, index) => {
101+for (const plugin of registry.plugins) {
102+if (plugin.status === "loaded" && hookPluginIdsBySource[index].has(plugin.id)) {
103+claimOwner(plugin.id, index);
104+}
105+}
106+});
107+// Then a loaded record owns the plugin's status when no live source
108+// contributes a hook for it, keeping status paired with a single owner.
109+sources.forEach((registry, index) => {
110+for (const plugin of registry.plugins) {
111+if (plugin.status === "loaded") {
112+claimOwner(plugin.id, index);
113+}
114+}
115+});
116+sources.forEach((registry, index) => {
117+for (const plugin of registry.plugins) {
118+claimOwner(plugin.id, index);
119+}
120+});
121+// Defensive: claim any hook whose plugin record is absent from .plugins so a
122+// malformed registry never silently drops a registered hook.
123+sources.forEach((registry, index) => {
124+for (const hook of registry.typedHooks) {
125+claimOwner(hook.pluginId, index);
126+}
127+for (const hook of registry.hooks) {
128+claimOwner(hook.pluginId, index);
129+}
130+});
131+return {
132+hooks: sources.flatMap((registry, index) =>
133+registry.hooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
134+),
135+typedHooks: sources.flatMap((registry, index) =>
136+registry.typedHooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index),
137+),
138+plugins: sources.flatMap((registry, index) =>
139+registry.plugins.filter((plugin) => ownerSourceIndexByPluginId.get(plugin.id) === index),
140+),
141+};
142+}
143+144+function createComposedHookRegistryFacade(state: HookRunnerGlobalState): GlobalHookRunnerRegistry {
145+// Live getters: createHookRunner reads these on every hasHooks/getHooksForName
146+// call, so the runner always dispatches the current live registry set rather
147+// than a snapshot captured at initialization. Composition is bounded by the
148+// small live registry set and runs on hook-paced events, not tight loops.
149+return {
150+get hooks() {
151+return composeLiveHookRegistry(state.registry).hooks;
152+},
153+get typedHooks() {
154+return composeLiveHookRegistry(state.registry).typedHooks;
155+},
156+get plugins() {
157+return composeLiveHookRegistry(state.registry).plugins;
158+},
159+};
160+}
161+28162/**
29163 * Initialize the global hook runner with a plugin registry.
30- * Called once when plugins are loaded during gateway startup.
164+ * Called on every plugin registry activation and by SDK consumers. The runner
165+ * instance stays stable so references captured mid-run keep seeing current
166+ * hooks; the passed registry becomes the highest-precedence composition source.
31167 */
32168export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): void {
33169const state = getState();
34170const log = getLog();
35171state.registry = registry;
36-state.hookRunner = createHookRunner(registry, {
37-logger: {
38-debug: (msg) => log.debug(msg),
39-warn: (msg) => log.warn(msg),
40-error: (msg) => log.error(msg),
41-},
42-catchErrors: true,
43-failurePolicyByHook: {
44-before_agent_run: "fail-closed",
45-before_install: "fail-closed",
46-before_tool_call: "fail-closed",
47-},
48-});
172+if (!state.hookRunner) {
173+state.hookRunner = createHookRunner(createComposedHookRegistryFacade(state), {
174+logger: {
175+debug: (msg) => log.debug(msg),
176+warn: (msg) => log.warn(msg),
177+error: (msg) => log.error(msg),
178+},
179+catchErrors: true,
180+failurePolicyByHook: {
181+before_agent_run: "fail-closed",
182+before_install: "fail-closed",
183+before_tool_call: "fail-closed",
184+},
185+});
186+}
4918750188const hookCount = registry.hooks.length;
51189if (hookCount > 0) {
@@ -62,8 +200,9 @@ export function getGlobalHookRunner(): HookRunner | null {
62200}
6320164202/**
65- * Get the global plugin registry.
66- * Returns null if plugins haven't been loaded yet.
203+ * Get the registry from the most recent activation or explicit initialization.
204+ * Returns null if plugins haven't been loaded yet. Hook dispatch does not use
205+ * this single registry; the runner resolves hooks from the live composed view.
67206 */
68207export function getGlobalPluginRegistry(): GlobalHookRunnerRegistry | null {
69208return getState().registry;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。