Skip to content

Commit 6bce189

Browse files
fix: select exact production route executable (#692)
* fix: select exact production route executable * chore: add issue 680 changeset * fix: preserve canonical event execution * test: allow real invocation children to settle * fix: preserve manifest event runtime selection * fix: reject non-rendered production CLI surfaces * fix: keep projected tool CLI routes eligible * test: cover projected TypeScript tool commands * test: allow real invocation child to settle
1 parent 3ddbead commit 6bce189

12 files changed

Lines changed: 919 additions & 150 deletions

File tree

‎.changeset/exact-routes-close.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
Select the exact Workbench production route executable from the artifact manifest and report AB8250–AB8252 for unavailable, ineligible, or failed preparation bindings (#692).

‎docs/diagnostics.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ even when no error diagnostic was reported.
4949
| `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). |
5050
| `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). |
5151
| `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. |
52-
| `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`. |
52+
| `AB8250`–`AB8255` | Workbench production route execution: `AB8250` no manifest-selected published compiler artifact is available, `AB8251` the selected route/surface/host has no eligible executable or preparation binding in the published artifact, `AB8252` the selected compiled CLI projection or event preparation could not be imported or 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 or choose an eligible emitted host 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`. |
5353
| `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. |
5454
| `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`. |
5555
| `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. |

‎packages/agent-bundle/src/adapters/composite-layout.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ export const hookWrapperPath = (
6262
const reached = hookTargets.filter((target) => selection.has(target));
6363
return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`;
6464
};
65+
66+
/** Artifact-relative path of the standalone event-route Flight worker. */
67+
export const hooksFlightWorkerPath = 'hooks/hooks-flight.mjs';

‎packages/agent-bundle/src/build/entries.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFile, stat } from 'node:fs/promises';
33
import { dirname, extname, join, relative, resolve } from 'node:path';
44
import { fileURLToPath } from 'node:url';
55

6+
import { hooksFlightWorkerPath } from '../adapters/composite-layout.ts';
67
import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts';
78
import {
89
eventArtifactEpochToken,
@@ -601,7 +602,7 @@ export const planCompiledHooks = (
601602
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
602603
...(index === workerOwner
603604
? {
604-
workerOutput: resolveArtifactDestination(resolve(options.outDir, 'hooks'), 'hooks-flight.mjs'),
605+
workerOutput: resolveArtifactDestination(options.outDir, hooksFlightWorkerPath),
605606
workerSourceInputs,
606607
}
607608
: {}),
@@ -639,7 +640,7 @@ export const planHooksSurface = (
639640
? undefined
640641
: {
641642
name: 'hooks-flight',
642-
outputRelativePath: 'hooks/hooks-flight.mjs',
643+
outputRelativePath: hooksFlightWorkerPath,
643644
reactServer: true as const,
644645
rscManifest: true as const,
645646
source: standaloneEventRoutes[0]!.source,
@@ -731,7 +732,7 @@ export const planHooksSurface = (
731732
?? (() => { throw new Error(`Missing bundled deferred hook executor evidence for ${JSON.stringify(entry.name)}.`); })(),
732733
}),
733734
...(entry.workerOutput === undefined ? {} : {
734-
workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
735+
workerSourceInputs: evidenceByPath.get(hooksFlightWorkerPath) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(),
735736
}),
736737
})));
737738
},

‎packages/agent-bundle/src/dev/routes/route-invocation-production.ts‎

Lines changed: 82 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { existsSync } from 'node:fs';
2-
import { readdir } from 'node:fs/promises';
31
import { join } from 'node:path';
42
import { pathToFileURL } from 'node:url';
53
import { Worker } from 'node:worker_threads';
@@ -85,6 +83,7 @@ interface WorkerMessage {
8583
type ProductionRequest = RouteInvocationChildRequest & Readonly<{
8684
readonly artifactEpoch: string;
8785
readonly artifactRoot: string;
86+
readonly production: NonNullable<RouteInvocationChildRequest['production']>;
8887
}>;
8988

9089
const preparationFailure = (error: unknown): ProductionRouteInvocationError =>
@@ -110,27 +109,6 @@ const completeDocument = (value: JsonValue | undefined): AgentDocument => create
110109
version: AGENT_DOCUMENT_VERSION,
111110
});
112111

113-
const workerFiles = async (root: string): Promise<readonly string[]> => {
114-
if (!existsSync(root)) return Object.freeze([]);
115-
return Object.freeze((await readdir(root))
116-
.filter((name) => name.endsWith('-flight.mjs'))
117-
.sort()
118-
.map((name) => join(root, name)));
119-
};
120-
121-
const eventWrapperPath = (
122-
request: ProductionRequest,
123-
): string | undefined => {
124-
const event = request.manifest.routes[request.routeId]?.event;
125-
const target = request.surface.kind === 'event' ? request.surface.host : undefined;
126-
if (event === undefined || target === undefined) return undefined;
127-
const stem = `event-route-${event.replace('/', '-')}`;
128-
const suffixed = join(request.artifactRoot, 'hooks', `${stem}.${target}.mjs`);
129-
if (existsSync(suffixed)) return suffixed;
130-
const plain = join(request.artifactRoot, 'hooks', `${stem}.mjs`);
131-
return existsSync(plain) ? plain : undefined;
132-
};
133-
134112
const isCliInvocationModule = (module: Partial<CompiledCliInvocationModule>): module is CompiledCliInvocationModule =>
135113
typeof module.prepareRouteInvocation === 'function' && typeof module.routeInvocationExitCode === 'function';
136114

@@ -146,42 +124,58 @@ const prepareInput = async (
146124
observeTrace: EventTraceObserver,
147125
signal: AbortSignal,
148126
): Promise<PreparedInput> => {
149-
const route = request.manifest.routes[request.routeId];
150-
if (request.surface.kind === 'cli') {
151-
const binRoot = join(request.artifactRoot, 'bin');
152-
const bins = existsSync(binRoot)
153-
? (await readdir(binRoot)).filter((name) => name.endsWith('.mjs') && !name.endsWith('-flight.mjs')).sort()
154-
: [];
155-
for (const name of bins) {
156-
const module = await importedModule<Partial<CompiledCliInvocationModule>>(join(binRoot, name));
157-
if (!isCliInvocationModule(module)) continue;
127+
switch (request.production.kind) {
128+
case 'direct':
129+
return { input: request.input };
130+
case 'cli': {
131+
if (request.surface.kind !== 'cli') {
132+
throw new ProductionRouteInvocationError(
133+
ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE,
134+
`Manifest CLI binding does not match route surface ${JSON.stringify(request.surface.kind)}.`,
135+
);
136+
}
137+
const module = await importedModule<Partial<CompiledCliInvocationModule>>(
138+
join(request.artifactRoot, request.production.preparation),
139+
);
140+
if (!isCliInvocationModule(module)) {
141+
throw new ProductionRouteInvocationError(
142+
ROUTE_INVOCATION_PREPARATION_FAILURE_CODE,
143+
`Compiled CLI preparation ${JSON.stringify(request.production.preparation)} does not export the route invocation contract.`,
144+
);
145+
}
158146
return {
159147
cli: module,
160148
input: module.prepareRouteInvocation(request.routeId, request.surface.args) as JsonValue,
161149
};
162150
}
163-
throw new ProductionRouteInvocationError(
164-
ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE,
165-
`Compiled CLI route ${JSON.stringify(request.routeId)} has no invocation entry.`,
166-
);
151+
case 'event': {
152+
const wrapper = await importedModule<CompiledEventWrapperModule>(
153+
join(request.artifactRoot, request.production.preparation),
154+
);
155+
if (typeof wrapper.prepareRouteInvocation !== 'function') {
156+
throw new ProductionRouteInvocationError(
157+
ROUTE_INVOCATION_PREPARATION_FAILURE_CODE,
158+
`Compiled event preparation ${JSON.stringify(request.production.preparation)} does not export prepareRouteInvocation.`,
159+
);
160+
}
161+
const native = (request.input as { readonly native?: JsonObject }).native ?? {};
162+
const preflight = await wrapper.prepareRouteInvocation(native, signal, observeTrace);
163+
return {
164+
input: {
165+
canonical: preflight.props.canonical,
166+
native: preflight.native,
167+
...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute'
168+
? { preflight: preflight.gate.data }
169+
: {}),
170+
},
171+
preflight,
172+
};
173+
}
174+
default: {
175+
const exhaustive: never = request.production;
176+
throw new Error(`Unsupported production binding ${String(exhaustive)}.`);
177+
}
167178
}
168-
if (route?.kind !== 'event-route') return { input: request.input };
169-
const wrapperPath = eventWrapperPath(request);
170-
if (wrapperPath === undefined) return { input: request.input };
171-
const wrapper = await importedModule<CompiledEventWrapperModule>(wrapperPath);
172-
if (typeof wrapper.prepareRouteInvocation !== 'function') return { input: request.input };
173-
const native = (request.input as { readonly native?: JsonObject }).native ?? {};
174-
const preflight = await wrapper.prepareRouteInvocation(native, signal, observeTrace);
175-
return {
176-
input: {
177-
canonical: preflight.props.canonical,
178-
native: preflight.native,
179-
...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute'
180-
? { preflight: preflight.gate.data }
181-
: {}),
182-
},
183-
preflight,
184-
};
185179
};
186180

187181
const invocationFor = (
@@ -227,39 +221,6 @@ const invocationFor = (
227221
}
228222
};
229223

230-
const candidatesFor = async (request: ProductionRequest): Promise<readonly string[]> => {
231-
const route = request.manifest.routes[request.routeId];
232-
if (route === undefined) return Object.freeze([]);
233-
if (request.surface.kind === 'cli') {
234-
return workerFiles(join(request.artifactRoot, 'bin'));
235-
}
236-
switch (route.kind) {
237-
case 'cli':
238-
return workerFiles(join(request.artifactRoot, 'bin'));
239-
case 'script': {
240-
const name = request.manifest.scripts.find((candidate) => candidate.routeId === request.routeId)?.name;
241-
return name === undefined
242-
? Object.freeze([])
243-
: Object.freeze([join(request.artifactRoot, 'scripts', `${name}-flight.mjs`)]);
244-
}
245-
case 'event-route':
246-
return Object.freeze([
247-
...await workerFiles(join(request.artifactRoot, 'mcp')),
248-
join(request.artifactRoot, 'hooks', 'hooks-flight.mjs'),
249-
].filter(existsSync));
250-
case 'prompt':
251-
case 'resource':
252-
case 'tool':
253-
return workerFiles(join(request.artifactRoot, 'mcp'));
254-
case 'app':
255-
return Object.freeze([]);
256-
default: {
257-
const exhaustive: never = route.kind;
258-
throw new Error(`Unsupported route kind ${String(exhaustive)}.`);
259-
}
260-
}
261-
};
262-
263224
const streamFromWorker = (
264225
workerPath: string,
265226
request: ProductionRequest,
@@ -453,13 +414,6 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly<Reco
453414
: { input };
454415
};
455416

456-
const missingRouteWorkerError = (error: unknown): boolean =>
457-
error instanceof Error
458-
&& (
459-
error.message.includes('Generated route must default-export')
460-
|| error.message.includes('Generated rendered route must default-export')
461-
);
462-
463417
/**
464418
* Drives one compiled worker's render stream. Each event is handed to
465419
* `publishRender` as it arrives and then dropped; only the `complete` event's
@@ -481,49 +435,52 @@ const renderCompiled = async (
481435
};
482436
}>> => {
483437
const invocation = invocationFor(request, input);
484-
const candidates = await candidatesFor(request);
485-
for (const workerPath of candidates) {
486-
const startedAt = performance.now();
487-
const session = streamFromWorker(workerPath, request, invocation, input, signal, env, trace);
488-
let document: AgentDocument | undefined;
489-
try {
490-
const reader = session.events.getReader();
491-
for (;;) {
492-
const next = await reader.read();
493-
if (next.done) break;
494-
if (next.value.type === 'complete') document = next.value.document;
495-
await publishRender?.(next.value);
496-
}
497-
if (document === undefined) throw new Error('Compiled route render ended without a complete event.');
498-
return Object.freeze({
499-
document,
500-
durationMs: performance.now() - startedAt,
501-
observed: {
502-
providers: Object.freeze([...session.observed.providers]),
503-
timings: Object.freeze([...session.observed.timings]),
504-
},
505-
});
506-
} catch (error) {
507-
if (!missingRouteWorkerError(error)) throw error;
508-
} finally {
509-
await session.close();
438+
const startedAt = performance.now();
439+
const session = streamFromWorker(
440+
join(request.artifactRoot, request.production.executable),
441+
request,
442+
invocation,
443+
input,
444+
signal,
445+
env,
446+
trace,
447+
);
448+
let document: AgentDocument | undefined;
449+
try {
450+
const reader = session.events.getReader();
451+
for (;;) {
452+
const next = await reader.read();
453+
if (next.done) break;
454+
if (next.value.type === 'complete') document = next.value.document;
455+
await publishRender?.(next.value);
510456
}
457+
if (document === undefined) throw new Error('Compiled route render ended without a complete event.');
458+
return Object.freeze({
459+
document,
460+
durationMs: performance.now() - startedAt,
461+
observed: {
462+
providers: Object.freeze([...session.observed.providers]),
463+
timings: Object.freeze([...session.observed.timings]),
464+
},
465+
});
466+
} finally {
467+
await session.close();
511468
}
512-
throw new ProductionRouteInvocationError(
513-
ROUTE_INVOCATION_COMPILED_ROUTE_UNAVAILABLE_CODE,
514-
`No compiled worker owns route ${JSON.stringify(request.routeId)}.`,
515-
);
516469
};
517470

518471
export const renderProductionRoute = async (
519472
request: RouteInvocationChildRequest,
520473
publishTrace?: EventTraceObserver,
521474
publishRender?: (event: AgentRenderEvent) => Promise<void> | void,
522475
): Promise<RouteInvocationChildResult> => {
523-
if (request.artifactEpoch === undefined || request.artifactRoot === undefined) {
476+
if (
477+
request.artifactEpoch === undefined
478+
|| request.artifactRoot === undefined
479+
|| request.production === undefined
480+
) {
524481
throw new ProductionRouteInvocationError(
525482
ROUTE_INVOCATION_ARTIFACT_UNAVAILABLE_CODE,
526-
'Production route invocation requires a published artifact.',
483+
'Production route invocation requires a manifest-selected published artifact executable.',
527484
);
528485
}
529486
const productionRequest = request as ProductionRequest;

0 commit comments

Comments
 (0)