diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index 7b95e5d60..5f11282a5 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -1,9 +1,9 @@ -import { Deferred, Effect, Semaphore } from 'effect'; +import { Cause, Deferred, Effect, Exit, Result, Semaphore } from 'effect'; import { resolve } from 'node:path'; import { freezeDiagnostics, hasErrors } from '../core/diagnostics.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; -import { liftPromise } from '../effect/lift.ts'; +import { liftPromise, liftTry } from '../effect/lift.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { ArtifactService, type ArtifactEpochResult, type FailedArtifactEpochResult } from './artifacts/artifact-service.ts'; import { DiagnosticService, type DiagnosticReport } from './diagnostic-service.ts'; @@ -199,7 +199,6 @@ const artifactStatusFor = ( export class DevCoordinator { readonly #acquireLock: (options: DevLockOptions) => Promise; readonly #artifactService: ArtifactBuilder; - readonly #cancelStartup: () => void; readonly #createAttemptId: () => string; readonly #createWatcher: (options: ProjectWatcherOptions) => DevelopmentWatcher; readonly #diagnosticService: AffectedFileDiagnostics; @@ -216,7 +215,11 @@ export class DevCoordinator { /** Serializes build passes; admission below guarantees one holder, the permit makes the invariant structural. */ readonly #buildPermit: Semaphore.Semaphore = runSync(Semaphore.make(1)); readonly #startRebuildToken = Symbol('DevCoordinator initial rebuild'); - readonly #startupCancellation: Promise; + /** + * Fails once `close()` cancels a startup that is still blocked before its + * first build; every startup step races against it (`#awaitStartup`). + */ + readonly #startupClosed: Deferred.Deferred = runSync(Deferred.make()); #activeEpoch: ArtifactEpoch | undefined; #closing = false; #closePromise: Promise | undefined; @@ -236,11 +239,6 @@ export class DevCoordinator { this.#acquireLock = options.acquireLock ?? acquireDevLock; this.#epochStore = options.epochStore ?? new EpochStore({ projectRoot: this.#root }); this.#artifactService = options.artifactService ?? new ArtifactService({ epochStore: this.#epochStore }); - let cancelStartup: () => void = () => undefined; - this.#startupCancellation = new Promise((resolvePromise) => { - cancelStartup = resolvePromise; - }); - this.#cancelStartup = cancelStartup; this.#createAttemptId = options.createAttemptId ?? (() => crypto.randomUUID()); this.#createWatcher = options.createWatcher ?? ((watcherOptions) => new ProjectWatcher(watcherOptions)); this.#diagnosticService = options.diagnosticService ?? new DiagnosticService({ root: this.#root }); @@ -264,7 +262,7 @@ export class DevCoordinator { async start(): Promise { if (this.#startPromise !== undefined) return this.#startPromise; if (this.#closing) throw new Error('DevCoordinator is closed.'); - this.#startPromise = this.#start(); + this.#startPromise = runPromise(this.#startEffect()); return this.#startPromise; } @@ -312,68 +310,89 @@ export class DevCoordinator { return this.#closePromise; } - async #start(): Promise { - try { - await this.#acquireStartupLock(); - this.#assertOpen(); - await this.#awaitStartup(this.#epochStore.recoverStaging()); - this.#assertOpen(); - this.#activeEpoch = await this.#awaitStartup(this.#epochStore.readActiveEpoch()); - this.#assertOpen(); - const projectIgnoreRules = await this.#awaitStartup(readProjectIgnoreRules(this.#root)); - this.#assertOpen(); - this.#watcher = this.#createWatcher({ + /** + * Startup as one Effect: every blocking step races the close signal + * (`#awaitStartup`), and a failed startup releases whatever it acquired — + * watcher and lock concurrently, outcomes ignored — before re-raising the + * original error. Sync steps are lifted so a throw is a typed failure the + * cleanup handler sees, never a defect that skips it. + */ + #startEffect(): Effect.Effect { + const startup = Effect.gen({ self: this }, function* (this: DevCoordinator) { + yield* this.#acquireStartupLock(); + yield* this.#assertOpenEffect(); + yield* this.#awaitStartup(() => this.#epochStore.recoverStaging()); + yield* this.#assertOpenEffect(); + this.#activeEpoch = yield* this.#awaitStartup(() => this.#epochStore.readActiveEpoch()); + yield* this.#assertOpenEffect(); + const projectIgnoreRules = yield* this.#awaitStartup(() => readProjectIgnoreRules(this.#root)); + yield* this.#assertOpenEffect(); + const watcher = yield* liftTry(() => this.#createWatcher({ ignoredPaths: this.#ignoredPaths, isIgnored: (source) => isProjectPathIgnored(projectIgnoreRules, this.#root, source), now: this.#now, onInvalidation: async (invalidation) => this.rebuild(invalidation), outputPaths: this.#outputPaths, root: this.#root, - }); - await this.#awaitStartup(this.#watcher.ready?.() ?? Promise.resolve()); - this.#assertOpen(); - await this.#rebuild(nowInvalidation(this.#now, 'initial', []), this.#startRebuildToken); + })); + this.#watcher = watcher; + yield* this.#awaitStartup(() => watcher.ready?.() ?? Promise.resolve()); + yield* this.#assertOpenEffect(); + yield* liftPromise(() => this.#rebuild(nowInvalidation(this.#now, 'initial', []), this.#startRebuildToken)); const session: DevSession = Object.freeze({ close: () => this.close(), status: () => this.status(), }); this.#session = session; return session; - } catch (error) { - await Promise.allSettled([ - this.#releaseWatcher(), - this.#releaseLock(), - ]); + }); + return startup.pipe(Effect.catch((error) => Effect.gen({ self: this }, function* (this: DevCoordinator) { + yield* Effect.forEach( + [() => this.#releaseWatcher(), () => this.#releaseLock()], + (release) => Effect.exit(liftPromise(release)), + { concurrency: 'unbounded' }, + ); this.#watcher = undefined; this.#lock = undefined; - throw error; - } + return yield* Effect.fail(error); + }))); } - #assertOpen(): void { - if (this.#closing) throw new Error('DevCoordinator is closed.'); + #assertOpenEffect(): Effect.Effect { + return Effect.suspend(() => this.#closing + ? Effect.fail(new Error('DevCoordinator is closed.')) + : Effect.void); } - async #acquireStartupLock(): Promise { - const acquisition = this.#acquireLock({ projectRoot: this.#root }); - try { - this.#lock = await this.#awaitStartup(acquisition); - } catch (error) { - void acquisition.then( - (lock) => lock.close().catch(() => undefined), - () => undefined, + /** + * A lock that resolves after startup was cancelled is released, not kept: + * the acquisition is started once, raced against close, and drained when + * it loses. + */ + #acquireStartupLock(): Effect.Effect { + return Effect.suspend(() => { + const acquisition = this.#acquireLock({ projectRoot: this.#root }); + return this.#awaitStartup(() => acquisition).pipe( + Effect.flatMap((lock) => Effect.sync(() => { + this.#lock = lock; + })), + Effect.tapError(() => Effect.sync(() => { + void acquisition.then( + (lock) => lock.close().catch(() => undefined), + () => undefined, + ); + })), ); - throw error; - } + }); + } + + /** One startup step raced against `close()`; the loser is interrupted. */ + #awaitStartup(operation: () => Promise): Effect.Effect { + return Effect.raceFirst(liftPromise(operation), Deferred.await(this.#startupClosed)); } - async #awaitStartup(operation: Promise): Promise { - return Promise.race([ - operation, - this.#startupCancellation.then(() => { - throw new Error('DevCoordinator is closed.'); - }), - ]); + #cancelStartup(): void { + runSync(Deferred.fail(this.#startupClosed, new Error('DevCoordinator is closed.'))); } #releaseLock(): Promise { @@ -399,7 +418,7 @@ export class DevCoordinator { */ #startBuild(invalidation: Invalidation): Promise { const current = runPromise(this.#buildPermit.withPermit( - liftPromise(() => this.#performBuild(invalidation)).pipe( + this.#performBuild(invalidation).pipe( Effect.onExit(() => Effect.sync(() => this.#drainQueuedBuild())), ), )); @@ -473,53 +492,63 @@ export class DevCoordinator { return result; } - async #performBuild(invalidation: Invalidation): Promise { - let prepared: PreparedProject; - try { - const initial = this.#nextPreparedProject; - this.#nextPreparedProject = undefined; - prepared = initial ?? await this.#projectService.prepare(this.#prepareCommand); - } catch (error) { - const source = withDiagnostics(this.#status.source, [phaseDiagnostic('prepare', error)]); + /** + * One serialized build pass. Only the leaf I/O is lifted — prepare, the + * prepared-project hook, lint, the artifact build, and the package build — + * and each phase's failure is exposed as a `Result` so it completes the + * attempt as a failed build result. Status and event bookkeeping stays + * synchronous inside the fiber; the program itself never fails. + */ + readonly #performBuild = Effect.fnUntraced(function* ( + this: DevCoordinator, + invalidation: Invalidation, + ): Effect.fn.Return { + const initial = this.#nextPreparedProject; + this.#nextPreparedProject = undefined; + const preparation = yield* Effect.result(initial === undefined + ? liftPromise(() => this.#projectService.prepare(this.#prepareCommand)) + : Effect.succeed(initial)); + if (Result.isFailure(preparation)) { + const source = withDiagnostics(this.#status.source, [phaseDiagnostic('prepare', preparation.failure)]); return this.#completeFailure(this.#beginBuild(invalidation, source), source, source.diagnostics); } + const prepared = preparation.success; this.#watcher?.addOutputPaths?.([prepared.artifactDistPath, ...prepared.outputRoots]); const running = this.#beginBuild(invalidation, prepared.source); - try { - await this.#onPreparedProject?.(prepared); - } catch (error) { - const source = withDiagnostics(prepared.source, [phaseDiagnostic('prepare', error)]); + const onPrepared = this.#onPreparedProject; + const hook = yield* Effect.result(onPrepared === undefined + ? Effect.void + : liftPromise(() => onPrepared(prepared))); + if (Result.isFailure(hook)) { + const source = withDiagnostics(prepared.source, [phaseDiagnostic('prepare', hook.failure)]); return this.#completeFailure(running, source, source.diagnostics); } - let lintDiagnostics: readonly Diagnostic[]; - try { - const report = await this.#diagnosticService.lint(invalidation.paths); - lintDiagnostics = freezeDiagnostics(report.diagnostics); - } catch (error) { - const source = withDiagnostics(prepared.source, [phaseDiagnostic('lint', error)]); + const lint = yield* Effect.result(liftPromise(() => this.#diagnosticService.lint(invalidation.paths))); + if (Result.isFailure(lint)) { + const source = withDiagnostics(prepared.source, [phaseDiagnostic('lint', lint.failure)]); return this.#completeFailure(running, source, source.diagnostics); } + const lintDiagnostics: readonly Diagnostic[] = freezeDiagnostics(lint.success.diagnostics); const source = withDiagnostics(prepared.source, lintDiagnostics); if (hasErrors(lintDiagnostics)) { return this.#completeFailure(running, source, source.diagnostics); } - let result: ArtifactEpochResult; - try { - result = await this.#artifactService.build(prepared); - } catch (error) { + const built = yield* Effect.result(liftPromise(() => this.#artifactService.build(prepared))); + if (Result.isFailure(built)) { return this.#completeFailure(running, source, [ ...source.diagnostics, - phaseDiagnostic('artifact', error), + phaseDiagnostic('artifact', built.failure), ]); } + const result = built.success; // The package build (bin/lib) rebuilds inside the same serialized pass, // after the artifact epoch committed: its failure never invalidates the // epoch and surfaces as warning diagnostics on the succeeded attempt. const packageDiagnostics = result.outcome === 'succeeded' - ? (await this.#packageBuildService.build(prepared, invalidation)).diagnostics + ? (yield* liftPromise(() => this.#packageBuildService.build(prepared, invalidation))).diagnostics : Object.freeze([]); const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics, ...packageDiagnostics]); if (result.outcome === 'succeeded') { @@ -546,9 +575,16 @@ export class DevCoordinator { return Object.freeze({ diagnostics, epoch: result.epoch, outcome: 'succeeded' }); } return this.#completeFailure(running, source, diagnostics); - } + }); - async #close(): Promise { + /** + * Shutdown as one Effect: wait for the in-flight build or startup to + * settle, then release every resource concurrently, capturing each `Exit` + * so no failure short-circuits another release. Every failure is reported + * together as `DevCoordinatorCloseError` (build first, then resources in + * declaration order). + */ + #close(): Promise { const hasBuildInFlight = this.#currentBuild !== undefined; const startupBlockedBeforeBuild = !hasBuildInFlight && this.#session === undefined && this.#startPromise !== undefined; @@ -557,8 +593,7 @@ export class DevCoordinator { void this.#releaseWatcher().catch(() => undefined); void this.#releaseLock().catch(() => undefined); } - const inFlight = this.#currentBuild ?? this.#startPromise; - const buildResult = await Promise.allSettled([inFlight ?? Promise.resolve()]); + const inFlight: Promise = this.#currentBuild ?? this.#startPromise ?? Promise.resolve(); const resources: readonly Readonly<{ readonly close: () => Promise; readonly resource: DevCoordinatorCloseFailure['resource']; @@ -567,19 +602,23 @@ export class DevCoordinator { { close: () => this.#diagnosticService.close(), resource: 'diagnostics' }, { close: () => this.#releaseLock(), resource: 'lock' }, ]; - const results = await Promise.allSettled(resources.map(async ({ close }) => close())); - const failures = [ - ...buildResult.flatMap((result): readonly DevCoordinatorCloseFailure[] => - hasBuildInFlight && result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: 'build' })] - : [], - ), - ...results.flatMap((result, index): readonly DevCoordinatorCloseFailure[] => - result.status === 'rejected' - ? [Object.freeze({ error: result.reason, resource: resources[index]!.resource })] - : [], - ), - ]; - if (failures.length > 0) throw new DevCoordinatorCloseError(failures); + const closeFailure = ( + exit: Exit.Exit, + resource: DevCoordinatorCloseFailure['resource'], + ): readonly DevCoordinatorCloseFailure[] => + Exit.isFailure(exit) ? [Object.freeze({ error: Cause.squash(exit.cause), resource })] : []; + return runPromise(Effect.gen(function* () { + const buildExit = yield* Effect.exit(liftPromise(() => inFlight)); + const releases = yield* Effect.forEach( + resources, + ({ close }) => Effect.exit(liftPromise(close)), + { concurrency: 'unbounded' }, + ); + const failures = [ + ...(hasBuildInFlight ? closeFailure(buildExit, 'build') : []), + ...releases.flatMap((exit, index) => closeFailure(exit, resources[index]!.resource)), + ]; + if (failures.length > 0) return yield* Effect.fail(new DevCoordinatorCloseError(failures)); + })); } } diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index f9d70f246..29b811541 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -1,5 +1,6 @@ import { Cause, Effect, Exit, Semaphore } from 'effect'; import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; @@ -431,9 +432,9 @@ export class EpochStore { /** Returns detached safe epoch identities, ordered newest first. */ async listEpochs(): Promise { return runPromise(this.#transitions.withPermit( - liftPromise(async () => Object.freeze((await this.#readAllEpochMetadata()) + this.#readAllEpochMetadata().pipe(Effect.map((entries) => Object.freeze(entries .map((metadata) => freezeArtifactEpoch(metadata.epoch)) - .sort(compareNewestFirst))), + .sort(compareNewestFirst)))), )); } @@ -469,22 +470,25 @@ export class EpochStore { /** Removes leftover staging directories from crashed or interrupted publishes. */ async recoverStaging(): Promise { - await runPromise(this.#transitions.withPermit(liftPromise(async () => { - let entries; - try { - entries = await readdir(this.#epochsPath, { withFileTypes: true }); - } catch (error) { - if (isErrno(error, 'ENOENT')) return; - throw error; - } - await Promise.all( - entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith(stagingPrefix)) - .map((entry) => rm(join(this.#epochsPath, entry.name), { force: true, recursive: true })), + await runPromise(this.#transitions.withPermit(Effect.gen({ self: this }, function* (this: EpochStore) { + const entries = yield* this.#readDirectoryEntries(this.#epochsPath); + yield* Effect.forEach( + entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(stagingPrefix)), + (entry) => liftPromise(() => rm(join(this.#epochsPath, entry.name), { force: true, recursive: true })), + { concurrency: 'unbounded', discard: true }, ); }))); } + /** Directory entries, or none when the directory does not exist yet. */ + #readDirectoryEntries(path: string): Effect.Effect { + return liftPromise(() => readdir(path, { withFileTypes: true })).pipe( + Effect.catch((error) => isErrno(error, 'ENOENT') + ? Effect.succeed([] as readonly Dirent[]) + : Effect.fail(error)), + ); + } + async #readActiveEpoch(): Promise { let value: unknown; try { @@ -537,7 +541,7 @@ export class EpochStore { #cleanupUnderLease(): Effect.Effect { return Effect.gen({ self: this }, function* (this: EpochStore) { const active = yield* liftPromise(() => this.#readActiveEpoch()); - const metadata = yield* liftPromise(() => this.#readAllEpochMetadata()); + const metadata = yield* this.#readAllEpochMetadata(); const protectedIds = new Set(active === undefined ? [] : [active.id]); for (const entry of metadata) { if ((epochReferenceCounts.get(join(this.#epochsPath, entry.epoch.id)) ?? 0) > 0) { @@ -993,23 +997,23 @@ export class EpochStore { return metadata; } - async #readAllEpochMetadata(): Promise { - let entries; - try { - entries = await readdir(this.#epochMetadataPath, { withFileTypes: true }); - } catch (error) { - if (isErrno(error, 'ENOENT')) return Object.freeze([]); - throw error; - } - return Object.freeze(await Promise.all(entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) - .map(async (entry) => { - const epochId = entry.name.slice(0, -'.json'.length); - if (!isSafePathSegment(epochId)) { - throw new EpochStoreError('EPOCH_METADATA_INVALID', 'Epoch metadata file name is not path-safe.'); - } - return this.#readEpochMetadata(epochId); - }))); + /** Every persisted epoch's metadata, read concurrently; the first failure wins. */ + #readAllEpochMetadata(): Effect.Effect { + return Effect.gen({ self: this }, function* (this: EpochStore) { + const entries = yield* this.#readDirectoryEntries(this.#epochMetadataPath); + const metadata = yield* Effect.forEach( + entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')), + (entry) => Effect.suspend(() => { + const epochId = entry.name.slice(0, -'.json'.length); + if (!isSafePathSegment(epochId)) { + return Effect.fail(new EpochStoreError('EPOCH_METADATA_INVALID', 'Epoch metadata file name is not path-safe.')); + } + return liftPromise(() => this.#readEpochMetadata(epochId)); + }), + { concurrency: 'unbounded' }, + ); + return Object.freeze(metadata); + }); } } diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 43807785e..3e7458567 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -978,6 +978,104 @@ it('uses one initial development preparation before preparing later development } }); +it('re-raises a startup failure after releasing the watcher and lock it acquired', async () => { + const root = await createProject(); + const readyFailure = new Error('watcher never became ready'); + let watcherCloses = 0; + let lockCloses = 0; + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => { lockCloses += 1; } }), + createWatcher: () => ({ + close: async () => { watcherCloses += 1; }, + ready: async () => { throw readyFailure; }, + }), + diagnosticService: { close: async () => undefined, lint: async (paths) => ({ diagnostics: [], paths }) }, + epochStore: new EpochStore({ projectRoot: root }), + projectService: new ProjectService({ root }), + root, + }); + + await expect(coordinator.start()).rejects.toBe(readyFailure); + expect([watcherCloses, lockCloses]).toEqual([1, 1]); + // A repeated start returns the same settled startup instead of retrying. + await expect(coordinator.start()).rejects.toBe(readyFailure); + await expect(coordinator.rebuild(invalidation(['src/changed.ts']))).resolves.toMatchObject({ + diagnostics: [{ code: 'AB7200', message: 'DevCoordinator must finish starting before rebuilding.' }], + outcome: 'failed', + }); + await coordinator.close(); + expect([watcherCloses, lockCloses]).toEqual([1, 1]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('fails a synchronous watcher construction error closed and releases the lock', async () => { + const root = await createProject(); + const constructionFailure = new Error('watcher construction failed'); + let lockCloses = 0; + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => { lockCloses += 1; } }), + createWatcher: () => { throw constructionFailure; }, + diagnosticService: { close: async () => undefined, lint: async (paths) => ({ diagnostics: [], paths }) }, + epochStore: new EpochStore({ projectRoot: root }), + projectService: new ProjectService({ root }), + root, + }); + + await expect(coordinator.start()).rejects.toBe(constructionFailure); + expect(lockCloses).toBe(1); + await coordinator.close(); + expect(lockCloses).toBe(1); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('turns a rejected prepared-project hook into a failed prepare attempt', async () => { + const root = await createProject(); + const hub = new ProjectEventHub({ now: () => new Date('2026-08-14T12:00:00.000Z') }); + const events: string[] = []; + hub.subscribe((event) => { + if (event.type !== 'replay.gap') events.push(event.type); + }); + let builds = 0; + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => undefined }), + artifactService: { build: async (prepared) => { + builds += 1; + return succeeded(epochFor(root, 'epoch-hook', prepared.source.revision ?? 'missing')); + } }, + createWatcher: () => ({ close: async () => undefined }), + diagnosticService: { close: async () => undefined, lint: async (paths) => ({ diagnostics: [], paths }) }, + epochStore: new EpochStore({ projectRoot: root }), + eventHub: hub, + onPreparedProject: async () => { throw new Error('hook rejection'); }, + projectService: new ProjectService({ root }), + root, + }); + + const session = await coordinator.start(); + expect(builds).toBe(0); + expect(session.status()).toMatchObject({ + build: { + lastAttempt: { + diagnostics: [{ code: 'AB7201', message: 'Prepare failed during development rebuild: hook rejection' }], + outcome: 'failed', + }, + state: 'failed', + }, + }); + expect(events).toEqual(expect.arrayContaining(['build.started', 'build.failed', 'artifact.status'])); + await session.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('turns prepare, lint, and artifact rejections into failed attempts and events', async () => { const phases = ['prepare', 'lint', 'artifact'] as const; for (const phase of phases) { diff --git a/packages/agent-bundle/tests/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index 9b9ca9160..6fec10298 100644 --- a/packages/agent-bundle/tests/epoch-store.test.ts +++ b/packages/agent-bundle/tests/epoch-store.test.ts @@ -885,6 +885,39 @@ it('removes abandoned staging directories without touching the active epoch', as } }); +it('recovers every abandoned staging directory at once and tolerates a store that never published', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent bundle staging recovery batch ')); + + try { + const fresh = new EpochStore({ projectRoot: join(root, 'never-published') }); + await expect(fresh.recoverStaging()).resolves.toBeUndefined(); + await expect(fresh.listEpochs()).resolves.toEqual([]); + + const store = new EpochStore({ projectRoot: root }); + const stagings = await Promise.all([1, 2, 3].map((index) => store.createStagingEpoch({ + epoch: epochFor(root, `epoch-${index}`, undefined, { claude: `claude-digest-${index}` }), + targets: ['claude'], + }))); + for (const staging of stagings) { + await mkdir(join(staging.root, 'claude'), { recursive: true }); + await writeFile(join(staging.root, 'claude', 'plugin.json'), 'abandoned\n'); + } + await mkdir(join(root, '.agent-bundle', 'epochs', 'not-staging'), { recursive: true }); + + await store.recoverStaging(); + + for (const staging of stagings) { + await expect(readFile(join(staging.root, 'claude', 'plugin.json'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + await expect(readdir(join(root, '.agent-bundle', 'epochs'))).resolves.toEqual(['not-staging']); + await Promise.all(stagings.map((staging) => staging.close())); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('requires selected targets to exactly match the epoch target digests', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle epoch target identity '));