Skip to content

Commit 2fa71d4

Browse files
feat(runtime): separate the framework state root from the plugin code root (#640)
* feat(runtime): separate the framework state root from the plugin code root Artifact-hosted shells (stdio MCP entry, Flight worker, artifact CLI bin and render worker, standalone hook wrappers) resolve their state root under ~/.agent-bundle/state/<plugin>-<digest> (or $XDG_STATE_HOME/agent-bundle/...) instead of <plugin root>/state, so a read-only install launches and two installs never share state. AGENT_BUNDLE_PLUGIN_ROOT keeps naming the code root; AGENT_BUNDLE_STATE_ROOT overrides the state root. Closes #637 * chore: name #640 in the changeset * fix(state-root): anchor script workers on the artifact, pin dev sessions to <epoch>/state, ignore relative XDG_STATE_HOME Self-review findings: rendered script workers kept the cwd fallback; dev and Workbench MCP sessions would have derived one user-data root per build epoch; a relative XDG_STATE_HOME would have anchored state on the shell's cwd. uninstall --purge-data / doctor coverage of the derived root is #641. * docs: scope --purge-data to legacy state, fix zh plugin.value.root, XDG absolute-path wording
1 parent e2cddfa commit 2fa71d4

49 files changed

Lines changed: 903 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/637-state-root.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"agent-bundle": minor
3+
"@agent-bundle/runtime": minor
4+
---
5+
6+
Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/<plugin>-<digest>` (`$XDG_STATE_HOME/agent-bundle/<plugin>-<digest>` when `XDG_STATE_HOME` is an absolute path) instead of `<plugin root>/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=<old root>/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `<epoch>/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `<root>/state` directory (#641). (#640)

‎docs/entry-conventions.md‎

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -150,23 +150,33 @@ Volatile lifetimes use the memory driver. Request lifetime opens and releases
150150
fresh project and notice stores per invocation; process lifetime shares them
151151
for the generated worker or executable process.
152152

153-
Workspace-durable generated MCP workers store under
154-
`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent,
155-
the worker derives the artifact root from the parent of its own `mcp/`
156-
directory. The npm package's routed CLI bin and rendered scripts use
157-
`$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise
158-
`$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin
159-
(`bin/<name>.mjs` in the plugin root) derives the artifact root from the parent of its
160-
own `bin/` directory instead, like the MCP worker. Each generated process
161-
resolves that anchor exactly once (`resolvePluginRoot` from
162-
`@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the
163-
lineage journal, and every request scope the process opens read the same
164-
value, published as `(await agent()).plugin` — `{ root, stateRoot }` with
165-
`source: 'native'` from `AGENT_BUNDLE_PLUGIN_ROOT` or `'derived'` from the
166-
fallback — and handed to conventional providers as `plugin` beside
167-
`invocation` and `signal`. An anchor still carrying an unexpanded `${…}`
168-
token is treated as unset (reported once on stderr), never joined into a
169-
path. Notice authorization is deliberately permissive
153+
Workspace-durable artifact shells — generated MCP workers, artifact CLI bins
154+
and their render workers, rendered script workers, and standalone hook
155+
wrappers — call `resolvePluginRoot`
156+
with `stateAnchor: 'user-data'`. `AGENT_BUNDLE_PLUGIN_ROOT` still names the
157+
code root and otherwise falls back to the artifact root derived from the
158+
shell's own location. An expanded, non-blank `AGENT_BUNDLE_STATE_ROOT`
159+
independently overrides the framework state root and is made absolute with
160+
`resolve()`; otherwise the state root is
161+
`~/.agent-bundle/state/<plugin>-<digest>`, or
162+
`$XDG_STATE_HOME/agent-bundle/<plugin>-<digest>` when `XDG_STATE_HOME` is an absolute path (a relative value is ignored).
163+
`<plugin>` is the code root's safe basename (or `plugin`) and `<digest>`
164+
is the first 16 hexadecimal characters of SHA-256 over that code root's
165+
realpath, so symlinked spellings share one state root while distinct installs
166+
do not. `resolvePluginRoot` uses `os.homedir()` unless its `home` test seam is
167+
supplied. The npm package's routed CLI bin and rendered scripts keep the default
168+
`stateAnchor: 'root'`: `$AGENT_BUNDLE_PLUGIN_ROOT/state` when supplied and
169+
otherwise `$PWD/.agent-bundle/state`.
170+
171+
Each generated process resolves both roots exactly once
172+
(`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel,
173+
notice ledger, lineage journal, and every request scope the process opens read
174+
the same `stateRoot`, published with the code `root` as
175+
`(await agent()).plugin` and handed to conventional providers as `plugin`
176+
beside `invocation` and `signal`. `source` records whether the code root was
177+
native or derived; `stateSource` does the same independently for the state
178+
root. An unexpanded `${…}` token in either root override is treated as unset
179+
(and reported once on stderr), never joined into a path. Notice authorization is deliberately permissive
170180
in generated mounting v1 (`authorized`); recipient/principal matching remains
171181
enforced by the ledger — every generated scope mounts the request's `lineage`
172182
on the notice principal, so `recipient.conversation` / `recipient.root` are
@@ -272,7 +282,7 @@ interface AgentProviderContext {
272282
host: Observed<{ name }>; // exactly what the route reads on `await agent()`
273283
session: Observed<{ sessionId }>;
274284
workspace: Observed<{ root }>;
275-
plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468)
285+
plugin: Observed<{ root; stateRoot }>; // resolved code and framework state roots (#468)
276286
lineage: Observed<AgentLineage>; // own chain plus the live `tree` (#457)
277287
state?: { lifetime; read(options?) }; // the mounted state handle, `read` only
278288
notices?: { inbox(); published() }; // the request's notice handle, reads only
@@ -911,10 +921,12 @@ executable bit — invoke it as `node <plugin-root>/bin/<plugin-name>.mjs
911921
<args>`, exactly like `scripts/*.mjs`. Help, argv parsing, output modes,
912922
exit codes, and signals are identical to the package bin. One deliberate
913923
difference: workspace-durable state without a host-supplied
914-
`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of
915-
`bin/`, the same fallback the generated MCP worker beside it uses) rather
916-
than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe
917-
one store. The npm package bin keeps its `cwd` fallback.
924+
`AGENT_BUNDLE_STATE_ROOT` uses `stateAnchor: 'user-data'`, deriving
925+
`~/.agent-bundle/state/<plugin>-<digest>` (or the `XDG_STATE_HOME` equivalent)
926+
from the artifact code root. The generated MCP worker beside it makes the
927+
same derivation, so a co-installed CLI and server observe one store without
928+
writing beneath a read-only artifact. The npm package bin keeps
929+
`stateAnchor: 'root'` and its `cwd` fallback, `$PWD/.agent-bundle/state`.
918930

919931
Reaching the bin from the other surfaces:
920932

@@ -1652,7 +1664,11 @@ input, and a refresh rebinds that retained result. `<plugin> web` keeps the
16521664
installed artifact immutable: framework-owned per-server web state
16531665
(`${PLUGIN_DATA}` in declared env) lives under the user's home
16541666
(`~/.agent-bundle/web-data/<plugin>-<digest>/<server>`), never inside the
1655-
plugin root, so a read-only install still launches.
1667+
plugin root. The spawned server's SQLite state kernel, notice ledger, and
1668+
lineage journal likewise use `stateAnchor: 'user-data'` and live under
1669+
`~/.agent-bundle/state/<plugin>-<digest>` (or the `XDG_STATE_HOME` equivalent)
1670+
unless `AGENT_BUNDLE_STATE_ROOT` overrides it, so a read-only install still
1671+
launches.
16561672

16571673
## `agent-bundle/app` — the App-side bridge client
16581674

‎docs/framework-mode.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,8 @@ manifest. A root whose selection includes `cursor` or `portable` also
635635
includes a standalone `install.mjs`. Its staged copy is idempotent for identical
636636
content, records an install receipt (`.agent-bundle-install.json`: plugin,
637637
version, content hash, owned files and directories), replaces a same-version stale copy of its
638-
own plugin in place (owned files only; `state/` survives), and accepts
638+
own plugin in place (owned files only; legacy `state/` survives, while current builds keep
639+
framework state outside the plugin root), and accepts
639640
`--replace` (alias `--force`) to replace a different installed version or adopt
640641
a pre-receipt copy. Foreign directories are refused with a content-hash
641642
comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
@@ -669,7 +670,8 @@ node artifact/install.mjs --uninstall [--plan] [--mode marketplace]
669670

670671
Uninstall removes exactly what the receipt owns and reverses exactly the
671672
registrations it recorded; anything else stays and is listed as retained.
672-
Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`;
673+
Legacy durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`
674+
(current builds keep framework state outside the plugin root);
673675
the typed `data.outcome` says what the host itself decided where Agent Bundle
674676
cannot (`retained-by-host` for Claude's ~14-day orphaned copy,
675677
`removed-by-host` / `unavailable` for Codex, which has no keep-data option). A
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Plain object export keeps this packed fixture independent of the package
2+
// build, like web-surface. The generated `journal` server (src/mcp/journal)
3+
// carries the config-declared status App, and src/state.ts makes the project
4+
// workspace-durable: the packed read-only-install proof spawns its entry and
5+
// its CLI bin against an artifact nothing may write beneath (#637).
6+
export default {
7+
mcp: {
8+
servers: {
9+
journal: {
10+
apps: {
11+
status: {
12+
entry: './views/status.ts',
13+
resourceUri: 'ui://durable-web-surface-fixture/status.html',
14+
targets: ['portable'],
15+
template: './views/status.html',
16+
},
17+
},
18+
},
19+
},
20+
},
21+
plugin: {
22+
description: 'A workspace-durable plugin whose MCP App is exposed through web.apps and whose CLI reads the same state.',
23+
name: 'durable-web-surface-fixture',
24+
version: '1.0.0',
25+
},
26+
targets: ['portable'],
27+
web: { apps: [{ allow: ['call-tool'], app: 'journal/status' }] },
28+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "durable-web-surface-fixture",
3+
"private": true,
4+
"type": "module"
5+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { agent } from '@agent-bundle/runtime';
2+
import type { CliRouteConfig } from 'agent-bundle';
3+
import { z } from 'zod';
4+
5+
export const config = {
6+
description: 'Lists the journal entries the MCP record tool has written.',
7+
} satisfies CliRouteConfig;
8+
9+
export const inputSchema = z.object({}).strict();
10+
11+
export const resultSchema = z.object({
12+
entries: z.array(z.object({ note: z.string() }).strict()),
13+
revision: z.number().int().nonnegative(),
14+
}).strict();
15+
16+
interface JournalState {
17+
readonly entries: readonly { readonly note: string }[];
18+
}
19+
20+
export default async function entries() {
21+
const context = await agent();
22+
if (context.state === undefined) throw new TypeError('Journal state is unavailable.');
23+
const snapshot = await context.state.read();
24+
return { entries: (snapshot.state as JournalState).entries, revision: snapshot.revision };
25+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Agent, agent } from '@agent-bundle/runtime';
2+
import { z } from 'zod';
3+
4+
export const config = {
5+
_meta: { ui: { resourceUri: 'ui://durable-web-surface-fixture/status.html' } },
6+
description: 'Appends one note to the durable journal and reports every entry.',
7+
title: 'Record',
8+
};
9+
10+
export const inputSchema = z.object({ note: z.string().min(1) }).strict();
11+
12+
export const resultSchema = z.object({
13+
entries: z.array(z.object({ note: z.string() }).strict()),
14+
revision: z.number().int().nonnegative(),
15+
}).strict();
16+
17+
interface JournalState {
18+
readonly entries: readonly { readonly note: string }[];
19+
}
20+
21+
export default async function Record({ input }: { readonly input: z.infer<typeof inputSchema> }) {
22+
const context = await agent();
23+
if (context.state === undefined) throw new TypeError('Journal state is unavailable.');
24+
// The note is the idempotency key: a replayed note is recorded once.
25+
await context.state.dispatch('recorded', { note: input.note }, { idempotencyKey: `record:${input.note}` });
26+
const snapshot = await context.state.read();
27+
const state = snapshot.state as JournalState;
28+
const result = { entries: state.entries, revision: snapshot.revision };
29+
return (
30+
<Agent.Result value={result}>
31+
<Agent.Text>{`recorded ${String(state.entries.length)} note(s)`}</Agent.Text>
32+
</Agent.Result>
33+
);
34+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineState } from '@agent-bundle/runtime/state';
2+
import { z } from 'zod';
3+
4+
const entrySchema = z.object({ note: z.string() }).strict();
5+
6+
export default defineState({
7+
events: {
8+
recorded: entrySchema,
9+
},
10+
id: 'durable-web-surface/journal',
11+
initial: { entries: [] },
12+
lifetime: 'workspace-durable',
13+
reduce: (state, event) => ({ entries: [...state.entries, event.payload] }),
14+
schema: z.object({ entries: z.array(entrySchema) }).strict(),
15+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>Status</title>
6+
</head>
7+
<body>
8+
<main id="view"></main>
9+
</body>
10+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
document.querySelector('#view')!.textContent = 'durable-web-surface fixture status';

0 commit comments

Comments
 (0)