Skip to content

Epic: lean on Effect where it earns it — CLI, agent-runtime seam, and a composition root #32

Description

@FreshlyBrewedCode

What to build

Use Effect where it earns its place, and say plainly where it does not. An audit after the POC
phase found the dependency is carried in full (effect@4.0.0-rc.115) but used in three files'
worth of runtime combinators — Schema is everywhere and genuinely load-bearing, Cron is used
properly, and beyond that it is Stream in one file, Fiber in another, and one
Effect.repeat in the scheduler. There are zero uses of Layer, Context, or any platform
module, on a version that ships effect/unstable/cli, http, sql and process in-package.

Two gaps cost us something concrete today:

The CLI. src/cli.ts hand-rolls four argument parsers that disagree with each other — a
stride-2 flag walker that structurally cannot represent a boolean flag, a separate single-step
loop for factory start because it needed one, an inline loop for init, and a serve parser
that passes an unvalidated Number(portRaw) straight to Bun.serve. usageError calls
process.exit inside a parser, so the whole argv layer is untestable and untested: every CLI
test bypasses argv entirely.

Dependency injection. We already built a hand-rolled DI container out of optional fields —
six Injectable for tests comments, a DispatchEnv bag threaded recursively through the
dispatch chain, a dedupeRegistry? option that exists only because the registry is a
process-wide singleton, a now?: () => number clock seam, a beforeStart? hook whose only job
is holding a window open in one test. Four module-level mutable maps mean two daemons cannot
coexist in one process. Every feature since #13 has added another ?: seam field, and that drip
continues until the pattern is replaced by the thing it is imitating.

The agent runtime is where both gaps meet, and it is the worst case of each: the adapter is
threaded by hand through eight files and ~20 call sites, defaulted to opencodeAdapter in two
separate places, and not selectable from config — while the interpretation of its chunk stream
sits on the Factory side of the seam, so a second adapter would have to emit opencode's exact
CUSTOM event names to surface a session id.

Decisions

Recorded as ADRs, written before the code:

  • ADR 0012 (new) — the agent-runtime seam: chunks stay opaque and AG-UI-shaped, the adapter
    interprets its own stream and prepares its own workspace, the agent runtime becomes a
    context-resolved service, the adapter contract stays plain async, and the sandbox provider is
    explicitly not abstracted yet.
  • ADR 0009 §5 (amendment) — Effect's boundary moves in one direction: it gains the CLI and a
    DI composition root inside the daemon. The authoring surface, the event store (D21) and the
    HTTP route handlers (D22) are reaffirmed as plain, not reopened.
  • ADR 0004 (status note) — D22 reaffirmed with a clarification, so nobody reads "plain
    Bun.serve" as "no Effect anywhere in the server" once the composition root lands.

The failure mode this epic is steering away from is not "too little Effect" — it is shallow
Effect spread evenly everywhere, which costs the ergonomics of both idioms and buys neither.

Subtasks

#33, #34 and #35 have no blockers and can start in parallel.

Related, deliberately outside this epic

Both surfaced in the same audit, both are worth fixing whether or not this epic proceeds, and
neither depends on any Effect work.

Out of scope

  • effect/unstable/http / HttpApi — deferred, not rejected. D22's reasoning still mostly
    holds; the trigger is the API surface growing past what web/api.ts's hand-written client can
    mirror without drift. Doing it before DI means doing it twice.
  • effect/unstable/sql for the event store — ADR 0010/D21 stands. bun:sqlite is
    synchronous in-process I/O and the store is 181 lines. Only Migrator is worth revisiting, and
    only once the schema outgrows one table.
  • effect/unstable/process for hostExec — 20 lines of Bun.spawn + AbortSignal, and the
    ExecFn seam already exists.
  • Schedule.cron in the scheduler — the cron string is stored deliberately so next-fire is
    computable for the UI (Schedules page with next-fire times and manual trigger #17). The 30s poll granularity is the accepted cost.
  • The workflow authoring surface — ADR 0009 §1/§2. "Use Effect more" must not be read as
    including ctx.*.
  • Abstracting the sandbox provider — ADR 0012 §6. One implementation; the trigger is a real
    non-host sandbox, not a second model provider.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicA bigger unit of work, broken down into subtasks; closes when all subtasks are complete

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions