Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/reconcile-dev-host-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Make `agent-bundle dev --install-host` remove stale manager-published entries while preserving neighboring host and user state, including rollback after a failed epoch publication. (#784)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1596,7 +1596,7 @@ host-facing build together with the failed checks.
| --- | --- | --- | --- |
| `AB7200` | error | A development rebuild could not be admitted: the coordinator is closed, closing, or not yet started. | Restart `agent-bundle dev`; no epoch changed. |
| `AB7201` | error | The prepare, lint, or artifact phase of a development rebuild threw instead of reporting diagnostics. The message names the phase and the underlying error. | Fix the named failure and save again; the last-good epoch stays active. |
| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. |
| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication restores removed manager-owned entries and rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. |
| `AB7210` | error | `dev.contracts` is malformed, its `fixtures` module escapes the project root, cannot be loaded, or default-exports something other than route-id keyed `ContractRouteFixture` objects. Reported on `dev.contract.status` for the affected epoch; compilation is unaffected. | Correct `dev.contracts` or the fixture module and rebuild; host surfaces keep the last passing epoch meanwhile. |
| `AB7211` | error | The development contract matrix failed or could not complete for a published epoch. The message carries the aggregated `contract-violation` detail; `dev.contract.status` lists the failed check names grouped by route. That epoch is never adopted by live host connections or development installs. | Fix the failing route or fixture and rebuild; a passing epoch is adopted normally. |
| `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. |
Expand Down
6 changes: 4 additions & 2 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,10 @@ the host ever seeing a disconnect:
installs a marked development variant through the ordinary installer once,
pointing the host's MCP document at the proxy, then re-syncs hooks, Skills,
and MCP Apps into the host's own layout on every adopted epoch with atomic
generation swaps and rollback (`AB7202`). Hooks are spawned per event, so
they pick up the new epoch on their next invocation.
generation swaps and rollback (`AB7202`). Re-sync removes only top-level
entries recorded as manager-published; neighboring host or user entries are
never inferred to be owned. Hooks are spawned per event, so they pick up the
new epoch on their next invocation.
3. **A contract gate on adoption.** Declaring `dev.contracts` in
`agent-bundle.config.ts` runs the generated contract matrix against each
published epoch through an epoch-pinned generated stdio session before any
Expand Down
12 changes: 7 additions & 5 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,11 +405,13 @@ not affect whether the project-local framework can be spawned.

Each later `artifact.available` event copies the new target into an immutable installed generation.
Top-level directories switch by atomic symlink (or Windows junction) rename and top-level files by
atomic sibling-file rename, so a host sees an old or new complete entry and no synchronized
directory disappears between generations. A failed publication rolls pointers back to the prior
generation and emits an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no
`artifact.available`, so the last-good install is unchanged. Re-sync writes the host cache directly
and does not invoke the Claude or Codex CLI again.
atomic sibling-file rename, so a host sees each retained entry as an old or new complete value.
Re-sync records top-level ownership under `.agent-bundle-dev/` and removes only manager-published
entries the new epoch no longer contains, leaving neighboring host and user state untouched. A
failed publication restores removed entries and rolls pointers back to the prior generation before
emitting an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no `artifact.available`, so
the last-good install is unchanged. Re-sync writes the host cache directly and does not invoke the
Claude or Codex CLI again.

Stopping the dev server leaves the marked development install in place. Hooks and Skills remain on
disk, while the stable proxy command fails closed until that project dev server is running again.
Expand Down
186 changes: 175 additions & 11 deletions packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, join, relative, resolve } from 'node:path';
Expand All @@ -16,6 +18,8 @@ import { Effect, FileSystem } from 'effect';
import { readArtifactManifest } from '../build/manifest-file.ts';
import { reindexArtifactManifest } from '../build/manifest-reindex.ts';
import { stableJson } from '../core/digest.ts';
import { isErrno } from '../core/errors.ts';
import { isPortablePathSegment } from '../core/paths.ts';
import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts';
import { platformRunOf } from './platform-run.ts';
import type { DevPlatformRuntime } from './platform-runtime.ts';
Expand Down Expand Up @@ -43,6 +47,7 @@ import type { EpochReference, EpochStore } from './epoch-store.ts';
import type { ProjectEventHub, ProjectEventSubscription } from './events.ts';

export const DEV_INSTALL_MARKER = '.agent-bundle-dev.json';
const DEV_INSTALL_STATE = '.agent-bundle-dev';

interface EpochReferenceSource {
acquireEpochReference(epochId: string): Promise<Pick<EpochReference, 'close' | 'epoch' | 'root'>>;
Expand Down Expand Up @@ -76,6 +81,12 @@ interface DevInstallMarker {
readonly schemaVersion: 1;
}

interface PublishedEntriesManifest {
readonly entries: readonly string[];
readonly epochId: string;
readonly schemaVersion: 1;
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);

Expand Down Expand Up @@ -222,7 +233,75 @@ const pathExists = async (path: string): Promise<boolean> => {
};

const generationRoot = (destination: string, epochId: string): string =>
join(destination, '.agent-bundle-dev', 'generations', epochId);
join(destination, DEV_INSTALL_STATE, 'generations', epochId);

const publishedEntriesPath = (destination: string): string =>
join(destination, DEV_INSTALL_STATE, 'published.json');

const entryNames = async (root: string): Promise<readonly string[]> => {
const names: string[] = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is not a regular file or directory.`);
}
if (entry.name === DEV_INSTALL_STATE) {
throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is reserved for manager state.`);
}
names.push(entry.name);
}
return names.sort((left, right) => left.localeCompare(right));
};

const readPublishedEntries = async (destination: string): Promise<PublishedEntriesManifest | undefined> => {
const path = publishedEntriesPath(destination);
let document: unknown;
try {
if (!(await lstat(path)).isFile()) {
throw new TypeError(`Development published-entries manifest ${JSON.stringify(path)} is not a regular file.`);
}
document = JSON.parse(await readFile(path, 'utf8')) as unknown;
} catch (error) {
if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined;
throw error;
}
if (
!isRecord(document) ||
document.schemaVersion !== 1 ||
typeof document.epochId !== 'string' ||
!isPortablePathSegment(document.epochId) ||
!Array.isArray(document.entries) ||
!document.entries.every(
(entry) => typeof entry === 'string' && entry !== DEV_INSTALL_STATE && isPortablePathSegment(entry),
) ||
new Set(document.entries).size !== document.entries.length
) {
return undefined;
}
return Object.freeze({
entries: Object.freeze([...document.entries]),
epochId: document.epochId,
schemaVersion: 1,
});
};

const writePublishedEntries = async (
destination: string,
epochId: string,
entries: readonly string[],
): Promise<void> => {
const path = publishedEntriesPath(destination);
const temporary = `${path}.${process.pid}-${crypto.randomUUID()}.tmp`;
await mkdir(join(destination, DEV_INSTALL_STATE), { recursive: true });
try {
await writeFile(temporary, `${stableJson({ entries, epochId, schemaVersion: 1 })}\n`, {
flag: 'wx',
mode: 0o600,
});
await rename(temporary, path);
} finally {
await rm(temporary, { force: true });
}
};

const installGeneration = async (
destination: string,
Expand Down Expand Up @@ -294,6 +373,7 @@ const publishFile = async (
const publishInstalledGeneration = async (
destination: string,
epochId: string,
published: string[] = [],
): Promise<void> => {
const generation = generationRoot(destination, epochId);
const entries = await readdir(generation, { withFileTypes: true });
Expand All @@ -305,23 +385,89 @@ const publishInstalledGeneration = async (
} else {
throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is not a regular file or directory.`);
}
published.push(entry.name);
}
};

