Skip to content

refactor(dev): finish the coordinator's Effect orchestration; fan out epoch-store reads with Effect.forEach - #513

Merged
ScriptedAlchemy merged 1 commit into
mainfrom
refactor/effect-coordinator-orchestration
Sep 4, 2026
Merged

refactor(dev): finish the coordinator's Effect orchestration; fan out epoch-store reads with Effect.forEach#513
ScriptedAlchemy merged 1 commit into
mainfrom
refactor/effect-coordinator-orchestration

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Effect-conformance cleanup 2 of the prior audit (dev/coordinator.ts + dev/epoch-store.ts): finish the Effect orchestration around the Semaphore/Deferred that Stage 3 already introduced, so startup, shutdown, and the build pass are one Effect program each instead of Promise combinators wrapped around fibers. Behavior-preserving: same rejection values ('DevCoordinator is closed.', the original startup error, DevCoordinatorCloseError with the same failures order), same AB7200/AB7201 diagnostics, same events.

What changed

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

  • #awaitStartup (was Promise.race([operation, cancellation.then(throw)]), audit row coordinator.ts:370–376) is now Effect.raceFirst(liftPromise(operation), Deferred.await(this.#startupClosed)). The startup-cancellation Promise + resolver pair became #startupClosed: Deferred<never, Error>, failed by #cancelStartup() with the same Error('DevCoordinator is closed.'). The losing step is interrupted; the underlying Promise (e.g. a blocked lock acquisition) keeps running exactly as before, and #acquireStartupLock still drains a lock that resolves after cancellation.
  • #start (was async + try/catch + Promise.allSettled([releaseWatcher, releaseLock]), audit row :343–346) is #startEffect(): an Effect.gen whose sync steps (#assertOpen, #createWatcher) are lifted with liftTry so a throw is a typed failure — not a defect that would skip cleanup — and whose failure handler runs Effect.forEach([releaseWatcher, releaseLock], (r) => Effect.exit(liftPromise(r)), { concurrency: 'unbounded' }) before re-raising the original error. start() runs it through the boundary's runPromise once.
  • #close (was two Promise.allSettled, audit row :561–570) waits for the in-flight build/startup with Effect.exit(liftPromise(...)), then releases every resource with Effect.forEach(resources, ({ close }) => Effect.exit(liftPromise(close)), { concurrency: 'unbounded' }), and aggregates Exit failures via Cause.squash into the same DevCoordinatorCloseError (build first, then resources in declaration order) — the same settle-then-aggregate shape as McpSessionServiceCloseError / EpochCleanupError.
  • #performBuild (was an async function lifted whole by liftPromise, audit row :401–405) is now Effect.fnUntraced(function* (this: DevCoordinator, invalidation) { … }). Only the leaf I/O is lifted — projectService.prepare, the onPreparedProject hook, diagnosticService.lint, artifactService.build, packageBuildService.build — and each phase is exposed with Effect.result so Result.isFailure completes the attempt as a failed build result, exactly where the try/catch blocks did. Status/event bookkeeping stays synchronous inside the fiber; #startBuild now composes this.#performBuild(invalidation) directly under withPermit + onExit.

