Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 129a646308 | |||
| 00335982ad | |||
| 83f25e88b6 | |||
| 1f9305b3af | |||
| a142d76eec |
+454
@@ -0,0 +1,454 @@
|
||||
# Plugin Readiness and Generation Lifetime
|
||||
|
||||
## Recommendation
|
||||
|
||||
Revise draft PR #35755 to expose one fixed-target readiness barrier. When Session execution reaches the barrier, it captures the current requested activation revision, waits until the Location has applied at least that revision, then continues without retaining an activation lock.
|
||||
|
||||
Immediately afterward, the runner resolves the selected agent and fails closed with `Session.AgentNotFoundError` if it is absent. This closes the empty-generation tool leak without claiming that a complete model step or request preparation is an immutable plugin snapshot.
|
||||
|
||||
Do not merge the current whole-step semaphore. It serializes Sessions and deterministically deadlocks a foreground subagent: the parent holds the Location semaphore while waiting for a child Session that needs the same semaphore.
|
||||
|
||||
Do not replace it with a preparation lock. Request hooks are arbitrary Effects that can reenter Sessions, and model resolution may perform credential/network work. Holding the Location semaphore across either can recreate deadlocks or block every Session.
|
||||
|
||||
Do not add generation leases in this PR. A correct lease design requires generation-local executable state or owner borrowing for tools, hooks, and AI SDK models. Effect supplies useful scope and reference-counting pieces, but no primitive can make the current shared mutable modules generation-isolated.
|
||||
|
||||
This document records the evidence, alternatives, and migration plan behind that recommendation.
|
||||
|
||||
## Problem
|
||||
|
||||
A Location becomes available before its initial plugin generation finishes activating. Session execution can therefore resolve an absent selected agent or materialize tools from incomplete plugin-owned state.
|
||||
|
||||
The original failure looked like this:
|
||||
|
||||
1. A Session selected `explore`.
|
||||
2. Plugin activation had not installed the `explore` agent yet.
|
||||
3. `AgentV2.select("explore")` returned an ID with no agent definition.
|
||||
4. Tool materialization received `permissions: undefined` and advertised tools such as `shell`.
|
||||
5. Tool execution later failed closed because permission evaluation could not find the agent.
|
||||
|
||||
Location eviction makes this more than a process-start race. Reopening an evicted Location reconstructs Location-scoped agent, catalog, hook, and tool state asynchronously.
|
||||
|
||||
SDK registration and Config updates add an ordering requirement: if update publication completes before Session execution begins synchronization, the barrier must apply at least that update's revision before returning.
|
||||
|
||||
## Required Semantics
|
||||
|
||||
### Progressive Location startup
|
||||
|
||||
Location acquisition and TUI bootstrap must remain non-blocking. Plugin imports, provider discovery, and external setup must not become a prerequisite for first paint or unrelated filesystem operations.
|
||||
|
||||
### Session readiness
|
||||
|
||||
Before constructing a model request, Session execution captures the current requested activation revision and waits until the Location has applied at least that revision.
|
||||
|
||||
Readiness includes:
|
||||
|
||||
- plugin planning and module import
|
||||
- top-level plugin setup
|
||||
- initial agent, model, command, skill, hook, and tool contributions
|
||||
- final Config projection plugins
|
||||
- all tracked updates ordered before the captured target revision
|
||||
|
||||
Readiness excludes:
|
||||
|
||||
- future registrations
|
||||
- arbitrary background fibers
|
||||
- ongoing listeners
|
||||
- MCP connection completion unless setup deliberately awaits it
|
||||
- global process quiescence
|
||||
|
||||
### Readiness is a barrier, not a snapshot
|
||||
|
||||
The barrier guarantees that activation requests observed when the wait begins have completed before authoritative agent resolution starts. It does not freeze plugin-owned state after the barrier.
|
||||
|
||||
Config, SDK, and credential updates may occur immediately afterward. Current registries and caches retain their existing live-reload semantics. This PR must not claim coherent request or whole-step generations because the current shared mutable modules cannot provide them.
|
||||
|
||||
### Missing agents fail closed
|
||||
|
||||
After readiness settles, a missing selected agent must produce typed `Session.AgentNotFoundError`. It must not fall back to another agent, a generic system prompt, or unrestricted tool materialization.
|
||||
|
||||
### Failure and boundedness
|
||||
|
||||
The barrier must not hang forever on network-dependent activation. Existing optional-plugin import/setup failures may still be logged and skipped; selected agents and models fail closed if their required contribution is absent after the barrier.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
`PluginSupervisor` maintains two process-local counters per Location:
|
||||
|
||||
```text
|
||||
requested = number of observed initial/config/SDK activation requests
|
||||
applied = latest request boundary completed by activation
|
||||
```
|
||||
|
||||
Its activation loop snapshots `requested`, plans and activates a generation, assigns the snapshot to `applied`, and repeats if another update arrived during activation.
|
||||
|
||||
```text
|
||||
requested = 1
|
||||
activate generation 1
|
||||
SDK update -> requested = 2
|
||||
finish generation 1 -> applied = 1
|
||||
activate generation 2
|
||||
finish generation 2 -> applied = 2
|
||||
return
|
||||
```
|
||||
|
||||
`SessionRunnerLLM` wraps each complete model-step attempt in:
|
||||
|
||||
```ts
|
||||
plugins.withGeneration(attemptStep(...))
|
||||
```
|
||||
|
||||
`withGeneration` currently:
|
||||
|
||||
1. acquires a Location-wide semaphore
|
||||
2. drains pending activation requests
|
||||
3. runs the complete model step while retaining the semaphore
|
||||
4. releases the semaphore after provider streaming and owned tool settlement
|
||||
|
||||
This prevents plugin scope replacement from invalidating tool identities during a step.
|
||||
|
||||
The OpenAI and OpenCode provider plugins now await their initial refresh because a completed generation must contain the initial catalog required for Session model resolution. The OpenCode config request has a 10-second timeout and no retry. Errors are logged and remote providers remain absent.
|
||||
|
||||
`PluginSupervisorNode` is not a new runtime module. The existing node definition was moved out of `location-services.ts` so both the Location graph and `SessionRunnerLLM.node` can depend on it without a module import cycle.
|
||||
|
||||
## Current Strengths
|
||||
|
||||
- The initial empty-generation race is closed.
|
||||
- Config and SDK updates ordered before synchronization begins are applied.
|
||||
- Missing selected agents fail before model execution.
|
||||
- Location acquisition and TUI startup remain progressive.
|
||||
- The revision accounting is localized in the supervisor.
|
||||
|
||||
## Current Risks
|
||||
|
||||
### Location-wide serialization and nested-Session deadlock
|
||||
|
||||
The semaphore is held across provider streaming and tool execution. Different Sessions in one Location cannot execute model steps concurrently even though Session coordination otherwise permits that concurrency.
|
||||
|
||||
Foreground subagents make this a correctness bug, not only a performance cost:
|
||||
|
||||
1. A parent Session holds the Location semaphore while settling the subagent tool.
|
||||
2. The subagent tool starts a child Session and waits for it to finish.
|
||||
3. The child Session needs the same Location semaphore before its model step.
|
||||
4. The parent waits for the child while the child waits for the parent-held semaphore.
|
||||
|
||||
### Delayed reloads
|
||||
|
||||
A long model call or tool execution prevents Config and SDK updates from activating. This is safe but potentially surprising and can make reload latency unbounded.
|
||||
|
||||
### Moving drain target
|
||||
|
||||
The activation loop drains until `applied === requested`. A continuous update stream can prevent every Session from starting. The contract cannot guarantee both bounded completion and inclusion of every event published before return. Synchronization needs a target revision captured when the wait begins.
|
||||
|
||||
### Remote work inside activation
|
||||
|
||||
Awaiting initial provider refresh makes catalog readiness truthful, but network and credential resolution now sit on the activation path. OpenCode fetch is bounded to 10 seconds; credential resolution is not separately bounded, and there is no transient retry policy.
|
||||
|
||||
### Forced full replacement
|
||||
|
||||
Config and SDK updates force replacement even when plugin IDs and versions compare equal. This is currently required for Config projections and same-ID SDK replacement, but it repeats all plugin setup and provider refresh work.
|
||||
|
||||
### Failure semantics
|
||||
|
||||
The registry currently logs and skips individual plugin setup failures. A generation can therefore be "complete" while configured plugins are absent. We need to distinguish optional plugin failure from failure of a required selected agent or model.
|
||||
|
||||
## Design Questions
|
||||
|
||||
1. Can Effect v4 model an atomic swappable scoped resource whose old scope closes only after all readers release it?
|
||||
2. Does `LayerMap`, `RcMap`, `ScopedRef`, `Resource`, `Pool`, `Scope.fork`, or another Effect primitive already provide the required lease semantics?
|
||||
3. Can tool materialization own everything needed for later settlement, removing the need to retain the plugin generation?
|
||||
4. Can plugin scopes remain alive across replacement while domain state atomically switches generations?
|
||||
5. Which executable contributions need owner borrowing if seamless hot reload becomes a product requirement?
|
||||
6. What is the precise linearization point for Config and SDK updates?
|
||||
7. Should synchronization drain updates that arrive during activation, or only the revision captured when synchronization begins?
|
||||
8. How should concurrent Session readers interact with a writer without serializing each other?
|
||||
9. How should activation failure affect the previous healthy generation?
|
||||
10. What bounded retry policy, if any, should initial remote provider discovery use?
|
||||
|
||||
## Candidate Design Families
|
||||
|
||||
These are starting points for research, not recommendations.
|
||||
|
||||
### A. Semaphore pinning
|
||||
|
||||
Keep the current implementation. One writer/reader semaphore makes correctness obvious but serializes all readers.
|
||||
|
||||
### B. Read/write locking
|
||||
|
||||
Allow concurrent Session steps as readers and activation as an exclusive writer. This preserves generation pinning but a long reader still delays reload. Fairness and cancellation become part of the interface.
|
||||
|
||||
### C. Reference-counted generation lease
|
||||
|
||||
Activation builds a new scoped generation, atomically swaps the current pointer, and retires the old generation. Old scopes close after their active leases reach zero. Readers do not block one another, and reload does not wait for old model steps.
|
||||
|
||||
### D. Immutable materialization
|
||||
|
||||
Request construction snapshots stable agent/model/hook/tool values. Tool settlement calls captured handlers directly rather than checking current registry identity. Reload may close plugin scopes immediately only if captured handlers do not depend on those scopes.
|
||||
|
||||
### E. Stable registration cells
|
||||
|
||||
Tool and hook registrations use stable indirection cells whose implementation can be versioned or swapped. A request captures a version or cell lease. This may reduce whole-generation lifetime management but can move complexity into every plugin-owned domain.
|
||||
|
||||
### F. Admit-only readiness
|
||||
|
||||
Drain readiness only before request construction and accept stale-tool failures after reload as an explicit semantic. This is simplest, but likely violates the promise that an advertised tool remains callable during the step.
|
||||
|
||||
## Pressure Tests
|
||||
|
||||
Any accepted design must explain these paths:
|
||||
|
||||
1. Fresh Location, Session begins before initial activation completes.
|
||||
2. SDK plugin B registers before or after a Session captures its target while generation A is activating.
|
||||
3. Config update arrives before or after target capture while synchronization is waiting.
|
||||
4. Two Sessions in the same Location execute long model steps concurrently.
|
||||
5. Reload occurs while both Sessions hold tools from the old generation.
|
||||
6. One old-generation tool runs for minutes after the new generation is active.
|
||||
7. Plugin setup fails while a previous healthy generation exists.
|
||||
8. The selected agent is removed by the new generation.
|
||||
9. Remote provider discovery times out, then credentials change later.
|
||||
10. A continuous stream of Config updates arrives faster than activation.
|
||||
11. Location scope closes while activation, readers, or retired generations remain.
|
||||
12. A Session is interrupted while holding a generation lease.
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
The preferred design should:
|
||||
|
||||
- make the required ordering obvious at the interface
|
||||
- allow concurrent Sessions in one Location
|
||||
- close the initial and previously published activation ordering race
|
||||
- avoid making stronger snapshot or executable-lifetime claims
|
||||
- avoid blocking reload behind model streams or tools
|
||||
- preserve interruption safety
|
||||
- keep Location startup progressive
|
||||
- bound network-dependent readiness
|
||||
- centralize activation ordering in one deep module
|
||||
- remain testable with deterministic activation barriers
|
||||
|
||||
## Research Findings
|
||||
|
||||
### Effect has no direct generation-swap primitive
|
||||
|
||||
Effect v4 has useful pieces, but no public primitive that atomically installs a prebuilt scoped replacement, allows concurrent readers to lease the old value, and closes the old scope after its final lease.
|
||||
|
||||
- `ScopedRef.set` closes the old scope before installing its replacement. Reads are not leases.
|
||||
- `Resource` is built on `ScopedRef` and has the same replacement semantics.
|
||||
- `RcRef` and `RcMap` preserve an invalidated resource until active scoped borrowers release it, but replacement is lazy and they do not provide an atomic current-generation pointer.
|
||||
- `LayerMap` is a keyed `RcMap` wrapper, not an intra-Location generation manager.
|
||||
- `Scope`, `Ref`, `Deferred`, `Effect.acquireUseRelease`, and a writer semaphore could implement a generation manager if one becomes necessary.
|
||||
|
||||
The correct custom lease design would build a candidate in a child scope, atomically publish it through a `Ref`, mark the previous generation retired, and close retired scopes after their reader count reaches zero. Readers would acquire and release through `Effect.acquireUseRelease`. This is implementable, but it is a substantial new lifecycle module.
|
||||
|
||||
### Whole-generation leasing does not fit the current state model
|
||||
|
||||
Plugin activation does not currently build one isolated generation object. It mutates shared Location-scoped agent, catalog, hook, tool, command, skill, and reference modules through `State.batch` and scoped registrations.
|
||||
|
||||
Retaining only an old plugin scope would preserve its resources, but new request reads would still use shared current state. A true whole-generation lease would require generation-owned snapshots or adapters for every plugin-derived module. Wrapping the current modules in a generation context would not make them isolated.
|
||||
|
||||
That design may eventually be valuable, but it is much larger than the readiness bug and would replace the current plugin state architecture.
|
||||
|
||||
### Per-registration leases are narrower but still invasive
|
||||
|
||||
Another viable design is to make materialized executable values retain their owning plugin scopes:
|
||||
|
||||
- capture tool handlers rather than rechecking the live registry
|
||||
- capture tool hooks and AI SDK hooks
|
||||
- retain the plugin owners backing those closures
|
||||
- retire registration visibility immediately on reload
|
||||
- close plugin scopes after all captured executable values release them
|
||||
|
||||
This permits concurrent Sessions and immediate reload, but old and new plugin listeners may coexist, and every executable contribution needs owner provenance. The public plugin contract permits closures over scoped resources, so simply copying handler functions without retaining owners is unsound.
|
||||
|
||||
### Existing stale-tool behavior is incomplete lifetime protection
|
||||
|
||||
`ToolRegistry.materialize` captures registration identities. Settlement compares that identity with the live registry and returns `tool.stale` when replacement completed before the check.
|
||||
|
||||
The check prevents many invocations of removed handlers, but it has a time-of-check/time-of-use window: reload may close the plugin scope after identity validation and before or during handler execution. Tool hooks may also change during one settlement. The readiness barrier neither solves nor depends on this behavior; executable lifetime needs separate work.
|
||||
|
||||
### A Location-epoch design is simpler but changes shipped behavior
|
||||
|
||||
The simplest possible design would activate plugins once per Location and require reopening the Location for SDK or plugin-topology changes. Ordinary Config projections could continue through their existing listeners.
|
||||
|
||||
This removes generation management entirely, but late SDK registration and live plugin code reload are existing behaviors with regression coverage. Changing them is a product decision, not an implementation simplification appropriate for this PR.
|
||||
|
||||
## Recommended Design
|
||||
|
||||
Use one **fixed-target readiness barrier**. Do not run caller effects while holding the supervisor semaphore.
|
||||
|
||||
The supervisor interface has one operation:
|
||||
|
||||
```ts
|
||||
export interface PluginSupervisor {
|
||||
/** Apply at least every activation request observed when this Effect begins. */
|
||||
readonly synchronize: Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
Session execution uses it immediately before authoritative agent resolution:
|
||||
|
||||
```ts
|
||||
yield * plugins.synchronize
|
||||
|
||||
const agent = yield * agents.select(session.agent)
|
||||
if (!agent.info) {
|
||||
return (
|
||||
yield *
|
||||
new AgentNotFoundError({
|
||||
sessionID: session.id,
|
||||
agent: session.agent ?? agent.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Existing live state and reload semantics apply from here onward.
|
||||
```
|
||||
|
||||
Each call to `synchronize`:
|
||||
|
||||
1. Captures `target = requested` when the Effect begins, before waiting for the semaphore.
|
||||
2. Acquires the per-Location activation semaphore.
|
||||
3. If `applied < target`, plans and activates from the current stores once, recording the target covered by that activation.
|
||||
4. Releases the semaphore when `applied >= target`.
|
||||
|
||||
The implementation may activate a source state newer than `target` because Config and SDK stores mutate before publishing their update event. That is valid: `target` is a minimum required revision, not an exact generation.
|
||||
|
||||
Background activation follows the same fixed-target rule. Each event handler captures one target before waiting for the permit, applies through that target, and releases. It must not use a moving `while (applied < requested)` drain that can monopolize the semaphore indefinitely.
|
||||
|
||||
### Readiness linearization
|
||||
|
||||
```text
|
||||
update publication -> requested increments synchronously
|
||||
synchronize begins -> target = requested
|
||||
activation commit -> applied advances to that activation's target
|
||||
barrier returns -> applied >= target
|
||||
```
|
||||
|
||||
Updates ordered before target capture are required. Updates ordered afterward are not required for that wait, but may be coalesced because Config and SDK stores mutate before their update events publish. The barrier promises a minimum revision, not an exact snapshot.
|
||||
|
||||
This closes the issue's ordering paths:
|
||||
|
||||
- A fresh Location has initial `requested = 1`; the first Session waits for activation.
|
||||
- A reopened Location after eviction has a new supervisor and the same initial barrier.
|
||||
- SDK or Config publication increments `requested` synchronously before the publisher returns, so a later barrier captures it.
|
||||
- Continuous later updates cannot move an already captured completion target.
|
||||
|
||||
### No protected preparation callback
|
||||
|
||||
The supervisor must not expose `prepare(effect)`, `read(effect)`, or `withGeneration(effect)` in this PR.
|
||||
|
||||
Request hooks are arbitrary Effects and can start or await nested Sessions. Model resolution may refresh credentials over the network. Instruction loading may touch files, MCP state, and the database. Letting callers place this work under the activation semaphore recreates deadlocks and unbounded Location-wide serialization.
|
||||
|
||||
The barrier intentionally permits a reload immediately after it returns. This is the existing live-reload product behavior. The selected agent is still checked fail-closed, so the original absent-agent path cannot advertise unrestricted tools.
|
||||
|
||||
### Activation failure
|
||||
|
||||
Preserve existing optional-plugin behavior in this PR. Import and setup failures may be logged and skipped. Required selected resources fail at use:
|
||||
|
||||
- missing selected agent -> `Session.AgentNotFoundError`
|
||||
- missing selected model -> existing model resolution error
|
||||
|
||||
Do not claim transactional activation or last-known-good fallback. Current activation closes old scopes before rebuilding shared state. A truthful transactional design requires isolated candidate state and is separate work.
|
||||
|
||||
### Remote provider discovery
|
||||
|
||||
Readiness and retry remain separate policies.
|
||||
|
||||
For this PR:
|
||||
|
||||
- await one bounded initial refresh so the first activation attempts to populate the initial catalog
|
||||
- bound the entire OpenCode discovery operation, including credential resolution, not only the final HTTP fetch
|
||||
- do not retry indefinitely inside activation
|
||||
- allow later integration connection updates to refresh again
|
||||
|
||||
There is currently no retry in `fetchProviders` or `FetchHttpClient`. A follow-up may add one short retry for transient transport/5xx failures within the same total timeout. Authentication failures, schema errors, and ordinary 4xx responses should not retry.
|
||||
|
||||
## Why This Design
|
||||
|
||||
It closes the demonstrated readiness bug without making claims the current architecture cannot satisfy:
|
||||
|
||||
- Initial and previously published activation requests complete before agent resolution.
|
||||
- Missing selected agents fail before model or tool materialization.
|
||||
- Location startup remains progressive.
|
||||
- Different Sessions do not hold a shared permit during model work.
|
||||
- Foreground subagents cannot deadlock on the supervisor semaphore.
|
||||
- Continuous updates cannot starve a captured wait target.
|
||||
- No new Effect lifecycle abstraction is needed.
|
||||
|
||||
The module remains deep despite its one-operation interface: it hides synchronous update accounting, target capture, serialized activation, and initial boot behavior from every authoritative caller.
|
||||
|
||||
## Separate Hot-Reload Defects
|
||||
|
||||
The reviews found existing lifetime problems that the readiness barrier does not solve and must not obscure:
|
||||
|
||||
1. `ToolRegistry.settleWith` checks registration identity and then invokes the handler. Reload can close the plugin scope between the check and invocation.
|
||||
2. Tool before/after hooks can cross generations during one settlement.
|
||||
3. `AISDK.language` caches plugin-produced executable model objects without generation identity or invalidation.
|
||||
4. Credential and Config projection listeners mutate shared Location state outside supervisor activation.
|
||||
5. Activation failure destroys the prior healthy generation before skipping failed replacements.
|
||||
6. Title and compaction model requests have their own readiness requirements.
|
||||
|
||||
These need separate product contracts and regression tests. Fixing them correctly may require per-registration owner borrowing, cache generation keys, or generation-local state. They are not reasons to retain a whole-step lock, and they are not solved by a preparation lock.
|
||||
|
||||
## Rejected Designs
|
||||
|
||||
### Whole-step semaphore
|
||||
|
||||
Rejected. It serializes Sessions, blocks reload behind long tools, and deadlocks foreground subagents.
|
||||
|
||||
### Preparation callback under the semaphore
|
||||
|
||||
Rejected. Arbitrary request hooks and model resolution can reenter Sessions or perform unbounded work.
|
||||
|
||||
### Read/write lock
|
||||
|
||||
Rejected. It still blocks reload behind the longest reader and introduces writer/read reentrancy hazards.
|
||||
|
||||
### Reference-counted whole generations
|
||||
|
||||
Deferred. It is internally coherent only after Agent, Catalog, permissions, hooks, and ToolRegistry become generation-addressable and immutable.
|
||||
|
||||
### Captured handlers without owner retention
|
||||
|
||||
Rejected as unsound. Plugin handlers and AI SDK models may close over scope-owned resources.
|
||||
|
||||
### Per-registration owner leases
|
||||
|
||||
Potential follow-up for seamless hot reload, but ownership provenance must cover tools, hooks, and AI SDK models.
|
||||
|
||||
### Location-epoch topology
|
||||
|
||||
Architecturally simple, but removes existing live SDK/plugin-topology reload and requires a product decision.
|
||||
|
||||
## Pressure-Test Results
|
||||
|
||||
1. **Fresh Location:** the initial target waits for initial activation.
|
||||
2. **SDK update before target capture:** the barrier requires it.
|
||||
3. **SDK update after target capture:** it is not required but may be coalesced.
|
||||
4. **Config update before/after capture:** same minimum-revision semantics.
|
||||
5. **Two long Sessions:** both pass the short barrier and stream concurrently.
|
||||
6. **Foreground subagent:** parent holds no supervisor permit while waiting for the child.
|
||||
7. **Continuous updates:** later revisions cannot move the captured target.
|
||||
8. **Plugin setup failure:** optional failure behavior remains unchanged; selected resources fail closed.
|
||||
9. **Agent removed after barrier:** existing live-reload race remains; the next authoritative resolution observes current state.
|
||||
10. **Location closes:** normal scoped interruption applies; no Session-held supervisor lease exists.
|
||||
11. **Session interruption:** no supervisor resource needs release after the barrier.
|
||||
12. **Reload during tool/AI SDK execution:** explicitly outside this PR and covered by separate lifetime work.
|
||||
|
||||
## Migration From PR #35755
|
||||
|
||||
1. Remove `withGeneration` and the whole-attempt wrapper.
|
||||
2. Keep `synchronize` as the V2 interface; retain `ready` only as a deprecated V1 compatibility alias until V1 is removed.
|
||||
3. Capture a fixed target when each synchronization or background reload Effect begins.
|
||||
4. Change activation to apply through that target rather than drain a moving `requested` value.
|
||||
5. Await `plugins.synchronize` immediately before selected-agent resolution in each model request path.
|
||||
6. Keep typed missing-agent failure.
|
||||
7. Bound the complete initial OpenCode provider discovery operation.
|
||||
8. Revert runner changes added only to accommodate unavailable-tool continuation if they are unrelated to readiness.
|
||||
9. Add production-layer tests for concurrent Sessions and foreground subagents.
|
||||
10. Add fixed-target tests for before-capture, after-capture, and continuous updates.
|
||||
11. Keep fresh-Location, eviction/reopen, embedded SDK first-Session, and missing-agent coverage.
|
||||
12. Add separate follow-up issues/tests for tool settlement TOCTOU, AI SDK cache lifetime, transactional activation, title readiness, and compaction readiness.
|
||||
13. Re-run the full core suite and update the PR body with the reduced contract.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Revise PR #35755 to implement only the fixed-target readiness barrier and fail-closed selected-agent resolution. Do not merge either the whole-step lock or a protected preparation callback. Do not introduce generation leases in this PR.
|
||||
@@ -5,37 +5,29 @@ import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeLocationNode, Node } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Global } from "./global"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Image } from "./image"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { Npm } from "./npm"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { SdkPlugins } from "./plugin/sdk"
|
||||
import { PluginSupervisor } from "./plugin/supervisor"
|
||||
import { PluginSupervisorNode } from "./plugin/supervisor-node"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
@@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const pluginSupervisorNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: PluginSupervisor.layer,
|
||||
deps: [
|
||||
PluginV2.node,
|
||||
SdkPlugins.node,
|
||||
AgentV2.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Config.node,
|
||||
EventV2.node,
|
||||
FileMutation.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
PermissionV2.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Config.node,
|
||||
@@ -104,7 +58,7 @@ const locationServiceNodes = [
|
||||
Catalog.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
pluginSupervisorNode,
|
||||
PluginSupervisorNode.node,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
|
||||
@@ -21,7 +21,10 @@ import { ToolHooks } from "./tool/hooks"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
|
||||
readonly activate: (
|
||||
plugins: readonly { readonly plugin: Plugin; readonly version?: string }[],
|
||||
options?: { readonly force?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ const layer = Layer.effect(
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly { readonly plugin: Plugin; readonly version?: string }[],
|
||||
options?: { readonly force?: boolean },
|
||||
) {
|
||||
const definitions = plugins.map((entry) => ({
|
||||
...entry.plugin,
|
||||
@@ -54,6 +58,7 @@ const layer = Layer.effect(
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
!options?.force &&
|
||||
generation !== undefined &&
|
||||
generation.length === definitions.length &&
|
||||
generation.every(
|
||||
|
||||
@@ -209,7 +209,7 @@ export const OpenAIPlugin = define({
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* refresh()
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
|
||||
@@ -85,12 +85,15 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
connected = connection !== undefined
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
providers = connection
|
||||
? yield* Effect.gen(function* () {
|
||||
const credential = yield* ctx.integration.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return credential ? yield* fetchProviders(http, credential) : undefined
|
||||
}).pipe(
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
@@ -186,7 +189,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* refresh()
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export * as PluginSupervisorNode from "./supervisor-node"
|
||||
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Form } from "../form"
|
||||
import { Global } from "../global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { Layer } from "effect"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import type { PluginInternal } from "./internal"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { PluginSupervisor } from "./supervisor"
|
||||
|
||||
const layer = PluginSupervisor.layer as Layer.Layer<PluginSupervisor.Service, never, PluginInternal.Requirements>
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer,
|
||||
deps: [
|
||||
PluginV2.node,
|
||||
SdkPlugins.node,
|
||||
AgentV2.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Config.node,
|
||||
EventV2.node,
|
||||
FileMutation.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
PermissionV2.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Fiber, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../config"
|
||||
@@ -246,6 +246,9 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
/** Apply at least every Config and SDK plugin update observed when this Effect begins. */
|
||||
readonly synchronize: Effect.Effect<void>
|
||||
/** @deprecated Use synchronize. */
|
||||
readonly ready: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -258,35 +261,54 @@ const layer = Layer.effect(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const runActivation = yield* FiberSet.makeRuntime<never, void>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const reload = Effect.fn("PluginSupervisor.reload")(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre, ...sdk.all()]
|
||||
// Read the current layered config before resolving plugin directives and packages.
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, internal.post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
}),
|
||||
),
|
||||
let requested = 1
|
||||
let applied = 0
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== Event.Updated.type && event.type !== SdkPlugins.Updated.type) return
|
||||
if (
|
||||
event.location &&
|
||||
(event.location.directory !== location.directory || event.location.workspaceID !== location.workspaceID)
|
||||
)
|
||||
return
|
||||
requested++
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const activateThrough = Effect.fn("PluginSupervisor.activateThrough")(function* (target: number) {
|
||||
if (applied >= target) return
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre, ...sdk.all()]
|
||||
const operations = yield* scan(yield* config.entries())
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, internal.post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins, { force: applied > 0 })
|
||||
applied = target
|
||||
})
|
||||
const activateTarget = Effect.fn("PluginSupervisor.activateTarget")((target: number) =>
|
||||
lock.withPermit(activateThrough(target)),
|
||||
)
|
||||
const reload = Effect.suspend(() => activateTarget(requested))
|
||||
yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe(
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
reload.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const fiber = yield* reload().pipe(
|
||||
Effect.withSpan("PluginSupervisor.boot"),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ ready: Fiber.join(fiber) })
|
||||
yield* reload.pipe(Effect.withSpan("PluginSupervisor.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
const context = yield* Effect.context<Effect.Services<ReturnType<typeof activateTarget>>>()
|
||||
const synchronize = Effect.suspend(() => {
|
||||
const target = requested
|
||||
const activation = activateTarget(target).pipe(Effect.provideContext(context))
|
||||
return Fiber.join(runActivation(activation))
|
||||
})
|
||||
return Service.of({ synchronize, ready: synchronize })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentNotFoundError extends Schema.TaggedErrorClass<AgentNotFoundError>()("Session.AgentNotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Agent not found: "${this.agent}"`
|
||||
}
|
||||
}
|
||||
|
||||
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
||||
error: SessionError.Error,
|
||||
}) {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { Instructions } from "../../instructions/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
@@ -12,6 +12,7 @@ export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| AgentNotFoundError
|
||||
| StepFailedError
|
||||
| UserInterruptedError
|
||||
| Instructions.InitializationBlocked
|
||||
|
||||
@@ -47,11 +47,13 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
import { StepFailedError, UserInterruptedError } from "../error"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor"
|
||||
import { PluginSupervisorNode } from "../../plugin/supervisor-node"
|
||||
|
||||
type StepTokens = {
|
||||
readonly input: number
|
||||
@@ -148,6 +150,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
@@ -208,53 +211,76 @@ const layer = Layer.effect(
|
||||
const session = yield* getSession(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
||||
db,
|
||||
events,
|
||||
loadInstructions(agent, session.id),
|
||||
session.id,
|
||||
)
|
||||
yield* plugins.synchronize
|
||||
const prepared = yield* Effect.gen(function* () {
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const agentInfo = agent.info
|
||||
if (!agentInfo)
|
||||
return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
||||
db,
|
||||
events,
|
||||
loadInstructions(agent, session.id),
|
||||
session.id,
|
||||
)
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const resolved = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||
const toolMaterialization = isLastStep
|
||||
? undefined
|
||||
: yield* tools.materialize({ permissions: agentInfo.permissions, model: resolved.model })
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
return {
|
||||
agent,
|
||||
agentInfo,
|
||||
currentStep,
|
||||
resolved,
|
||||
context,
|
||||
isLastStep,
|
||||
toolMaterialization,
|
||||
request: LLM.request({
|
||||
model: resolved.model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [
|
||||
agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(resolved.model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const agent = prepared.agent
|
||||
const agentInfo = prepared.agentInfo
|
||||
const currentStep = prepared.currentStep
|
||||
const resolved = prepared.resolved
|
||||
const model = resolved.model
|
||||
const context = prepared.context
|
||||
const isLastStep = prepared.isLastStep
|
||||
const toolMaterialization = prepared.toolMaterialization
|
||||
const request = prepared.request
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const resolved = yield* models.resolve(session)
|
||||
const model = resolved.model
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep
|
||||
? undefined
|
||||
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [
|
||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
||||
const requestEvent: SessionHooks["request"] = {
|
||||
sessionID: session.id,
|
||||
@@ -340,6 +366,15 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
if (!advertisedTools.has(event.name)) {
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: `Tool is not available for this request: ${event.name}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
@@ -436,7 +471,7 @@ const layer = Layer.effect(
|
||||
if (
|
||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
(agent.info?.steps === undefined || currentStep < agent.info.steps)
|
||||
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
|
||||
) {
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
@@ -682,5 +717,6 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
PluginSupervisorNode.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Integration } from "../integration"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { StepFailedError, UserInterruptedError } from "./error"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
@@ -41,6 +41,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof ToolFailure)
|
||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
if (
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
|
||||
@@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => {
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.synchronize
|
||||
})
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
@@ -273,9 +273,9 @@ function withLocation<A, E, R>(
|
||||
function mutablePlugin(description: string) {
|
||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
|
||||
return `
|
||||
import { define } from ${JSON.stringify(plugin)}
|
||||
import { Plugin } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default EffectPlugin.define({
|
||||
export default Plugin.define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -53,7 +53,7 @@ describe("LocationServiceMap", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.synchronize
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
@@ -66,6 +66,205 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("waits for explorer activation to complete", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-initial-activation",
|
||||
effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const synchronized = yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
expect(synchronized.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(synchronized)
|
||||
|
||||
const explorer = yield* Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.resolve("explore")
|
||||
}).pipe(Effect.provide(context))
|
||||
|
||||
expect(explorer).toBeDefined()
|
||||
expect(explorer?.permissions.length).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("does not extend synchronization for SDK plugins registered after target capture", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "fixed-target-first-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const synchronized = yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "fixed-target-second-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(synchronized.pollUnsafe()).toBeDefined()
|
||||
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(synchronized)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(Effect.provide(context))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("includes SDK plugins registered before synchronization begins", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-first-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-second-plugin",
|
||||
effect: (ctx) =>
|
||||
Deferred.succeed(secondStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseSecond)),
|
||||
Effect.andThen(
|
||||
ctx.agent.transform((agents) => agents.update(AgentV2.ID.make("second-sdk-agent"), () => {})),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const synchronized = yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(synchronized.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(synchronized)
|
||||
|
||||
const second = yield* AgentV2.Service.use((agents) => agents.get(AgentV2.ID.make("second-sdk-agent"))).pipe(
|
||||
Effect.provide(context),
|
||||
)
|
||||
expect(second).toBeDefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("includes config updates published before synchronization begins", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const activations = { count: 0 }
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-config-reload",
|
||||
effect: () =>
|
||||
Effect.sync(() => ++activations.count).pipe(
|
||||
Effect.flatMap((activation) =>
|
||||
activation === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(
|
||||
Config.Event.Updated,
|
||||
{},
|
||||
{
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
},
|
||||
)
|
||||
const synchronized = yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(synchronized.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(synchronized)
|
||||
expect(activations.count).toBe(2)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -78,7 +277,7 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
yield* (yield* PluginSupervisor.Service).synchronize
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -105,7 +304,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* PluginV2.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.synchronize
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
|
||||
@@ -30,20 +30,6 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -159,16 +145,10 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
expect(authorization).toEqual([])
|
||||
release()
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = required(
|
||||
yield* eventually(
|
||||
catalog.provider.get(ProviderV2.ID.make("remote")),
|
||||
(item) => item?.integrationID === Integration.ID.make("opencode"),
|
||||
),
|
||||
)
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
|
||||
expect(provider).toMatchObject({
|
||||
name: "Remote",
|
||||
integrationID: "opencode",
|
||||
|
||||
@@ -37,6 +37,8 @@ import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginSupervisorNode } from "@opencode-ai/core/plugin/supervisor-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
@@ -79,6 +81,10 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ synchronize: Effect.void, ready: Effect.void }),
|
||||
)
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
@@ -92,6 +98,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Config.node, config],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisorNode.node, pluginSupervisor],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -140,6 +147,7 @@ const it = testEffect(
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[Config.node, config],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[PluginSupervisorNode.node, pluginSupervisor],
|
||||
[SessionExecution.node, execution],
|
||||
],
|
||||
),
|
||||
@@ -149,6 +157,12 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
||||
describe("SessionRunnerLLM recorded", () => {
|
||||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
|
||||
@@ -38,6 +38,8 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginSupervisorNode } from "@opencode-ai/core/plugin/supervisor-node"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
@@ -281,6 +283,14 @@ const config = Layer.succeed(
|
||||
]),
|
||||
}),
|
||||
)
|
||||
let pluginSyncHook = Effect.void
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
synchronize: Effect.suspend(() => pluginSyncHook),
|
||||
ready: Effect.suspend(() => pluginSyncHook),
|
||||
}),
|
||||
)
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
@@ -294,6 +304,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Config.node, config],
|
||||
[McpGuidance.node, mcpGuidance],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisorNode.node, pluginSupervisor],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -348,6 +359,7 @@ const it = testEffect(
|
||||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisorNode.node, pluginSupervisor],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -385,6 +397,7 @@ const setup = Effect.gen(function* () {
|
||||
systemUnavailable = false
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
pluginSyncHook = Effect.void
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
@@ -397,6 +410,12 @@ const setup = Effect.gen(function* () {
|
||||
toolExecutionsReady = 5
|
||||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
@@ -1045,10 +1064,73 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails before the model request when the selected agent is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
shell: Tool.make({
|
||||
description: "Execute a shell command",
|
||||
input: Schema.Struct({ command: Schema.String }),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({}),
|
||||
}),
|
||||
})
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: "explore" })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "Session.AgentNotFoundError",
|
||||
sessionID,
|
||||
agent: "explore",
|
||||
})
|
||||
expect(requests).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for plugin synchronization before constructing the model request", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const release = yield* Deferred.make<void>()
|
||||
pluginSyncHook = Deferred.await(release)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(running.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(running)
|
||||
expect(requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates selected-agent skill guidance after an agent switch", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||
yield* admit(session, "First")
|
||||
|
||||
@@ -2591,7 +2673,10 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-missing",
|
||||
state: { status: "error", error: { message: "Unknown tool: missing" } },
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Tool is not available for this request: missing" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
@@ -105,6 +106,9 @@ const it = testEffect(layer)
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.synchronize).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
yield* AgentV2.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
||||
"model.default",
|
||||
Effect.fn(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.ready.pipe(
|
||||
yield* plugins.synchronize.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
|
||||
Reference in New Issue
Block a user