Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7ff85d1
fix(test): sequence script-playground drain scenarios instead of raci…
ScriptedAlchemy Aug 31, 2026
5130a0e
fix(test): build the rsc-agent-runtime example payload once before po…
ScriptedAlchemy Aug 31, 2026
73b3ad6
perf(test): derive CI integration workers from cores like local (remo…
ScriptedAlchemy Aug 31, 2026
7578a38
perf(test): drop rstest maxWorkers pins and isolate shared roots per …
ScriptedAlchemy Aug 31, 2026
cecd987
fix(test): await reload-channel socket observations instead of assert…
ScriptedAlchemy Aug 31, 2026
92df9b2
fix(test): scale the request/response capture polls in mcp-app-real t…
ScriptedAlchemy Aug 31, 2026
bc862e0
Merge PR #90 (perf/rstest-auto-workers): per-RSTEST_WORKER_ID isolati…
ScriptedAlchemy Aug 31, 2026
745ef40
fix(workbench): give the MCP App frame force-close timer a load-toler…
ScriptedAlchemy Aug 31, 2026
cf0d100
fix(test): write watched dev-workbench configs atomically and scale t…
ScriptedAlchemy Aug 31, 2026
90d4e3e
Merge remote-tracking branch 'origin/main' into perf/ci-parallel-unpin
ScriptedAlchemy Aug 31, 2026
007e2d7
fix(dev): make the graceful-close receipt window dominate the relay's…
ScriptedAlchemy Aug 31, 2026
844bf44
Merge remote-tracking branch 'origin/main' into perf/ci-parallel-unpin
ScriptedAlchemy Sep 1, 2026
2462715
fix(dev): restart preparation when the config changes between load an…
ScriptedAlchemy Sep 1, 2026
107b242
test(dev): shorten the graceful-close receipt expiry probe through an…
ScriptedAlchemy Sep 1, 2026
9128116
fix(test): sequence the overview HMR edit through the owned reload ch…
ScriptedAlchemy Sep 1, 2026
4533319
fix(test): scale the CLI suite's fixed budgets by the suite time scale
ScriptedAlchemy Sep 1, 2026
c14f05f
Merge remote-tracking branch 'origin/main' into land-118
ScriptedAlchemy Sep 1, 2026
0298dae
fix(test): browser pools load a browser-safe setup without node: buil…
ScriptedAlchemy Sep 1, 2026
88313ac
Merge remote-tracking branch 'origin/main' into land-118
ScriptedAlchemy Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import type { McpAppConsentRequest } from './mcp-app-sandbox.ts';
import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts';

const bodyLimit = 64 * 1024;
const gracefulCloseReceiptTimeoutMs = 5_000;
// A force-close DELETE that lands after an accepted graceful close must stay
// idempotent (200, not 404), so this window has to dominate the frame relay's
// force-close budget — clients may fall back as late as their closeTimeoutMs,
// which mcp-app-frame.tsx caps at 30s.
const gracefulCloseReceiptTimeoutMs = 35_000;

interface RequestDiagnostic {
readonly code: string;
Expand Down Expand Up @@ -84,6 +88,12 @@ export interface McpAppRoutePreviewService {

export interface McpAppRoutesOptions {
readonly authorize: (request: IncomingMessage) => void;
/**
* Test-only override for the graceful-close receipt window. Production
* callers must leave this unset so the window keeps dominating the frame
* relay's force-close budget.
*/
readonly gracefulCloseReceiptTimeoutMs?: number;
readonly service?: McpAppRoutePreviewService;
}

Expand Down Expand Up @@ -445,13 +455,15 @@ const bridgeHostContext = (host: McpAppPreviewHostContext): McpAppBridgeJsonReco
/** Authenticated HTTP boundary for already-bound MCP App previews. */
export class McpAppRoutes {
readonly #authorize: (request: IncomingMessage) => void;
readonly #gracefulCloseReceiptTimeoutMs: number;
readonly #service: McpAppRoutePreviewService | undefined;
readonly #tails = new Map<string, Promise<void>>();
readonly #teardowns = new Map<string, ReturnType<typeof setTimeout>>();
#closed = false;

constructor(options: McpAppRoutesOptions) {
this.#authorize = options.authorize;
this.#gracefulCloseReceiptTimeoutMs = options.gracefulCloseReceiptTimeoutMs ?? gracefulCloseReceiptTimeoutMs;
this.#service = options.service;
}

Expand Down Expand Up @@ -644,7 +656,7 @@ export class McpAppRoutes {
if (this.#closed) return;
const receipt = setTimeout(() => {
if (this.#teardowns.get(bindingId) === receipt) this.#teardowns.delete(bindingId);
}, gracefulCloseReceiptTimeoutMs);
}, this.#gracefulCloseReceiptTimeoutMs);
this.#teardowns.set(bindingId, receipt);
}

Expand Down
28 changes: 27 additions & 1 deletion packages/agent-bundle/src/dev/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ export class ProjectService {
}
}

async #prepare(command: ProjectCommand): Promise<PreparedProject> {
async #prepare(command: ProjectCommand, tornRetries = 0): Promise<PreparedProject> {
const requestedRoot = resolve(this.#options.root);
const registry = this.#registry;
const requestedConfigPath = resolve(requestedRoot, this.#options.configPath ?? 'agent-bundle.config.ts');
Expand Down Expand Up @@ -678,6 +678,14 @@ export class ProjectService {
return failedPreparation('AB7002', 'Unable to prepare project paths.', requestedConfigPath, 'project.invalid-source');
}
const configPath = resolve(root, this.#options.configPath ?? 'agent-bundle.config.ts');
const configIdentity = async (): Promise<string | undefined> => {
try {
return createHash('sha256').update(await readFile(configPath)).digest('hex');
} catch {
return undefined;
}
};
const configIdentityBeforeLoad = await configIdentity();
log(this.#options.logger, 'project.load', { command, root });

let loaded;
Expand Down Expand Up @@ -724,6 +732,24 @@ export class ProjectService {
} catch {
return failedPreparation('AB7003', 'Unable to snapshot project source.', loaded.configPath, 'project.invalid-source');
}
// loadConfig evaluated the config from one read while the snapshot hashed
// it in another; a config replacement landing between the two reads would
// otherwise produce a torn preparation whose model belongs to the old
// bytes while its revision hashes the new tree. Consumers dedupe prepared
// deliveries by revision, so a torn preparation reconciles a stale model
// under a fresh revision. When the config changed mid-prepare, restart the
// preparation so both reads agree; the retry cap only yields once writes
// outpace prepares for several consecutive rounds, which no real editor
// or test harness sustains.
if (tornRetries < 3) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P2] Fail closed instead of publishing the fourth torn preparation

After three consecutive config changes, tornRetries is 3 and this entire comparison is skipped, so the method proceeds with the config loaded before the latest write but the snapshot/revision captured after it—the exact stale-model/fresh-revision state this fix is intended to prevent. A save burst or generated config writer can sustain four rounds; assuming it will not does not preserve the preparation invariant. Keep checking on the final attempt and return a typed invalid/transient preparation (or otherwise retry outside this bounded call) when the identities still differ, rather than accepting the torn state.

const configIdentityAfterSnapshot = await configIdentity();
if (
configIdentityBeforeLoad !== undefined && configIdentityAfterSnapshot !== undefined &&
configIdentityBeforeLoad !== configIdentityAfterSnapshot
) {
return this.#prepare(command, tornRetries + 1);
}
}
const runtime = runtimeDeclaration(this.#options.includeDevRuntime === true, loaded.config, loaded.configPath);
const runtimeMetadata = runtime.declaration === undefined
? Object.freeze({ changed: false, config: loaded.config })
Expand Down
15 changes: 8 additions & 7 deletions packages/agent-bundle/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core';

import { runCli as runSourceCli } from '../src/cli.ts';
import { cachedNpmInstallArguments } from './support/shared-pack.ts';
import { timeScale } from './support/time-scale.ts';

const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
Expand Down Expand Up @@ -163,7 +164,7 @@ it('builds a selected target through the built executable from a path containing
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);

it('runs MCP and hook operations from a packed consumer with explicit and temporary artifacts', async () => {
await buildCliPackage();
Expand Down Expand Up @@ -294,7 +295,7 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor
rm(consumer.root, { force: true, recursive: true }),
]);
}
}, 60_000);
}, 60_000 * timeScale);

it('keeps inspect JSON stable and validates only the supplied artifact', async () => {
await buildCliPackage();
Expand Down Expand Up @@ -352,7 +353,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async (
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);

it('prints a complete invalid inspection on JSON and human output', async () => {
const project = await createCliProject();
Expand Down Expand Up @@ -381,7 +382,7 @@ it('prints a complete invalid inspection on JSON and human output', async () =>
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);

it('reports an unselected inspect target on JSON and human output', async () => {
const project = await createCliProject();
Expand Down Expand Up @@ -415,7 +416,7 @@ it('reports an unselected inspect target on JSON and human output', async () =>
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);

it('dumps the synthesized bundler configuration with inspect --bundler', async () => {
const project = await createCliProject();
Expand Down Expand Up @@ -476,7 +477,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async (
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);

it('reports source validation diagnostics on stderr before staging an artifact', async () => {
await buildCliPackage();
Expand Down Expand Up @@ -508,4 +509,4 @@ it('reports source validation diagnostics on stderr before staging an artifact',
} finally {
await rm(resolve(project.root, '..'), { force: true, recursive: true });
}
}, 30_000);
}, 30_000 * timeScale);
47 changes: 25 additions & 22 deletions packages/agent-bundle/tests/dev-workbench-packaging.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile as executeFile } from 'node:child_process';
import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -16,13 +16,7 @@ const workbenchRoot = join(workspaceRoot, 'packages', 'workbench');
const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE');
let built: Promise<void> | undefined;

const buildPackage = async (force = false): Promise<void> => {
if (force) {
// The stale-asset pruning test rebuilds on purpose; the prebuilt seam
// never skips it because the rebuild itself is the behavior under test.
await execFile('pnpm', ['build'], { cwd: workspaceRoot });
return;
}
const buildPackage = async (): Promise<void> => {
if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return;
built ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined);
await built;
Expand Down Expand Up @@ -57,17 +51,26 @@ it('copies stable prebuilt workbench assets and the exact app-renderer license i

it('prunes stale copied workbench assets without removing the package library output', async () => {
await buildPackage();
const workbench = join(packageRoot, 'dist', 'workbench');
const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js');
await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true });
await writeFile(stale, 'obsolete workbench output\n');
await expect(access(stale)).resolves.toBeUndefined();

await buildPackage(true);

await expect(access(stale)).rejects.toThrow();
await expect(access(join(packageRoot, 'dist', 'cli.js'))).resolves.toBeUndefined();
expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map');
const isolatedRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-prune-'));
const isolatedDist = join(isolatedRoot, 'dist');
try {
await cp(join(packageRoot, 'dist'), isolatedDist, { recursive: true });
const workbench = join(isolatedDist, 'workbench');
const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js');
await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true });
await writeFile(stale, 'obsolete workbench output\n');
await expect(access(stale)).resolves.toBeUndefined();
await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [
'build',
'--config', join(packageRoot, 'rslib.config.ts'),
'--dist-path', isolatedDist,
], { cwd: workspaceRoot });
await expect(access(stale)).rejects.toThrow();
await expect(access(join(isolatedDist, 'cli.js'))).resolves.toBeUndefined();
expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map');
} finally {
await rm(isolatedRoot, { force: true, recursive: true });
}
}, 60_000);

it('serves prebuilt workbench assets from an installed tarball without the repository source tree', async () => {
Expand All @@ -83,7 +86,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos
expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu);

await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n');
await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer });
await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() });
await mkdir(join(project, 'skills', 'review'), { recursive: true });
await Promise.all([
writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
Expand All @@ -99,7 +102,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos
' console.log(JSON.stringify({ body: await response.text(), status: response.status }));',
'} finally { await session.close(); }',
].join('\n');
const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer });
const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() });
expect(JSON.parse(served.stdout)).toMatchObject({
body: expect.stringContaining('Agent Bundle workbench'),
status: 200,
Expand All @@ -115,7 +118,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d
const project = join(consumer, 'project');
try {
await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n');
await execFile('npm', ['install', '--omit=dev', ...cachedNpmInstallArguments, tarball], { cwd: consumer });
await execFile('npm', ['install', '--omit=dev', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() });
await mkdir(join(project, 'skills', 'review'), { recursive: true });
await Promise.all([
writeFile(join(project, 'package.json'), '{"type":"module"}\n'),
Expand Down
22 changes: 13 additions & 9 deletions packages/agent-bundle/tests/dev-workbench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import type { ForegroundCoordinator, ForegroundServerOptions } from '../src/dev/foreground-server.ts';
import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts';
import { agentBundleNodeModules } from './helpers/workspace-paths.ts';
import { timeScale } from './support/time-scale.ts';
import { replaceWatchedSource } from './support/watched-files.ts';

const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
const decoder = new TextDecoder();
Expand Down Expand Up @@ -437,7 +439,7 @@ it('latches a runtime declaration added to an ordinary Workbench session as rest
if (cookie === null) throw new Error('Expected foreground session bootstrap cookie.');
events = openProjectEventStream(server.url, cookie);
await events.opened;
await writeFile(project.configPath, [
await replaceWatchedSource(project.root, project.configPath, [
"import { defineConfig } from 'agent-bundle';",
'',
'export default defineConfig({',
Expand Down Expand Up @@ -610,7 +612,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl
});
expect(runtimeState.subscribes).toBe(0);

await writeFile(project.configPath, config(['portable'], 'valid-first', '{}'));
await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-first', '{}'));
await within((async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const response = await create();
Expand All @@ -634,7 +636,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl
unsubscribes: runtimeState.unsubscribes,
};

await writeFile(project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN'));
await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN'));
const invalid = await fetch(`${server.url}/api/project/rebuild`, {
body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }),
headers,
Expand Down Expand Up @@ -669,7 +671,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl
}).then((response) => response.status)).resolves.toBe(200);
expect(runtimeState).toEqual(stableRuntime);

await writeFile(project.configPath, config(['portable'], 'valid-repair', '{}'));
await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-repair', '{}'));
await expect(fetch(`${server.url}/api/project/rebuild`, {
body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }),
headers,
Expand All @@ -683,7 +685,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl
unsubscribes: stableRuntime.unsubscribes,
});

await writeFile(project.configPath, config(['portable'], 'valid-removal', undefined, false));
await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-removal', undefined, false));
await expect(fetch(`${server.url}/api/project/rebuild`, {
body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }),
headers,
Expand Down Expand Up @@ -788,7 +790,7 @@ it('fences a closing foreground before a held valid runtime reconcile can attach
const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } });
const { token } = await bootstrap.json() as { readonly token: string };
const headers = { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token };
await writeFile(project.configPath, config(['portable']));
await replaceWatchedSource(project.root, project.configPath, config(['portable']));
const rebuilding = fetch(`${server.url}/api/project/rebuild`, {
body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }),
headers,
Expand Down Expand Up @@ -913,7 +915,7 @@ it('does not reconcile a valid preparation released after foreground close begin
subscribes: runtimeState.subscribes,
};

await writeFile(project.configPath, config('held-after-close', true));
await replaceWatchedSource(project.root, project.configPath, config('held-after-close', true));
const rebuilding = fetch(`${server.url}/api/project/rebuild`, {
body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }),
headers,
Expand Down Expand Up @@ -976,7 +978,7 @@ it('does not publish a prepared runtime topology after foreground close begins',
},
},
});
await writeFile(project.configPath, [
await replaceWatchedSource(project.root, project.configPath, [
"import { defineConfig } from 'agent-bundle';",
`const state = globalThis[${JSON.stringify(stateKey)}];`,
"if (state === undefined) throw new Error('Missing prepared topology close state.');",
Expand Down Expand Up @@ -1529,7 +1531,9 @@ it('records a durable playground trace and promotes it through the packaged fore
expect(run.id).not.toBe(binding.hook);
expect(run.session.state).toBe('open');
let terminal: string | undefined;
for (let attempt = 0; attempt < 25; attempt += 1) {
// Finalization settles asynchronously after the run settles, so the poll
// budget follows the suite time scale like every other readiness wait.
for (let attempt = 0; attempt < 250 * timeScale; attempt += 1) {
const session = await fetch(`${server.url}/api/playground/sessions/${run.session.id}`, { headers });
const body = await session.json() as { readonly session: { readonly state: string } };
terminal = body.session.state;
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/tests/helpers/project-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';

import { rstestWorkerRoot } from '../../../../rstest.worker-isolation.ts';

export interface ProjectFixture {
configPath: string;
imagePath: string;
Expand Down Expand Up @@ -33,7 +34,7 @@ const sourceEntryPoint = resolve(
export const createProjectFixture = async (
options: ProjectFixtureOptions = {},
): Promise<ProjectFixture> => {
const root = await mkdtemp(join(tmpdir(), options.prefix ?? 'agent-bundle-config-'));
const root = await mkdtemp(join(rstestWorkerRoot(), options.prefix ?? 'agent-bundle-config-'));
const skillDir = join(root, 'skills/review');
const skillSource = join(skillDir, 'SKILL.md');
const imagePath = join(skillDir, 'assets/diagram.png');
Expand Down
Loading
Loading