Repository navigation
V2: first Location can expose an empty plugin generation #35556
Description
Summary
A first Location-scoped request can observe an empty or partial plugin generation because PluginSupervisor exposes its service while its initial reload is still running in a forked fiber.
This issue now tracks only the transient initial-generation race. The separate permanent failure where SDK plugins registered after Location boot never appeared was fixed by #35576 and is tracked historically in #34799.
Exact Reproduction
Verified on V2 commit 175d200e5310712033cbb3b009780f7994770386:
- Register an SDK plugin before touching a Location.
- Make the first Location-scoped request immediately.
- Observe an empty plugin list or materialize tools before the initial generation activates.
- Poll again tens of milliseconds later and observe the expected internal and SDK plugins.
The failure self-heals after the boot reload finishes. The correctness problem is that a first Session Step can consume the incomplete generation before it heals.
Cause Boundary
PluginSupervisor.layer starts its initial reload() with Effect.forkScoped and returns PluginSupervisor.Service immediately. The service exposes ready as Fiber.join(fiber), but Location acquisition does not establish that readiness as a general contract.
locationServices includes the supervisor and Session runner as sibling Location nodes. SessionRunner can therefore materialize agents and tools while the first generation is still empty or partial.
Readiness is consumed selectively today. Some operations await PluginSupervisor.ready; plugin listing and Session execution do not. Correctness depends on which Location-scoped operation wins startup ordering.
Required Contract
Session execution must not materialize agents or tools before the Location's initial plugin generation is active.
This does not necessarily require making every Location-scoped operation block. The TUI intentionally boots Locations lazily, and synchronously gating all Location acquisition would put plugin activation on first-paint paths such as the CLI's pre-render file request.
Acceptance Criteria
- Registering an SDK plugin before the first Session makes it visible to that Session's first model Step without polling or sleeping.
- Session execution cannot materialize agents or tools before initial plugin activation settles.
- Initial activation failure is surfaced to Session execution rather than exposing an apparently healthy partial Location.
- Regression coverage exercises the public embedded SDK ordering:
OpenCode.create()->opencode.plugin(...)-> first Session/Location use. - The fix does not remove intentional asynchronous startup from unrelated Location consumers without measuring the startup impact.
Already Fixed Separately
#35576 added sdk.plugin.updated and reloads every already-booted Location after SDK registration. Its tests cover multi-Location reloads, ordinary tool activation, and isolation between embedded hosts. Post-boot registration is no longer part of this issue.
Related
- V2: support registering SDK plugins after a Location has booted #34799: late SDK registration policy, completed by fix(plugin): reload late SDK plugins #35576.
- @opencode-ai/sdk: Tools intermittently fail to load (0 tools) when using createOpencodeServer #7060: historical SDK report of intermittently loading zero tools.
opencode serveaccepts requests while plugin npm install is still blocking the event loop #27971: separate plugin-package installation readiness issue.
Out of Scope
- Mixed-version daemon restart loops (V2: reconnecting clients can restart-loop the shared daemon #35448).
- TUI transcript reconciliation after reconnect (tui: interrupted tool can remain stuck in preparing state #35454).
- Linux watcher exhaustion (fix(core): bound recursive file watching #35323).
Activity
kitlangton commentedon Jul 6, 2026
Updated the issue body with new findings verified on origin/v2 at 175d200.
The key addition: losing the registration/boot race is not just a delayed activation, it is permanent. The supervisor's only reload triggers are the forked boot reload and config
Event.Updated,SdkPlugins.registerpublishes nothing, and the Location cache never expires under active traffic. I reproduced this deterministically at two levels: a core test that polls for 2 seconds after post-boot registration (never appears, then activates immediately after one manually published config event), and an embedded SDK test where the already-booted Location never sees the plugin while a Location booted after registration does.This means the fix needs both halves: gating Location readiness on initial activation (this issue) and making post-boot registration either apply or fail loudly (#34799).
kitlangton commentedon Jul 6, 2026
Narrowed this issue to the remaining initial-generation race. The permanent post-boot registration miss is fixed by #35576;
PluginSupervisorinitial readiness remains intentionally unchanged and is the only behavior tracked here now.kitlangton commentedon Jul 7, 2026
Production evidence from the personal OpenCode Agent on V2 commit
f2f62438bconfirms that this race can recur well after process startup because the cached Location is evicted and later rebuilt.Timeline (UTC)
2026-07-06 23:50:01.863: Location services booted for the agent root.2026-07-07 02:21: last activity before the idle period.2026-07-07 03:21:35.971: the Location watcher stopped, matching the configured 60-minuteLayerMapidle TTL.2026-07-07 10:47:04.808: the same Location booted again on the next Telegram message.2026-07-07 10:47:07.085: the first Session drain failed withSessionRunnerModel.ModelUnavailableError: Model unavailable: console-openai/gpt-5.6-sol.2026-07-07 10:47:08.201:agent.updatedwas published.2026-07-07 10:47:08.450:catalog.updatedwas published.The model lookup therefore ran about 1.36 seconds before the catalog generation finished.
Second symptom: advertised tools failed closed
At 11:00 the same Session was able to call the model, and the model was advertised
read,glob,shell, andwebsearch. Every call failed locally (provider.executed: false) with the generic wrappers:Unable to read HEARTBEAT.mdUnable to find files matching HEARTBEAT.mdUnable to execute command: ...Unable to search the web for testThese tools all pass through
PermissionV2.assert. If the configured SDK agent is absent,PermissionV2.configuredreturnsmissingAgentPermissions(deny all). MeanwhileSessionRunnerLLMpassesagent.info?.permissionsto tool materialization, so an absent agent suppliesundefined: tools are advertised, then denied when executed. The tool-specificmapErrorwrappers erase the underlying denial and make the failures look unrelated.Restarting the service rebuilt the Location after plugin registration and immediately restored the model, agent permissions, and tools.
Deterministic same-Location reproduction
I added a focused test at
packages/core/test/location-layer.test.ts:ready, and verify the agent exists.Deferred.AgentV2after activation has started but before it is released.On
f2f62438b, the test deterministically fails:bun typecheckpasses; only the readiness assertion fails.This expands the impact boundary: "first Location" includes the first request after any idle eviction, not just initial process startup. The fix needs a readiness contract that Session execution can rely on for every fresh Location generation. The current
PluginSupervisor.Service.readyis only a join of its boot reload fiber; whether that primitive is sufficient or needs a stronger generation-aware contract should be decided explicitly rather than assuming that merely having areadyfield closes the race.kitlangton commentedon Jul 7, 2026
Follow-up: the SDK plugin is not lost across Location eviction.
SdkPlugins.Storeis host-global, and the reopened Location does start the plugin's second activation. The problem is that Location-scoped contributions (agents, tools, catalog entries, hooks) are torn down on eviction and then repopulated asynchronously.More importantly, simply awaiting the current
PluginSupervisor.Service.readyfrom Session execution is not a complete fix for hosts that register plugins later.I added a second deterministic test covering registration during reopen:
ready.supervisor.readyafter B's registration returns.readyhas not completed while B is still activating.Current behavior fails the assertion:
readyhas already completed even though B's activation is blocked. This is expected from the implementation:readyisFiber.join(fiber)for the one boot reload; it does not represent subsequent reloads queued bysdk.plugin.updated.So there are two distinct guarantees to design:
sdk.register(B)completes before a Session asks for readiness, that Session must not run against a generation older than B.A fixed boot-fiber join can provide (1), but not (2). A stronger contract likely needs monotonically increasing desired/applied plugin revisions (or an equivalent generation token): registration increments the host SDK revision; each Location supervisor records the revision included in an activated generation; Session execution captures the current desired revision and waits until that Location has applied at least that revision. This gives a clear happens-before rule without making registration synchronously wait for every booted Location.
Config/plugin-file updates may need the same desired/applied-generation treatment if they require equivalent ordering guarantees. Otherwise the contract should explicitly distinguish SDK registration consistency from eventually consistent config reloads.
kitlangton commentedon Jul 7, 2026
Confirmed a second, persistent post-eviction bug with a deterministic test using the same replacement graph as the embedded SDK host.
OpenCode.create()owns one sharedSdkPlugins.Store.createEmbeddedRoutes(store)passesSdkPlugins.layerWithStore(store)as an AppNode replacement. When the lazy Location graph hoists that replacement, the replacement layer is owned by the Location scope. ButlayerWithStoreinstalls this finalizer:Therefore evicting any Location closes that Location's
SdkPluginsservice and clears the process-global shared store. On reopen, the newPluginSupervisorcan complete its initial reload normally, butsdk.all()is empty. SDK-contributed agents and tools do not return at all; this is not merely the initial readiness race. A process restart makes the host register them again, explaining why restart repaired both the persona permissions and tools.The focused test:
[SdkPlugins.node, SdkPlugins.layerWithStore(sharedStore)]as a replacement.PluginSupervisor.ready, and verifies the agent exists.LocationServiceMap.invalidate(ref), the deterministic equivalent of idle eviction.ready, and expects the agent to exist.It fails deterministically after
ready:bun typecheckpasses.So there are two independent defects:
readyalso does not cover a later reload queued during boot.The lifetime fix should make the owner of the shared host store solely responsible for clearing it. A Location-scoped
SdkPluginsservice may reference the store but must never clear it. The readiness/generation fix remains separately necessary.kitlangton commentedon Jul 7, 2026
Added a deterministic reproduction on latest
v2(8deb0e5780). It covers both boundaries:AgentV2.select("explore")returns{ id: "explore", info: undefined }, and tool materialization advertises all built-ins, includingshell.shell.The focused tests fail with
shellpresent in both tool lists, whilebun typecheckpasses. Draft PR: #35755kitlangton commentedon Jul 7, 2026
Proposed readiness semantics
The supervisor should expose a drain wait for plugin activation. When Session execution waits, the supervisor must finish the current generation and also apply any plugin/config registrations requested while that generation was activating. It returns only when
requested === appliedat the completion boundary. A registration after that boundary belongs to the next synchronization.This is not a promise that the whole process is globally quiet. Readiness means:
Readiness does not mean:
This implies a plugin contract: top-level setup must await every initial contribution required for correct Session execution. It may fork only ongoing listeners and later refresh work. For example, an initial provider refresh needed to construct the catalog must be awaited; the listener for future credential changes should be forked.
Session execution should call this wait before resolving its agent, model, instructions, or tools. After the wait, an explicitly selected missing agent fails with a typed
AgentNotFoundError; it never proceeds withpermissions: undefinedor falls back to another agent.Location acquisition and TUI bootstrap remain progressive. They do not wait. This keeps first paint fast while giving model execution the stronger consistency contract it requires.
Draft reproducer: #35755.
opencode-agent commentedon Jul 8, 2026
Audit update (2026-07-08): #35725 fixed the separate SDK-plugin lifetime/eviction defect and merged into
v2. The transient initial-generation readiness race described here remains open; #35755 is the active deterministic reproduction, while #35660 is adev-targeted proposed wait-for-boot fix.kitlangton commentedon Jul 8, 2026
PR #35755 now implements the final one-shot readiness contract from #35942 at
f49e25408b. Session execution waits for one successfully activated, quiescent startup generation, then fails with typedAgentNotFoundErrorbefore any model request if the selected agent is still absent. Later hot reload remains asynchronous.The final simplify pass bounds pending invalidations with capacity-one sliding queues because each activation rereads current Config and SDK state. Combined location, runner, recorded, and provider suites pass (343 tests), and repository typecheck passes (31 tasks). Linux CI is green; Windows CI is still running.
kitlangton commentedon Jul 8, 2026
Fixed by merged PR #35755 (
5b39972947). The initial Location plugin generation is now gated at Session execution, startup updates settle before the one-shot barrier opens, and missing selected agents fail before model execution.Fade78 commentedon Oct 11, 2026
Same thing here in 2.0.26. Loading agents fail:
opencode service restart
opencode debug agents -> []
(few seconds later)
opencode debug agents -> [ full list ]
opencode --standalone can't have agents.