packages/agent-bundle/src/dev/epoch-store.ts

  • recoverStaging (audit row :480): the Promise.all inside the lifted thunk is Effect.forEach(entries, rm, { concurrency: 'unbounded', discard: true }); the readdir + ENOENT tolerance moved into a small #readDirectoryEntries(path) Effect (liftPromise + Effect.catch).
  • #readAllEpochMetadata (audit row :1004) is an Effect method: #readDirectoryEntries + Effect.forEach(..., { concurrency: 'unbounded' }) over liftPromise(() => this.#readEpochMetadata(id)), with the path-safety check as Effect.fail(EpochStoreError('EPOCH_METADATA_INVALID', …)). listEpochs and #cleanupUnderLease compose it directly instead of lifting it.
  • Deliberately not changed — :774 and :848. Those two Promise.all([realpath, realpath]) pairs live inside #verifyStaging (which also checks dev/ino identity) and #validateActiveEpoch (lstat containment). Both are imperative leaf verification helpers of the durable-fs protocol that the brief keeps raw ("Do NOT touch the durable-fs protocol (lstat, dir fsync, inode)"), and the Stage 3 convention keeps leaf helpers imperative and identity-lifted as a unit (docs/effect-conventions.md § Stage 3). Rewriting them as Effect generators would lift every lstat/realpath individually for no orchestration gain. If the maintainer wants them converted anyway, Effect.all([a, b], { concurrency: 'unbounded' }) is the fixed-tuple form (agent-patterns/effect-concurrency.md "Bounded work").
  • The durable-fs syscalls (#syncPath, #writeJsonAtomically, lstat, dir fsync, inode checks) are untouched.

Idioms and citations

Idiom Doc repos/effect/LLMS.md / repo section
Effect.raceFirst — first completion wins, loser interrupted (instead of Promise.race around Effects) Concurrency → Basic Concurrency → raceFirst agent-patterns/effect-concurrency.md "Racing and cancellation: Do not Promise.race around Effects"
Deferred as the one-shot close signal Concurrency → Deferred agent-patterns/effect-concurrency.md "Bounded work"; docs/effect-conventions.md Stage 3 helped (Deferred + runSync admission)
Effect.forEach(..., { concurrency: 'unbounded' }) + per-element Effect.exit as the Promise.allSettled analogue Control Flow Operators → forEach, Basic Concurrency → Concurrency Options docs/effect-conventions.md Stage 3 helped: "Effect.forEach(..., { concurrency: 'unbounded' }) with per-element Effect.exit is the exact analogue of Promise.allSettled"
Effect.fnUntraced for a reusable library-internal generator (with this: Self) Code Style → Guidelines LLMS.md § Using Effect.fn and Effect.fnUntraced; docs/effect-conventions.md § Generator style
Effect.result + Result.isFailure to expose a phase failure as a value Error Management → Expected Errors → result LLMS.md § Error handling
Sync steps lifted so cleanup sees a typed failure, not a defect Error Management → Two Types of Errors agent-patterns/effect-errors.md "Defect (bug): Effect.die — not for expected fail-closed states"

Tests

  • New in tests/dev-coordinator.test.ts: re-raises a startup failure after releasing the watcher and lock it acquired (rejected watcher.ready, same error re-raised, both releases run once, later start() returns the settled startup, rebuild fails closed with AB7200, close() does not re-release); fails a synchronous watcher construction error closed and releases the lock (the liftTry path); turns a rejected prepared-project hook into a failed prepare attempt (AB7201 "Prepare failed during development rebuild: hook rejection", no artifact build, build.started/build.failed/artifact.status events).
  • New in tests/epoch-store.test.ts: recovers every abandoned staging directory at once and tolerates a store that never published (three staging roots removed concurrently, an unrelated directory untouched, recoverStaging/listEpochs on a store with no epochs directory).
  • Existing startup-cancellation tests (cancels blocked startup readiness…, …blocked startup recovery…, …blocked active epoch recovery…) still assert 'DevCoordinator is closed.', one watcher/lock close each, and no later watcher/build state.
  • pnpm typecheck, pnpm lint green; pnpm test:unit green (3155 passed); integration dev-artifact-service + dev-package-build + dev-workbench green (45 passed).

Changeset

skip-changeset: internal orchestration refactor; every Promise-side value, diagnostic code, message, and event is unchanged, and nothing new is exported.

Review status

No PR comments are posted by the author; every review thread is answered in this section.

Head Review Threads
6becd76 Codex completed, no findings — . CI: the first Verify (Node 24) attempt failed only in packages/workbench/tests/mcp-app-real.e2e.test.ts (Playwright waitForRequest timeout on the "Close MCP session" click — a Workbench e2e untouched by this PR; the same job's unit/route-unit/projection pools passed); the failed job was re-run and is green.

…out epoch-store reads with Effect.forEach

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.
@ScriptedAlchemy ScriptedAlchemy added the skip-changeset PR changes a publishable package but ships no observable change; changeset not required label Sep 4, 2026
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6becd76

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T03:03:33.394402Z 6becd76 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@513
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@513
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@513

commit: 6becd76

@ScriptedAlchemy
ScriptedAlchemy merged commit b9e9dc5 into main Sep 4, 2026
20 of 22 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the refactor/effect-coordinator-orchestration branch September 4, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR changes a publishable package but ships no observable change; changeset not required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant