Skip to content

Commit b9e9dc5

Browse files
refactor(dev): finish the coordinator's Effect orchestration and fan out epoch-store reads with Effect.forEach (#513)
Startup races each blocking step against a Deferred close signal with Effect.raceFirst instead of Promise.race, and a failed startup releases the watcher and lock through Effect.forEach + Effect.exit before re-raising the original error. Shutdown captures the in-flight build's Exit and every resource release's Exit the same way and aggregates them into the unchanged DevCoordinatorCloseError. The build pass is an Effect.fnUntraced generator that lifts only its leaf I/O and exposes each phase with Effect.result. EpochStore.recoverStaging and #readAllEpochMetadata fan out with Effect.forEach({ concurrency: 'unbounded' }) instead of Promise.all inside lifted thunks; the durable-fs protocol (lstat containment, inode identity, dir fsync, atomic publication) is untouched.
1 parent 5c00b3e commit b9e9dc5

4 files changed

Lines changed: 300 additions & 126 deletions

File tree

‎packages/agent-bundle/src/dev/coordinator.ts‎

Lines changed: 133 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Deferred, Effect, Semaphore } from 'effect';
1+
import { Cause, Deferred, Effect, Exit, Result, Semaphore } from 'effect';
22
import { resolve } from 'node:path';
33

