Skip to content
5 changes: 5 additions & 0 deletions .changeset/680-workbench-exact-executable-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Bind Workbench production route invocations to the exact executables the epoch's `agent-bundle.manifest.json` names — `executables.bins[]`, `executables.scripts[]`, `executables.mcpServers[].launch.worker`, the host's `executables.hooks[]` wrapper row and the worker its `routes.events[].execution` selects (`hooks/hooks-flight.mjs` standalone, otherwise the first compiled server reaching the host, the one the compiler gave the shared event runtime) — before anything runs, instead of listing `*-flight.mjs` candidates and hopping to the next worker on a missing-route error. The binding fails closed: a root without a readable manifest is `AB8250`; a route the manifest does not compile, a hosted event with no wrapper row for that host, or a bin, script, or server without a worker is `AB8251`; a bound bin or wrapper missing its preparation export, or a preflight route that reaches the child without a host (`AB8255` at the service), is `AB8252` — the handler is never reached, and a handler failure inside the bound worker never runs another executable (#680)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ even when no error diagnostic was reported.
| `AB8240`–`AB8242` | Workbench unified trace routes (`/api/trace`, `/api/trace/stream`): `AB8240` invalid `after` cursor (400), `AB8241` cursor ahead of the current trace sequence (409), and `AB8242` trace routes unavailable before composition or during shutdown (404/503). |
| `AB8247`–`AB8249` | Workbench hook receipt route (`POST /api/trace/receipts`, posted by a generated hook wrapper of the dev plugin): `AB8247` receipt refused — peer not loopback, `Origin` header present, missing or wrong bearer token (403), or receipts closed (409); `AB8248` malformed receipt — query string, non-object body, unknown key, out-of-range enum, or unbounded field (400, the message names the field); `AB8249` receipt over the 16 KiB limit (413). |
| `AB8239` | Workbench route invocation service (`/api/routes/invocations`): the published manifest digest or source revision moved while the request waited for a concurrency slot (409). Retry against the current revision so the recorded `manifestDigest`/`sourceRevision` cannot describe a different build than the one that ran. |
| `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no published compiler artifact is available, `AB8251` the selected route has no executable in the published artifact, `AB8252` compiled CLI projection or event preflight preparation failed, `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:<command>` id was used instead of its canonical `tool:<server>/<tool>` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host surface. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`; select a generated host wrapper for `AB8255`. |
| `AB8250`–`AB8255` | Workbench production route execution, bound from the epoch's `agent-bundle.manifest.json` before anything runs: `AB8250` no published compiler artifact is available or the epoch root has no readable manifest, `AB8251` the manifest binds no executable to the selected route (a route it does not compile, a hosted event with no `executables.hooks[]` wrapper row for that host, a shared-runtime event no compiled server reaching the host can run, a rendered route whose bin, script, or server carries no worker), `AB8252` compiled CLI projection or event preflight preparation failed — including a bound bin or wrapper without its preparation export, and a preflight route that reached the child without a host (the service refuses that first as `AB8255`) — `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:<command>` id was used instead of its canonical `tool:<server>/<tool>` id plus CLI surface, and `AB8255` an event route with compiled preflight was submitted without a concrete host surface. The handler is never reached for any of these, and a failure inside the bound worker never runs another executable. Rebuild the project for `AB8250`/`AB8251`; fix the reported projection or preflight failure for `AB8252`; use the command or canonical operation named by `AB8253`/`AB8254`; select a generated host wrapper for `AB8255`. |
| `AB8256` | Workbench route invocation cancellation (`POST /api/routes/invocations/<id>/cancel`): the invocation is already final (409). Reload the final invocation instead of cancelling it. |
| `AB8260` | Workbench host sessions: `@lydell/node-pty` could not be resolved from the project or loaded (503). Install the PTY module in the project workspace and restart `agent-bundle dev`. |
| `AB8261` | Workbench host sessions: a request body, path, query, dimension, input, or live-session delete is malformed (400/409). Send only the documented `/api/sessions` fields and forget sessions only after they exit. |
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/adapters/composite-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,11 @@ export const hookWrapperPath = (
const reached = hookTargets.filter((target) => selection.has(target));
return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`;
};

/**
* The artifact-relative path of the one standalone react-server Flight worker
* every event-route wrapper of the composite root shares (`planHooksSurface`).
* Emitted exactly when some event route runs or falls back standalone; it is
* a `files[]` row of the manifest, not an `executables` row of its own.
*/
export const hooksFlightWorkerPath = 'hooks/hooks-flight.mjs';
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFile, stat } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { hooksFlightWorkerPath } from '../adapters/composite-layout.ts';
import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts';
import {
eventArtifactEpochToken,
Expand Down Expand Up @@ -601,7 +602,7 @@ export const planCompiledHooks = (
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
...(index === workerOwner
? {
workerOutput: resolveArtifactDestination(resolve(options.outDir, 'hooks'), 'hooks-flight.mjs'),
workerOutput: resolveArtifactDestination(options.outDir, hooksFlightWorkerPath),
workerSourceInputs,
}
: {}),
Expand Down Expand Up @@ -639,7 +640,7 @@ export const planHooksSurface = (
? undefined
: {
name: 'hooks-flight',
outputRelativePath: 'hooks/hooks-flight.mjs',
outputRelativePath: hooksFlightWorkerPath,
reactServer: true as const,
rscManifest: true as const,
source: standaloneEventRoutes[0]!.source,
Expand Down Expand Up @@ -731,7 +732,7 @@ export const planHooksSurface = (
?? (() => { throw new Error(`Missing bundled deferred hook executor evidence for ${JSON.stringify(entry.name)}.`); })(),
}),
...(entry.workerOutput === undefined ? {} : {
workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
workerSourceInputs: evidenceByPath.get(hooksFlightWorkerPath) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
}),
})));
},
Expand Down
181 changes: 181 additions & 0 deletions packages/agent-bundle/src/dev/routes/route-invocation-executable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { join } from 'node:path';

import { hooksFlightWorkerPath } from '../../adapters/composite-layout.ts';
import type { ArtifactManifest, ArtifactManifestEventExecution, ArtifactManifestRoute } from '../../build/manifest.ts';
import {
ProductionRouteInvocationError,
ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE,
ROUTE_INVOCATION_PREPARATION_FAILURE_CODE,
} from './route-invocation-production-error.ts';
import type { RouteInvocationSurface } from './route-invocation.ts';

/**
* The compiled executables one production invocation runs, bound from the
* artifact manifest (`agent-bundle.manifest.json`, #604) before anything is
* imported or spawned. Every path is absolute under the artifact root and
* names a `files[]` row the manifest parser already proved; nothing here is
* discovered by listing a directory or by trying a worker and reading its
* error.
*/
export interface RouteExecutableBinding {
/** CLI surface only: the generated bin (`executables.bins[]`) that prepares argv and decides the exit code. */
readonly bin?: string;
/** The one compiled Flight worker that owns the route. */
readonly worker: string;
/** Hosted event surface only: the compiled wrapper (`executables.hooks[]`) whose preflight gates execution. */
readonly wrapper?: string;
}

export interface ResolveRouteExecutableInput {
readonly artifactRoot: string;
readonly manifest: ArtifactManifest;
readonly routeId: string;
readonly surface: RouteInvocationSurface;
}

const unavailable = (message: string): ProductionRouteInvocationError =>
new ProductionRouteInvocationError(ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, message);

const manifestRoute = (manifest: ArtifactManifest, routeId: string): ArtifactManifestRoute | undefined =>
manifest.routes.servers.flatMap((server) => server.routes).find((route) => route.id === routeId)
?? manifest.routes.cli?.routes.find((route) => route.id === routeId)
?? manifest.routes.events.find((route) => route.id === routeId)
?? manifest.routes.scripts.find((route) => route.id === routeId);

const bindCliBin = (input: ResolveRouteExecutableInput): RouteExecutableBinding => {
const { manifest, routeId } = input;
if (manifest.routes.cli?.routes.some((route) => route.id === routeId) !== true) {
throw unavailable(`Route ${JSON.stringify(routeId)} is not compiled into the routed CLI of the published artifact.`);
}
// The routed CLI is the one generated bin named after the plugin
// (`normalizeBinEntries`); `executables.bins[]` lists no other kind.
const bin = manifest.executables.bins.find((candidate) => candidate.name === manifest.application.name);
if (bin === undefined) {
throw unavailable(`The published artifact has no routed CLI bin ${JSON.stringify(manifest.application.name)} for route ${JSON.stringify(routeId)}.`);
}
if (bin.worker === undefined) {
throw unavailable(`Routed CLI bin ${JSON.stringify(bin.path)} renders no route, so route ${JSON.stringify(routeId)} has no compiled worker.`);
}
return Object.freeze({ bin: join(input.artifactRoot, bin.path), worker: join(input.artifactRoot, bin.worker) });
};

const bindScriptWorker = (input: ResolveRouteExecutableInput): RouteExecutableBinding => {
const script = input.manifest.executables.scripts.find((candidate) => candidate.rendered?.routeId === input.routeId);
if (script?.worker === undefined) {
throw unavailable(`Rendered script route ${JSON.stringify(input.routeId)} has no compiled worker in the published artifact.`);
}
return Object.freeze({ worker: join(input.artifactRoot, script.worker) });
};

const bindMcpWorker = (input: ResolveRouteExecutableInput, route: ArtifactManifestRoute): RouteExecutableBinding => {
const server = input.manifest.executables.mcpServers.find((candidate) => candidate.id === route.serverId);
if (server?.kind !== 'compiled' || server.launch?.worker === undefined) {
throw unavailable(`MCP route ${JSON.stringify(input.routeId)} has no compiled server worker in the published artifact.`);
}
return Object.freeze({ worker: join(input.artifactRoot, server.launch.worker) });
};

/**
* The compiled server whose Flight worker registers the composite root's
* event routes for `host`. `eventRuntimeHosting` (`build/entries.ts`) gives
* the runtime to the first generated server in model order that targets the
* host; `normalizeMcpServers` orders the model by server name and
* `executables.mcpServers[]` is sorted by `id` (`mcp:<name>`), so the first
* compiled row with a Flight worker reaching the host is that server. A
* canonical (host-less) run takes the first such row of any host: being
* first by name, it hosts the runtime for every host it reaches, and every
* hosting worker registers every event route.
*/
const sharedRuntimeWorker = (
input: ResolveRouteExecutableInput,
host: string | undefined,
): string | undefined => {
const owner = input.manifest.executables.mcpServers.find((server) =>
server.kind === 'compiled' && server.launch?.worker !== undefined && (host === undefined || server.hosts.includes(host)));
return owner?.launch?.worker === undefined ? undefined : join(input.artifactRoot, owner.launch.worker);
};

/**
* The host's wrapper row: the only module that can run the route's compiled
* preflight. A canonical (host-less) submission has no wrapper, so a route
* with preflight cannot be prepared and must not reach its handler.
*/
const eventWrapper = (
input: ResolveRouteExecutableInput,
execution: ArtifactManifestEventExecution,
host: string | undefined,
): string | undefined => {
if (host === undefined) {
if (execution.preflight === undefined) return undefined;
throw new ProductionRouteInvocationError(
ROUTE_INVOCATION_PREPARATION_FAILURE_CODE,
`Event route ${JSON.stringify(input.routeId)} has compiled preflight ${JSON.stringify(execution.preflight)}; canonical execution cannot select a host wrapper to run it, so the handler is not reached.`,
);
}
const hook = input.manifest.executables.hooks.find((candidate) =>
candidate.kind === 'event-route' && candidate.routeId === input.routeId && candidate.host === host);
if (hook === undefined) {
throw unavailable(`The published artifact compiles no ${host} wrapper for event route ${JSON.stringify(input.routeId)}.`);
}
return join(input.artifactRoot, hook.path);
};

const bindEventExecutable = (input: ResolveRouteExecutableInput, route: ArtifactManifestRoute): RouteExecutableBinding => {
const execution = route.execution;
if (execution === undefined) {
throw unavailable(`Event route ${JSON.stringify(input.routeId)} carries no execution record in the published artifact.`);
}
const host = input.surface.kind === 'event' ? input.surface.host : undefined;
const wrapper = eventWrapper(input, execution, host);
const standaloneWorker = input.manifest.files.some((file) => file.path === hooksFlightWorkerPath)
? join(input.artifactRoot, hooksFlightWorkerPath)
: undefined;
const worker = execution.runtime === 'standalone'
? standaloneWorker
: sharedRuntimeWorker(input, host) ?? (execution.fallback === 'standalone' ? standaloneWorker : undefined);
if (worker === undefined) {
throw unavailable(
`Event route ${JSON.stringify(input.routeId)} runs ${execution.runtime}${execution.fallback === 'standalone' ? ' with standalone fallback' : ''}, but the published artifact has no compiled worker hosting it${host === undefined ? '' : ` for ${host}`}.`,
);
}
return Object.freeze({ worker, ...(wrapper === undefined ? {} : { wrapper }) });
};

/**
* Binds the route's executables from the manifest rows the compiler wrote
* (#604): the routed CLI bin and its worker for a CLI surface, the rendered
* script's worker, the owning compiled MCP server's worker, or — for an
* event route — the host's wrapper row plus the worker its execution record
* selects: `hooks/hooks-flight.mjs` for a standalone runtime, the host's
* shared runtime owner otherwise, the standalone worker again when the
* route declares that fallback and no compiled server hosts the runtime.
* Fails closed (`AB8251`, `AB8252`) instead of guessing: a route the
* artifact does not compile, a hosted event with no wrapper row, and a
* canonical submission of a route whose preflight only a wrapper can run
* all stop here, before any module is imported.
*/
export const resolveRouteExecutable = (input: ResolveRouteExecutableInput): RouteExecutableBinding => {
const route = manifestRoute(input.manifest, input.routeId);
if (route === undefined) {
throw unavailable(`Route ${JSON.stringify(input.routeId)} is absent from the published artifact manifest.`);
}
if (input.surface.kind === 'cli') return bindCliBin(input);
switch (route.kind) {
case 'cli':
return bindCliBin(input);
case 'script':
return bindScriptWorker(input);
case 'event-route':
return bindEventExecutable(input, route);
case 'prompt':
case 'resource':
case 'tool':
return bindMcpWorker(input, route);
case 'app':
throw unavailable('MCP App routes are not invocable through the route execution boundary.');
default: {
const exhaustive: never = route.kind;
throw new Error(`Unsupported route kind ${String(exhaustive)}.`);
}
}
};
Loading
Loading