Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7e34582
fix: address late review threads on merged PRs #368/#373/#374/#377/#3…
ScriptedAlchemy Sep 3, 2026
3298444
fix(dev): lease before publishing contract status; align fixtures wit…
ScriptedAlchemy Sep 3, 2026
2976ccc
chore(changeset): drop the Codex tool_response bullet already release…
ScriptedAlchemy Sep 3, 2026
f701116
fix(playground,test): withdraw a failed catalog publication before re…
ScriptedAlchemy Sep 3, 2026
3d19529
chore(changeset): one-paragraph summary ending with the PR reference
ScriptedAlchemy Sep 3, 2026
0eb4c32
fix(playground): recover a catalog staging link abandoned by an exite…
ScriptedAlchemy Sep 3, 2026
f7b7b99
fix(playground): fsync the catalog directory after withdrawing an aba…
ScriptedAlchemy Sep 3, 2026
15baf3f
fix(dev): forward request _meta (progress token) through McpSession a…
ScriptedAlchemy Sep 3, 2026
bf1ba3e
fix(dev,playground): restart the adoption drain after a handoff race;…
ScriptedAlchemy Sep 3, 2026
56c017e
ci: retrigger checks for the rebased head
ScriptedAlchemy Sep 3, 2026
3bfc760
chore: drop the portable byte-lane changes superseded by #406; keep t…
ScriptedAlchemy Sep 3, 2026
c9bd5a2
fix(playground): restore the staging guard when a recovery fsync fails
ScriptedAlchemy Sep 3, 2026
2e6c8d1
fix(playground): accept a concurrently restored staging guard (EEXIST…
ScriptedAlchemy Sep 3, 2026
b641117
fix(dev): recheck supersession after the adoption lease settles
ScriptedAlchemy Sep 3, 2026
7cfbc67
fix(playground): keep a fresh pid-owned guard when recovery can neith…
ScriptedAlchemy Sep 3, 2026
d0861a1
fix(playground): fsync every compensating recovery guard before trust…
ScriptedAlchemy Sep 3, 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
17 changes: 17 additions & 0 deletions .changeset/late-review-thread-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"agent-bundle": patch
---

`agent-bundle dev` now leases the adopted epoch until another epoch replaces it
or the server closes, so store retention cannot delete the advertised last-good
build during a run of failing rebuilds, and an epoch that cannot be leased is
reported as `AB7211` instead of adopted; the `dev.contracts` matrix opens the
configured server on a target whose manifest carries it, applies the session
timeout per request, forwards each request's `_meta.progressToken` so generated
routes emit progress, and observes lifecycle progress through the session trace
(`ContractMatrixClient` from `agent-bundle/test` gains an optional
`observeProgress` seam, `ContractMatrixProgressSource`; `McpSession.callTool`
accepts `_meta`). Native Playground catalog readers wait for a hard-link
publisher to release its staging link before adopting the sidecar, return to
discovery when that publication is rolled back, and recover a staging link
abandoned by an exited publisher instead of rejecting the epoch forever. (#408)
14 changes: 8 additions & 6 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,14 @@ must not regress it: `pnpm bench:hook-cold-start -- --check`.

## Parked toolchain follow-ups

Toolchain pins that are deliberately held back ride the same named chore as
the Effect RC re-pin (see `AGENTS.md`). Each row records the pin, the exact
registry state observed when the row was written (`npm view <pkg> dist-tags`
/ `versions`), and the trigger that turns the row into a chore. Re-verify
every row during a re-pin; when a trigger has fired, do the upgrade in its
own chore PR and retire the row.
Toolchain pins that are deliberately held back are tracked here. Only the
`repos/effect` subtree update is coupled to the Effect RC re-pin (see
`AGENTS.md`); every other row has its own independent trigger. Each row
records the pin, the exact registry state observed when the row was written
(`npm view <pkg> dist-tags` / `versions`), and the trigger that turns the row
into a chore. Re-verify every row during a re-pin, but never delay or bundle a
row whose trigger has already fired: do that upgrade in its own chore PR as
soon as the trigger fires and retire the row.

| Recorded | Pin (where) | Observed registry state | Trigger / action |
| --- | --- | --- | --- |
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ assets against this anchor rather than the process working directory: Claude Cod
launches stdio servers from the host's own working directory and ignores stdio `cwd` at runtime,
and its placeholder-substitution table excludes `cwd`, so the Claude adapter emits no `cwd` for a
plugin-root working directory (the absolute `${CLAUDE_PLUGIN_ROOT}/mcp/...` entry path plus this
env anchor carry the guarantee) and rejects token-bearing `cwd` values outright. A server's own
env anchor carry the guarantee). That canonical plugin-root `cwd` is the one accepted token-bearing
value on Claude; any other `cwd` that carries a path token is rejected. A server's own
`env` entries win over the injected value, so declaring
`env: { AGENT_BUNDLE_PLUGIN_ROOT: ... }` replaces the anchor. The `pluginRootEnvAnchor` export
names the variable for consumer code.
Expand Down
71 changes: 57 additions & 14 deletions packages/agent-bundle/src/dev/dev-contract-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ContractMatrixViolationError,
runDevEpochContractMatrix,
type ContractMatrixClient,
type ContractProgressNotification,
} from '../test/contract.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { emptyCompiledRouteGraph } from '../routes/graph.ts';
Expand Down Expand Up @@ -45,10 +46,29 @@ const failed = (
summary,
});

const targetFor = (prepared: PreparedProject): string => {
const targets = prepared.model?.targets.map((target) => target.name) ?? [];
const target = targets.includes('portable') ? 'portable' : targets[0];
if (target === undefined) throw new Error('Development contract matrix requires at least one generated target.');
/**
* The generated target whose manifest carries the selected server. A server
* restricted to `targets: ['claude']` is absent from the portable manifest, so
* the choice is made over the server's own target list intersected with the
* project's; `portable` still wins whenever it is eligible.
*/
export const devContractTarget = (
prepared: Pick<PreparedProject, 'model'>,
serverName: string,
): string => {
const projectTargets = prepared.model?.targets.map((target) => target.name) ?? [];
const server = prepared.model?.mcpServers.find((candidate) => candidate.name === serverName);
const eligible = server === undefined
? projectTargets
: projectTargets.filter((target) => server.targets.includes(target));
const target = eligible.includes('portable') ? 'portable' : eligible[0];
if (target === undefined) {
throw new Error(
projectTargets.length === 0
? 'Development contract matrix requires at least one generated target.'
: `Development contract matrix server ${JSON.stringify(serverName)} is emitted for none of the project's targets.`,
);
}
return target;
};

Expand All @@ -61,40 +81,64 @@ const serverFor = (prepared: PreparedProject, requested: string | undefined): st
return names[0];
};

const matrixClient = (session: McpSession, signal: AbortSignal): ContractMatrixClient => ({
/** The session's per-request timeout, applied afresh to each matrix request rather than once for the whole matrix. */
const requestSignal = (session: Pick<McpSession, 'timeoutMs'>, options: { readonly signal?: AbortSignal } | undefined): AbortSignal => {
const timeout = AbortSignal.timeout(session.timeoutMs);
return options?.signal === undefined ? timeout : AbortSignal.any([timeout, options.signal]);
};

type MatrixSession = Pick<
McpSession,
'callTool' | 'getPrompt' | 'listPrompts' | 'listResources' | 'listTools' | 'readResource' | 'subscribeTrace' | 'timeoutMs' | 'trace'
>;

export const matrixClient = (session: MatrixSession): ContractMatrixClient => ({
callTool: async (params, options) => session.callTool({
// Lifecycle fixtures pass their progress token here; generated routes only
// send progress when the request's `_meta.progressToken` is present.
...(params._meta === undefined ? {} : { _meta: params._meta }),
arguments: params.arguments ?? {},
name: params.name,
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
Comment on lines 96 to 103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Forward progress metadata through the dev matrix client

When a development lifecycle fixture expects progress, callToolResult supplies its generated token in params._meta, but this adapter forwards only arguments and name; McpSession.callTool likewise reconstructs the wire request without _meta. Generated routes enable sendProgress only when context.mcpReq._meta.progressToken is present, so these calls emit no matching notifications and every lifecycle fixture requiring progress fails the matrix, preventing the epoch from being adopted. Extend the session call options and wire request to preserve _meta.

Useful? React with 👍 / 👎.

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.

Fixed in ee9ec2f: McpSessionToolCallOptions and McpClient.callTool params gain _meta (McpRequestMeta, with progressToken), McpSession.#callToolEffect forwards it to the SDK client's callTool params, and the dev matrix adapter passes params._meta through. Tests: dev-contract-runner.test.ts asserts the adapter forwards the lifecycle progress token (and omits _meta when absent); the McpSession timeout test now also asserts the wire params carry _meta only when supplied.

}),
getPrompt: async (params, options) => session.getPrompt({
...(params.arguments === undefined ? {} : { arguments: params.arguments }),
name: params.name,
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
}),
listPrompts: async (_params, options) => ({
prompts: [...await session.listPrompts({
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
})],
}),
listResources: async (_params, options) => ({
resources: [...await session.listResources({
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
})],
}),
listTools: async (_params, options) => ({
tools: [...await session.listTools({
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
})],
}),
// Lifecycle fixtures count live progress; the session records every server
// notification in its trace synchronously on receipt, so a live trace
// subscription is the supported notification path for this non-SDK client.
observeProgress: (listener) => {
const latest = session.trace().entries.at(-1)?.sequence ?? 0;
const subscription = session.subscribeTrace({ afterSequence: latest }, (entry) => {
if ('kind' in entry && entry.kind === 'progress') listener({ params: entry.payload as ContractProgressNotification['params'] });
});
return () => subscription.unsubscribe();
},
readResource: async (params, options) => ({
contents: [...(await session.readResource({
signal: options?.signal === undefined ? signal : AbortSignal.any([signal, options.signal]),
signal: requestSignal(session, options),
...(options?.timeout === undefined ? {} : { timeoutMs: options.timeout }),
uri: params.uri,
})).contents] as never[],
Expand All @@ -113,8 +157,8 @@ export const runDevEpochContracts = async (
contracts.diagnostics,
);
}
const target = targetFor(prepared);
const serverName = serverFor(prepared, contracts.server);
const target = devContractTarget(prepared, serverName);
const manifest = testManifestFromRouteGraph({
apps: prepared.model?.mcpApps ?? [],
configPath: prepared.configPath,
Expand Down Expand Up @@ -145,13 +189,12 @@ export const runDevEpochContracts = async (
serverName,
target,
});
const signal = AbortSignal.timeout(session.timeoutMs);
await runDevEpochContractMatrix({
fixtures: contracts.fixtures,
manifest,
...(contracts.server === undefined ? {} : { server: contracts.server }),
session: {
client: matrixClient(session, signal),
client: matrixClient(session),
provenance: {
epochId,
proofLevel: DEV_EPOCH_PROOF_LEVEL,
Expand Down
Loading
Loading