Skip to content

Commit 883d1bd

Browse files
fix(build,test): snapshot the process hit synchronously before state bindings
Generated stateful scopes incremented processLifetime.hits, awaited state bindings, then snapshotted the value, so concurrent requests could observe the same count; the harness's in-memory server claimed its hit only after the bindings resolved. Both now claim and snapshot in one synchronous step before any await: the emitted scopes bind `processHit` at the increment, and the harness claims through claimProcessHit before requestBindings. The harness also looks up CLI command paths among authored commands only, since projected MCP commands carry their tool's route id and render through the tool branch.
1 parent dfa07c1 commit 883d1bd

8 files changed

Lines changed: 119 additions & 36 deletions

File tree

‎packages/agent-bundle/src/build/entry-shell.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
293293
" if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');",
294294
' const parsed = parseInput(route, input);',
295295
' const cwd = process.cwd();',
296-
' processLifetime.hits += 1;',
296+
...processHitSource(' '),
297297
...(options.state === undefined
298298
? []
299299
: [' const bindings = await runtimeState.requestBindings({ signal: context.signal });', ' try {']),
@@ -412,7 +412,7 @@ export const generatedRenderedRouteWorkerSource = (
412412
" if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');",
413413
' const controller = new AbortController();',
414414
' requests.set(message.id, controller);',
415-
' processLifetime.hits += 1;',
415+
...processHitSource(' '),
416416
' try {',
417417
' const cwd = process.cwd();',
418418
...(options.state === undefined
@@ -583,8 +583,18 @@ const providerRegistrySource = (providers: readonly CompiledProvider[]): readonl
583583
? []
584584
: ['const providers = Object.freeze([', ...providerRecords(providers), ']);'];
585585

586-
const processLifetimeValueSource =
587-
'{ hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid }';
586+
/**
587+
* Claims this request's hit on the process identity and snapshots it in the
588+
* same synchronous step, before any state binding or provider `await`, so a
589+
* concurrent request on the same scope cannot move the value this request
590+
* mounts as `providers.processLifetime`.
591+
*/
592+
const processHitSource = (indent: string): readonly string[] => [
593+
`${indent}processLifetime.hits += 1;`,
594+
`${indent}const processHit = { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid };`,
595+
];
596+
597+
const processLifetimeValueSource = 'processHit';
588598

589599
/**
590600
* Per-request provider execution shared by every generated request scope
@@ -661,7 +671,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
661671
" if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated route must default-export an async Server Component.');",
662672
' const controller = new AbortController();',
663673
' requests.set(message.id, controller);',
664-
' processLifetime.hits += 1;',
674+
...processHitSource(' '),
665675
' try {',
666676
...(options.state === undefined
667677
? []

‎packages/agent-bundle/src/routes/provider-execution.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,15 @@ export const createProviderProcessLifetime = (): ProviderProcessLifetime => ({
4141
});
4242

4343
/** The immutable snapshot of one process lifetime a request observes. */
44+
export interface ProviderProcessLifetimeValue {
45+
readonly hits: number;
46+
readonly instanceId: string;
47+
readonly pid: number;
48+
}
49+
4450
export const providerProcessLifetimeValue = (
4551
lifetime: ProviderProcessLifetime,
46-
): { readonly hits: number; readonly instanceId: string; readonly pid: number } => ({
52+
): ProviderProcessLifetimeValue => ({
4753
hits: lifetime.hits,
4854
instanceId: lifetime.instanceId,
4955
pid: lifetime.pid,

‎packages/agent-bundle/src/test/cli.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { createProviderProcessLifetime } from '../routes/provider-execution.ts';
2525
import type { CompiledCliCommand } from '../routes/types.ts';
2626
import { AgentTestError, captured } from './errors.ts';
2727
import { CLI_DISPATCH_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
28-
import { mountProviders } from './providers.ts';
28+
import { claimProcessHit, mountProviders } from './providers.ts';
2929
import { registeredRouteLoader, testManifest } from './registry.ts';
3030
import { prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit } from './render.ts';
3131
import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts';
@@ -239,7 +239,7 @@ export const invokeCli = async (
239239
explicit: context.providers,
240240
invocation: { kind: 'cli', props: { args: execution.args, command: commandPath(command) } },
241241
manifest,
242-
processLifetime,
242+
processHit: claimProcessHit(processLifetime),
243243
provenance: { ...provenance, kind: 'cli', routeId: command.routeId, source: 'manifest', targets: [] },
244244
signal: execution.signal,
245245
});

‎packages/agent-bundle/src/test/mcp.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';
2525
import { createProviderProcessLifetime } from '../routes/provider-execution.ts';
2626
import { AgentTestError, captured } from './errors.ts';
2727
import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
28-
import { mountProviders } from './providers.ts';
28+
import { claimProcessHit, mountProviders } from './providers.ts';
2929
import { registeredRouteLoader, testManifest } from './registry.ts';
3030
import type { HarnessOptionsArguments, RenderRouteContextInit } from './render.ts';
3131
import type { RenderedRouteProvenance, TestableRouteDescriptor } from './types.ts';
@@ -333,6 +333,10 @@ export const openInMemoryMcpServer = async <
333333
details: [`registered: ${Object.keys(routes).sort().join(', ')}`],
334334
});
335335
}
336+
// The hit is claimed before state bindings are awaited, in the
337+
// generated worker's order, so a failed or slow binding still consumes
338+
// this request's hit and concurrent requests keep arrival order.
339+
const processHit = claimProcessHit(processLifetime);
336340
const bindings = await runtimeState?.requestBindings({ signal: request.signal });
337341
try {
338342
// Conventional providers run before the scope opens, over the same
@@ -342,7 +346,7 @@ export const openInMemoryMcpServer = async <
342346
explicit: context.providers,
343347
invocation: request.invocation,
344348
manifest,
345-
processLifetime,
349+
processHit,
346350
...(descriptor === undefined ? {} : { provenance: routeProvenance(descriptor, manifest) }),
347351
signal: request.signal,
348352
});

‎packages/agent-bundle/src/test/providers.ts‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
providerProcessLifetimeValue,
66
type ExecutableProvider,
77
type ProviderProcessLifetime,
8+
type ProviderProcessLifetimeValue,
89
} from '../routes/provider-execution.ts';
910
import { AgentTestError } from './errors.ts';
1011
import type { AgentBundleTestManifest, TestableProviderDescriptor } from './manifest.ts';
@@ -31,15 +32,10 @@ export interface MountProvidersOptions {
3132
/** Absent for a module rendered directly: no project, so nothing to discover. */
3233
readonly manifest: AgentBundleTestManifest | undefined;
3334
/**
34-
* The process identity of the simulated executable, scoped exactly as the
35-
* artifact scopes its module-level `processLifetime`: one per CLI
36-
* invocation (each generated executable starts at hit 1), one per rendered
37-
* route request, and one per open in-memory MCP server session (shared by
38-
* every request that session handles). Never shared across unrelated
39-
* helper calls, so a provider branching on `hits` or `instanceId` cannot
40-
* observe warmth the artifact would not exhibit.
35+
* This request's claimed hit on the simulated executable's process identity
36+
* (see {@link claimProcessHit}); mounted verbatim as `providers.processLifetime`.
4137
*/
42-
readonly processLifetime: ProviderProcessLifetime;
38+
readonly processHit: ProviderProcessLifetimeValue;
4339
readonly provenance?: RenderedRouteProvenance;
4440
readonly signal: AbortSignal;
4541
}
@@ -63,29 +59,41 @@ const loadProvider = async (
6359
return { key: descriptor.key, module: await loader(), source: descriptor.relativePath };
6460
};
6561

