Repository navigation
Unify the split Effect DI graphs (AppLayer vs. LayerNode) #10
Description
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:
- 2026-08-02 (today).
AutoMode.Service
(packages/opencode/src/auto-mode/service.ts) was wired intoAppLayer
and, after a first fix attempt, intoSessionPrompt.defaultLayer— but was
never added to theLayerNode.group([...])node list in
server/routes/instance/httpapi/server.ts. Since that list is what
actually serves TUI/opencode runsessions, every prompt crashed at
runtime withService not found: @opencode/AutoMode, even though
bun run typecheckpassed cleanly on both attempts. Fixed by adding
AutoMode.nodetoserver.ts's node list.- Side finding:
SessionPrompt.defaultLayer's.pipe(Layer.provide(...))
chain was sitting at exactly 20 arguments — TypeScript'spipe()
overload ceiling. A 21st argument produces a hard arity error
(TS2554: Expected 0-20 arguments, but got 21) and the whole chain's
inferredRdegrades tounknown, breaking a dozen unrelated files that
structurally depend on that type. The workaround (folding the new layer
into an existingLayer.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.
- Side finding:
- 2026-07-19 (
openspec/changes/intake-20260718-203617-8301aa/.skein/agent-notes.md):
identical failure mode forPatternDetection—
"PatternDetection.nodemissing fromLayerNode.makeat prompt.ts:1779" →
"typecheck error + service-not-found in tests." openspec/changes/retire-auto-reply/(open, unstarted): documents the
inverse failure mode.auto-reply,automation/automation-features,
pattern-detection, andschedulerall exportlayer/defaultLayerbut
were never given aLayerNodenodeand never added toserver.tsat
all — so instead of crashing, they are silently inert.opencode auto-reply --enablereports 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/defaultLayerpair and
determine whether it also exports and registers aLayerNode.nodein
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.
LayerNodehas
compile-time dependency checking; the legacy.pipe(Layer.provide(...))
style does not and has a silent arity ceiling. Recommendation: migrate
app-runtime.ts'sAppLayerandbootstrap-runtime.ts'sBootstrapLayer
ontoLayerNode, retiring the hand-rolleddefaultLayercomposition
pattern where it duplicates what a.nodealready 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
defaultLayerexport but no corresponding.noderegistered in
server.ts, or vice versa) so this class of bug is caught before merge
rather than at runtime. - Flag
session/prompt.ts'sSessionPrompt.defaultLayerspecifically — 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. viaLayerNode,
or by pre-emptively grouping more entries intoLayer.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 isretire-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
exportsdefaultLayer/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) acrosspackages/opencode/src
andpackages/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
nodeis actually present in theLayerNode.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
defaultLayerexists but thenodeis missing
or unregistered — these are live crash risks (theAutoMode/
PatternDetectionfailure mode: reachable viaAppLayer/direct calls,
absent from the graph that actually serves requests). - Validation: list of mismatches, each with the call site(s) that would
trigger a runtimeService not foundif exercised. - 1.4 Flag every row where a
nodeexists but is only reachable through
LayerNode.group([...])in server.ts and never throughAppLayer/
BootstrapLayer— confirm whether anything outside the HTTP server path
(CLI-only commands,bootstrap-runtime.tsconsumers) 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,
sinceCheckDependenciesgives compile-time missing-dependency errors
that the legacy.pipe(Layer.provide(...))style cannot). Record the
decision and rationale indesign.md. - 2.2 If migrating: port
app-runtime.ts'sAppLayerand
bootstrap-runtime.ts'sBootstrapLayerontoLayerNode.group(...)/
LayerNode.buildLayer(...), reusing each service's existing.node
export instead of re-deriving dependencies by hand. - Validation:bun run typecheckandbun testgreen in
packages/opencodeafter the port;opencode runand 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 orbun run typecheck) that fails
when a service exportingdefaultLayerhas no corresponding.node
registered inserver.ts's node list, and vice versa. - Validation: guard fails on a deliberately-reintroduced version of
today's bug (temporarily removeAutoMode.nodefrom 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'spipe()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 toLayerNode(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 typecheckpasses; 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 60000green. - 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 justAppLayer, is exercised). - Validation: noService not founderrors in server logs.
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Activity
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.