const publishDevGeneration = async (
const reconcilePublishedEntries = async (
destination: string,
bundleRoot: string,
previous: PublishedEntriesManifest | undefined,
epochId: string,
nextEntries: readonly string[],
publish: ((published: string[]) => Promise<void>) | undefined,
afterManifest?: () => Promise<void>,
): Promise<void> => {
await installGeneration(destination, bundleRoot, epochId);
await publishInstalledGeneration(destination, epochId);
const previousEntries = new Set(previous?.entries ?? []);
const stale = [...previousEntries].filter((entry) => !nextEntries.includes(entry));
const previousGeneration = previous === undefined ? undefined : generationRoot(destination, previous.epochId);
const canRepublishPrevious = previousGeneration !== undefined && await pathExists(previousGeneration);
const needsBackup = !canRepublishPrevious && (
publish === undefined ? stale.length > 0 : previousEntries.size > 0
);
const backup = needsBackup
? join(destination, DEV_INSTALL_STATE, `rollback-${process.pid}-${crypto.randomUUID()}`)
: undefined;
const backupEntries = backup === undefined
? []
: publish === undefined ? stale : [...previousEntries];
if (backup !== undefined) await mkdir(backup, { recursive: true });
const published: string[] = [];
let manifestPublished = false;
try {
for (const entry of canRepublishPrevious ? stale : backupEntries) {
const path = join(destination, entry);
if (backup === undefined) {
await rm(path, { force: true, recursive: true });
Comment thread
ScriptedAlchemy marked this conversation as resolved.
} else {
try {
await rename(path, join(backup, entry));
} catch (error) {
if (!isErrno(error, 'ENOENT')) throw error;
}
}
}
await publish?.(published);
await writePublishedEntries(destination, epochId, nextEntries);
manifestPublished = true;
await afterManifest?.();
} catch (error) {
try {
for (const entry of published) {
if (backup !== undefined || !previousEntries.has(entry)) {
await rm(join(destination, entry), { force: true, recursive: true });
}
}
if (canRepublishPrevious && previous !== undefined) {
await publishInstalledGeneration(destination, previous.epochId);
} else if (backup !== undefined) {
for (const entry of backupEntries) {
const path = join(backup, entry);
if (await pathExists(path)) await rename(path, join(destination, entry));
}
}
if (manifestPublished) {
if (previous === undefined) {
await rm(publishedEntriesPath(destination), { force: true });
} else {
await writePublishedEntries(destination, previous.epochId, previous.entries);
}
}
if (backup !== undefined) await rm(backup, { force: true, recursive: true });
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'Failed to roll back development host publication.', {
cause: rollbackError,
});
}
throw error;
}
if (backup !== undefined) await rm(backup, { force: true, recursive: true }).catch(() => undefined);
};

