Skip to content

Commit cd0b4a6

Browse files
fix(events): stand by instead of exiting when the event runtime socket is owned (#561)
* fix(events): stand by instead of exiting when the event runtime socket is owned A generated MCP server exited before answering `initialize` whenever another process from the same plugin install already owned the event runtime socket (`Event runtime endpoint already has a live server.`), so every session after the first launched from one install root got no server at all. `createEventRuntimeServer` gains `whenOwned: 'fail' | 'standby'` (default `'fail'`, unchanged). Under `'standby'` a live owner is not fatal: the server resolves in the `standby` role and a scope-bound fiber probes the endpoint every ~1 s (jittered) and runs the existing `openServer` path the moment it is free; the claim lock lets exactly one standby win. `EventRuntimeServer` exposes `role()` and `onRoleChange()`. The generated runtime passes `'standby'` and announces both the standby start and the takeover on stderr only. Closes #559 * fix(events): hold the claim across teardown; report standby failures; guard role listeners Self-review findings on #561 (752879a): - close() now takes the endpoint claim before it stops listening and holds it through the identity-checked removal, so no standby can bind a new socket at the path — possibly on the inode the kernel just freed — before the old owner's identity check runs. Standbys wait out the claim (bounded retries). - The standby loop only swallows "still owned"; every other takeover failure keeps it standing by but is reported through the new `onStandbyError` option, which the generated runtime writes to stderr at one line per distinct message per 30 s. - Role listeners run in their own guards: a throwing listener is reported via `onStandbyError` and never stops the remaining listeners or the transition. * fix(events): never let a throwing onStandbyError reporter end the standby loop or skip role listeners Every onStandbyError invocation goes through one guarded helper that swallows a reporter throw, so the standby keeps probing and the remaining role listeners still run (second-pass self-review finding on 43a85a1).
1 parent d88cc10 commit cd0b4a6

9 files changed

Lines changed: 988 additions & 96 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

‎packages/agent-bundle/src/events/ipc.ts‎

Lines changed: 297 additions & 74 deletions
Large diffs are not rendered by default.

‎packages/agent-bundle/src/mcp-server-runtime.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
runAgentRequest,
3030
unavailable,
3131
} from '@agent-bundle/runtime';
32-
import type { createEventRuntimeServer } from './events/ipc.ts';
32+
import type { createEventRuntimeServer, EventRuntimeTransportError } from './events/ipc.ts';
3333
import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts';
3434
import { createTaskAugmentedMcpServer, type TaskAugmentedMcpServer } from './mcp-tasks.ts';
3535
import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.ts';
@@ -731,6 +731,35 @@ const noticeDiagnostic = (line: string): void => {
731731

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

734+
/** stderr only: stdout is the protocol stream. */
735+
const eventRuntimeDiagnostic = (line: string): void => {
736+
process.stderr.write(`agent-bundle event runtime: ${line}\n`);
737+
};
738+
739+
/** How long one takeover-failure message stays reported before the same message earns another line. */
740+
const STANDBY_ERROR_REPEAT_INTERVAL_MS = 30_000;
741+
742+
/**
743+
* Turns the standby loop's recoverable failures into stderr lines without
744+
* repeating one per probe tick: a message is reported when first seen and
745+
* again once `STANDBY_ERROR_REPEAT_INTERVAL_MS` has passed since its last
746+
* line, so a takeover that keeps failing the same way stays visible at one
747+
* line per half minute while a new failure mode is reported at once.
748+
*/
749+
export const createStandbyErrorReporter = (
750+
report: (line: string) => void,
751+
now: () => number = Date.now,
752+
): ((error: EventRuntimeTransportError) => void) => {
753+
const lastReportedAt = new Map<string, number>();
754+
return (error) => {
755+
const at = now();
756+
const previous = lastReportedAt.get(error.message);
757+
if (previous !== undefined && at - previous < STANDBY_ERROR_REPEAT_INTERVAL_MS) return;
758+
lastReportedAt.set(error.message, at);
759+
report(`takeover failed, still standing by: ${error.message}`);
760+
};
761+
};
762+
734763
/**
735764
* Installs `resources/subscribe` / `resources/unsubscribe` for the notice
736765
* inbox URI and returns the post-render observation that emits
@@ -878,6 +907,15 @@ const canonicalEvent = (event: string): CanonicalAgentEvent => {
878907
* The shared event runtime for one artifact: native envelopes arrive over the
879908
* IPC socket, render through the same dispatcher every route uses, and project
880909
* back into the host's own hook response shape.
910+
*
911+
* One process per install root owns the socket. Hosts routinely launch several
912+
* sessions of the same plugin from one install (every Claude Code session
913+
* Claude Desktop starts, Cursor importing a Claude-installed plugin), so a
914+
* second server finding the socket owned is normal, not fatal: it starts in
915+
* the `standby` role, serves its own MCP session, and takes the socket over
916+
* when the owner exits. Both role changes, and any takeover failure other than
917+
* "still owned", are announced on stderr only — the process's stdout is the
918+
* MCP protocol stream.
881919
*/
882920
const startEventRuntime = async (
883921
events: GeneratedEventRuntimeBinding,
@@ -888,7 +926,10 @@ const startEventRuntime = async (
888926
pluginRoot: Observed<AgentPluginIdentity> | undefined,
889927
): Promise<{ readonly close: () => Promise<void> }> => {
890928
const startedAt = new Date().toISOString();
891-
return events.createEventRuntimeServer({
929+
// The socket path is known once the server exists; the standby loop only
930+
// reports after its first probe interval, by which time it is.
931+
let endpointLabel = events.endpointId;
932+
const server = await events.createEventRuntimeServer({
892933
artifactEpoch: events.artifactEpoch,
893934
endpointId: events.endpointId,
894935
handle: async (request, signal) => settled(async () => {
@@ -966,7 +1007,19 @@ const startEventRuntime = async (
9661007
pid: process.pid,
9671008
startedAt,
9681009
}),
1010+
onStandbyError: createStandbyErrorReporter((line) => {
1011+
eventRuntimeDiagnostic(`${endpointLabel} ${line}`);
1012+
}),
1013+
whenOwned: 'standby',
9691014
});
1015+
endpointLabel = server.endpoint;
1016+
if (server.role() === 'standby') {
1017+
eventRuntimeDiagnostic(`${server.endpoint} is owned by another process; standing by`);
1018+
server.onRoleChange((role) => {
1019+
if (role === 'owner') eventRuntimeDiagnostic(`${server.endpoint} was released by its owner; took it over`);
1020+
});
1021+
}
1022+
return server;
9701023
};
9711024

9721025
/**

‎packages/agent-bundle/src/test/mcp.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,11 @@ export const openInMemoryMcpServer = async <
551551
allowedTargets: [options.lineageHost],
552552
artifactEpoch,
553553
createCanonicalEventProps: (() => { throw new Error('in-memory lineage sessions dispatch no events'); }) as never,
554-
createEventRuntimeServer: (async () => ({ close: async () => undefined })) as never,
554+
createEventRuntimeServer: (async () => ({
555+
close: async () => undefined,
556+
onRoleChange: () => () => undefined,
557+
role: () => 'owner',
558+
})) as never,
555559
endpointId: `${artifactEpoch}:in-memory`,
556560
projectEventDocument: (() => undefined) as never,
557561
target: options.lineageHost,

0 commit comments

Comments
 (0)