62+
/**
63+
* Claims one request's hit on a simulated executable's process identity and
64+
* snapshots it in the same synchronous step, exactly where the generated
65+
* scopes do: before any state binding or provider module `await`, so a
66+
* concurrent request on the same identity cannot move this request's value.
67+
*
68+
* Callers scope the identity as the artifact scopes its module-level
69+
* `processLifetime`: one per CLI invocation (each generated executable starts
70+
* at hit 1), one per rendered route request, and one per open in-memory MCP
71+
* server session (shared by every request that session handles). It is never
72+
* shared across unrelated helper calls, so a provider branching on `hits` or
73+
* `instanceId` cannot observe warmth the artifact would not exhibit.
74+
*/
75+
export const claimProcessHit = (processLifetime: ProviderProcessLifetime): ProviderProcessLifetimeValue => {
76+
processLifetime.hits += 1;
77+
return providerProcessLifetimeValue(processLifetime);
78+
};
79+
6680
/**
6781
* The `providers` value for one harness request scope: the explicit map when
6882
* the test supplied one, otherwise the project's conventional providers
69-
* executed in the generated order over the caller's process identity.
83+
* executed in the generated order over the claimed process hit.
7084
*/
7185
export const mountProviders = async (options: MountProvidersOptions): Promise<AgentProviderValues> => {
7286
if (options.explicit !== undefined) return options.explicit;
73-
const { processLifetime } = options;
74-
processLifetime.hits += 1;
75-
// Snapshot before the first await, as the generated worker does right after
76-
// its increment: a concurrent request on the same lifetime must not move
77-
// this request's hit count while its provider modules load.
78-
const snapshot = providerProcessLifetimeValue(processLifetime);
7987
if (options.manifest === undefined) {
80-
return { processLifetime: snapshot };
88+
return { processLifetime: options.processHit };
8189
}
8290
const providers: ExecutableProvider[] = [];
8391
for (const descriptor of options.manifest.providers ?? []) {
8492
providers.push(await loadProvider(options.manifest, descriptor, options.provenance));
8593
}
8694
return executeProviders({
8795
invocation: options.invocation,
88-
processLifetime: { ...snapshot },
96+
processLifetime: { ...options.processHit },
8997
providers,
9098
signal: options.signal,
9199
});

‎packages/agent-bundle/src/test/render.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { createProviderProcessLifetime, type ProviderProcessLifetime } from '../
3030
import type { CompiledCliCommand } from '../routes/types.ts';
3131
import { AgentTestError, captured } from './errors.ts';
3232
import { ROUTE_UNIT_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
33-
import { mountProviders } from './providers.ts';
33+
import { claimProcessHit, mountProviders } from './providers.ts';
3434
import {
3535
registeredManifestIdentity,
3636
registeredRouteLoader,
@@ -238,7 +238,12 @@ const executableSurface = (
238238
case 'event-route':
239239
return routeId.startsWith('event:') ? routeId.slice('event:'.length) : routeId;
240240
case 'cli': {
241-
const command = manifest?.cliCommands.find((candidate) => candidate.routeId === routeId);
241+
// Only authored `src/cli/**` commands have a `cli` route kind. Projected
242+
// MCP commands (`command.mcp`) carry their tool's route id, so a request
243+
// for one resolves as that `tool` route above, exactly like the generated
244+
// entry's `command.mcp !== undefined` branch.
245+
const command = manifest?.cliCommands.find((candidate) =>
246+
candidate.mcp === undefined && candidate.routeId === routeId);
242247
if (command !== undefined) return command.path.join(' ');
243248
return (routeId.startsWith('cli:') ? routeId.slice('cli:'.length) : routeId).replaceAll('/', ' ');
244249
}
@@ -717,7 +722,7 @@ export const prepareCliRenderHost = async (
717722
explicit: context.providers,
718723
invocation,
719724
manifest: options.manifest,
720-
processLifetime: options.processLifetime,
725+
processHit: claimProcessHit(options.processLifetime),
721726
provenance: { ...options.provenance, routeId: command.routeId },
722727
signal: request.signal,
723728
});
@@ -813,7 +818,7 @@ const prepareRender = async (
813818
explicit: context.providers,
814819
invocation: request.invocation,
815820
manifest: resolved.manifest,
816-
processLifetime,
821+
processHit: claimProcessHit(processLifetime),
817822
provenance: resolved.provenance,
818823
signal: request.signal,
819824
}),

‎packages/agent-bundle/tests/entry-shell.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat
364364
'"hook:event-route:tool-after": Object.freeze({ event: "tool/after", id: "event:tool/after", kind: \'event-route\'',
365365
);
366366
expect(createHash('sha256').update(source).digest('hex')).toBe(
367-
'f0a574cc26aa4c7d5556e7468d13743c1da55d372fe5e6eae9151df0be873948',
367+
'36f042498df1933c6321bd21e4585599a0d39e5ddb3890657bd660c322f4cc23',
368368
);
369369
expect(generate({
370370
artifactEpoch: 'route-fixture@1.2.3',
@@ -518,6 +518,12 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3
518518
expect(withProviders.indexOf('for (const provider of providers)')).toBeLessThan(
519519
withProviders.indexOf('const result = await runAgentRequest({'),
520520
);
521+
// The request's hit is claimed and snapshotted in one synchronous step
522+
// before any await, so concurrent requests cannot move each other's value.
523+
expect(withProviders).toContain(
524+
'processLifetime.hits += 1;\n const processHit = { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid };',
525+
);
526+
expect(withProviders).toContain('const providerValues = { processLifetime: processHit };');
521527

522528
// A project without providers still mounts only the framework-owned process identity.
523529
const withoutProviders = entryShellModule.generatedCliBinEntrySource({
@@ -526,9 +532,7 @@ it('mounts deterministic per-request providers for plain routed CLI commands (#3
526532
routes: [route],
527533
});
528534
expect(withoutProviders).not.toContain('const providers = Object.freeze([');
529-
expect(withoutProviders).toContain(
530-
'providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },',
531-
);
535+
expect(withoutProviders).toContain('providers: { processLifetime: processHit },');
532536
expect(withoutProviders).not.toContain('import * as provider0');
533537
});
534538

‎packages/agent-bundle/tests/projection/providers.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { setTimeout as sleep } from 'node:timers/promises';
2+
13
import { describe, expect, it } from '@rstest/core';
4+
import { createMemoryStateDriver, defineState, type AgentStateDriver } from '@agent-bundle/runtime/state';
5+
import { z } from 'zod';
26

37
import { cliJson, invokeCli } from '../../src/test/cli.ts';
48
import { AgentTestError } from '../../src/test/errors.ts';
@@ -72,7 +76,11 @@ describe('conventional providers through the harness', () => {
7276
});
7377
});
7478

75-
it('mounts providers for an MCP route at the route-unit level', async () => {
79+
it('mounts providers for an MCP route at the route-unit level, including when it is also a projected CLI command', async () => {
80+
// `harness tooling` is projected onto the CLI from this tool; its command
81+
// carries the tool's route id, so rendering it takes the tool branch the
82+
// generated entry takes for `command.mcp !== undefined`.
83+
expect(testManifest().cliCommands.find((command) => command.mcp?.tool === 'tooling')?.routeId).toBe('tool:harness/tooling');
7684
const rendered = await renderRoute('tool:harness/tooling');
7785

7886
expect(rendered.result).toEqual({
@@ -152,6 +160,44 @@ describe('conventional providers through the harness', () => {
152160
expect(new Set(lifetimes.map((lifetime) => lifetime.instanceId)).size).toBe(1);
153161
});
154162

163+
it('claims the hit before awaiting state bindings, so hits follow arrival order like the generated worker', async () => {
164+
type Lifetime = { processLifetime: { hits: number; instanceId: string } };
165+
const definition = defineState({
166+
events: { changed: z.object({ value: z.string() }).strict() },
167+
id: 'providers/request-state',
168+
initial: { value: '' },
169+
lifetime: 'request',
170+
reduce: (_state, event) => ({ value: event.payload.value }),
171+
schema: z.object({ value: z.string() }).strict(),
172+
});
173+
const inner = createMemoryStateDriver({ lifetime: 'request' });
174+
let projectOpens = 0;
175+
const driver: AgentStateDriver = {
176+
...inner,
177+
open: async (opened) => {
178+
// Only the first request's project store is slow to open; the second
179+
// request's bindings resolve first.
180+
if (opened.id === definition.id && projectOpens++ === 0) await sleep(150);
181+
return inner.open(opened);
182+
},
183+
};
184+
await using session = await openInMemoryMcpServer({ state: { definition, driver } });
185+
186+
const [first, second] = await Promise.all([
187+
session.client.callTool({ arguments: {}, name: 'tooling' }),
188+
session.client.callTool({ arguments: {}, name: 'tooling' }),
189+
]);
190+
const lifetimeOf = (result: unknown): Lifetime['processLifetime'] =>
191+
(result as { structuredContent: Lifetime }).structuredContent.processLifetime;
192+
193+
// The generated worker increments and snapshots before `requestBindings`;
194+
// the request that arrived first keeps hit 1 even though its state
195+
// bindings resolved last.
196+
expect(lifetimeOf(first).hits).toBe(1);
197+
expect(lifetimeOf(second).hits).toBe(2);
198+
expect(projectOpens).toBe(2);
199+
});
200+
155201
it('gives every route-unit render a fresh process identity', async () => {
156202
type Result = { processLifetime: { hits: number; instanceId: string } };
157203
const first = (await renderRoute('tool:harness/tooling')).result as Result;

0 commit comments

Comments
 (0)