Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/event-runtime-standby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep a generated MCP server running when another process from the same install already owns the event runtime socket: the server stands by and takes the socket over when the owner exits, instead of exiting with `Event runtime endpoint already has a live server`. `createEventRuntimeServer` gains `whenOwned: 'fail' | 'standby'` (default `'fail'`), and the returned server exposes `role()` and `onRoleChange()`; the standby start and the takeover are announced on stderr only (#561)
371 changes: 297 additions & 74 deletions packages/agent-bundle/src/events/ipc.ts

Large diffs are not rendered by default.

57 changes: 55 additions & 2 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
runAgentRequest,
unavailable,
} from '@agent-bundle/runtime';
import type { createEventRuntimeServer } from './events/ipc.ts';
import type { createEventRuntimeServer, EventRuntimeTransportError } from './events/ipc.ts';
import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts';
import { createTaskAugmentedMcpServer, type TaskAugmentedMcpServer } from './mcp-tasks.ts';
import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.ts';
Expand Down Expand Up @@ -731,6 +731,35 @@ const noticeDiagnostic = (line: string): void => {

const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error));

/** stderr only: stdout is the protocol stream. */
const eventRuntimeDiagnostic = (line: string): void => {
process.stderr.write(`agent-bundle event runtime: ${line}\n`);
};

/** How long one takeover-failure message stays reported before the same message earns another line. */
const STANDBY_ERROR_REPEAT_INTERVAL_MS = 30_000;

/**
* Turns the standby loop's recoverable failures into stderr lines without
* repeating one per probe tick: a message is reported when first seen and
* again once `STANDBY_ERROR_REPEAT_INTERVAL_MS` has passed since its last
* line, so a takeover that keeps failing the same way stays visible at one
* line per half minute while a new failure mode is reported at once.
*/
export const createStandbyErrorReporter = (
report: (line: string) => void,
now: () => number = Date.now,
): ((error: EventRuntimeTransportError) => void) => {
const lastReportedAt = new Map<string, number>();
return (error) => {
const at = now();
const previous = lastReportedAt.get(error.message);
if (previous !== undefined && at - previous < STANDBY_ERROR_REPEAT_INTERVAL_MS) return;
lastReportedAt.set(error.message, at);
report(`takeover failed, still standing by: ${error.message}`);
};
};

/**
* Installs `resources/subscribe` / `resources/unsubscribe` for the notice
* inbox URI and returns the post-render observation that emits
Expand Down Expand Up @@ -878,6 +907,15 @@ const canonicalEvent = (event: string): CanonicalAgentEvent => {
* The shared event runtime for one artifact: native envelopes arrive over the
* IPC socket, render through the same dispatcher every route uses, and project
* back into the host's own hook response shape.
*
* One process per install root owns the socket. Hosts routinely launch several
* sessions of the same plugin from one install (every Claude Code session
* Claude Desktop starts, Cursor importing a Claude-installed plugin), so a
* second server finding the socket owned is normal, not fatal: it starts in
* the `standby` role, serves its own MCP session, and takes the socket over
* when the owner exits. Both role changes, and any takeover failure other than
* "still owned", are announced on stderr only — the process's stdout is the
* MCP protocol stream.
*/
const startEventRuntime = async (
events: GeneratedEventRuntimeBinding,
Expand All @@ -888,7 +926,10 @@ const startEventRuntime = async (
pluginRoot: Observed<AgentPluginIdentity> | undefined,
): Promise<{ readonly close: () => Promise<void> }> => {
const startedAt = new Date().toISOString();
return events.createEventRuntimeServer({
// The socket path is known once the server exists; the standby loop only
// reports after its first probe interval, by which time it is.
let endpointLabel = events.endpointId;
const server = await events.createEventRuntimeServer({
artifactEpoch: events.artifactEpoch,
endpointId: events.endpointId,
handle: async (request, signal) => settled(async () => {
Expand Down Expand Up @@ -966,7 +1007,19 @@ const startEventRuntime = async (
pid: process.pid,
startedAt,
}),
onStandbyError: createStandbyErrorReporter((line) => {
eventRuntimeDiagnostic(`${endpointLabel} ${line}`);
}),
whenOwned: 'standby',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve MCP-session affinity for standby hooks

When two sessions run concurrently, this policy leaves session B's MCP requests on B's dispatcher and lineage registry, but every hook wrapper still connects to the single install-wide socket owned by session A. Consequently B's hook events render against A's module/provider state, B's pre-tool observations cannot correlate with tool calls handled by B's registry, and event-triggered notifications use A's MCP connection. This breaks the documented guarantee that hooks share state with tools in exactly the concurrent-session scenario being enabled; the new integration test only invokes a stateless ping tool and never sends a hook belonging to the standby session. The endpoint needs per-session routing or equivalent affinity rather than one active handler per install.

AGENTS.md reference: AGENTS.md:L78-L81

Useful? React with 👍 / 👎.

});
endpointLabel = server.endpoint;
if (server.role() === 'standby') {
eventRuntimeDiagnostic(`${server.endpoint} is owned by another process; standing by`);
server.onRoleChange((role) => {
if (role === 'owner') eventRuntimeDiagnostic(`${server.endpoint} was released by its owner; took it over`);
});
}
return server;
};

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/test/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,11 @@ export const openInMemoryMcpServer = async <
allowedTargets: [options.lineageHost],
artifactEpoch,
createCanonicalEventProps: (() => { throw new Error('in-memory lineage sessions dispatch no events'); }) as never,
createEventRuntimeServer: (async () => ({ close: async () => undefined })) as never,
createEventRuntimeServer: (async () => ({
close: async () => undefined,
onRoleChange: () => () => undefined,
role: () => 'owner',
})) as never,
endpointId: `${artifactEpoch}:in-memory`,
projectEventDocument: (() => undefined) as never,
target: options.lineageHost,
Expand Down
Loading
Loading