From 853872e21aff8fb7e9d4db28878ebfab5c8f6b33 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:29:03 +0000 Subject: [PATCH 1/3] feat(events): expose runtime identity over event IPC (#269) Add a pinned read-only status verb and carry warm-runtime availability through Doctor and Workbench discovery. --- .changeset/runtime-identity-status.md | 7 ++ docs/diagnostics.md | 9 +- .../agent-bundle/src/contracts/discovery.ts | 12 ++ .../dev/playground/host-discovery-service.ts | 1 + packages/agent-bundle/src/events/ipc.ts | 115 +++++++++++++++++- packages/agent-bundle/src/install/doctor.ts | 44 ++++++- .../agent-bundle/src/mcp-server-runtime.ts | 29 +++-- packages/agent-bundle/tests/doctor.test.ts | 94 +++++++++++++- packages/agent-bundle/tests/event-ipc.test.ts | 59 +++++++++ .../tests/generated-route-server.test.ts | 17 ++- .../tests/host-discovery-service.test.ts | 12 +- packages/rsc-runtime/src/index.ts | 1 + packages/rsc-runtime/src/warm-runtime.ts | 13 +- .../rsc-runtime/tests/warm-runtime.test.ts | 3 + .../src/discovery/discovery-client.ts | 16 +++ .../src/discovery/discovery-page.tsx | 25 ++++ .../workbench/tests/discovery-client.test.ts | 14 +++ 17 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 .changeset/runtime-identity-status.md diff --git a/.changeset/runtime-identity-status.md b/.changeset/runtime-identity-status.md new file mode 100644 index 000000000..8cd911e4f --- /dev/null +++ b/.changeset/runtime-identity-status.md @@ -0,0 +1,7 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Expose warm-runtime availability and add a read-only event IPC status verb +that carries runtime identity through Doctor and Workbench discovery. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 788e5c473..8b98ce5de 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -28,7 +28,7 @@ gate a build, a validation, or a dev rebuild. | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. | +| `AB7300`–`AB7318` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, and durable-state inventory. | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8xxx` | Development server configuration. | @@ -369,6 +369,13 @@ SQLite lock or shared-memory files. | --- | --- | --- | | `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | +## Read-only runtime identity introspection (`AB7317`–`AB7318`) + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. Restart it after upgrading Agent Bundle. | +| `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. Inspect or restart the runtime, then rerun Doctor. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/packages/agent-bundle/src/contracts/discovery.ts b/packages/agent-bundle/src/contracts/discovery.ts index 669bf652e..6696c92de 100644 --- a/packages/agent-bundle/src/contracts/discovery.ts +++ b/packages/agent-bundle/src/contracts/discovery.ts @@ -49,10 +49,22 @@ export interface DiscoveryFinding { readonly manifest?: string; readonly name?: string; readonly path?: string; + readonly runtime?: DiscoveryRuntimeStatus; readonly state: DiscoveryFindingState; readonly version?: string; } +export type DiscoveryRuntimeStatus = + | Readonly<{ + readonly artifactEpoch: string; + readonly availability: 'available' | 'runtime-restarted' | 'runtime-unavailable'; + readonly instanceId: string; + readonly pid: number; + readonly startedAt?: string; + readonly status: 'available'; + }> + | Readonly<{ readonly status: 'failed' | 'unavailable' | 'unsupported' }>; + export interface DiscoveryMcpServer { readonly name: string; readonly transport: 'stdio' | 'streamable-http'; diff --git a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts index 6b7b02abe..62cb638fb 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts @@ -86,6 +86,7 @@ const findingFields = (value: DoctorFinding): DiscoveryFinding => Object.freeze( ...(value.manifest === undefined ? {} : { manifest: value.manifest }), ...(value.name === undefined ? {} : { name: value.name }), ...(value.path === undefined ? {} : { path: value.path }), + ...(value.runtime === undefined ? {} : { runtime: value.runtime }), state: value.state, ...(value.version === undefined ? {} : { version: value.version }), }); diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index c6f98ea86..aebf6754e 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -41,6 +41,20 @@ const eventRequestSchema = z.object({ target: z.string().min(1), }).strict(); +const eventStatusRequestSchema = z.object({ + kind: z.literal('status'), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), +}).strict(); + +const runtimeAvailabilitySchema = z.enum(['available', 'runtime-restarted', 'runtime-unavailable']); +const eventRuntimeStatusPayloadSchema = z.object({ + artifactEpoch: z.string().min(1), + availability: runtimeAvailabilitySchema, + instanceId: z.string().min(1), + pid: z.number().int().positive(), + startedAt: z.string().min(1).optional(), +}).strict(); + const eventResponseSchema = z.discriminatedUnion('status', [ z.object({ artifactEpoch: z.string().min(1), @@ -57,6 +71,22 @@ const eventResponseSchema = z.discriminatedUnion('status', [ }).strict(), ]); +const eventStatusResponseSchema = z.discriminatedUnion('status', [ + z.object({ + kind: z.literal('status'), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + runtime: eventRuntimeStatusPayloadSchema, + status: z.literal('ok'), + }).strict(), + z.object({ + artifactEpoch: z.string().min(1), + code: z.literal('invalid-message'), + message: z.string(), + protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), + status: z.literal('error'), + }).strict(), +]); + export interface EventRuntimeRequest { readonly artifactEpoch: string; readonly event: string; @@ -65,10 +95,20 @@ export interface EventRuntimeRequest { readonly target: string; } +export type EventRuntimeAvailability = z.infer; +export interface EventRuntimeStatus { + readonly artifactEpoch: string; + readonly availability: EventRuntimeAvailability; + readonly instanceId: string; + readonly pid: number; + readonly startedAt?: string; +} + export interface CreateEventRuntimeServerOptions { readonly artifactEpoch: string; readonly endpointId: string; readonly handle: (request: EventRuntimeRequest, signal: AbortSignal) => Promise; + readonly status?: () => EventRuntimeStatus; } export interface EventRuntimeServer { @@ -82,6 +122,17 @@ export interface RequestEventRuntimeOptions extends EventRuntimeRequest { readonly timeoutMs: number; } +export type RequestEventRuntimeStatusOptions = Readonly<{ + readonly timeoutMs: number; +}> & ( + | Readonly<{ readonly endpoint: string; readonly endpointId?: never }> + | Readonly<{ readonly endpoint?: never; readonly endpointId: string }> +); + +export type EventRuntimeStatusResult = + | Readonly + | Readonly<{ readonly status: 'unavailable' | 'unsupported' }>; + export const eventRuntimeEndpoint = (endpointId: string): string => { const hash = createHash('sha256').update(endpointId, 'utf8').digest('hex').slice(0, 32); if (process.platform === 'win32') return `\\\\.\\pipe\\agent-bundle-event-${hash}`; @@ -170,6 +221,26 @@ const handleConnection = Effect.fnUntraced(function*( }); return; } + const statusRequest = eventStatusRequestSchema.safeParse(raw.value); + if (statusRequest.success) { + if (options.status === undefined) { + writeResponse(socket, { + artifactEpoch: options.artifactEpoch, + code: 'invalid-message', + message: 'Event runtime request does not match the wire schema.', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + status: 'error', + }); + return; + } + writeResponse(socket, { + kind: 'status', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + runtime: options.status(), + status: 'ok', + }); + return; + } const parsed = eventRequestSchema.safeParse(raw.value); if (!parsed.success) { writeResponse(socket, { @@ -749,7 +820,10 @@ const connect = (endpoint: string): Effect.Effect { socket.removeListener('connect', onConnect); - resume(Effect.fail(transportError('runtime-unavailable', 'Shared event runtime is unavailable.', error))); + const code = (error as NodeJS.ErrnoException).code; + resume(Effect.fail(code === 'ENOENT' || code === 'ECONNREFUSED' + ? transportError('runtime-unavailable', 'Shared event runtime is unavailable.', error) + : transportError('runtime-failed', 'Shared event runtime connection failed.', error))); }; socket.once('connect', onConnect); socket.once('error', onError); @@ -798,3 +872,42 @@ const requestProgram = ( export const requestEventRuntime = async ( options: RequestEventRuntimeOptions, ): Promise => runPromise(requestProgram(options), { signal: options.signal }); + +const statusProgram = ( + options: RequestEventRuntimeStatusOptions, +): Effect.Effect => Effect.acquireUseRelease( + connect(options.endpoint ?? eventRuntimeEndpoint(options.endpointId)), + (socket) => Effect.gen(function*() { + socket.write(`${JSON.stringify({ + kind: 'status', + protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, + })}\n`); + const raw = yield* readOneMessage(socket); + const response = eventStatusResponseSchema.safeParse(raw); + if (!response.success) { + return yield* Effect.fail(transportError( + 'invalid-message', + 'Event runtime status response does not match the wire schema.', + )); + } + if (response.data.status === 'error') return Object.freeze({ status: 'unsupported' as const }); + return Object.freeze({ + ...response.data.runtime, + status: 'available' as const, + }); + }), + (socket) => Effect.sync(() => socket.destroy()), +).pipe( + Effect.raceFirst( + Effect.sleep(Duration.millis(options.timeoutMs)).pipe( + Effect.andThen(Effect.fail(transportError('runtime-timeout', 'Event runtime status exceeded its deadline.'))), + ), + ), + Effect.catch((error) => error.code === 'runtime-unavailable' + ? Effect.succeed(Object.freeze({ status: 'unavailable' as const })) + : Effect.fail(error)), +); + +export const requestEventRuntimeStatus = async ( + options: RequestEventRuntimeStatusOptions, +): Promise => runPromise(statusProgram(options)); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index c7ef3444a..504d717f2 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -14,6 +14,7 @@ import type { BoundedChildProcessResult, } from '../host-contracts/process.ts'; import { runBoundedChildProcess } from '../host-contracts/process.ts'; +import { requestEventRuntimeStatus } from '../events/ipc.ts'; import { treeHash, type InstallHost } from './install.ts'; export type DoctorHost = InstallHost; @@ -62,10 +63,22 @@ export interface DoctorFinding { readonly manifest?: string; readonly name?: string; readonly path?: string; + readonly runtime?: DoctorRuntimeStatus; readonly state: DoctorFindingState; readonly version?: string; } +export type DoctorRuntimeStatus = + | Readonly<{ + readonly artifactEpoch: string; + readonly availability: 'available' | 'runtime-restarted' | 'runtime-unavailable'; + readonly instanceId: string; + readonly pid: number; + readonly startedAt?: string; + readonly status: 'available'; + }> + | Readonly<{ readonly status: 'failed' | 'unavailable' | 'unsupported' }>; + export interface DoctorDurableStateStore { /** Main database plus any present `-wal` and `-shm` sidecars. */ readonly bytes: number; @@ -972,7 +985,36 @@ const scanEndpoints = async ( if (state === 'missing') continue; if (state === 'live') { live += 1; - findings.push({ path, state: 'live' }); + let runtime: DoctorRuntimeStatus; + try { + const probed = await requestEventRuntimeStatus({ endpoint: path, timeoutMs: 1_000 }); + runtime = probed; + if (probed.status === 'unsupported') { + diagnostics.push(diagnostic( + 'AB7317', + `Runtime socket ${JSON.stringify(path)} predates read-only runtime identity introspection.`, + 'Restart the runtime after upgrading Agent Bundle to expose its process-lifetime identity.', + 'info', + )); + } else if (probed.status === 'unavailable') { + diagnostics.push(diagnostic( + 'AB7318', + `Runtime socket ${JSON.stringify(path)} became unavailable during its status probe.`, + 'Restart the runtime or inspect the socket, then rerun Doctor.', + 'error', + )); + } + } catch (error) { + runtime = Object.freeze({ status: 'failed' }); + diagnostics.push(diagnostic( + 'AB7318', + `Runtime socket ${JSON.stringify(path)} status probe failed: ` + + `${error instanceof Error ? error.message : String(error)}`, + 'Inspect the runtime protocol and socket responsiveness, then rerun Doctor.', + 'error', + )); + } + findings.push({ path, runtime, state: 'live' }); continue; } staleSockets += 1; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 4bc1297cb..aa52a2cd7 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -333,6 +333,7 @@ export const createFlightWorkerHost = ( }); worker.on('exit', (code: number) => { exited = true; + warmHost.markUnavailable(code === 0 ? 'runtime-unavailable' : 'runtime-restarted'); failPending(new AgentRuntimeError( code === 0 ? 'runtime-unavailable' : 'runtime-restarted', code === 0 @@ -360,7 +361,7 @@ export const createFlightWorkerHost = ( }, })); }); - return createWarmFlightHost({ + const warmHost = createWarmFlightHost({ artifactEpoch, close: async (): Promise => { await worker.terminate(); @@ -404,6 +405,7 @@ export const createFlightWorkerHost = ( }, }, }); + return warmHost; }; /** The render host a generated server closes over, plus its teardown. */ @@ -464,10 +466,13 @@ const canonicalEvent = (event: string): CanonicalAgentEvent => { const startEventRuntime = async ( events: GeneratedEventRuntimeBinding, dispatcher: AgentRenderDispatcher, -): Promise<{ readonly close: () => Promise }> => events.createEventRuntimeServer({ - artifactEpoch: events.artifactEpoch, - endpointId: events.endpointId, - handle: async (request, signal) => { + host: WarmFlightHost, +): Promise<{ readonly close: () => Promise }> => { + const startedAt = new Date().toISOString(); + return events.createEventRuntimeServer({ + artifactEpoch: events.artifactEpoch, + endpointId: events.endpointId, + handle: async (request, signal) => { const event = canonicalEvent(request.event); const target = events.allowedTargets.find((candidate) => candidate === request.target); if (target === undefined) { @@ -515,8 +520,16 @@ const startEventRuntime = async ( target, nativeEvent, )); - }, -}); + }, + status: () => ({ + artifactEpoch: host.identity.artifactEpoch, + availability: host.availability(), + instanceId: host.identity.instanceId, + pid: process.pid, + startedAt, + }), + }); +}; /** * Builds the MCP server a generated artifact serves: the dispatcher over the @@ -532,7 +545,7 @@ export const createGeneratedRouteMcpServer = async ( const dispatcher = createAgentRenderDispatcher(options.host); const events = options.events === undefined ? undefined - : await startEventRuntime(options.events, dispatcher); + : await startEventRuntime(options.events, dispatcher, options.host); registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch); registerGeneratedMcpApps(server, options.apps ?? []); const close = server.close.bind(server); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 1817797c9..fd8eabb66 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { spawn, type ChildProcess } from 'node:child_process'; -import { createServer, type Server } from 'node:net'; +import { createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -647,9 +647,18 @@ it('reports Codex bundle registration as unknown', async () => { } }); -const listen = async (path: string): Promise => { +const serverSockets = new WeakMap>(); + +const listen = async (path: string, response?: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); - const server = createServer(); + const server = createServer((socket) => { + const sockets = serverSockets.get(server)!; + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + if (response === undefined) return; + socket.once('data', () => socket.end(`${JSON.stringify(response)}\n`)); + }); + serverSockets.set(server, new Set()); await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(path, resolvePromise); @@ -658,6 +667,7 @@ const listen = async (path: string): Promise => { }; const close = (server: Server): Promise => new Promise((resolvePromise, reject) => { + for (const socket of serverSockets.get(server) ?? []) socket.destroy(); server.close((error) => { if (error === undefined) resolvePromise(); else reject(error); @@ -675,10 +685,16 @@ const findDeadPid = (): Promise => new Promise((resolvePromise, reject) child.once('exit', () => { resolvePromise(pid); }); }); -it('scans live sockets and a lock with a live sibling without warnings', async () => { +it('reports old live runtime sockets as unsupported without warnings', async () => { const fixture = await temporaryDoctor(); const endpoint = join(fixture.endpointDirectory, 'event-live.sock'); - const server = await listen(endpoint); + const server = await listen(endpoint, { + artifactEpoch: 'epoch-old', + code: 'invalid-message', + message: 'Event runtime request does not match the wire schema.', + protocolVersion: 1, + status: 'error', + }); try { await writeFile(`${endpoint}.lock`, ''); const report = await runDoctor({ @@ -687,9 +703,12 @@ it('scans live sockets and a lock with a live sibling without warnings', async ( hosts: [], }); expect(report.endpoints.findings).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: endpoint, state: 'live' }), + expect.objectContaining({ path: endpoint, runtime: { status: 'unsupported' }, state: 'live' }), expect.objectContaining({ path: `${endpoint}.lock`, state: 'live' }), ])); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7317', severity: 'info' }), + ])); expect(report.endpoints.summary).toMatchObject({ live: 1, staleLocks: 0, staleSockets: 0 }); } finally { await close(server); @@ -697,6 +716,69 @@ it('scans live sockets and a lock with a live sibling without warnings', async ( } }); +it('reports runtime identity from a live status endpoint', async () => { + const fixture = await temporaryDoctor(); + const endpoint = join(fixture.endpointDirectory, 'event-identity.sock'); + const server = await listen(endpoint, { + kind: 'status', + protocolVersion: 1, + runtime: { + artifactEpoch: 'epoch-a', + availability: 'available', + instanceId: 'runtime-a', + pid: 1234, + }, + status: 'ok', + }); + try { + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual([ + expect.objectContaining({ + path: endpoint, + runtime: { + artifactEpoch: 'epoch-a', + availability: 'available', + instanceId: 'runtime-a', + pid: 1234, + status: 'available', + }, + state: 'live', + }), + ]); + } finally { + await close(server); + await fixture.cleanup(); + } +}); + +it('bounds a silent runtime status probe', async () => { + const fixture = await temporaryDoctor(); + const endpoint = join(fixture.endpointDirectory, 'event-silent.sock'); + const server = await listen(endpoint); + try { + const started = Date.now(); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(Date.now() - started).toBeLessThan(2_500); + expect(report.endpoints.findings).toEqual([ + expect.objectContaining({ runtime: { status: 'failed' }, state: 'live' }), + ]); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7318', severity: 'error' }), + ])); + } finally { + await close(server); + await fixture.cleanup(); + } +}); + it('reports stale sockets and stale locks as warnings', async () => { const fixture = await temporaryDoctor(); const staleSocket = join(fixture.endpointDirectory, 'event-stale.sock'); diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 590aa4992..c7eb20812 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -14,6 +14,7 @@ import { eventRuntimeEndpoint, EventRuntimeTransportError, requestEventRuntime, + requestEventRuntimeStatus, } from '../src/events/ipc.ts'; interface EndpointClaimOwner { @@ -162,6 +163,64 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so }); })); +it.live('reports read-only runtime identity without an artifact epoch gate', () => Effect.gen(function*() { + const endpointId = `event-ipc-status-${crypto.randomUUID()}`; + const server = yield* Effect.acquireRelease( + Effect.promise(() => createEventRuntimeServer({ + artifactEpoch: 'epoch-server', + endpointId, + handle: async () => undefined, + status: () => ({ + artifactEpoch: 'epoch-server', + availability: 'available', + instanceId: 'runtime-1', + pid: 1234, + }), + })), + (runtime) => Effect.promise(() => runtime.close()), + ); + + const byId = yield* Effect.promise(() => requestEventRuntimeStatus({ + endpointId, + timeoutMs: 1_000, + })); + const byPath = yield* Effect.promise(() => requestEventRuntimeStatus({ + endpoint: server.endpoint, + timeoutMs: 1_000, + })); + expect(byId).toEqual({ + artifactEpoch: 'epoch-server', + availability: 'available', + instanceId: 'runtime-1', + pid: 1234, + status: 'available', + }); + expect(byPath).toEqual(byId); +})); + +it.live('reports unsupported and unavailable status endpoints distinctly', () => Effect.gen(function*() { + const endpointId = `event-ipc-status-unsupported-${crypto.randomUUID()}`; + yield* Effect.scoped(Effect.gen(function*() { + yield* Effect.acquireRelease( + Effect.promise(() => createEventRuntimeServer({ + artifactEpoch: 'epoch-1', + endpointId, + handle: async () => undefined, + })), + (runtime) => Effect.promise(() => runtime.close()), + ); + expect(yield* Effect.promise(() => requestEventRuntimeStatus({ + endpointId, + timeoutMs: 1_000, + }))).toEqual({ status: 'unsupported' }); + })); + + expect(yield* Effect.promise(() => requestEventRuntimeStatus({ + endpointId: `event-ipc-status-missing-${crypto.randomUUID()}`, + timeoutMs: 100, + }))).toEqual({ status: 'unavailable' }); +})); + it.live('rejects a second live server without disturbing the endpoint owner', () => Effect.gen(function*() { if (process.platform === 'win32') return; const endpointId = `event-ipc-owner-${crypto.randomUUID()}`; diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index c450fd73c..a5d835928 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -8,7 +8,11 @@ import { dirname, join, resolve } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; -import { eventRuntimeEndpoint, requestEventRuntime } from '../src/events/ipc.ts'; +import { + eventRuntimeEndpoint, + requestEventRuntime, + requestEventRuntimeStatus, +} from '../src/events/ipc.ts'; const roots: string[] = []; @@ -572,6 +576,17 @@ it('renders one tool/after event route through two native thin clients', { retry const endpointId = `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(mcp.output)))}`; const expectedEndpoint = eventRuntimeEndpoint(endpointId); await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + const secondStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + expect(firstStatus).toMatchObject({ + artifactEpoch: 'generated-events-fixture@1.0.0', + availability: 'available', + status: 'available', + }); + expect(secondStatus).toMatchObject({ + instanceId: firstStatus.status === 'available' ? firstStatus.instanceId : undefined, + status: 'available', + }); const native = target === 'cursor' ? { conversation_id: 'conversation-1', diff --git a/packages/agent-bundle/tests/host-discovery-service.test.ts b/packages/agent-bundle/tests/host-discovery-service.test.ts index ee5bbace8..ec5122ae5 100644 --- a/packages/agent-bundle/tests/host-discovery-service.test.ts +++ b/packages/agent-bundle/tests/host-discovery-service.test.ts @@ -27,7 +27,17 @@ const doctorReport = Object.freeze({ diagnostics: Object.freeze([]), directory: '/tmp/agent-bundle-test', findings: Object.freeze([ - Object.freeze({ path: '/tmp/agent-bundle-test/event-live.sock', state: 'live' as const }), + Object.freeze({ + path: '/tmp/agent-bundle-test/event-live.sock', + runtime: Object.freeze({ + artifactEpoch: 'epoch-a', + availability: 'available' as const, + instanceId: 'runtime-a', + pid: 1234, + status: 'available' as const, + }), + state: 'live' as const, + }), ]), status: 'healthy' as const, summary: Object.freeze({ live: 1, staleLocks: 0, staleSockets: 0 }), diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index 1c9b6b68f..b5b2dfdea 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -76,6 +76,7 @@ export type { AgentRuntimeErrorCode, CreateWarmFlightHostOptions, WarmFlightHost, + WarmRuntimeAvailability, WarmRuntimeIdentity, } from './warm-runtime.js'; export { decodeAgentFlightStream } from './reconciler.js'; diff --git a/packages/rsc-runtime/src/warm-runtime.ts b/packages/rsc-runtime/src/warm-runtime.ts index 9ba15db4f..d8a0f4ce7 100644 --- a/packages/rsc-runtime/src/warm-runtime.ts +++ b/packages/rsc-runtime/src/warm-runtime.ts @@ -40,7 +40,13 @@ export interface WarmRuntimeIdentity { readonly instanceId: string; } +export type WarmRuntimeAvailability = + | 'available' + | 'runtime-restarted' + | 'runtime-unavailable'; + export interface WarmFlightHost extends AgentFlightExecutionHost { + readonly availability: () => WarmRuntimeAvailability; readonly close: () => Promise; readonly identity: WarmRuntimeIdentity; readonly markUnavailable: (code?: Exclude) => void; @@ -78,11 +84,16 @@ export const createWarmFlightHost = (options: CreateWarmFlightHostOptions): Warm artifactEpoch: options.artifactEpoch, instanceId: options.instanceId ?? crypto.randomUUID(), }); + let availability: WarmRuntimeAvailability = 'available'; let unavailable: AgentRuntimeError | undefined; return Object.freeze({ + availability: (): WarmRuntimeAvailability => availability, identity, markUnavailable(code: Exclude = 'runtime-unavailable') { - unavailable ??= unavailableError(code); + if (unavailable === undefined) { + availability = code; + unavailable = unavailableError(code); + } }, async close() { try { diff --git a/packages/rsc-runtime/tests/warm-runtime.test.ts b/packages/rsc-runtime/tests/warm-runtime.test.ts index c5c56f32d..5b6cc387b 100644 --- a/packages/rsc-runtime/tests/warm-runtime.test.ts +++ b/packages/rsc-runtime/tests/warm-runtime.test.ts @@ -72,9 +72,11 @@ describe('createWarmFlightHost', () => { host: { execute: async () => emptyFlight() }, }); + expect(host.availability()).toBe('available'); await host.execute(dispatch()); host.markUnavailable('runtime-restarted'); + expect(host.availability()).toBe('runtime-restarted'); await expect(host.execute(dispatch())).rejects.toBeInstanceOf(AgentRuntimeError); await expect(host.execute(dispatch())).rejects.toMatchObject({ code: 'runtime-restarted' }); }); @@ -86,6 +88,7 @@ describe('createWarmFlightHost', () => { }); host.markUnavailable('runtime-unavailable'); + expect(host.availability()).toBe('runtime-unavailable'); await expect(host.execute(dispatch())).rejects.toBeInstanceOf(AgentRuntimeError); await expect(host.execute(dispatch())).rejects.toMatchObject({ code: 'runtime-unavailable' }); }); diff --git a/packages/workbench/src/discovery/discovery-client.ts b/packages/workbench/src/discovery/discovery-client.ts index 416727c4d..0c067ca7a 100644 --- a/packages/workbench/src/discovery/discovery-client.ts +++ b/packages/workbench/src/discovery/discovery-client.ts @@ -13,6 +13,7 @@ import type { DiscoveryInventoryStatus, DiscoveryProbe, DiscoveryProbeStatus, + DiscoveryRuntimeStatus, HostDiscoveryReport, } from '../../../agent-bundle/src/contracts/discovery.ts'; import type { @@ -43,6 +44,7 @@ export type { DiscoveryInventoryStatus, DiscoveryProbe, DiscoveryProbeStatus, + DiscoveryRuntimeStatus, HostDiscoveryReport, McpProbeFailure, McpProbeFailureKind, @@ -118,12 +120,26 @@ const findingStateSchema = z.enum([ 'unknown', 'unregistered', ]); +const runtimeStatusSchema = z.discriminatedUnion('status', [ + z.strictObject({ + artifactEpoch: textSchema, + availability: z.enum(['available', 'runtime-restarted', 'runtime-unavailable']), + instanceId: textSchema, + pid: z.number().int().positive(), + startedAt: textSchema.optional(), + status: z.literal('available'), + }), + z.strictObject({ + status: z.enum(['failed', 'unavailable', 'unsupported']), + }), +]); const findingShape = { durableState: durableStateSchema.optional(), entry: textSchema.optional(), manifest: textSchema.optional(), name: textSchema.optional(), path: textSchema.optional(), + runtime: runtimeStatusSchema.optional(), state: findingStateSchema, version: textSchema.optional(), } as const; diff --git a/packages/workbench/src/discovery/discovery-page.tsx b/packages/workbench/src/discovery/discovery-page.tsx index 21b6c85e6..39dce44ee 100644 --- a/packages/workbench/src/discovery/discovery-page.tsx +++ b/packages/workbench/src/discovery/discovery-page.tsx @@ -425,6 +425,30 @@ const HostCard = ({ refreshKey, view }: Readonly<{ ; +const RuntimeIdentity = ({ finding }: Readonly<{ readonly finding: DiscoveryFinding }>) => { + const runtime = finding.runtime; + if (runtime === undefined) return

Runtime identity not reported.

; + switch (runtime.status) { + case 'available': + return
+
Instance ID
{runtime.instanceId}
+
Artifact epoch
{runtime.artifactEpoch}
+
Availability
{runtime.availability}
+
PID
{String(runtime.pid)}
+
; + case 'unsupported': + return

Runtime identity is unsupported by this endpoint.

; + case 'unavailable': + return

Runtime identity became unavailable during discovery.

; + case 'failed': + return

Runtime identity probe failed.

; + default: { + const exhaustive: never = runtime; + return exhaustive; + } + } +}; + const EndpointFindings = ({ findings }: Readonly<{ readonly findings: readonly DiscoveryFindingView[]; }>) => findings.length === 0 @@ -433,6 +457,7 @@ const EndpointFindings = ({ findings }: Readonly<{ {findings.map(({ finding, presentation }, index) =>
  • {valueOrDash(finding.path)} +
  • )} ; diff --git a/packages/workbench/tests/discovery-client.test.ts b/packages/workbench/tests/discovery-client.test.ts index 0c827b67a..fe293e909 100644 --- a/packages/workbench/tests/discovery-client.test.ts +++ b/packages/workbench/tests/discovery-client.test.ts @@ -52,6 +52,13 @@ const fullReport = { directory: '/tmp/agent-bundle', findings: [{ path: '/tmp/agent-bundle/live.sock', + runtime: { + artifactEpoch: 'epoch-a', + availability: 'available' as const, + instanceId: 'runtime-a', + pid: 1234, + status: 'available' as const, + }, state: 'live' as const, }, { path: '/tmp/agent-bundle/stale.lock', @@ -155,6 +162,13 @@ it('rejects unknown keys at every discovery response boundary', async () => { { ...fullReport, endpoints: { ...fullReport.endpoints, summary: { ...fullReport.endpoints.summary, extra: true } } }, { ...fullReport, endpoints: { ...fullReport.endpoints, diagnostics: [{ ...fullReport.endpoints.diagnostics[0]!, extra: true }] } }, { ...fullReport, endpoints: { ...fullReport.endpoints, findings: [{ ...firstEndpointFinding, extra: true }] } }, + { + ...fullReport, + endpoints: { + ...fullReport.endpoints, + findings: [{ ...firstEndpointFinding, runtime: { ...firstEndpointFinding.runtime!, extra: true } }], + }, + }, { ...fullReport, hosts: [{ ...firstHost, extra: true }] }, { ...fullReport, hosts: [{ ...firstHost, probe: { ...firstHost.probe, extra: true } }] }, { ...fullReport, hosts: [{ ...firstHost, inventory: { ...firstHost.inventory, extra: true } }] }, From 5d9c70183f6c548dedd24261934157e6c1f6803d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:34:12 +0000 Subject: [PATCH 2/3] test(events): pin runtime identity after worker restart (#269) Exercise the generated event status endpoint before and after a worker exits nonzero. --- .../tests/generated-route-server.test.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index a5d835928..e3e675a4a 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -208,6 +208,7 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re const writeGeneratedProject = async ( root: string, files: Readonly>, + target: 'cursor' | 'portable' = 'portable', ): Promise => { await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); await Promise.all([ @@ -224,24 +225,28 @@ const writeGeneratedProject = async ( })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'generated-routes-fixture', version: '1.0.0' }, targets: ['portable'] });", + `export default defineConfig({ plugin: { name: 'generated-routes-fixture', version: '1.0.0' }, targets: ['${target}'] });`, '', ].join('\n')), ...Object.entries(files).map(([path, contents]) => writeProjectFile(root, path, contents)), ]); }; -const connectGeneratedServer = async (root: string): Promise<{ +const connectGeneratedServer = async ( + root: string, + target: 'cursor' | 'portable' = 'portable', +): Promise<{ readonly client: Client; readonly close: () => Promise; + readonly endpointId: string; }> => { const output = join(root, 'artifact'); - const compiled = await build({ output, root, targets: ['portable'] }); + const compiled = await build({ output, root, targets: [target] }); const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); const client = new Client({ name: 'generated-route-test', version: '0.0.0' }); const transport = new StdioClientTransport({ - args: [join(output, 'portable', server.args[0])], + args: [join(output, target, server.args[0])], command: process.execPath, stderr: 'pipe', }); @@ -257,6 +262,7 @@ const connectGeneratedServer = async (root: string): Promise<{ close: async () => { await client.close(); }, + endpointId: `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(join(output, target, server.args[0]))))}`, }; }; @@ -456,14 +462,28 @@ it('fails closed when the generated runtime worker restarts', { retry: 2, timeou '}', '', ].join('\n'), - }); - const session = await connectGeneratedServer(root); + 'src/events/session/start.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { targets: ['cursor'] };", + "export default async function SessionStart() { return createElement(Agent.Text, null, 'ready'); }", + '', + ].join('\n'), + }, 'cursor'); + const session = await connectGeneratedServer(root, 'cursor'); try { await expect(session.client.callTool({ arguments: {}, name: 'warmth' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ structuredContent: { hits: 1 }, }); + const beforeRestart = await requestEventRuntimeStatus({ endpointId: session.endpointId, timeoutMs: 1_000 }); + expect(beforeRestart).toMatchObject({ availability: 'available', status: 'available' }); const halted = await callGeneratedTool(session.client, 'halt'); expectFailClosed(halted, /unavailable|restarted|exited/i); + await expect(requestEventRuntimeStatus({ endpointId: session.endpointId, timeoutMs: 1_000 })).resolves.toMatchObject({ + availability: 'runtime-restarted', + instanceId: beforeRestart.status === 'available' ? beforeRestart.instanceId : undefined, + status: 'available', + }); const afterRestart = await callGeneratedTool(session.client, 'warmth'); expectFailClosed(afterRestart, /unavailable|restarted|exited|connection closed/i); expect(afterRestart).not.toMatchObject({ structuredContent: { hits: 2 } }); From e785249da5a257f96273b55752e69f02483f9591 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:39:47 +0000 Subject: [PATCH 3/3] fix(events): preserve hook fallback on connect errors (#269) Keep every event-runtime connection failure classified as unavailable so generated hooks retain standalone fallback behavior. --- packages/agent-bundle/src/events/ipc.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index aebf6754e..05156d5d3 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -820,10 +820,7 @@ const connect = (endpoint: string): Effect.Effect { socket.removeListener('connect', onConnect); - const code = (error as NodeJS.ErrnoException).code; - resume(Effect.fail(code === 'ENOENT' || code === 'ECONNREFUSED' - ? transportError('runtime-unavailable', 'Shared event runtime is unavailable.', error) - : transportError('runtime-failed', 'Shared event runtime connection failed.', error))); + resume(Effect.fail(transportError('runtime-unavailable', 'Shared event runtime is unavailable.', error))); }; socket.once('connect', onConnect); socket.once('error', onError);