From 06bcdf248282eebc7ea6e9f09be9639951b6399c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:14:38 +0000 Subject: [PATCH 1/7] refactor(build): name the standalone hooks Flight worker path once `hooksFlightWorkerPath` lives in the composite-layout leaf so the hooks surface plan and the Workbench binding read the same artifact path. Co-authored-by: Zack Jackson --- packages/agent-bundle/src/adapters/composite-layout.ts | 8 ++++++++ packages/agent-bundle/src/build/entries.ts | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts index ad230ccc1..520f7f78f 100644 --- a/packages/agent-bundle/src/adapters/composite-layout.ts +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -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'; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 2d575d9f9..3ddab87b2 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -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, @@ -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, } : {}), @@ -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, @@ -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.'); })(), }), }))); }, From b6712171ea55b0543ea9459cd6452c4785c99178 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:14:39 +0000 Subject: [PATCH 2/7] fix(workbench): bind production route executables from the artifact manifest Resolve the routed CLI bin, rendered script worker, owning compiled MCP server worker, and event wrapper plus runtime worker from agent-bundle.manifest.json before anything runs, instead of listing *-flight.mjs candidates and hopping to the next worker on a missing-route error. Preparation fails closed (AB8250/AB8251/AB8252) when the manifest cannot bind the route or the bound module lacks its preparation export, and a failure inside the bound worker never runs another executable. Fixes #680 Co-authored-by: Zack Jackson --- .../dev/routes/route-invocation-executable.ts | 185 ++++++++ .../dev/routes/route-invocation-production.ts | 204 ++++---- .../tests/route-invocation-production.test.ts | 434 ++++++++++++++++++ 3 files changed, 705 insertions(+), 118 deletions(-) create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-executable.ts create mode 100644 packages/agent-bundle/tests/route-invocation-production.test.ts diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts new file mode 100644 index 000000000..1b64ec5dc --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts @@ -0,0 +1,185 @@ +import { join } from 'node:path'; + +import { hooksFlightWorkerPath } from '../../adapters/composite-layout.ts'; +import type { ArtifactManifest, ArtifactManifestMcpServer, 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; + /** + * The generated MCP server the compiler pass named as the shared event + * runtime owner (`AgentBundleTestManifest.eventRuntimeServerId`); breaks + * the tie when several compiled servers of one host carry a Flight worker. + */ + readonly eventRuntimeServerId?: 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) }); +}; + +const hostsFlightWorker = (server: ArtifactManifestMcpServer): server is ArtifactManifestMcpServer & Readonly<{ + readonly launch: Readonly<{ readonly worker: string }>; +}> => server.kind === 'compiled' && server.launch?.worker !== undefined; + +/** + * The compiled server whose Flight worker registers the composite root's + * event routes for `host` (`eventRuntimeHosting`): the runtime owner the + * compiler pass named when it reaches the host, else the one server that + * does. Two candidates and no named owner is a choice the manifest cannot + * make, so none is made. + */ +const sharedRuntimeWorker = ( + input: ResolveRouteExecutableInput, + host: string | undefined, +): string | undefined => { + const candidates = input.manifest.executables.mcpServers + .filter(hostsFlightWorker) + .filter((server) => host === undefined || server.hosts.includes(host)); + const owner = candidates.find((server) => server.id === input.eventRuntimeServerId) ?? (candidates.length === 1 ? candidates[0] : undefined); + if (owner === undefined && candidates.length > 1) { + throw unavailable( + `Event route ${JSON.stringify(input.routeId)} could run in ${String(candidates.length)} compiled servers ` + + `(${candidates.map((server) => server.id).join(', ')}) and the published artifact names no shared runtime owner among them.`, + ); + } + return owner === undefined ? undefined : join(input.artifactRoot, owner.launch.worker); +}; + +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; + let wrapper: string | undefined; + if (host === undefined) { + if (execution.preflight !== 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.`, + ); + } + } else { + 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)}.`); + } + wrapper = join(input.artifactRoot, hook.path); + } + 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 shared + * runtime owner's worker 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)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index 7263bcdc7..1a66f1356 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; -import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Worker } from 'node:worker_threads'; @@ -14,6 +11,8 @@ import { type AgentRenderInvocation, } from '@agent-bundle/runtime'; +import { artifactManifestName } from '../../build/manifest.ts'; +import { readArtifactManifest } from '../../build/manifest-file.ts'; import { renderedDocumentExitCode } from '../../cli-entry.ts'; import type { EventPreflightResult } from '../../events/preflight.ts'; import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; @@ -24,10 +23,10 @@ import type { RouteInvocationChildRequest, RouteInvocationChildResult, } from './route-invocation-service.ts'; +import { resolveRouteExecutable, type RouteExecutableBinding } from './route-invocation-executable.ts'; import { ProductionRouteInvocationError, ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, - ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, } from './route-invocation-production-error.ts'; import type { RouteInvocationProvider, RouteInvocationTiming } from './route-invocation.ts'; @@ -107,27 +106,6 @@ const completeDocument = (value: JsonValue | undefined): AgentDocument => create version: AGENT_DOCUMENT_VERSION, }); -const workerFiles = async (root: string): Promise => { - if (!existsSync(root)) return Object.freeze([]); - return Object.freeze((await readdir(root)) - .filter((name) => name.endsWith('-flight.mjs')) - .sort() - .map((name) => join(root, name))); -}; - -const eventWrapperPath = ( - request: ProductionRequest, -): string | undefined => { - const event = request.manifest.routes[request.routeId]?.event; - const target = request.surface.kind === 'event' ? request.surface.host : undefined; - if (event === undefined || target === undefined) return undefined; - const stem = `event-route-${event.replace('/', '-')}`; - const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`); - if (existsSync(suffixed)) return suffixed; - const plain = join(request.artifactRoot, 'hooks', `${stem}.mjs`); - return existsSync(plain) ? plain : undefined; -}; - const isCliInvocationModule = (module: Partial): module is CompiledCliInvocationModule => typeof module.prepareRouteInvocation === 'function' && typeof module.routeInvocationExitCode === 'function'; @@ -138,35 +116,66 @@ interface PreparedInput { readonly preflight?: CompiledEventPreflight; } +/** + * Binds the route's executables from the published artifact manifest before + * anything runs. The manifest is the #604 contract: a root without a + * readable, canonical manifest is not a published artifact (`AB8250`), and a + * route the manifest does not compile has no executable (`AB8251`). + */ +const bindExecutable = async (request: ProductionRequest): Promise => { + const read = await readArtifactManifest(request.artifactRoot); + if (read.status !== 'ok') { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE, + read.status === 'missing' + ? `The published artifact at ${read.root} has no ${artifactManifestName}.` + : `The published artifact manifest at ${read.path} is not readable: ${read.detail}`, + ); + } + return resolveRouteExecutable({ + artifactRoot: request.artifactRoot, + ...(request.manifest.eventRuntimeServerId === undefined ? {} : { eventRuntimeServerId: request.manifest.eventRuntimeServerId }), + manifest: read.manifest, + routeId: request.routeId, + surface: request.surface, + }); +}; + +/** + * Runs the bound bin or wrapper's own preparation. Each is the module the + * manifest named, so a bound module that does not export the preparation + * contract is a broken artifact, not a cue to look elsewhere: the run fails + * before the handler (`AB8252`). + */ const prepareInput = async ( request: ProductionRequest, + binding: RouteExecutableBinding, observeTrace: EventTraceObserver, signal: AbortSignal, ): Promise => { - const route = request.manifest.routes[request.routeId]; - if (request.surface.kind === 'cli') { - const binRoot = join(request.artifactRoot, 'bin'); - const bins = existsSync(binRoot) - ? (await readdir(binRoot)).filter((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')).sort() - : []; - for (const name of bins) { - const module = await importedModule>(join(binRoot, name)); - if (!isCliInvocationModule(module)) continue; - return { - cli: module, - input: module.prepareRouteInvocation(request.routeId, request.surface.args) as JsonValue, - }; + if (binding.bin !== undefined) { + const module = await importedModule>(binding.bin); + if (!isCliInvocationModule(module)) { + throw new ProductionRouteInvocationError( + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Routed CLI bin ${binding.bin} does not export the route invocation contract.`, + ); } + // A bin is bound for a CLI surface only (`resolveRouteExecutable`). + const args = request.surface.kind === 'cli' ? request.surface.args : []; + return { + cli: module, + input: module.prepareRouteInvocation(request.routeId, args) as JsonValue, + }; + } + if (binding.wrapper === undefined) return { input: request.input }; + const wrapper = await importedModule(binding.wrapper); + if (typeof wrapper.prepareRouteInvocation !== 'function') { throw new ProductionRouteInvocationError( - ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, - `Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`, + ROUTE_INVOCATION_PREPARATION_FAILURE_CODE, + `Event wrapper ${binding.wrapper} does not export the route invocation preparation contract.`, ); } - if (route?.kind !== 'event-route') return { input: request.input }; - const wrapperPath = eventWrapperPath(request); - if (wrapperPath === undefined) return { input: request.input }; - const wrapper = await importedModule(wrapperPath); - if (typeof wrapper.prepareRouteInvocation !== 'function') return { input: request.input }; const native = (request.input as { readonly native?: JsonObject }).native ?? {}; const preflight = await wrapper.prepareRouteInvocation(native, signal, observeTrace); return { @@ -224,39 +233,6 @@ const invocationFor = ( } }; -const candidatesFor = async (request: ProductionRequest): Promise => { - const route = request.manifest.routes[request.routeId]; - if (route === undefined) return Object.freeze([]); - if (request.surface.kind === 'cli') { - return workerFiles(join(request.artifactRoot, 'bin')); - } - switch (route.kind) { - case 'cli': - return workerFiles(join(request.artifactRoot, 'bin')); - case 'script': { - const name = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId)?.name; - return name === undefined - ? Object.freeze([]) - : Object.freeze([join(request.artifactRoot, 'scripts', `${name}-flight.mjs`)]); - } - case 'event-route': - return Object.freeze([ - ...await workerFiles(join(request.artifactRoot, 'mcp')), - join(request.artifactRoot, 'hooks', 'hooks-flight.mjs'), - ].filter(existsSync)); - case 'prompt': - case 'resource': - case 'tool': - return workerFiles(join(request.artifactRoot, 'mcp')); - case 'app': - return Object.freeze([]); - default: { - const exhaustive: never = route.kind; - throw new Error(`Unsupported route kind ${String(exhaustive)}.`); - } - } -}; - const streamFromWorker = ( workerPath: string, request: ProductionRequest, @@ -429,15 +405,14 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly - error instanceof Error - && ( - error.message.includes('Generated route must default-export') - || error.message.includes('Generated rendered route must default-export') - ); - +/** + * Renders the route in the one worker the manifest bound. A failure inside + * it — a handler throw, a worker crash, a route the worker turns out not to + * register — is this invocation's failure; no other executable is tried. + */ const renderCompiled = async ( request: ProductionRequest, + workerPath: string, input: JsonValue, signal: AbortSignal, env: NodeJS.ProcessEnv, @@ -453,40 +428,31 @@ const renderCompiled = async ( }; }>> => { const invocation = invocationFor(request, input); - const candidates = await candidatesFor(request); - for (const workerPath of candidates) { - const startedAt = performance.now(); - const session = streamFromWorker(workerPath, request, invocation, input, signal, env, trace); - const events: AgentRenderEvent[] = []; - try { - const reader = session.events.getReader(); - for (;;) { - const next = await reader.read(); - if (next.done) break; - events.push(next.value); - publishRender?.(next.value); - } - const complete = events.findLast((event) => event.type === 'complete'); - if (complete === undefined) throw new Error('Compiled route render ended without a complete event.'); - return Object.freeze({ - document: complete.document, - durationMs: performance.now() - startedAt, - events: Object.freeze(events), - observed: { - providers: Object.freeze([...session.observed.providers]), - timings: Object.freeze([...session.observed.timings]), - }, - }); - } catch (error) { - if (!missingRouteWorkerError(error)) throw error; - } finally { - await session.close(); + const startedAt = performance.now(); + const session = streamFromWorker(workerPath, request, invocation, input, signal, env, trace); + const events: AgentRenderEvent[] = []; + try { + const reader = session.events.getReader(); + for (;;) { + const next = await reader.read(); + if (next.done) break; + events.push(next.value); + publishRender?.(next.value); } + const complete = events.findLast((event) => event.type === 'complete'); + if (complete === undefined) throw new Error('Compiled route render ended without a complete event.'); + return Object.freeze({ + document: complete.document, + durationMs: performance.now() - startedAt, + events: Object.freeze(events), + observed: { + providers: Object.freeze([...session.observed.providers]), + timings: Object.freeze([...session.observed.timings]), + }, + }); + } finally { + await session.close(); } - throw new ProductionRouteInvocationError( - ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, - `No compiled worker owns route ${JSON.stringify(request.routeId)}.`, - ); }; export const renderProductionRoute = async ( @@ -513,9 +479,10 @@ export const renderProductionRoute = async ( publishTrace?.(event); }; const controller = new AbortController(); + const binding = await bindExecutable(productionRequest); let prepared: PreparedInput; try { - prepared = await prepareInput(productionRequest, observeTrace, controller.signal); + prepared = await prepareInput(productionRequest, binding, observeTrace, controller.signal); } catch (error) { throw preparationFailure(error); } @@ -539,6 +506,7 @@ export const renderProductionRoute = async ( try { const rendered = await renderCompiled( productionRequest, + binding.worker, prepared.input, controller.signal, env, diff --git a/packages/agent-bundle/tests/route-invocation-production.test.ts b/packages/agent-bundle/tests/route-invocation-production.test.ts new file mode 100644 index 000000000..dd3e27b5e --- /dev/null +++ b/packages/agent-bundle/tests/route-invocation-production.test.ts @@ -0,0 +1,434 @@ +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import type { RequestContextProvenance } from '../src/contracts/request-provenance.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestName, + artifactManifestVersion, + serializeArtifactManifest, + type ArtifactManifest, + type ArtifactManifestFile, +} from '../src/build/manifest.ts'; +import { digest } from '../src/core/digest.ts'; +import { resolveRouteExecutable } from '../src/dev/routes/route-invocation-executable.ts'; +import { renderProductionRoute } from '../src/dev/routes/route-invocation-production.ts'; +import type { RouteInvocationChildRequest } from '../src/dev/routes/route-invocation-service.ts'; +import type { RouteInvocationSurface } from '../src/dev/routes/route-invocation.ts'; +import type { CompiledRouteGraph } from '../src/routes/types.ts'; +import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; + +const hash = (character: string): string => character.repeat(64); + +const sourceInputs = Object.freeze([Object.freeze({ path: 'agent-bundle.config.ts', sha256: hash('a') })]); + +const bundle = (path: string): ArtifactManifestFile => ({ bytes: 1, kind: 'bundle', path, sha256: hash('f') }); + +interface FixtureOptions { + /** Event route execution record; defaults to a standalone runtime. */ + readonly execution?: Readonly<{ readonly fallback: 'none' | 'standalone'; readonly preflight?: string; readonly runtime: 'shared' | 'standalone' }>; + /** Whether the hooks surface emitted `hooks/hooks-flight.mjs`. */ + readonly hooksWorker?: boolean; + /** The wrapper rows of the event route, one per host. */ + readonly wrappers?: readonly string[]; + /** Compiled MCP servers with a Flight worker, by name, and the hosts each reaches. */ + readonly servers?: readonly Readonly<{ readonly hosts: readonly string[]; readonly name: string }>[]; +} + +/** + * A canonical manifest of a two-host root with one tool route on server + * `alpha`, a rendered script, a routed CLI, and one event route. `beta` is a + * second compiled server whose worker exists on disk but owns no route the + * tests invoke. + */ +const manifestFixture = (options: FixtureOptions = {}): ArtifactManifest => { + const servers = options.servers ?? [{ hosts: ['claude', 'cursor'], name: 'alpha' }, { hosts: ['claude', 'cursor'], name: 'beta' }]; + const wrappers = options.wrappers ?? ['claude', 'cursor']; + const hooksWorker = options.hooksWorker ?? true; + const execution = options.execution ?? { fallback: 'none', runtime: 'standalone' }; + const files: ArtifactManifestFile[] = [ + bundle('bin/fixture.mjs'), + bundle('bin/fixture-flight.mjs'), + bundle('scripts/report.mjs'), + bundle('scripts/report-flight.mjs'), + ...servers.flatMap((server) => [bundle(`mcp/mcp-${server.name}.mjs`), bundle(`mcp/mcp-${server.name}-flight.mjs`)]), + ...wrappers.map((host) => bundle(`hooks/event-route-tool-before.${host}.mjs`)), + ...(hooksWorker ? [bundle('hooks/hooks-flight.mjs')] : []), + ].sort((left, right) => left.path.localeCompare(right.path)); + return { + application: { id: 'plugin:fixture', name: 'fixture', version: '1.0.0' }, + compiler: { + adapters: ['claude', 'cursor'].map((host) => ({ adapterRevision: `${host}-adapter-v1`, host, observedVersion: '1.0.0', schemas: [] })), + agentSkills: { + schemaSha256: hash('b'), + sourceRevision: hash('c'), + specification: 'https://example.invalid/specification.mdx', + }, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: files.map((file) => ({ path: file.path, sourceInputs: ['agent-bundle.config.ts'] })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }, { host: 'cursor', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { + bins: [{ hosts: ['claude', 'cursor'], name: 'fixture', path: 'bin/fixture.mjs', worker: 'bin/fixture-flight.mjs' }], + hooks: wrappers.map((host) => ({ + event: 'tool/before', + host, + id: 'hook:event-route-tool-before', + kind: 'event-route' as const, + name: 'event-route-tool-before', + path: `hooks/event-route-tool-before.${host}.mjs`, + routeId: 'event:tool/before', + })).sort((left, right) => left.host.localeCompare(right.host)), + mcpServers: servers.map((server) => ({ + apps: [], + hosts: [...server.hosts].sort(), + id: `mcp:${server.name}`, + kind: 'compiled' as const, + launch: { args: [], entry: `mcp/mcp-${server.name}.mjs`, env: {}, worker: `mcp/mcp-${server.name}-flight.mjs` }, + name: server.name, + transport: 'stdio', + })).sort((left, right) => left.id.localeCompare(right.id)), + scripts: [{ + hosts: ['claude', 'cursor'], + id: 'script:report', + mode: 'bundle', + name: 'report', + path: 'scripts/report.mjs', + rendered: { routeId: 'script:report' }, + worker: 'scripts/report-flight.mjs', + }], + }, + files, + manifestVersion: artifactManifestVersion, + projections: [{ documents: {}, host: 'claude' }, { documents: {}, host: 'cursor' }], + routes: { + cli: { + commands: [{ + aliases: [], + exitCode: 'zero', + options: [], + path: ['greet'], + routeId: 'cli:greet', + }], + mode: 'generated', + routes: [{ + id: 'cli:greet', + kind: 'cli', + provenance: { kind: 'conventional' }, + source: 'src/cli/greet.tsx', + }], + }, + digest: hash('d'), + events: [{ + event: 'tool/before', + execution, + id: 'event:tool/before', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/before.tsx', + }], + layouts: [], + providers: [], + scripts: [{ + id: 'script:report', + kind: 'script', + provenance: { kind: 'conventional' }, + source: 'src/scripts/report.tsx', + }], + servers: [{ + id: 'mcp:alpha', + mode: 'generated', + name: 'alpha', + routes: [{ + id: 'tool:alpha/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:alpha', + source: 'src/mcp/alpha/tools/echo.tsx', + }], + }], + }, + runtime: { node: '22.12.0' }, + }; +}; + +const bind = ( + routeId: string, + surface: RouteInvocationSurface, + manifest: ArtifactManifest = manifestFixture(), + eventRuntimeServerId?: string, +) => resolveRouteExecutable({ + artifactRoot: '/artifact', + ...(eventRuntimeServerId === undefined ? {} : { eventRuntimeServerId }), + manifest, + routeId, + surface, +}); + +describe('resolveRouteExecutable', () => { + it('binds MCP, script, and CLI routes to the executables the manifest rows name', () => { + expect(bind('tool:alpha/echo', { kind: 'mcp' })).toEqual({ worker: '/artifact/mcp/mcp-alpha-flight.mjs' }); + expect(bind('script:report', { kind: 'script' })).toEqual({ worker: '/artifact/scripts/report-flight.mjs' }); + expect(bind('cli:greet', { args: ['Ada'], command: 'greet', kind: 'cli' })).toEqual({ + bin: '/artifact/bin/fixture.mjs', + worker: '/artifact/bin/fixture-flight.mjs', + }); + }); + + it('binds a hosted standalone event to its host wrapper row and the hooks Flight worker', () => { + expect(bind('event:tool/before', { host: 'cursor', kind: 'event' })).toEqual({ + worker: '/artifact/hooks/hooks-flight.mjs', + wrapper: '/artifact/hooks/event-route-tool-before.cursor.mjs', + }); + expect(bind('event:tool/before', { kind: 'event' })).toEqual({ worker: '/artifact/hooks/hooks-flight.mjs' }); + }); + + it('binds a shared-runtime event to the server the compiler named as runtime owner', () => { + const shared = manifestFixture({ execution: { fallback: 'none', runtime: 'shared' }, hooksWorker: false }); + + expect(bind('event:tool/before', { host: 'claude', kind: 'event' }, shared, 'mcp:beta')).toEqual({ + worker: '/artifact/mcp/mcp-beta-flight.mjs', + wrapper: '/artifact/hooks/event-route-tool-before.claude.mjs', + }); + expect(() => bind('event:tool/before', { host: 'claude', kind: 'event' }, shared)).toThrow(expect.objectContaining({ + code: 'AB8251', + message: expect.stringContaining('names no shared runtime owner'), + })); + expect(() => bind('event:tool/before', { host: 'claude', kind: 'event' }, shared, 'mcp:gamma')).toThrow( + expect.objectContaining({ code: 'AB8251' }), + ); + }); + + it('binds a shared-runtime event to the one compiled server reaching the host, else to the declared standalone fallback', () => { + const shared = manifestFixture({ + execution: { fallback: 'standalone', runtime: 'shared' }, + servers: [{ hosts: ['claude'], name: 'alpha' }], + }); + + expect(bind('event:tool/before', { host: 'claude', kind: 'event' }, shared).worker).toBe('/artifact/mcp/mcp-alpha-flight.mjs'); + expect(bind('event:tool/before', { host: 'cursor', kind: 'event' }, shared).worker).toBe('/artifact/hooks/hooks-flight.mjs'); + const noFallback = manifestFixture({ + execution: { fallback: 'none', runtime: 'shared' }, + hooksWorker: false, + servers: [{ hosts: ['claude'], name: 'alpha' }], + }); + expect(() => bind('event:tool/before', { host: 'cursor', kind: 'event' }, noFallback)).toThrow( + expect.objectContaining({ code: 'AB8251', message: expect.stringContaining('for cursor') }), + ); + }); + + it('fails closed instead of guessing', () => { + expect(() => bind('tool:alpha/missing', { kind: 'mcp' })).toThrow(expect.objectContaining({ code: 'AB8251' })); + expect(() => bind('event:tool/before', { host: 'codex', kind: 'event' })).toThrow(expect.objectContaining({ + code: 'AB8251', + message: expect.stringContaining('no codex wrapper'), + })); + expect(() => bind('event:tool/before', { host: 'claude', kind: 'event' }, manifestFixture({ hooksWorker: false }))).toThrow( + expect.objectContaining({ code: 'AB8251' }), + ); + const preflight = manifestFixture({ + execution: { fallback: 'none', preflight: 'src/events/tool/before.preflight.ts', runtime: 'standalone' }, + }); + expect(() => bind('event:tool/before', { kind: 'event' }, preflight)).toThrow(expect.objectContaining({ + code: 'AB8252', + message: expect.stringContaining('handler is not reached'), + })); + expect(bind('event:tool/before', { host: 'claude', kind: 'event' }, preflight).wrapper).toBe('/artifact/hooks/event-route-tool-before.claude.mjs'); + expect(() => bind('tool:alpha/echo', { args: [], command: 'echo', kind: 'cli' })).toThrow(expect.objectContaining({ + code: 'AB8251', + message: expect.stringContaining('routed CLI'), + })); + }); +}); + +const context: RequestContextProvenance = { + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { reason: 'host-omitted', state: 'unavailable' }, + invocation: { kind: 'workbench', operationId: 'tool:alpha/echo', surface: 'mcp' }, + lineage: { reason: 'no-shared-runtime', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { source: 'derived', state: 'available', value: { root: '/project' } }, +}; + +/** A Flight worker stand-in: records that it started, then answers every render with `message`. */ +const workerSource = (name: string, message: string): string => [ + "import { writeFileSync } from 'node:fs';", + "import { fileURLToPath } from 'node:url';", + "import { parentPort } from 'node:worker_threads';", + '', + `writeFileSync(fileURLToPath(new URL(${JSON.stringify(`../started-${name}`)}, import.meta.url)), 'started');`, + "parentPort.on('message', (message) => {", + " if (message.type !== 'render') return;", + ` parentPort.postMessage({ id: message.id, message: ${JSON.stringify(message)}, type: 'error' });`, + '});', + '', +].join('\n'); + +const missingRouteMessage = 'Generated route must default-export a route module.'; + +interface ArtifactFixture { + readonly request: (routeId: string, surface: RouteInvocationSurface, input?: RouteInvocationChildRequest['input']) => RouteInvocationChildRequest; + readonly root: string; + readonly started: (name: string) => boolean; +} + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const artifactFixture = async ( + manifest: ArtifactManifest | undefined, + files: Readonly>, +): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-production-')); + roots.push(root); + const artifactRoot = join(root, 'artifact'); + await Promise.all(Object.entries({ + ...(manifest === undefined ? {} : { [artifactManifestName]: serializeArtifactManifest(manifest) }), + ...files, + }).map(async ([path, text]) => { + await mkdir(dirname(join(artifactRoot, path)), { recursive: true }); + await writeFile(join(artifactRoot, path), text); + })); + const eventSource = join(root, 'src/events/tool/before.tsx'); + const toolSource = join(root, 'src/mcp/alpha/tools/echo.tsx'); + const graph = { + diagnostics: [], + digest: 'digest', + events: [{ + config: {}, + event: 'tool/before', + id: 'event:tool/before', + kind: 'event-route', + provenance: { kind: 'conventional', relativePath: 'src/events/tool/before.tsx' }, + source: eventSource, + }], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:alpha', + mode: 'generated', + name: 'alpha', + routes: [{ + config: {}, + id: 'tool:alpha/echo', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/alpha/tools/echo.tsx' }, + serverId: 'mcp:alpha', + source: toolSource, + }], + }], + } satisfies CompiledRouteGraph; + const harness = testManifestFromRouteGraph({ graph, projectRoot: root }); + return { + request: (routeId, surface, input = {}) => ({ + artifactEpoch: 'fixture@1.0.0', + artifactRoot, + context, + input, + manifest: harness, + routeId, + stateRoot: join(root, 'state'), + surface, + }), + root: artifactRoot, + started: (name) => existsSync(join(artifactRoot, `started-${name}`)), + }; +}; + +describe('renderProductionRoute', () => { + it('runs only the worker the manifest bound: a missing-route error there is the failure, not a cue to try a sibling', async () => { + const fixture = await artifactFixture(manifestFixture(), { + 'mcp/mcp-alpha-flight.mjs': workerSource('alpha', missingRouteMessage), + 'mcp/mcp-beta-flight.mjs': workerSource('beta', 'beta must never run'), + 'hooks/hooks-flight.mjs': workerSource('hooks', 'hooks must never run'), + }); + + await expect(renderProductionRoute(fixture.request('tool:alpha/echo', { kind: 'mcp' }))).rejects.toThrow(missingRouteMessage); + expect(fixture.started('alpha')).toBe(true); + expect(fixture.started('beta')).toBe(false); + expect(fixture.started('hooks')).toBe(false); + }); + + it('propagates a handler failure without running another executable', async () => { + const fixture = await artifactFixture(manifestFixture(), { + 'hooks/hooks-flight.mjs': workerSource('hooks', 'handler exploded'), + 'mcp/mcp-alpha-flight.mjs': workerSource('alpha', 'alpha must never run'), + 'mcp/mcp-beta-flight.mjs': workerSource('beta', 'beta must never run'), + }); + + await expect(renderProductionRoute(fixture.request('event:tool/before', { kind: 'event' }, { canonical: {}, native: {} }))) + .rejects.toThrow('handler exploded'); + expect(fixture.started('hooks')).toBe(true); + expect(fixture.started('alpha')).toBe(false); + expect(fixture.started('beta')).toBe(false); + }); + + it('fails closed before any executable runs when the manifest cannot bind the route', async () => { + const preflight = manifestFixture({ + execution: { fallback: 'none', preflight: 'src/events/tool/before.preflight.ts', runtime: 'standalone' }, + wrappers: ['claude'], + }); + const workers = { + 'hooks/hooks-flight.mjs': workerSource('hooks', 'must never run'), + 'mcp/mcp-alpha-flight.mjs': workerSource('alpha', 'must never run'), + 'mcp/mcp-beta-flight.mjs': workerSource('beta', 'must never run'), + }; + const fixture = await artifactFixture(preflight, workers); + + await expect(renderProductionRoute(fixture.request('event:tool/before', { kind: 'event' }, { canonical: {}, native: {} }))) + .rejects.toMatchObject({ code: 'AB8252' }); + await expect(renderProductionRoute(fixture.request('event:tool/before', { host: 'cursor', kind: 'event' }, { canonical: {}, native: {} }))) + .rejects.toMatchObject({ code: 'AB8251' }); + await expect(renderProductionRoute(fixture.request('tool:alpha/missing', { kind: 'mcp' }))).rejects.toMatchObject({ code: 'AB8251' }); + expect(fixture.started('hooks')).toBe(false); + expect(fixture.started('alpha')).toBe(false); + expect(fixture.started('beta')).toBe(false); + + const unpublished = await artifactFixture(undefined, workers); + await expect(renderProductionRoute(unpublished.request('tool:alpha/echo', { kind: 'mcp' }))).rejects.toMatchObject({ code: 'AB8250' }); + expect(unpublished.started('alpha')).toBe(false); + }); + + it('runs the bound wrapper preparation and never reaches the handler when preflight does not execute', async () => { + const fixture = await artifactFixture(manifestFixture(), { + 'hooks/event-route-tool-before.claude.mjs': [ + 'export const prepareRouteInvocation = async (native) => Object.freeze({', + " gate: { outcome: 'deny', reason: 'blocked by fixture preflight' },", + ' native,', + " props: { canonical: { decoded: true } },", + " runtime: 'standalone',", + '});', + '', + ].join('\n'), + 'hooks/event-route-tool-before.cursor.mjs': 'export const unrelated = true;\n', + 'hooks/hooks-flight.mjs': workerSource('hooks', 'must never run'), + }); + + const denied = await renderProductionRoute(fixture.request('event:tool/before', { host: 'claude', kind: 'event' }, { native: { hook_event_name: 'PreToolUse' } })); + expect(denied.result).toEqual({ outcome: 'deny', reason: 'blocked by fixture preflight' }); + expect(denied.input).toEqual({ canonical: { decoded: true }, native: { hook_event_name: 'PreToolUse' } }); + await expect(renderProductionRoute(fixture.request('event:tool/before', { host: 'cursor', kind: 'event' }, { native: {} }))) + .rejects.toMatchObject({ code: 'AB8252', message: expect.stringContaining('preparation contract') }); + expect(fixture.started('hooks')).toBe(false); + }); +}); From 091a8901cd354dfc2f83d353681a6bc0d53b3bd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:14:39 +0000 Subject: [PATCH 3/7] docs(workbench): document manifest-bound production execution and its fail-closed diagnostics Co-authored-by: Zack Jackson --- .../680-workbench-exact-executable-binding.md | 5 +++++ docs/diagnostics.md | 2 +- website/docs/en/guide/development/workbench.mdx | 15 +++++++++++++++ website/docs/zh/guide/development/workbench.mdx | 11 +++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .changeset/680-workbench-exact-executable-binding.md diff --git a/.changeset/680-workbench-exact-executable-binding.md b/.changeset/680-workbench-exact-executable-binding.md new file mode 100644 index 000000000..e3e15b034 --- /dev/null +++ b/.changeset/680-workbench-exact-executable-binding.md @@ -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 — 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, or a shared-runtime event with several candidate servers and no named owner is `AB8251`; a canonical submission of an event route whose preflight only a host wrapper can run, or a bound bin or wrapper missing its preparation export, is `AB8252` — the handler is never reached, and a handler failure inside the bound worker never runs another executable (#680) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0e6f9a7c4..494c44ffc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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:` id was used instead of its canonical `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 several compiled servers could host and no named runtime owner among them, a rendered route whose bin, script, or server carries no worker), `AB8252` compiled CLI projection or event preflight preparation failed — including a canonical submission of a route whose preflight only a host wrapper can run, and a bound bin or wrapper without its preparation export — `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:` id was used instead of its canonical `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//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. | diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 92eeeead1..120664968 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -352,6 +352,21 @@ The explicit `unit-render` surface is a component-preview fallback: it loads liv route-unit harness and mounts disposable state. It is not an artifact-parity receipt. A projected CLI command keeps the canonical `tool:` route id; a mismatched command is `AB8253`, while using its duplicate-looking `cli:` id is `AB8254` with the canonical id and surface to use. + +A production surface binds its executables from the epoch's `agent-bundle.manifest.json` +before anything runs, never by listing the artifact or trying one worker after another: the +routed CLI bin and worker from `executables.bins[]`, the rendered script's worker from +`executables.scripts[]`, the owning compiled server's `launch.worker` from +`executables.mcpServers[]`, and, for an event route, the host's wrapper row from +`executables.hooks[]` plus the worker its `routes.events[].execution` selects +(`hooks/hooks-flight.mjs` for a standalone runtime, the shared runtime owner's worker +otherwise). 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, or a shared-runtime event +with several candidate servers and no named owner is `AB8251`, and a canonical submission of a +route whose preflight only a host wrapper can run, or a bound bin or wrapper missing its +preparation export, is `AB8252` — in every case before the handler is reached. A failure +inside the bound worker, a handler throw included, is the invocation's failure; no other +executable runs. Production state lives at `/.agent-bundle/state`, outside the published epochs that retirement removes. It is shared with dev MCP sessions through `AGENT_BUNDLE_STATE_ROOT`, so it survives a successful republish; `unit-render` still uses a fresh temporary state root per run. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 43b94d2fa..ea5a893df 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -296,6 +296,17 @@ providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲 显式的 `unit-render` 表面是组件预览后备模式:它通过 route-unit 测试工具加载实时源码并挂载一次性状态, 不能作为制品一致性的证明。投影后的 CLI 命令保留规范 `tool:` 路由 id;命令不匹配报告 `AB8253`, 使用看似重复的 `cli:` id 则报告 `AB8254`,并给出应使用的规范 id 与表面。 + +生产表面在运行任何代码之前,先从该 epoch 的 `agent-bundle.manifest.json` 绑定其可执行项,绝不通过 +列出制品目录或逐个尝试 worker 来发现:路由 CLI 的 bin 与 worker 取自 `executables.bins[]`,渲染脚本的 +worker 取自 `executables.scripts[]`,所属已编译服务器的 `launch.worker` 取自 +`executables.mcpServers[]`;事件路由则取该宿主在 `executables.hooks[]` 中的包装层行,加上其 +`routes.events[].execution` 选定的 worker(standalone 运行时为 `hooks/hooks-flight.mjs`,否则为 +共享运行时所有者的 worker)。绑定失败即关闭:缺少可读 manifest 的根报告 `AB8250`;manifest 未编译 +的路由、没有包装层行的宿主事件,或有多个候选服务器却未指明所有者的共享运行时事件报告 `AB8251`; +以 Canonical 方式提交只有宿主包装层才能运行其 preflight 的路由,或绑定到的 bin / 包装层缺少准备导出, +报告 `AB8252`——所有情况都发生在到达处理函数之前。绑定 worker 内部的失败(包括处理函数抛错)即为 +该次调用的失败;不会再运行其他可执行项。 生产状态位于 `/.agent-bundle/state`,在 retirement 会移除的已发布 epoch 之外。它通过 `AGENT_BUNDLE_STATE_ROOT` 与开发期 MCP 会话共享,因此能跨一次成功的重新发布保留;`unit-render` 仍为每次运行使用新的临时状态根。 From 61e315c12e163d50a90dbc971d5a5dadf98727f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:17:17 +0000 Subject: [PATCH 4/7] refactor(workbench): flatten event wrapper selection in the executable binding Co-authored-by: Zack Jackson --- .../dev/routes/route-invocation-executable.ts | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts index 1b64ec5dc..6e62f6882 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { hooksFlightWorkerPath } from '../../adapters/composite-layout.ts'; -import type { ArtifactManifest, ArtifactManifestMcpServer, ArtifactManifestRoute } from '../../build/manifest.ts'; +import type { ArtifactManifest, ArtifactManifestEventExecution, ArtifactManifestRoute } from '../../build/manifest.ts'; import { ProductionRouteInvocationError, ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE, @@ -81,10 +81,6 @@ const bindMcpWorker = (input: ResolveRouteExecutableInput, route: ArtifactManife return Object.freeze({ worker: join(input.artifactRoot, server.launch.worker) }); }; -const hostsFlightWorker = (server: ArtifactManifestMcpServer): server is ArtifactManifestMcpServer & Readonly<{ - readonly launch: Readonly<{ readonly worker: string }>; -}> => server.kind === 'compiled' && server.launch?.worker !== undefined; - /** * The compiled server whose Flight worker registers the composite root's * event routes for `host` (`eventRuntimeHosting`): the runtime owner the @@ -96,17 +92,44 @@ const sharedRuntimeWorker = ( input: ResolveRouteExecutableInput, host: string | undefined, ): string | undefined => { - const candidates = input.manifest.executables.mcpServers - .filter(hostsFlightWorker) - .filter((server) => host === undefined || server.hosts.includes(host)); - const owner = candidates.find((server) => server.id === input.eventRuntimeServerId) ?? (candidates.length === 1 ? candidates[0] : undefined); + const candidates = input.manifest.executables.mcpServers.flatMap((server) => + server.kind === 'compiled' && server.launch?.worker !== undefined && (host === undefined || server.hosts.includes(host)) + ? [{ id: server.id, worker: server.launch.worker }] + : []); + const owner = candidates.find((server) => server.id === input.eventRuntimeServerId) + ?? (candidates.length === 1 ? candidates[0] : undefined); if (owner === undefined && candidates.length > 1) { throw unavailable( `Event route ${JSON.stringify(input.routeId)} could run in ${String(candidates.length)} compiled servers ` + `(${candidates.map((server) => server.id).join(', ')}) and the published artifact names no shared runtime owner among them.`, ); } - return owner === undefined ? undefined : join(input.artifactRoot, owner.launch.worker); + return owner === undefined ? undefined : join(input.artifactRoot, owner.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 => { @@ -115,22 +138,7 @@ const bindEventExecutable = (input: ResolveRouteExecutableInput, route: Artifact 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; - let wrapper: string | undefined; - if (host === undefined) { - if (execution.preflight !== 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.`, - ); - } - } else { - 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)}.`); - } - wrapper = join(input.artifactRoot, hook.path); - } + const wrapper = eventWrapper(input, execution, host); const standaloneWorker = input.manifest.files.some((file) => file.path === hooksFlightWorkerPath) ? join(input.artifactRoot, hooksFlightWorkerPath) : undefined; From 667d370d442efa9d7935880e44e0ab0e3b31382c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:26:36 +0000 Subject: [PATCH 5/7] fix(workbench): select the shared event runtime owner per host from the manifest eventRuntimeHosting gives the runtime to the first generated server in model order that targets the host; the model is ordered by server name and executables.mcpServers[] by id (mcp:), so the first compiled row with a Flight worker reaching the host is that server. Drops the harness eventRuntimeServerId tie-breaker, which was global rather than per host, and the ambiguity failure it required. The dev-server integration test now runs a shared-runtime event route through a real artifact that also carries the standalone hooks worker. Co-authored-by: Zack Jackson --- .../dev/routes/route-invocation-executable.ts | 38 ++++++---------- .../dev/routes/route-invocation-production.ts | 1 - .../tests/route-invocation-dev-server.test.ts | 43 +++++++++++++++++++ .../tests/route-invocation-production.test.ts | 35 +++++++-------- 4 files changed, 72 insertions(+), 45 deletions(-) diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts index 6e62f6882..3c19b26e7 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-executable.ts @@ -28,12 +28,6 @@ export interface RouteExecutableBinding { export interface ResolveRouteExecutableInput { readonly artifactRoot: string; - /** - * The generated MCP server the compiler pass named as the shared event - * runtime owner (`AgentBundleTestManifest.eventRuntimeServerId`); breaks - * the tie when several compiled servers of one host carry a Flight worker. - */ - readonly eventRuntimeServerId?: string; readonly manifest: ArtifactManifest; readonly routeId: string; readonly surface: RouteInvocationSurface; @@ -83,28 +77,22 @@ const bindMcpWorker = (input: ResolveRouteExecutableInput, route: ArtifactManife /** * The compiled server whose Flight worker registers the composite root's - * event routes for `host` (`eventRuntimeHosting`): the runtime owner the - * compiler pass named when it reaches the host, else the one server that - * does. Two candidates and no named owner is a choice the manifest cannot - * make, so none is made. + * 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:`), 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 candidates = input.manifest.executables.mcpServers.flatMap((server) => - server.kind === 'compiled' && server.launch?.worker !== undefined && (host === undefined || server.hosts.includes(host)) - ? [{ id: server.id, worker: server.launch.worker }] - : []); - const owner = candidates.find((server) => server.id === input.eventRuntimeServerId) - ?? (candidates.length === 1 ? candidates[0] : undefined); - if (owner === undefined && candidates.length > 1) { - throw unavailable( - `Event route ${JSON.stringify(input.routeId)} could run in ${String(candidates.length)} compiled servers ` - + `(${candidates.map((server) => server.id).join(', ')}) and the published artifact names no shared runtime owner among them.`, - ); - } - return owner === undefined ? undefined : join(input.artifactRoot, owner.worker); + 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); }; /** @@ -158,8 +146,8 @@ const bindEventExecutable = (input: ResolveRouteExecutableInput, route: Artifact * (#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 shared - * runtime owner's worker otherwise, the standalone worker again when the + * 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 diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index 1a66f1356..82a3b4d98 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -134,7 +134,6 @@ const bindExecutable = async (request: ProductionRequest): Promise ({ outcome: 'deny', reason: 'blocked' });\n", 'src/events/tool/before.tsx': [ "export { default as preflight } from './before.preflight.js';", @@ -1059,6 +1068,40 @@ it('enforces compiled preflight, MCP schemas, and operator env across production expect(deniedResponse.status, JSON.stringify(denied)).toBe(200); expect(denied.invocation.timings.map((entry) => entry.phase)).toEqual(['projection']); expect(denied.invocation.providers).toEqual([]); + + // The artifact carries two Flight workers: the generated `status` server's + // (the shared event runtime) and `hooks/hooks-flight.mjs` (standalone + // routes only). `tool/after` runs shared, so only the server worker + // registers it; with no second candidate to fall back to, a rendered + // result proves the manifest bound that worker. + const manifest = JSON.parse(await readFile( + join(project.root, '.agent-bundle', 'epochs', artifact.activeEpoch.id, 'agent-bundle.manifest.json'), + 'utf8', + )) as { readonly files: readonly { readonly path: string }[]; readonly routes: { readonly events: readonly { readonly execution: { readonly runtime: string }; readonly id: string }[] } }; + expect(manifest.routes.events.find((route) => route.id === 'event:tool/after')?.execution.runtime).toBe('shared'); + expect(manifest.files.some((file) => file.path === 'hooks/hooks-flight.mjs')).toBe(true); + const sharedResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + input: { + cwd: project.root, + hook_event_name: 'PostToolUse', + session_id: 'session-shared', + tool_input: {}, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'use-shared', + transcript_path: join(project.root, 'transcript.json'), + }, + routeId: 'event:tool/after', + surface: { host: 'claude', kind: 'event' }, + }), + headers, + method: 'POST', + }); + const shared = await sharedResponse.json() as RouteInvocationResponse; + expect(sharedResponse.status, JSON.stringify(shared)).toBe(200); + expect(shared.invocation.status, JSON.stringify(shared.invocation.diagnostics)).toBe('succeeded'); + expect(shared.invocation.result).toEqual({ runtime: 'shared', toolName: 'Write' }); } finally { await server?.close().catch(() => undefined); await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); diff --git a/packages/agent-bundle/tests/route-invocation-production.test.ts b/packages/agent-bundle/tests/route-invocation-production.test.ts index dd3e27b5e..6322883f8 100644 --- a/packages/agent-bundle/tests/route-invocation-production.test.ts +++ b/packages/agent-bundle/tests/route-invocation-production.test.ts @@ -173,14 +173,7 @@ const bind = ( routeId: string, surface: RouteInvocationSurface, manifest: ArtifactManifest = manifestFixture(), - eventRuntimeServerId?: string, -) => resolveRouteExecutable({ - artifactRoot: '/artifact', - ...(eventRuntimeServerId === undefined ? {} : { eventRuntimeServerId }), - manifest, - routeId, - surface, -}); +) => resolveRouteExecutable({ artifactRoot: '/artifact', manifest, routeId, surface }); describe('resolveRouteExecutable', () => { it('binds MCP, script, and CLI routes to the executables the manifest rows name', () => { @@ -200,20 +193,24 @@ describe('resolveRouteExecutable', () => { expect(bind('event:tool/before', { kind: 'event' })).toEqual({ worker: '/artifact/hooks/hooks-flight.mjs' }); }); - it('binds a shared-runtime event to the server the compiler named as runtime owner', () => { - const shared = manifestFixture({ execution: { fallback: 'none', runtime: 'shared' }, hooksWorker: false }); + it('binds a shared-runtime event to the first compiled server reaching the host, as eventRuntimeHosting does', () => { + // `alpha` reaches claude only; cursor's runtime owner is the next server by + // name (`beta`), not the first server overall and not the last one. + const shared = manifestFixture({ + execution: { fallback: 'none', runtime: 'shared' }, + hooksWorker: false, + servers: [{ hosts: ['claude'], name: 'alpha' }, { hosts: ['cursor'], name: 'beta' }, { hosts: ['cursor'], name: 'gamma' }], + }); - expect(bind('event:tool/before', { host: 'claude', kind: 'event' }, shared, 'mcp:beta')).toEqual({ - worker: '/artifact/mcp/mcp-beta-flight.mjs', + expect(bind('event:tool/before', { host: 'claude', kind: 'event' }, shared)).toEqual({ + worker: '/artifact/mcp/mcp-alpha-flight.mjs', wrapper: '/artifact/hooks/event-route-tool-before.claude.mjs', }); - expect(() => bind('event:tool/before', { host: 'claude', kind: 'event' }, shared)).toThrow(expect.objectContaining({ - code: 'AB8251', - message: expect.stringContaining('names no shared runtime owner'), - })); - expect(() => bind('event:tool/before', { host: 'claude', kind: 'event' }, shared, 'mcp:gamma')).toThrow( - expect.objectContaining({ code: 'AB8251' }), - ); + expect(bind('event:tool/before', { host: 'cursor', kind: 'event' }, shared)).toEqual({ + worker: '/artifact/mcp/mcp-beta-flight.mjs', + wrapper: '/artifact/hooks/event-route-tool-before.cursor.mjs', + }); + expect(bind('event:tool/before', { kind: 'event' }, shared)).toEqual({ worker: '/artifact/mcp/mcp-alpha-flight.mjs' }); }); it('binds a shared-runtime event to the one compiled server reaching the host, else to the declared standalone fallback', () => { From 67ffdebf6e9f21c23cbfa321ecc5924a82da2ab5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:26:37 +0000 Subject: [PATCH 6/7] docs(workbench): AB8255 is the public refusal of a host-less preflight route; AB8252 is the child's guard Co-authored-by: Zack Jackson --- .../680-workbench-exact-executable-binding.md | 2 +- docs/diagnostics.md | 2 +- website/docs/en/guide/development/workbench.mdx | 17 +++++++++-------- website/docs/zh/guide/development/workbench.mdx | 11 ++++++----- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.changeset/680-workbench-exact-executable-binding.md b/.changeset/680-workbench-exact-executable-binding.md index e3e15b034..c9c4f70c4 100644 --- a/.changeset/680-workbench-exact-executable-binding.md +++ b/.changeset/680-workbench-exact-executable-binding.md @@ -2,4 +2,4 @@ "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 — 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, or a shared-runtime event with several candidate servers and no named owner is `AB8251`; a canonical submission of an event route whose preflight only a host wrapper can run, or a bound bin or wrapper missing its preparation export, is `AB8252` — the handler is never reached, and a handler failure inside the bound worker never runs another executable (#680) +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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 494c44ffc..fb871ba71 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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, 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 several compiled servers could host and no named runtime owner among them, a rendered route whose bin, script, or server carries no worker), `AB8252` compiled CLI projection or event preflight preparation failed — including a canonical submission of a route whose preflight only a host wrapper can run, and a bound bin or wrapper without its preparation export — `AB8253` a selected CLI command does not project onto the canonical operation id, `AB8254` a projected `cli:` id was used instead of its canonical `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`. | +| `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:` id was used instead of its canonical `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//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. | diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 120664968..1f3080c86 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -359,14 +359,15 @@ routed CLI bin and worker from `executables.bins[]`, the rendered script's worke `executables.scripts[]`, the owning compiled server's `launch.worker` from `executables.mcpServers[]`, and, for an event route, the host's wrapper row from `executables.hooks[]` plus the worker its `routes.events[].execution` selects -(`hooks/hooks-flight.mjs` for a standalone runtime, the shared runtime owner's worker -otherwise). 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, or a shared-runtime event -with several candidate servers and no named owner is `AB8251`, and a canonical submission of a -route whose preflight only a host wrapper can run, or a bound bin or wrapper missing its -preparation export, is `AB8252` — in every case before the handler is reached. A failure -inside the bound worker, a handler throw included, is the invocation's failure; no other -executable runs. +(`hooks/hooks-flight.mjs` for a standalone runtime, otherwise the worker of the first compiled +server reaching the host — the same server the compiler gave the shared event runtime). 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 is +`AB8252`. A preflight route submitted without a host is refused by the service as `AB8255` +before it is queued, and the child refuses it again as `AB8252` should it ever reach it — in +every case before the handler is reached. A failure inside the bound worker, a handler throw +included, is the invocation's failure; no other executable runs. Production state lives at `/.agent-bundle/state`, outside the published epochs that retirement removes. It is shared with dev MCP sessions through `AGENT_BUNDLE_STATE_ROOT`, so it survives a successful republish; `unit-render` still uses a fresh temporary state root per run. diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index ea5a893df..6775caff8 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -302,11 +302,12 @@ providers、持久状态、CLI `mapInput` 与确认、事件 preflight 以及渲 worker 取自 `executables.scripts[]`,所属已编译服务器的 `launch.worker` 取自 `executables.mcpServers[]`;事件路由则取该宿主在 `executables.hooks[]` 中的包装层行,加上其 `routes.events[].execution` 选定的 worker(standalone 运行时为 `hooks/hooks-flight.mjs`,否则为 -共享运行时所有者的 worker)。绑定失败即关闭:缺少可读 manifest 的根报告 `AB8250`;manifest 未编译 -的路由、没有包装层行的宿主事件,或有多个候选服务器却未指明所有者的共享运行时事件报告 `AB8251`; -以 Canonical 方式提交只有宿主包装层才能运行其 preflight 的路由,或绑定到的 bin / 包装层缺少准备导出, -报告 `AB8252`——所有情况都发生在到达处理函数之前。绑定 worker 内部的失败(包括处理函数抛错)即为 -该次调用的失败;不会再运行其他可执行项。 +到达该宿主的第一个已编译服务器的 worker——也就是编译器交付共享事件运行时的那台服务器)。绑定失败 +即关闭:缺少可读 manifest 的根报告 `AB8250`;manifest 未编译的路由、该宿主没有包装层行的宿主事件, +或没有 worker 的 bin / 脚本 / 服务器报告 `AB8251`;绑定到的 bin / 包装层缺少准备导出报告 `AB8252`。 +未指定宿主提交带 preflight 的路由会在排队前被服务以 `AB8255` 拒绝,若它仍到达子进程,子进程会再次 +以 `AB8252` 拒绝——所有情况都发生在到达处理函数之前。绑定 worker 内部的失败(包括处理函数抛错) +即为该次调用的失败;不会再运行其他可执行项。 生产状态位于 `/.agent-bundle/state`,在 retirement 会移除的已发布 epoch 之外。它通过 `AGENT_BUNDLE_STATE_ROOT` 与开发期 MCP 会话共享,因此能跨一次成功的重新发布保留;`unit-render` 仍为每次运行使用新的临时状态根。 From c2675aeb7ad779cb5a3332b3aff4d654edb0b6e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 20:32:06 +0000 Subject: [PATCH 7/7] test(workbench): assert the canonical toolName projection shape for the shared-runtime event Co-authored-by: Zack Jackson --- packages/agent-bundle/tests/route-invocation-dev-server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index dcd6f3b4f..801c5ddd7 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -1101,7 +1101,7 @@ it('enforces compiled preflight, MCP schemas, and operator env across production const shared = await sharedResponse.json() as RouteInvocationResponse; expect(sharedResponse.status, JSON.stringify(shared)).toBe(200); expect(shared.invocation.status, JSON.stringify(shared.invocation.diagnostics)).toBe('succeeded'); - expect(shared.invocation.result).toEqual({ runtime: 'shared', toolName: 'Write' }); + expect(shared.invocation.result).toMatchObject({ runtime: 'shared', toolName: { value: 'Write' } }); } finally { await server?.close().catch(() => undefined); await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 });