44
import { freezeDiagnostics, hasErrors } from '../core/diagnostics.ts';
55
import { runPromise, runSync } from '../effect/boundary.ts';
6-
import { liftPromise } from '../effect/lift.ts';
6+
import { liftPromise, liftTry } from '../effect/lift.ts';
77
import type { Diagnostic } from '../core/diagnostics.ts';
88
import { ArtifactService, type ArtifactEpochResult, type FailedArtifactEpochResult } from './artifacts/artifact-service.ts';
99
import { DiagnosticService, type DiagnosticReport } from './diagnostic-service.ts';
@@ -199,7 +199,6 @@ const artifactStatusFor = (
199199
export class DevCoordinator {
200200
readonly #acquireLock: (options: DevLockOptions) => Promise<DevLockHandle>;
201201
readonly #artifactService: ArtifactBuilder;
202-
readonly #cancelStartup: () => void;
203202
readonly #createAttemptId: () => string;
204203
readonly #createWatcher: (options: ProjectWatcherOptions) => DevelopmentWatcher;
205204
readonly #diagnosticService: AffectedFileDiagnostics;
@@ -216,7 +215,11 @@ export class DevCoordinator {
216215
/** Serializes build passes; admission below guarantees one holder, the permit makes the invariant structural. */
217216
readonly #buildPermit: Semaphore.Semaphore = runSync(Semaphore.make(1));
218217
readonly #startRebuildToken = Symbol('DevCoordinator initial rebuild');
219-
readonly #startupCancellation: Promise<void>;
218+
/**
219+
* Fails once `close()` cancels a startup that is still blocked before its
220+
* first build; every startup step races against it (`#awaitStartup`).
221+
*/
222+
readonly #startupClosed: Deferred.Deferred<never, Error> = runSync(Deferred.make<never, Error>());
220223
#activeEpoch: ArtifactEpoch | undefined;
221224
#closing = false;
222225
#closePromise: Promise<void> | undefined;
@@ -236,11 +239,6 @@ export class DevCoordinator {
236239
this.#acquireLock = options.acquireLock ?? acquireDevLock;
237240
this.#epochStore = options.epochStore ?? new EpochStore({ projectRoot: this.#root });
238241
this.#artifactService = options.artifactService ?? new ArtifactService({ epochStore: this.#epochStore });
239-
let cancelStartup: () => void = () => undefined;
240-
this.#startupCancellation = new Promise<void>((resolvePromise) => {
241-
cancelStartup = resolvePromise;
242-
});
243-
this.#cancelStartup = cancelStartup;
244242
this.#createAttemptId = options.createAttemptId ?? (() => crypto.randomUUID());
245243
this.#createWatcher = options.createWatcher ?? ((watcherOptions) => new ProjectWatcher(watcherOptions));
246244
this.#diagnosticService = options.diagnosticService ?? new DiagnosticService({ root: this.#root });
@@ -264,7 +262,7 @@ export class DevCoordinator {
264262
async start(): Promise<DevSession> {
265263
if (this.#startPromise !== undefined) return this.#startPromise;
266264
if (this.#closing) throw new Error('DevCoordinator is closed.');
267-
this.#startPromise = this.#start();
265+
this.#startPromise = runPromise(this.#startEffect());
268266
return this.#startPromise;
269267
}
270268

@@ -312,68 +310,89 @@ export class DevCoordinator {
312310
return this.#closePromise;
313311
}
314312

315-
async #start(): Promise<DevSession> {
316-
try {
317-
await this.#acquireStartupLock();
318-
this.#assertOpen();
319-
await this.#awaitStartup(this.#epochStore.recoverStaging());
320-
this.#assertOpen();
321-
this.#activeEpoch = await this.#awaitStartup(this.#epochStore.readActiveEpoch());
322-
this.#assertOpen();
323-
const projectIgnoreRules = await this.#awaitStartup(readProjectIgnoreRules(this.#root));
324-
this.#assertOpen();
325-
this.#watcher = this.#createWatcher({
313+
/**
314+
* Startup as one Effect: every blocking step races the close signal
315+
* (`#awaitStartup`), and a failed startup releases whatever it acquired —
316+
* watcher and lock concurrently, outcomes ignored — before re-raising the
317+
* original error. Sync steps are lifted so a throw is a typed failure the
318+
* cleanup handler sees, never a defect that skips it.
319+
*/
320+
#startEffect(): Effect.Effect<DevSession, unknown> {
321+
const startup = Effect.gen({ self: this }, function* (this: DevCoordinator) {
322+
yield* this.#acquireStartupLock();
323+
yield* this.#assertOpenEffect();
324+
yield* this.#awaitStartup(() => this.#epochStore.recoverStaging());
325+
yield* this.#assertOpenEffect();
326+
this.#activeEpoch = yield* this.#awaitStartup(() => this.#epochStore.readActiveEpoch());
327+
yield* this.#assertOpenEffect();
328+
const projectIgnoreRules = yield* this.#awaitStartup(() => readProjectIgnoreRules(this.#root));
329+
yield* this.#assertOpenEffect();
330+
const watcher = yield* liftTry(() => this.#createWatcher({
326331
ignoredPaths: this.#ignoredPaths,
327332
isIgnored: (source) => isProjectPathIgnored(projectIgnoreRules, this.#root, source),
328333
now: this.#now,
329334
onInvalidation: async (invalidation) => this.rebuild(invalidation),
330335
outputPaths: this.#outputPaths,
331336
root: this.#root,
332-
});
333-
await this.#awaitStartup(this.#watcher.ready?.() ?? Promise.resolve());
334-
this.#assertOpen();
335-
await this.#rebuild(nowInvalidation(this.#now, 'initial', []), this.#startRebuildToken);
337+
}));
338+
this.#watcher = watcher;
339+
yield* this.#awaitStartup(() => watcher.ready?.() ?? Promise.resolve());
340+
yield* this.#assertOpenEffect();
341+
yield* liftPromise(() => this.#rebuild(nowInvalidation(this.#now, 'initial', []), this.#startRebuildToken));
336342
const session: DevSession = Object.freeze({
337343
close: () => this.close(),
338344
status: () => this.status(),
339345
});
340346
this.#session = session;
341347
return session;
342-
} catch (error) {
343-
await Promise.allSettled([
344-
this.#releaseWatcher(),
345-
this.#releaseLock(),
346-
]);
348+
});
349+
return startup.pipe(Effect.catch((error) => Effect.gen({ self: this }, function* (this: DevCoordinator) {
350+
yield* Effect.forEach(
351+
[() => this.#releaseWatcher(), () => this.#releaseLock()],
352+
(release) => Effect.exit(liftPromise(release)),
353+
{ concurrency: 'unbounded' },
354+
);
347355
this.#watcher = undefined;
348356
this.#lock = undefined;
349-
throw error;
350-
}
357+
return yield* Effect.fail(error);
358+
})));
351359
}
352360

353-
#assertOpen(): void {
354-
if (this.#closing) throw new Error('DevCoordinator is closed.');
361+
#assertOpenEffect(): Effect.Effect<void, Error> {
362+
return Effect.suspend(() => this.#closing
363+
? Effect.fail(new Error('DevCoordinator is closed.'))
364+
: Effect.void);
355365
}
356366

357-
async #acquireStartupLock(): Promise<void> {
358-
const acquisition = this.#acquireLock({ projectRoot: this.#root });
359-
try {
360-
this.#lock = await this.#awaitStartup(acquisition);
361-
} catch (error) {
362-
void acquisition.then(
363-
(lock) => lock.close().catch(() => undefined),
364-
() => undefined,
367+
/**
368+
* A lock that resolves after startup was cancelled is released, not kept:
369+
* the acquisition is started once, raced against close, and drained when
370+
* it loses.
371+
*/
372+
#acquireStartupLock(): Effect.Effect<void, unknown> {
373+
return Effect.suspend(() => {
374+
const acquisition = this.#acquireLock({ projectRoot: this.#root });
375+
return this.#awaitStartup(() => acquisition).pipe(
376+
Effect.flatMap((lock) => Effect.sync(() => {
377+
this.#lock = lock;
378+
})),
379+
Effect.tapError(() => Effect.sync(() => {
380+
void acquisition.then(
381+
(lock) => lock.close().catch(() => undefined),
382+
() => undefined,
383+
);
384+
})),
365385
);
366-
throw error;
367-
}
386+
});
387+
}
388+
389+
/** One startup step raced against `close()`; the loser is interrupted. */
390+
#awaitStartup<T>(operation: () => Promise<T>): Effect.Effect<T, unknown> {
391+
return Effect.raceFirst(liftPromise(operation), Deferred.await(this.#startupClosed));
368392
}
369393

370-
async #awaitStartup<T>(operation: Promise<T>): Promise<T> {
371-
return Promise.race([
372-
operation,
373-
this.#startupCancellation.then(() => {
374-
throw new Error('DevCoordinator is closed.');
375-
}),
376-
]);
394+
#cancelStartup(): void {
395+
runSync(Deferred.fail(this.#startupClosed, new Error('DevCoordinator is closed.')));
377396
}
378397

379398
#releaseLock(): Promise<void> {
@@ -399,7 +418,7 @@ export class DevCoordinator {
399418
*/
400419
#startBuild(invalidation: Invalidation): Promise<ArtifactEpochResult> {
401420
const current = runPromise(this.#buildPermit.withPermit(
402-
liftPromise(() => this.#performBuild(invalidation)).pipe(
421+
this.#performBuild(invalidation).pipe(
403422
Effect.onExit(() => Effect.sync(() => this.#drainQueuedBuild())),
404423
),
405424
));
@@ -473,53 +492,63 @@ export class DevCoordinator {
473492
return result;
474493
}
475494

476-
async #performBuild(invalidation: Invalidation): Promise<ArtifactEpochResult> {
477-
let prepared: PreparedProject;
478-
try {
479-
const initial = this.#nextPreparedProject;
480-
this.#nextPreparedProject = undefined;
481-
prepared = initial ?? await this.#projectService.prepare(this.#prepareCommand);
482-
} catch (error) {
483-
const source = withDiagnostics(this.#status.source, [phaseDiagnostic('prepare', error)]);
495+
/**
496+
* One serialized build pass. Only the leaf I/O is lifted — prepare, the
497+
* prepared-project hook, lint, the artifact build, and the package build —
498+
* and each phase's failure is exposed as a `Result` so it completes the
499+
* attempt as a failed build result. Status and event bookkeeping stays
500+
* synchronous inside the fiber; the program itself never fails.
501+
*/
502+
readonly #performBuild = Effect.fnUntraced(function* (
503+
this: DevCoordinator,
504+
invalidation: Invalidation,
505+
): Effect.fn.Return<ArtifactEpochResult, unknown> {
506+
const initial = this.#nextPreparedProject;
507+
this.#nextPreparedProject = undefined;
508+
const preparation = yield* Effect.result(initial === undefined
509+
? liftPromise(() => this.#projectService.prepare(this.#prepareCommand))
510+
: Effect.succeed(initial));
511+
if (Result.isFailure(preparation)) {
512+
const source = withDiagnostics(this.#status.source, [phaseDiagnostic('prepare', preparation.failure)]);
484513
return this.#completeFailure(this.#beginBuild(invalidation, source), source, source.diagnostics);
485514
}
515+
const prepared = preparation.success;
486516

487517
this.#watcher?.addOutputPaths?.([prepared.artifactDistPath, ...prepared.outputRoots]);
488518
const running = this.#beginBuild(invalidation, prepared.source);
489-
try {
490-
await this.#onPreparedProject?.(prepared);
491-
} catch (error) {
492-
const source = withDiagnostics(prepared.source, [phaseDiagnostic('prepare', error)]);
519+
const onPrepared = this.#onPreparedProject;
520+
const hook = yield* Effect.result(onPrepared === undefined
521+
? Effect.void
522+
: liftPromise(() => onPrepared(prepared)));
523+
if (Result.isFailure(hook)) {
524+
const source = withDiagnostics(prepared.source, [phaseDiagnostic('prepare', hook.failure)]);
493525
return this.#completeFailure(running, source, source.diagnostics);
494526
}
495-
let lintDiagnostics: readonly Diagnostic[];
496-
try {
497-
const report = await this.#diagnosticService.lint(invalidation.paths);
498-
lintDiagnostics = freezeDiagnostics(report.diagnostics);
499-
} catch (error) {
500-
const source = withDiagnostics(prepared.source, [phaseDiagnostic('lint', error)]);
527+
const lint = yield* Effect.result(liftPromise(() => this.#diagnosticService.lint(invalidation.paths)));
528+
if (Result.isFailure(lint)) {
529+
const source = withDiagnostics(prepared.source, [phaseDiagnostic('lint', lint.failure)]);
501530
return this.#completeFailure(running, source, source.diagnostics);
502531
}
532+
const lintDiagnostics: readonly Diagnostic[] = freezeDiagnostics(lint.success.diagnostics);
503533

504534
const source = withDiagnostics(prepared.source, lintDiagnostics);
505535
if (hasErrors(lintDiagnostics)) {
506536
return this.#completeFailure(running, source, source.diagnostics);
507537
}
508538

509-
let result: ArtifactEpochResult;
510-
try {
511-
result = await this.#artifactService.build(prepared);
512-
} catch (error) {
539+
const built = yield* Effect.result(liftPromise(() => this.#artifactService.build(prepared)));
540+
if (Result.isFailure(built)) {
513541
return this.#completeFailure(running, source, [
514542
...source.diagnostics,
515-
phaseDiagnostic('artifact', error),
543+
phaseDiagnostic('artifact', built.failure),
516544
]);
517545
}
546+
const result = built.success;
518547
// The package build (bin/lib) rebuilds inside the same serialized pass,
519548
// after the artifact epoch committed: its failure never invalidates the
520549
// epoch and surfaces as warning diagnostics on the succeeded attempt.
521550
const packageDiagnostics = result.outcome === 'succeeded'
522-
? (await this.#packageBuildService.build(prepared, invalidation)).diagnostics
551+
? (yield* liftPromise(() => this.#packageBuildService.build(prepared, invalidation))).diagnostics
523552
: Object.freeze([]);
524553
const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics, ...packageDiagnostics]);
525554
if (result.outcome === 'succeeded') {
@@ -546,9 +575,16 @@ export class DevCoordinator {
546575
return Object.freeze({ diagnostics, epoch: result.epoch, outcome: 'succeeded' });
547576
}
548577
return this.#completeFailure(running, source, diagnostics);
549-
}
578+
});
550579

551-
async #close(): Promise<void> {
580+
/**
581+
* Shutdown as one Effect: wait for the in-flight build or startup to
582+
* settle, then release every resource concurrently, capturing each `Exit`
583+
* so no failure short-circuits another release. Every failure is reported
584+
* together as `DevCoordinatorCloseError` (build first, then resources in
585+
* declaration order).
586+
*/
587+
#close(): Promise<void> {
552588
const hasBuildInFlight = this.#currentBuild !== undefined;
553589
const startupBlockedBeforeBuild = !hasBuildInFlight &&
554590
this.#session === undefined && this.#startPromise !== undefined;
@@ -557,8 +593,7 @@ export class DevCoordinator {
557593
void this.#releaseWatcher().catch(() => undefined);
558594
void this.#releaseLock().catch(() => undefined);
559595
}
560-
const inFlight = this.#currentBuild ?? this.#startPromise;
561-
const buildResult = await Promise.allSettled([inFlight ?? Promise.resolve()]);
596+
const inFlight: Promise<unknown> = this.#currentBuild ?? this.#startPromise ?? Promise.resolve();
562597
const resources: readonly Readonly<{
563598
readonly close: () => Promise<unknown>;
564599
readonly resource: DevCoordinatorCloseFailure['resource'];
@@ -567,19 +602,23 @@ export class DevCoordinator {
567602
{ close: () => this.#diagnosticService.close(), resource: 'diagnostics' },
568603
{ close: () => this.#releaseLock(), resource: 'lock' },
569604
];
570-
const results = await Promise.allSettled(resources.map(async ({ close }) => close()));
571-
const failures = [
572-
...buildResult.flatMap((result): readonly DevCoordinatorCloseFailure[] =>
573-
hasBuildInFlight && result.status === 'rejected'
574-
? [Object.freeze({ error: result.reason, resource: 'build' })]
575-
: [],
576-
),
577-
...results.flatMap((result, index): readonly DevCoordinatorCloseFailure[] =>
578-
result.status === 'rejected'
579-
? [Object.freeze({ error: result.reason, resource: resources[index]!.resource })]
580-
: [],
581-
),
582-
];
583-
if (failures.length > 0) throw new DevCoordinatorCloseError(failures);
605+
const closeFailure = (
606+
exit: Exit.Exit<unknown, unknown>,
607+
resource: DevCoordinatorCloseFailure['resource'],
608+
): readonly DevCoordinatorCloseFailure[] =>
609+
Exit.isFailure(exit) ? [Object.freeze({ error: Cause.squash(exit.cause), resource })] : [];
610+
return runPromise(Effect.gen(function* () {
611+
const buildExit = yield* Effect.exit(liftPromise(() => inFlight));
612+
const releases = yield* Effect.forEach(
613+
resources,
614+
({ close }) => Effect.exit(liftPromise(close)),
615+
{ concurrency: 'unbounded' },
616+
);
617+
const failures = [
618+
...(hasBuildInFlight ? closeFailure(buildExit, 'build') : []),
619+
...releases.flatMap((exit, index) => closeFailure(exit, resources[index]!.resource)),
620+
];
621+
if (failures.length > 0) return yield* Effect.fail(new DevCoordinatorCloseError(failures));
622+
}));
584623
}
585624
}

0 commit comments

Comments
 (0)