diff --git a/.changeset/28713-stored-connector-plugin-name-at-start.md b/.changeset/28713-stored-connector-plugin-name-at-start.md new file mode 100644 index 00000000000..8578ecb6c10 --- /dev/null +++ b/.changeset/28713-stored-connector-plugin-name-at-start.md @@ -0,0 +1,14 @@ +--- +"@objectstack/service-automation": patch +"@objectstack/core": patch +--- + +fix(service-automation): a stored connector under a host plugin connector's name is skipped at start, as it is while the platform runs + +Clause-②: yes (widening) — `@objectstack/core` adds one read-only public member, `dispatchesBootPhaseHooks()`, on `ObjectKernel` and `ObjectKernelBase` (so `LiteKernel`); no accepted value changes. + +Metadata-protocol: none + +- **Now.** A provider-bound connector saved in the environment (through the metadata door or Studio) under the name of a connector that a plugin on the host provides is treated at start exactly as it is while the platform runs: the plugin's connector is served under that name, and the saved connector is not served, with the same log record naming it. Connectors saved in the environment are now materialized once every plugin has started, still before the server accepts requests. +- **Unchanged.** A connector the host's own stack declares, and an installed package's, keep their start behavior, including when their name collides with a plugin's connector. +- **`@objectstack/core`.** `ObjectKernel.dispatchesBootPhaseHooks()` answers whether `bootstrap()` dispatches `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` once every plugin has started (`false` exactly when the `bootPhaseHooks` option is `false`); `LiteKernel` always answers `true`. A plugin that leaves part of its start to one of those hooks reads it during its own `start()`. On a kernel that does not dispatch them, or a host that does not say, saved connectors are materialized during the automation plugin's own start, as before. diff --git a/packages/core/src/kernel-base.ts b/packages/core/src/kernel-base.ts index 5f54284aa1e..0c859f92fea 100644 --- a/packages/core/src/kernel-base.ts +++ b/packages/core/src/kernel-base.ts @@ -328,6 +328,22 @@ export abstract class ObjectKernelBase { return this.state; } + /** + * Whether `bootstrap()` dispatches the boot-phase hooks — `kernel:ready`, + * `kernel:bootstrapped` and `kernel:listening` — once every plugin's + * `start()` has run. A kernel on this base always does, so this answers + * `true`. + * + * Read-only. It is for a plugin that leaves part of its start to one of + * those hooks and must know, during its own `start()`, that the hook will + * come; where the answer is `false` (or a host does not say), it keeps + * that work in `start()`. `ObjectKernel` answers from its + * `bootPhaseHooks` option. + */ + dispatchesBootPhaseHooks(): boolean { + return true; + } + /** * Get all registered plugins */ diff --git a/packages/core/src/kernel.boot-phase-hooks.test.ts b/packages/core/src/kernel.boot-phase-hooks.test.ts index 39121cd84ee..a9e6f01b1f7 100644 --- a/packages/core/src/kernel.boot-phase-hooks.test.ts +++ b/packages/core/src/kernel.boot-phase-hooks.test.ts @@ -118,6 +118,23 @@ describe('ObjectKernel bootPhaseHooks', () => { }); describe('bootPhaseHooks: false', () => { + it('dispatchesBootPhaseHooks() answers false, read by a plugin during its own start()', async () => { + const { plugins } = composition(); + const read: unknown[] = []; + const reader: Plugin = { + name: 'test.reader', + version: '1.0.0', + init: async () => {}, + start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); }, + }; + const kernel = track(newKernel({ bootPhaseHooks: false })); + + await boot(kernel, [...plugins, reader]); + + expect(read).toEqual([false]); + expect(kernel.dispatchesBootPhaseHooks()).toBe(false); + }); + it('keeps every definition registered in init() and start(), and the kernel runs', async () => { const { plugins } = composition(); const kernel = track(newKernel({ bootPhaseHooks: false })); @@ -221,6 +238,22 @@ describe('ObjectKernel bootPhaseHooks', () => { ['true', { bootPhaseHooks: true }], ['undefined', { bootPhaseHooks: undefined }], ] as Array<[string, ObjectKernelConfig]>)('bootPhaseHooks %s (control)', (_label, extra) => { + it('dispatchesBootPhaseHooks() answers true, read by a plugin during its own start()', async () => { + const { plugins } = composition(); + const read: unknown[] = []; + const reader: Plugin = { + name: 'test.reader', + version: '1.0.0', + init: async () => {}, + start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); }, + }; + const kernel = track(newKernel(extra)); + + await boot(kernel, [...plugins, reader]); + + expect(read).toEqual([true]); + }); + it('fires all three hooks in order and keeps every definition, the kernel:ready one included', async () => { const { plugins, spies, order } = composition(); const kernel = track(newKernel(extra)); diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index da322014be0..21c0f56954c 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -891,6 +891,21 @@ export class ObjectKernel { return this.state; } + /** + * Whether `bootstrap()` dispatches the boot-phase hooks — `kernel:ready`, + * `kernel:bootstrapped` and `kernel:listening` — once every plugin's + * `start()` has run: `false` exactly when {@link ObjectKernelConfig.bootPhaseHooks} + * is `false`, `true` otherwise. + * + * Read-only; the option is fixed at construction. It is for a plugin that + * leaves part of its start to one of those hooks and must know, during its + * own `start()`, that the hook will come; where the answer is `false`, it + * keeps that work in `start()`. + */ + dispatchesBootPhaseHooks(): boolean { + return this.config.bootPhaseHooks !== false; + } + // Private methods private async initPluginWithTimeout(plugin: PluginMetadata): Promise { diff --git a/packages/core/src/lite-kernel.test.ts b/packages/core/src/lite-kernel.test.ts index 67ec564f3da..0334f87dab5 100644 --- a/packages/core/src/lite-kernel.test.ts +++ b/packages/core/src/lite-kernel.test.ts @@ -271,6 +271,22 @@ describe('LiteKernel with Configurable Logger', () => { expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']); }); + it('dispatchesBootPhaseHooks() answers true, read by a plugin during its own start()', async () => { + const read: unknown[] = []; + const plugin: Plugin = { + name: 'boot-phase-reader-plugin', + init: async () => {}, + start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); }, + }; + + kernel.use(plugin); + await kernel.bootstrap(); + await kernel.shutdown(); + + expect(read).toEqual([true]); + expect(kernel.dispatchesBootPhaseHooks()).toBe(true); + }); + // #5170 — the LiteKernel half of the cross-kernel pin. This is the // behaviour the issue changed: the throw used to be caught inside // `triggerHook`, logged as `Hook handler failed: kernel:ready`, and the diff --git a/packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts b/packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts index ea5af34fa65..8a79d25fd77 100644 --- a/packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts +++ b/packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts @@ -23,15 +23,22 @@ // environment-wide stdio `mcp` entry, and a control carrying its document // inline. [#28698] It also saves an environment-wide entry naming a provider // no installed plugin offers (accepted at save, skipped after boot: the shape -// a row takes when the plugin that offered its provider is removed). The -// second boot: +// a row takes when the plugin that offered its provider is removed). +// [#28713] And an environment-wide entry under the name of a connector a host +// plugin registers (`slack`, ConnectorSlackPlugin): accepted at save, not +// served beside the plugin's connector while the platform runs. The second +// boot: // // 1. starts (no throw); // 2. does not list any refused entry, and its boot record names each entry // and its ref (or, for the unknown provider, the provider); // 3. lists the stored inline control `ready` with its operation (the rows // were hydrated, so the refusal above was read at boot, not skipped); -// 4. lists the showcase's own file-ref and stdio entries `ready` (control). +// 4. lists the showcase's own file-ref and stdio entries `ready` (control); +// 5. [#28713] lists the plugin's `slack` connector `ready` with the plugin's +// actions, not the stored entry's, and its boot record names the stored +// entry; the stored inline control is already `ready` on the first read +// after the start (it is served before the server answers). // // Boots fixture stacks of its own (a database file), so it stays out of // `SHARED_SHOWCASE`. @@ -53,6 +60,8 @@ const AUTHORED_PACKAGE_ID = 'com.dogfood.storedrestart'; const INLINE = 'srr_inline'; /** [#28698] A stored entry whose provider no installed plugin offers. */ const UNKNOWN_PROVIDER = 'srr_unknown_provider'; +/** [#28713] The name of the connector ConnectorSlackPlugin registers on the showcase. */ +const PLUGIN_NAME = 'slack'; const HOST_FILE_ENTRY = 'showcase_status_openapi'; const HOST_STDIO_ENTRY = 'showcase_mcp_tools'; @@ -126,6 +135,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe let before: Answer; let bootError: unknown; let after: Answer; + let firstRead: Answer; let bootLines: string[] = []; let second: VerifyStack | undefined; @@ -154,6 +164,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe ...openapi(UNKNOWN_PROVIDER, DOCUMENT), provider: 'no_such_provider', })); + saves[PLUGIN_NAME] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${PLUGIN_NAME}`, openapi(PLUGIN_NAME, DOCUMENT))); saves[INLINE] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${INLINE}`, openapi(INLINE, DOCUMENT))); before = await settle(first, token, [INLINE]); } finally { @@ -168,6 +179,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe return undefined; } const token = await second.signIn(); + firstRead = await json(await second.apiAs(token, 'GET', '/automation/connectors')); return settle(second, token, [INLINE, HOST_FILE_ENTRY, HOST_STDIO_ENTRY]); }); bootLines = captured.lines; @@ -186,6 +198,8 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe expect(entryOf(before, INLINE)?.state, shown(before, INLINE)).toBe('ready'); expect(entryOf(before, ENV_FILE), shown(before, ENV_FILE)).toBeUndefined(); expect(entryOf(before, UNKNOWN_PROVIDER), shown(before, UNKNOWN_PROVIDER)).toBeUndefined(); + // [#28713] While the platform runs, the plugin's connector keeps the name. + expect(entryOf(before, PLUGIN_NAME)?.actions?.map((a: any) => a.key), shown(before, PLUGIN_NAME)).not.toContain('GetThings'); }); it('the second boot over the same database starts', () => { @@ -210,6 +224,19 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe expect(record).toContain('no provider factory is registered'); }); + it('[#28713] a stored entry under a host plugin connector\'s name: the plugin\'s connector lists ready after the restart, and the boot record names the stored entry', () => { + const plugin = entryOf(after, PLUGIN_NAME); + expect(plugin?.state, shown(after, PLUGIN_NAME)).toBe('ready'); + expect(plugin.actions.map((a: any) => a.key)).not.toContain('GetThings'); + expect(plugin.actions.length).toBeGreaterThan(0); + const record = bootLines.find((l) => l.includes(`declarative provider-bound instance '${PLUGIN_NAME}' collides with a plugin-registered connector`)); + expect(record, `no boot record names '${PLUGIN_NAME}'`).toBeDefined(); + }); + + it('[#28713] control: the stored inline entry is already ready on the first read after the restart', () => { + expect(entryOf(firstRead, INLINE)?.state, shown(firstRead, INLINE)).toBe('ready'); + }); + it('controls: the stored inline entry, and the showcase\'s own file-ref and stdio entries, list ready after the restart', () => { expect(entryOf(after, INLINE)?.state, shown(after, INLINE)).toBe('ready'); expect(entryOf(after, INLINE).actions.map((a: any) => a.key)).toEqual(['GetThings']); diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 39cd8789e52..b2489c6b56c 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -13,7 +13,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; +import { LiteKernel, ObjectKernel } from '@objectstack/core'; import type { Connector, ConnectorProviderContext, @@ -1217,6 +1217,150 @@ describe('#28698 — a stored entry\'s configuration fault never fails the boot; }); }); +// ── #28713 — a stored entry waits until every plugin has started ──────────── +// +// A connector plugin registers its connector in its own start(), after the +// automation plugin's (it depends on it). A stored entry under that name used +// to be materialized by the automation plugin's start() reconcile first, so the +// plugin's registration was refused and its start() failed the boot. Stored +// entries now wait for the `kernel:bootstrapped` pass, where they meet the +// plugin's connector the way a running platform does: the §4 conflict logged, +// the stored entry skipped, the plugin's connector served. + +describe('#28713 — a stored entry under a connector plugin\'s name never stops the boot; the plugin\'s connector serves', () => { + const HOST = 'com.acme.host'; + const packages = { [HOST]: { source: 'code' } }; + const ownedBy = (item: T, packageId: string) => ({ ...item, _packageId: packageId }); + const hostServed = ownedBy(providerConnector('host_served'), HOST); + + interface Observed { + /** What the registry holds when the connector plugin's start() begins (after the automation plugin's). */ + atPluginStart?: string[]; + /** What it holds when `kernel:listening` is dispatched. */ + atListening?: string[]; + logged: Array<{ msg: string; meta?: unknown }>; + } + + /** + * Boot `kernel` with the automation plugin and one harness standing for the + * registry and a connector plugin: it serves `declared` (`registered` are the + * host's, the rest stored) and registers `pluginConnectors` in its own + * start(), the way ConnectorSlackPlugin does. + */ + async function bootWith( + kernel: { use(p: unknown): unknown; bootstrap(): Promise }, + declared: unknown[], + opts: { registered: unknown[]; pluginConnectors?: string[] }, + ): Promise { + const observed: Observed = { logged: [] }; + const { factory } = makeFakeProvider(); + await kernel.use(new AutomationServicePlugin()); + await kernel.use({ + name: 'test.connector-plugin', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + ctx.registerService('objectql', { + registry: { + listItems: (type: string) => (type === 'connector' ? declared : []), + getPackage: (id: string) => (packages as Record)[id], + getArtifactItem: artifactReader(opts.registered), + }, + residualOwnerSource: () => undefined, + }); + const original = ctx.logger.error.bind(ctx.logger); + ctx.logger.error = (msg: string, err?: unknown, meta?: unknown) => { + observed.logged.push({ msg, meta }); + return original(msg, err, meta); + }; + ctx.getService('automation').registerConnectorProvider('fake', factory); + ctx.hook('kernel:listening', async () => { + observed.atListening = ctx.getService('automation').getRegisteredConnectors(); + }); + }, + async start(ctx: any) { + const automation = ctx.getService('automation'); + observed.atPluginStart = automation.getRegisteredConnectors(); + for (const name of opts.pluginConnectors ?? []) { + automation.registerConnector( + { name, label: name, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'plugin_action', label: 'A' }] }, + { plugin_action: async () => ({ ok: true }) }, + ); + } + }, + }); + await kernel.bootstrap(); + await flush(); + return observed; + } + + it('a stored entry under a name a plugin registers in its start(): the boot starts, the plugin\'s connector serves, the stored entry is skipped with its record', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + const observed = await bootWith(kernel, [{ ...hostServed }, providerConnector('slack')], { + registered: [hostServed], + pluginConnectors: ['slack'], + }); + const engine = automationOf(kernel); + expect(engine.getConnectorOrigin('slack')).toBe('plugin'); + expect(engine.resolveConnectorAction('slack', 'plugin_action')).toBeDefined(); + expect(engine.resolveConnectorAction('slack', 'ping')).toBeUndefined(); + expect(engine.getConnectorOrigin('host_served')).toBe('declarative'); + const record = observed.logged.find((l) => l.msg.includes(`declarative provider-bound instance 'slack' collides with a plugin-registered connector`)); + expect(record, JSON.stringify(observed.logged.map((l) => l.msg))).toBeDefined(); + await kernel.shutdown(); + }); + + it('control: a stored entry under a free name serves, materialized after every plugin has started and before kernel:listening', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + const observed = await bootWith(kernel, [{ ...hostServed }, providerConnector('stored_free')], { + registered: [hostServed], + }); + // The host's entry is served from the automation plugin's start(); the + // stored one is not there yet when a later plugin starts… + expect(observed.atPluginStart).toEqual(['host_served']); + // …and is served before the server listens. + expect(observed.atListening).toEqual(expect.arrayContaining(['host_served', 'stored_free'])); + expect(automationOf(kernel).getConnectorOrigin('stored_free')).toBe('declarative'); + expect(observed.logged).toEqual([]); + await kernel.shutdown(); + }); + + it('control: the host\'s own entry under a name a plugin registers in its start() still fails the boot', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + const hostSlack = ownedBy(providerConnector('slack'), HOST); + const err = await rejectionOf(bootWith(kernel, [{ ...hostSlack }], { + registered: [hostSlack], + pluginConnectors: ['slack'], + })); + expect(err.message).toContain(`Connector name conflict: 'slack' is already registered by a declarative provider-bound`); + }); + + it('a kernel that withholds its boot-phase hooks keeps stored entries in the start() reconcile, so they are served', async () => { + const kernel = new ObjectKernel({ logger: { level: 'error' }, gracefulShutdown: false, skipSystemValidation: true, bootPhaseHooks: false }); + const observed = await bootWith(kernel, [{ ...hostServed }, providerConnector('stored_free')], { + registered: [hostServed], + }); + expect(observed.atPluginStart).toEqual(expect.arrayContaining(['host_served', 'stored_free'])); + expect((kernel.getService('automation') as AutomationEngine).getConnectorOrigin('stored_free')).toBe('declarative'); + await kernel.shutdown(); + }); + + it.each([ + ['has no reader', (k: LiteKernel) => { (k as unknown as { dispatchesBootPhaseHooks?: unknown }).dispatchesBootPhaseHooks = undefined; }], + ['answers false', (k: LiteKernel) => { (k as unknown as { dispatchesBootPhaseHooks: () => boolean }).dispatchesBootPhaseHooks = () => false; }], + ] as const)('a host whose kernel %s keeps stored entries in the start() reconcile, so they are served', async (_label, shape) => { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + shape(kernel); + const observed = await bootWith(kernel, [{ ...hostServed }, providerConnector('stored_free')], { + registered: [hostServed], + }); + expect(observed.atPluginStart).toEqual(expect.arrayContaining(['host_served', 'stored_free'])); + expect(automationOf(kernel).getConnectorOrigin('stored_free')).toBe('declarative'); + await kernel.shutdown(); + }); +}); + // ── #3017 — upstream unavailable: degrade + retry, never a dead boot ───────── // // A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index d7b3f7ef358..9d2ff8e9b5d 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -575,6 +575,28 @@ export function nonHostDeclaringPackageOf( return declaresSameProviderBinding(entry, declaration, stamp) ? undefined : { packageId: stamp, why: 'stored' }; } +/** + * [#28713] Whether the kernel this plugin runs on dispatches its boot-phase + * hooks (`kernel:bootstrapped` among them) once every plugin has started — + * the kernel's own `dispatchesBootPhaseHooks()`. Only then may the `start()` + * reconcile leave stored entries to the `kernel:bootstrapped` pass. + * + * A host that does not say (no `getKernel`, or a kernel without the reader) + * answers `false`, as does a kernel that withholds the hooks: there the + * `start()` reconcile keeps every stored entry, so none waits for a pass that + * never comes. + */ +function kernelDispatchesBootPhaseHooks(ctx: PluginContext): boolean { + let kernel: unknown; + try { + kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined; + } catch { + return false; + } + const reader = (kernel as { dispatchesBootPhaseHooks?: unknown } | undefined)?.dispatchesBootPhaseHooks; + return typeof reader === 'function' && (reader as () => unknown).call(kernel) === true; +} + // The credential resolver lives in ./credential-resolver.ts so the engine reads // the same one for an `api` flow's `config.secretRef`; re-exported unchanged. export { defaultEnvCredentialResolver, type CredentialResolver }; @@ -1305,6 +1327,24 @@ export class AutomationServicePlugin implements Plugin { * `start()` runs no second reconcile. */ private reconciledDeclaration?: string; + /** + * [#28713] Whether a reconcile leaves STORED entries (rows of the + * environment, {@link nonHostDeclaringPackageOf}) for later. True from + * `start()` until the `kernel:bootstrapped` pass, on a kernel that + * dispatches that pass ({@link kernelDispatchesBootPhaseHooks}). + * + * A connector plugin registers its connector in its own `start()`, after + * this plugin's. A stored entry the `start()` reconcile materialized + * would take the name before the plugin's registration arrives, and the + * plugin's registration is then refused (ADR-0097 §4, no silent + * precedence) — a refusal inside that plugin's `start()`, which fails the + * boot. Held until every plugin has started, the stored entry meets the + * plugin's connector instead, as on a running platform: the §4 conflict + * is logged and the stored entry skipped, the plugin's connector served. + */ + private holdStoredEntries = false; + /** [#28713] A reconcile held at least one stored entry, so the `kernel:bootstrapped` pass must run. */ + private storedEntriesHeld = false; /** Serializes reconcile runs — see {@link materializeDeclaredConnectors}. */ private reconcileQueue: Promise = Promise.resolve(); private destroyed = false; @@ -2211,6 +2251,12 @@ export class AutomationServicePlugin implements Plugin { // #27237, a `credentialRef` no source answers for (it materializes once the // credential is set) or one its upstream refuses (CONNECTOR_CREDENTIAL_REJECTED). // An operational blip in one integration must not take the whole app down. + // [#28713] Stored entries (rows of the environment) wait for the + // `kernel:bootstrapped` pass below, when the kernel dispatches it: a + // connector plugin that starts after this one registers its connector + // first, so a stored entry under its name is skipped, not served in + // its place. See {@link holdStoredEntries}. + this.holdStoredEntries = kernelDispatchesBootPhaseHooks(ctx); await this.materializeDeclaredConnectors(ctx, { fatal: true }); // ── [#28591] Connectors registered after start(), during the boot ───── @@ -2694,8 +2740,10 @@ export class AutomationServicePlugin implements Plugin { * [#28591] Reconcile once more at `kernel:bootstrapped`, only when the * registry's connector declaration differs from the one the last reconcile * read — i.e. a plugin registered (or changed) a provider-bound entry after - * the boot reconcile in `start()`. A boot that registered nothing more runs - * no second reconcile. + * the boot reconcile in `start()`, or [#28713] that reconcile held stored + * entries ({@link holdStoredEntries}), which are reconciled here, once + * every plugin's connectors are registered. A boot that registered + * nothing more and held nothing runs no second reconcile. * * Reload semantics (`fatal: false`): an entry registered here arrives the * way a hot install's does, so a configuration fault in it is logged and @@ -2705,9 +2753,15 @@ export class AutomationServicePlugin implements Plugin { */ private reconcileConnectorsRegisteredDuringBoot(ctx: PluginContext): Promise { return this.onReconcileQueue(async () => { + // [#28713] Every plugin has started: stored entries are reconciled + // from here on, and this pass reconciles the ones held so far. + this.holdStoredEntries = false; + const held = this.storedEntriesHeld; + this.storedEntriesHeld = false; if (!this.engine || this.destroyed) return; const declared = await this.readDeclaredConnectorItems(ctx, { post: false }); - if (!declared || declaredInstanceFingerprint(declared) === this.reconciledDeclaration) return; + if (!declared) return; + if (!held && declaredInstanceFingerprint(declared) === this.reconciledDeclaration) return; try { await this.reconcileDeclaredConnectors(ctx, { fatal: false }); } catch (err) { @@ -2814,6 +2868,11 @@ export class AutomationServicePlugin implements Plugin { if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue; const bound = entry as DeclaredConnectorItem & { name: string; provider: string }; if (bound.enabled === false) continue; + // [#28713] Held until every plugin has started ({@link holdStoredEntries}). + if (this.holdStoredEntries && storedEntry(bound)) { + this.storedEntriesHeld = true; + continue; + } if (desired.has(bound.name)) { fail( `[Automation] duplicate declarative connector instance name '${bound.name}' — connector names must be unique (ADR-0097).`,