const pruneGenerations = async (
destination: string,
retainedEpochIds: readonly string[],
): Promise<void> => {
const root = join(destination, '.agent-bundle-dev', 'generations');
const root = join(destination, DEV_INSTALL_STATE, 'generations');
const retained = new Set(retainedEpochIds);
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && !retained.has(entry.name)) {
Expand Down Expand Up @@ -507,6 +653,8 @@ export class DevHostInstallManager {
this.#installed.set(host, installed);
}
const previousEpochId = installed.epochId;
const previousPublished = await readPublishedEntries(installed.destination);
const nextEntries = await entryNames(prepared.root);
let generationPublished = false;
try {
let refreshedByAppServer = false;
Expand All @@ -519,16 +667,32 @@ export class DevHostInstallManager {
if (plugin === undefined || marketplaceDocument === undefined) {
throw new TypeError('Cannot refresh a Codex development install with no plugin marketplace identity.');
}
await request('plugin/install', {
marketplacePath: join(source, marketplaceDocument),
pluginName: plugin,
});
await reconcilePublishedEntries(
installed.destination,
previousPublished,
epochId,
nextEntries,
undefined,
async () => {
await request('plugin/install', {
marketplacePath: join(source, marketplaceDocument),
pluginName: plugin,
});
},
);
return true;
},
) === true;
}
if (!refreshedByAppServer) {
await publishDevGeneration(installed.destination, prepared.root, epochId);
await installGeneration(installed.destination, prepared.root, epochId);
await reconcilePublishedEntries(
installed.destination,
previousPublished,
epochId,
nextEntries,
(published) => publishInstalledGeneration(installed.destination, epochId, published),
);
generationPublished = true;
}
} catch (error) {
Expand Down
Loading
Loading