Skip to content

Unify the split Effect DI graphs (AppLayer vs. LayerNode) #10

Description

@androidand
Proposal

Why

The server has two independent, hand-maintained dependency-injection graphs for
Effect services, and nothing keeps them in sync. A service can be fully and
correctly wired into one and silently absent from the other — TypeScript
typechecks each graph independently and cannot see the gap.

Graph A — legacy, hand-rolled, no compile-time dependency check.
packages/opencode/src/effect/app-runtime.ts builds AppLayer via
Layer.mergeAll(...) over ~40 X.defaultLayer exports, where each
defaultLayer is manually written as
layer.pipe(Layer.provide(Dep1.defaultLayer), Layer.provide(Dep2.defaultLayer), ...).
packages/opencode/src/effect/bootstrap-runtime.ts (BootstrapLayer) and
packages/opencode/src/session/prompt.ts (SessionPrompt.defaultLayer) follow
the same style. Nothing checks that a .pipe(Layer.provide(...)) chain
actually lists every dependency the wrapped layer needs — a missing entry is a
silent gap, not a compile error.

Graph B — LayerNode, compile-time-checked.
packages/core/src/effect/layer-node.ts defines LayerNode.make(layer, deps)
and LayerNode.group(nodes). Its CheckDependencies type produces an actual
TypeScript error ({"Missing dependencies": ...}) when a node's declared
deps don't cover what the wrapped layer requires. This is the graph that
matters at runtime: packages/opencode/src/server/routes/instance/httpapi/server.ts
builds the real HTTP API server — the one every TUI session and opencode run
invocation actually talks to — from LayerNode.group([...]) /
LayerNode.buildLayer(app), not from AppLayer.

Because the two graphs are separately maintained, a service correctly added to
Graph A gives zero signal about Graph B, and vice versa. This has caused three
independent incidents:

  1. 2026-08-02 (today). AutoMode.Service
    (packages/opencode/src/auto-mode/service.ts) was wired into AppLayer
    and, after a first fix attempt, into SessionPrompt.defaultLayer — but was
    never added to the LayerNode.group([...]) node list in
    server/routes/instance/httpapi/server.ts. Since that list is what
    actually serves TUI/opencode run sessions, every prompt crashed at
    runtime with Service not found: @opencode/AutoMode, even though
    bun run typecheck passed cleanly on both attempts. Fixed by adding
    AutoMode.node to server.ts's node list.
    • Side finding: SessionPrompt.defaultLayer's .pipe(Layer.provide(...))
      chain was sitting at exactly 20 arguments — TypeScript's pipe()
      overload ceiling. A 21st argument produces a hard arity error
      (TS2554: Expected 0-20 arguments, but got 21) and the whole chain's
      inferred R degrades to unknown, breaking a dozen unrelated files that
      structurally depend on that type. The workaround (folding the new layer
      into an existing Layer.mergeAll(...) argument instead of adding a new
      .pipe() slot) works but is a trap for the next person who doesn't know
      the ceiling exists.
  2. 2026-07-19 (openspec/changes/intake-20260718-203617-8301aa/.skein/agent-notes.md):
    identical failure mode for PatternDetection —
    "PatternDetection.node missing from LayerNode.make at prompt.ts:1779" →
    "typecheck error + service-not-found in tests."
  3. openspec/changes/retire-auto-reply/ (open, unstarted): documents the
    inverse failure mode. auto-reply, automation/automation-features,
    pattern-detection, and scheduler all export layer/defaultLayer but
    were never given a LayerNode node and never added to server.ts at
    all — so instead of crashing, they are silently inert. opencode auto-reply --enable reports success and does nothing, which that proposal calls out
    as "worse than not having it. It cost real debugging time to establish
    that it is inert."

Same root cause, three incidents, two opposite symptoms (silent no-op vs.
runtime crash) depending on which side of the split a service lands on. This
is a systemic gap, not a one-off mistake, and it will keep recurring for every
new service until the graphs are unified or guarded.

What Changes

  • Audit every service that exports a legacy layer/defaultLayer pair and
    determine whether it also exports and registers a LayerNode .node in
    server/routes/instance/httpapi/server.ts's node list. Produce a complete
    mismatch list in both directions.
  • Decide and document the canonical mechanism going forward. LayerNode has
    compile-time dependency checking; the legacy .pipe(Layer.provide(...))
    style does not and has a silent arity ceiling. Recommendation: migrate
    app-runtime.ts's AppLayer and bootstrap-runtime.ts's BootstrapLayer
    onto LayerNode, retiring the hand-rolled defaultLayer composition
    pattern where it duplicates what a .node already expresses.
  • If a full migration is judged too large for one change, land a regression
    guard instead (e.g. a script/CI check that fails when a service has a
    defaultLayer export but no corresponding .node registered in
    server.ts, or vice versa) so this class of bug is caught before merge
    rather than at runtime.
  • Flag session/prompt.ts's SessionPrompt.defaultLayer specifically — it is
    at the .pipe() argument ceiling today and the next added dependency will
    hit the same trap unless the chain is restructured (e.g. via LayerNode,
    or by pre-emptively grouping more entries into Layer.mergeAll(...)).
  • Cross-reference openspec/changes/retire-auto-reply/ — its Phase 1 audit
    (auto-reply, automation-features, pattern-detection, scheduler) is the same
    "who's actually registered in the graph" question from the opposite
    direction, and should reuse this change's audit method rather than
    duplicating it.

Non-Goals

  • Not rewriting every service's DI wiring in one change — this is audit,
    canonicalization decision, and (if in scope) a regression guard, not a
    wholesale rewrite.
  • Not deleting the auto-reply/automation/pattern-detection/scheduler code —
    that is retire-auto-reply's job; this change only supplies the audit
    method and flags the shared root cause.

Impact

  • Affected: packages/opencode/src/effect/app-runtime.ts,
    packages/opencode/src/effect/bootstrap-runtime.ts,
    packages/opencode/src/session/prompt.ts,
    packages/opencode/src/server/routes/instance/httpapi/server.ts,
    packages/core/src/effect/layer-node.ts, and every service module that
    exports defaultLayer/node.
  • No user-facing behavior change is intended beyond eliminating a class of
    runtime crash / silent-dead-service bug.

Disposition (2026-09-18)

Archived — superseded/obsolete. Unified upstream (451876b): one LayerNode graph in app-runtime.ts and server.ts already includes the fork nodes. The fork's dead re-created defaultLayer graph was deleted 2026-09-18 under retire-legacy-compat-shims.

Tasks

Phase 1: Audit the split

  • 1.1 List every service module that exports defaultLayer (the legacy
    layer.pipe(Layer.provide(...)) style) across packages/opencode/src
    and packages/core/src. - grep -rln "export const defaultLayer" packages/opencode/src packages/core/src - Validation: complete file list recorded in .skein/agent-notes.md.
  • 1.2 For each file from 1.1, check whether it also exports node (a
    LayerNode.make(...) / LayerNode.group(...) call) and whether that
    node is actually present in the LayerNode.group([...]) list in
    packages/opencode/src/server/routes/instance/httpapi/server.ts. - Validation: table of {service, has defaultLayer, has node, node registered in server.ts} recorded in .skein/agent-notes.md.
  • 1.3 Flag every row where defaultLayer exists but the node is missing
    or unregistered — these are live crash risks (the AutoMode /
    PatternDetection failure mode: reachable via AppLayer/direct calls,
    absent from the graph that actually serves requests). - Validation: list of mismatches, each with the call site(s) that would
    trigger a runtime Service not found if exercised.
  • 1.4 Flag every row where a node exists but is only reachable through
    LayerNode.group([...]) in server.ts and never through AppLayer/
    BootstrapLayer — confirm whether anything outside the HTTP server path
    (CLI-only commands, bootstrap-runtime.ts consumers) needs that service
    and would break. - Validation: list of any such gaps, or explicit note that none exist.

Phase 2: Canonicalize

  • 2.1 Decide the canonical DI mechanism (recommendation: LayerNode,
    since CheckDependencies gives compile-time missing-dependency errors
    that the legacy .pipe(Layer.provide(...)) style cannot). Record the
    decision and rationale in design.md.
  • 2.2 If migrating: port app-runtime.ts's AppLayer and
    bootstrap-runtime.ts's BootstrapLayer onto LayerNode.group(...) /
    LayerNode.buildLayer(...), reusing each service's existing .node
    export instead of re-deriving dependencies by hand. - Validation: bun run typecheck and bun test green in
    packages/opencode after the port; opencode run and TUI session
    smoke-tested manually per [[run]]-style verification (see
    AGENTS.md / project conventions for how this repo verifies CLI
    changes).
  • 2.3 If a full migration is descoped: implement a regression guard
    instead — a script (invoked from CI or bun run typecheck) that fails
    when a service exporting defaultLayer has no corresponding .node
    registered in server.ts's node list, and vice versa. - Validation: guard fails on a deliberately-reintroduced version of
    today's bug (temporarily remove AutoMode.node from server.ts and
    confirm the guard catches it), then passes with the fix restored.

Phase 3: Fix the .pipe() arity trap

  • 3.1 Confirm the current argument count of every hand-rolled
    .pipe(Layer.provide(...), ...) chain in the codebase (app-runtime.ts,
    bootstrap-runtime.ts, session/prompt.ts, and any others found in
    Phase 1). Flag any chain at or near TypeScript's pipe() overload
    ceiling (20 arguments) as fragile. - Validation: list of chains with their current argument counts.
  • 3.2 Restructure SessionPrompt.defaultLayer
    (packages/opencode/src/session/prompt.ts) so it is not sitting at the
    ceiling — either by migrating it to LayerNode (if 2.2 is in scope) or
    by pre-emptively grouping more of its dependencies into the existing
    Layer.mergeAll(...) argument so future additions don't require a new
    top-level .pipe() slot. - Validation: bun run typecheck passes; adding a throwaway extra
    Layer.provide(SomeExisting.defaultLayer) no longer produces
    TS2554.

Phase 4: Reconcile with retire-auto-reply

  • 4.1 Once openspec/changes/retire-auto-reply/ Phase 1 (its own audit of
    auto-reply/automation-features/pattern-detection/scheduler) lands,
    cross-check its findings against this change's Phase 1 table — both are
    answering "is this service actually registered in the graph that
    matters" and should agree. - Validation: no contradictions between the two audits; any found are
    resolved and noted in both changes.

Phase 5: Verification

  • 5.1 Full build and test pass. - Validation: bun run typecheck (root, all packages) and
    bun test packages/opencode --timeout 60000 green.
  • 5.2 Manual smoke test: start a TUI session and send a real prompt that
    exercises tool permission checks (confirms the actual HTTP API graph,
    not just AppLayer, is exercised). - Validation: no Service not found errors in server logs.

Unchecked items above: see Disposition in proposal.md (2026-09-18).

Related

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions