Skip to content

fix(core): run V2 turns only after the location's plugins boot - #350

Merged
brentyates-swx merged 7 commits into
swxtchfrom
fm/opencode-v2-provider-1952
Oct 23, 2026
Merged

brentyates-swx merged 7 commits into
swxtchfrom
fm/opencode-v2-provider-1952

Conversation

@brentyates-swx

Copy link
Copy Markdown

Refs swxtchio/swx-abbe#1952. Follows #347, which is merged as 774d90c7 and published as v1.18.32-swxtch.774d90c7.

Problem

On the published v1.18.32-swxtch.774d90c7, V2 turns in the OpenCode jail still never reach swxtch-egress. Abbe measured this at the jail boundary (swxtchio/swx-abbe#1952, 2026-10-23):

  • A session created with only a location fails with ModelNotSelectedError.
  • A session that names the egress model fails with ModelUnavailableError.
  • In both cases the provider receives nothing, and /wait answers 503.

Cause

A Location loads its internal plugins (config, providers, agents, models.dev) in a background fiber, and nothing on the turn path waited for that. The jail's adapter creates a session and prompts it at once, so the drain that boots the Location runs its first turn while the catalog is still empty or only partly built. Depending on timing, the first turn:

  • finds no model (ModelNotSelectedError);
  • cannot find the named egress model (ModelUnavailableError); or
  • is sent to a bundled model such as nano-gpt/... or vivgrid/....

A turn a few seconds later does reach egress. #347's tests waited for the catalog before resolving, so they hid the race.

Fix

  • PluginInternal now provides a service with a ready signal. It completes when the background boot ends, whether the boot loaded every plugin or failed.
  • SessionRunnerLLM waits for ready at the start of every turn, before it selects the agent, model, tools and guidance, since the same boot fills all of them.
  • PluginInternal.readyLayer is the seam for tests and embedders whose Location services are already loaded. The runner tests now use it.

Upstream has the same race and no fix. Fork-only.

Proof at the jail boundary

The rig:

  • Image: Abbe's arm64 build, opencode-variant-jail:b16-10-774d90c7-arm64, which is jail/Dockerfile.jail at swxtchio/swx-abbe#1951 head 03d7e234.
  • Launch: started through its own entrypoint with the exact docker run arguments from opencode_container_spawn.py: --read-only, the tmpfs mounts, OPENCODE_CONFIG=/jail/opencode.json, OPENCODE_DISABLE_MODELS_FETCH=1, and serve --pure --print-logs.
  • Network: an --internal Docker network, with a fake OpenAI-compatible provider where the egress proxy listens.
  • Calls: sent the way opencode_client.py sends them: POST /api/session with only location (or with model), then /prompt with id and prompt, then /wait.
Image Location only (3 runs) Named egress model (3 runs) Provider requests
published 774d90c7 ModelNotSelectedError with a 503 wait ×2, a bundled model with transport failure ×1 ModelUnavailableError ×3 0
the same image with only the binary and FFF sidecar replaced by this branch's build completed ×3 completed ×3 1 per run, accounts/fireworks/models/deepseek-v4p1-flash with Bearer capability

Unit test

The test is in packages/core/test/location-layer.test.ts: "runs a location-only V2 turn admitted while its location boots on the jail's egress model". It follows the jail's real path:

  • the jail's config sources (OPENCODE_CONFIG with placeholders, and the rendered OPENCODE_CONFIG_CONTENT);
  • every internal plugin, booting against the recorded models.dev corpus;
  • SessionExecutionLocal, with a session created with only a location, then prompted and resumed immediately, with no waiting;
  • a recording LLM client.

Without the fix, the turn was sent to vivgrid/gpt-5.6-luna (red). With the fix it goes to swxtch-egress at the proxy URL. The earlier jail resolution test now waits on ready instead of polling the catalog.

Focused runs, all with no provider keys in the environment:

  • packages/core: test/session-runner*.test.ts, test/location-layer.test.ts, test/catalog.test.ts, test/config/, test/plugin/ and test/effect/layer-node/, 451 pass.
  • packages/opencode: test/server/httpapi-session.test.ts (78 pass) and httpapi-wait-lifecycle.test.ts (1 pass).
  • bun typecheck is clean.

Abbe side

No producer change is needed. A session created with only a location, as create_session_v2 sends it, and a session that names the egress model both reach egress.

A turn now waits for its Location's plugin boot. If an external plugin's boot hangs (none in the jail, since it runs --pure), the turn waits until the client deadline or an interrupt, instead of running on a partial catalog.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp

)

Plan for swxtchio/swx-abbe#1952.

Root cause: the V2 Config layer (packages/core/src/config.ts) reads only
the global config directory and discovered project files. It ignores
OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT. The jail supplies its whole
config through those two variables (Dockerfile.jail sets OPENCODE_CONFIG,
opencode_server.py renders OPENCODE_CONFIG_CONTENT), so the V2 catalog
never sees the swxtch-egress provider or a configured default model.
V1 (packages/opencode/src/config/config.ts) reads both, which is why V1
turns run on the egress provider.

Change:
- Config takes explicit file and content sources, defaulting to
  Flag.OPENCODE_CONFIG and Flag.OPENCODE_CONFIG_CONTENT, with a
  layerWith seam for tests (mirrors Global.layerWith and upstream's
  Config.Options file/content from 43c0838).
- Precedence matches V1 and upstream: global directory, then the
  OPENCODE_CONFIG file, then project files, then project .opencode
  directories, then OPENCODE_CONFIG_CONTENT last.
- V1-format sources migrate through the existing loader path.

Tests:
- Config loader orders explicit file and content entries.
- A jail-shaped V1 config supplied as content reaches the catalog and
  SessionRunnerModel resolves the named swxtch-egress model to an
  OpenAI-compatible route at the proxy URL.

Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp
The V2 Config layer read only the global config directory and discovered
project files, ignoring OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT. The
OpenCode jail supplies its whole config through those variables, so the
V2 runner's catalog never held the swxtch-egress provider or the
configured default model: a named egress model failed with
ModelUnavailableError and an unnamed one fell to another provider
(swxtchio/swx-abbe#1952).

Config now takes explicit file and content sources, defaulting to the
two flags, and orders them as V1 does: global directory, explicit file,
project files, .opencode directories, then inline content. V1-format
sources migrate through the existing loader path. Config.nodeWith names
the sources directly for tests.

Tests drive the real loader: precedence of every source, and the jail's
V1 config (placeholder file plus rendered content) through the config
provider plugin and catalog to SessionRunnerModel, for a named model and
for the configured default against a competing provider.

Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp
Review round 1: with the env-config fix alone, a location-only V2
session in the jail (create_session_v2 names no model, and the jail
config sets none) still resolved a credential-free catalog model such
as opencode/hy3-free ahead of the config-declared swxtch-egress model,
so egress turns still failed.

V1's Provider.defaultModel takes its default from the config-declared
providers when no model is configured. The V2 catalog now does the
same: the config provider plugin records the declared providers in
config order, and Catalog.model.default picks the newest available
model of the first one that has any, before its newest-available
fallback. An explicit session model or configured `model` still wins.
The preference registers on the internal catalog under the plugin's
scope, because the public plugin catalog API has no such hook.

Tests: the jail path through the production location runtime (every
internal plugin, the recorded models.dev corpus, the jail config via
OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT) resolves a location-only
session to the egress model; and the preference skips an unavailable
declared provider and beats a newer undeclared model. Both are red
without the preference.

Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp
A Location boots its internal plugins in a forked fiber, and nothing on the
turn path waited for it. The jail's adapter creates a session and prompts it at
once, so the drain that boots the Location starts its first turn while the
catalog is still empty or only partly built. On the jail image running
v1.18.32-swxtch.774d90c7 the first turn failed with ModelNotSelectedError (or
ModelUnavailableError when the egress model is named), or was sent to a bundled
model such as nano-gpt/..., and the egress provider received nothing.

The internal plugin layer now exposes a readiness signal that completes when
its boot ends, whether it loaded every plugin or failed, and every runner turn
waits on it before it selects the agent, model, tools and guidance.

Refs swxtchio/swx-abbe#1952

Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp
Claude-Session: https://claude.ai/code/session_01HGGfNzV3BHB7W49PTT9MVp

# Conflicts:
#	packages/core/test/location-layer.test.ts
@brentyates-swx

Copy link
Copy Markdown
Author

Session ID: 34738ff3-3dcf-4587-8fde-7c58d5478796
Working repo: /home/byates/fm-worktrees/fm-llmrouter/opencode-v2-provider-1952/swx-opencode

@brentyates-swx
brentyates-swx marked this pull request as ready for review October 23, 2026 18:38
@brentyates-swx
brentyates-swx merged commit cd3f0ce into swxtch Oct 23, 2026
3 checks passed
@brentyates-swx
brentyates-swx deleted the fm/opencode-v2-provider-1952 branch October 23, 2026 19:49
brentyates-swx added a commit that referenced this pull request Oct 23, 2026
* docs(release): plan the cd3f0ce source-pinned release cut

Records the pickup identity and ancestry for source cd3f0ce (#350 on top
of #347) and the execution plan, including Abbe's jail V2 egress rig as a
publication gate on the unpublished packaged bytes.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1

* chore(release): record v1.18.32-swxtch.cd3f0ce2 native, jail-rig and publication evidence

Adds the published release body and the raw arm64 (vm14-001) and x64
(Actions run 40015678366) build and probe observations, the lease receipts,
the focused test runs, the Abbe jail V2 egress rig run on the unpublished
arm64 candidate, and the publication read-back and downloaded-byte checks.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1

* docs(release): record cd3f0ce release evidence, delivery and changeset entry

Adds RELEASE-CUT-4-EVIDENCE.md and the raw-evidence index, the SaaS wake-log
append receipt and peer outbox record, the unestablished Abbe B-16.10
recipient, and the #351 CHANGESET entry.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1

* docs(release): record the Abbe handoff as delivered by firstmate

Firstmate relayed the cd3f0ce release notice to Abbe through its peer
channel after this task's own sends were refused. The evidence now cites
and retains that relay, noting that it points to the published checksum
companions rather than quoting the digests.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1

* docs(release): record Abbe's acknowledgment and bump-brief citation as the handoff read-back

Abbe ruled that peer homes never write each other's runtime state, which
withdrew the task wake-log append. The evidence now records the two
read-back receipts Abbe named: acknowledgment p1792786679-2709706-0cf3 and
the bump brief's verbatim citation of the relay, plus the ruling's proof.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1

* docs(release): complete the cd3f0ce delivery receipt chain

Appends the delivery record to this task's producer wake log and keeps its
read-back receipt and verbatim copy. Records firstmate's disposition that
the Abbe captain's ruling waived the Abbe task wake-log append, and keeps
the rerun relay identity check against the archived and delivered copies.

Claude-Session: https://claude.ai/code/session_01NKeH8jbkJT2c3st6wJmeM1
brentyates-swx added a commit that referenced this pull request Oct 24, 2026
* docs(release): plan the d7f7888 source-pinned release cut

Records the pickup identity and ancestry for source d7f7888 (#354 on top
of #350 and #347), the brief's #353 discrepancy, and the execution plan: x64
via the release-candidate job, arm64 and Abbe's jail V2 egress rig (with the
compact mode) on vm14-001 under the lease, as the publication gate.

Claude-Session: https://claude.ai/code/session_013Se6FG9EEPtmFRtg1hLDMm

* chore(release): record v1.18.32-swxtch.d7f78881 native, jail-rig and publication evidence

Adds the published release body and the raw arm64 (vm14-001) and x64 (Actions
run 40052511379) build and probe observations, the three lease laps' receipts,
the focused test runs, Abbe's jail V2 egress rig in all four modes (including
compact) on the unpublished arm64 candidate, with the two setup-failed laps kept
as found, and the publication read-back and downloaded-byte checks.

Claude-Session: https://claude.ai/code/session_013Se6FG9EEPtmFRtg1hLDMm

* docs(release): record d7f7888 release evidence and changeset entry

Adds RELEASE-CUT-5-EVIDENCE.md and the raw-evidence index, and the #357
CHANGESET entry. Records the brief's #353 discrepancy, the three lease laps,
the arm64 jail rig pass in all four modes with the x64 caveat, why the V2 turn
modes' completed waits say nothing about #352, and that delivery is firstmate's.

Claude-Session: https://claude.ai/code/session_013Se6FG9EEPtmFRtg1hLDMm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant