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
7 changes: 7 additions & 0 deletions .changeset/runtime-identity-status.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-bundle/src/contracts/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
Expand Down
110 changes: 110 additions & 0 deletions packages/agent-bundle/src/events/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
Expand All @@ -65,10 +95,20 @@ export interface EventRuntimeRequest {
readonly target: string;
}

export type EventRuntimeAvailability = z.infer<typeof runtimeAvailabilitySchema>;
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<unknown>;
readonly status?: () => EventRuntimeStatus;
}

export interface EventRuntimeServer {
Expand All @@ -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<EventRuntimeStatus & { readonly status: 'available' }>
| 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}`;
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -798,3 +869,42 @@ const requestProgram = (
export const requestEventRuntime = async (
options: RequestEventRuntimeOptions,
): Promise<unknown> => runPromise(requestProgram(options), { signal: options.signal });

const statusProgram = (
options: RequestEventRuntimeStatusOptions,
): Effect.Effect<EventRuntimeStatusResult, EventRuntimeTransportError> => 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<EventRuntimeStatusResult> => runPromise(statusProgram(options));
44 changes: 43 additions & 1 deletion packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the endpoint scan as a whole

When the endpoint directory contains multiple listeners that accept connections but never answer—exactly the silent-runtime failure this probe is intended to diagnose—this await runs each one-second timeout serially inside the surrounding loop. Doctor and the Workbench discovery request therefore take roughly N seconds for N silent endpoints, so the per-socket timeout does not provide a useful overall bound; probe endpoints concurrently with bounded concurrency or apply a deadline to the complete scan.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in #397 (merged as d25a9c6). doctor.ts scans runtime endpoints with mapConcurrent (concurrency 8) instead of serially, so a directory of silent endpoints is bounded as a whole rather than costing one 1 s timeout each; tests/doctor.test.ts asserts concurrent probing timing against fake silent endpoints.

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;
Expand Down
29 changes: 21 additions & 8 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -360,7 +361,7 @@ export const createFlightWorkerHost = (
},
}));
});
return createWarmFlightHost({
const warmHost = createWarmFlightHost({
artifactEpoch,
close: async (): Promise<void> => {
await worker.terminate();
Expand Down Expand Up @@ -404,6 +405,7 @@ export const createFlightWorkerHost = (
},
},
});
return warmHost;
};

/** The render host a generated server closes over, plus its teardown. */
Expand Down Expand Up @@ -464,10 +466,13 @@ const canonicalEvent = (event: string): CanonicalAgentEvent => {
const startEventRuntime = async (
events: GeneratedEventRuntimeBinding,
dispatcher: AgentRenderDispatcher,
): Promise<{ readonly close: () => Promise<void> }> => events.createEventRuntimeServer({
artifactEpoch: events.artifactEpoch,
endpointId: events.endpointId,
handle: async (request, signal) => {
host: WarmFlightHost,
): Promise<{ readonly close: () => Promise<void> }> => {
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) {
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading
Loading