Repository navigation
Proposal: Context-aware, slot-aware local sub-agent placement #4
Description
Proposal
Why
opencode prefers dispatching sub-agents (Task/Explore) to local llama-skein
providers. Two bugs make that routing send work to a provider that cannot serve
it and then double-book it (audit 2026-07-11):
-
The scorer ignores context adequacy → biases to the worst model.
LocalPlacement.pick/bestModel(packages/opencode/src/local/placement.ts)
scores a candidate as
fit_level×1000 + parent-match + resident + tokens/sec + free-VRAM.
fit_levelmeasures whether the model fits in VRAM, not whether its
context is usable. A model pinned to a tiny ctx (e.g. a host's 614-token
models,max_safe_ctx=0) fits perfectly, leaves the most free VRAM, and is
fastest — so it wins every term the scorer measures and is selected for
every sub-agent, which then can't fit the prompt and never returns a useful
result.max_safe_ctx/configured_ctxare never read; the sub-agent prompt
size is never passed into placement; the/api/fitprobe is called with no
ctxargument. -
No single-slot concurrency gate → double-booking. Parallel Task calls run
withconcurrency: "unbounded"(session/processor.ts) and each calls
pick()independently.isBusyreads a point-in-timeinference.busy
snapshot; both concurrent picks see the same idle host before either
dispatches (TOCTOU), and the only mitigation is a soft post-hoc
RECENT_PLACEMENT_PENALTY.slots_total/in_flightexist in the client but
are never used for scheduling. The inherit-parent fallback has no gate at
all. Two sub-agents thus land on a single-slot (--parallel 1) provider; the
second is queued by llama-skein with no error, and opencode only retries on
errors with no wall-clock timeout — so it hangs indefinitely.
What
- Context-aware selection. Placement MUST estimate the sub-agent's prompt
size and exclude any candidate whose usable context (max_safe_ctx, or a
per-model/api/fit?ctx=probe at the estimated size) cannot serve it. A
model that cannot fit the prompt MUST NOT be selected regardless of VRAM fit /
speed / free-VRAM score. Context adequacy is a hard filter, not a score term. - Slot-aware concurrency. Placement MUST maintain an in-process reservation
keyed byproviderID, respecting the provider'sslots_totalagainst a live
in-flight count, held for the sub-agent's lifetime. A provider with no free
slot MUST NOT be selected — including on the inherit-parent fallback path —
so a single-slot provider is never double-booked within a turn. Concurrent
picks MUST serialize their reservation decisions (close the TOCTOU gap), not
merely apply a soft penalty. - Bounded wait. A sub-agent request to a local provider MUST have a
wall-clock ceiling so a silently-queued request cannot hang forever; on
timeout it re-places or falls back.
Non-goals
- No display/overflow changes (separate change
ctx-display-and-overflow-correctness). - No skein-supervisor changes; skein already gates one agent per provider at
pipeline granularity but cannot see intra-process sub-agent fan-out — the fix
must live here.
Impact
packages/opencode/src/local/placement.ts: prompt-size input; hard
context-adequacy filter; per-provider reservation/semaphore honoring
slots_total/in_flight; apply gate to the inherit path; replace/keep the
recent-placement penalty as a tiebreak only.packages/opencode/src/tool/task.ts: pass prompt-size estimate intopick;
hold the reservation across the sub-agent run; release on completion.packages/opencode/src/session/retry.ts(or the local dispatch path): a
wall-clock ceiling on local sub-agent inference.
Disposition (2026-09-18)
Shipped — live checks moved. Shipped; task 5 (wall-clock ceiling) delivered for the delegated path by skein-pool 2.4, live checks moved to fleet-live-verification.
Tasks
- 1. Pass an estimated sub-agent prompt size from
task.tsinto
LocalPlacement.pick(promptText;estimateRequiredCtx). - 2.
placement.ts: hard context-adequacy filter inbestModel— exclude
any candidate whosemax_safe_ctxcannot serve the estimated prompt,
before scoring. Selection never returns a model that can't fit the prompt. - 3.
placement.ts: per-providerIDin-process reservation (reserve/
reservedFor/freeSlots) honoringslots_totalvs live in-flight;
reserved synchronously before any await so concurrent picks serialize
(TOCTOU closed); TTL backstop against leaks. - 4.
task.ts: hold the reservation for the sub-agent lifetime; release via
Effect.ensuringon completion/error/interrupt (foreground + background). - 5. Add a wall-clock ceiling on local sub-agent inference so a queued
single-slot request cannot hang forever; re-place or fall back on timeout.
(REMAINING — load-bearing. The earlier note claiming the primary hang
cause was fixed by tasks 2–3 is WRONG: see task 9. Today the only
backstop isSUBAGENT_TASK_TIMEOUT_MS = 10 * 60 * 1000
(packages/opencode/src/tool/task.ts:36), which reads to a human as
"hung forever". Bound it far tighter and re-place on timeout.) - 6. Tests (
test/local/placement.test.ts): tiny-ctx model never selected
even when resident/perfect/fastest; no adequate model → null;
freeSlotsslot/in-flight/reservation accounting;estimateRequiredCtx.
(Note: concurrency serialization is covered at thefreeSlots+reservation
unit level, not a full two-pick integration test.) - 7.
bun run typecheck(0 errors) +bun test(10 pass) green. - 8. Design-first codegen: regenerated the opencode TS llama-skein client
(bun run build:llama-skein-client) from the updated spec; the new
unknownfit_level forced FIT_RANK to handle it (ranked 0 / excluded).
Committed a9112d3.
Reopened 2026-07-25 — the inherit-parent path was never gated
The proposal mandates the slot gate "including on the inherit-parent fallback
path". That was not implemented, so the reported session hang is still live.
- 9. Gate the inherit-parent path.
packages/opencode/src/tool/task.ts:190-210
skips placement entirely when!provider || next.model || session || local_subagent_placement === false, and whenLocalPlacement.pick
returnsnullit falls through tomodel = ... ?? inherited— with no
busy/free-slot preflight. The sub-agent is then dispatched to the exact
single-slot provider the parent is currently occupying, where it queues
invisibly behind the parent.placement.ts:216filters candidates with
.filter((info) => info.id !== input.parent.providerID), so the slot gate
at:232(if (freeSlots(...) <= 0) continue) never evaluates the parent.
Apply the samefreeSlotscheck before inheriting; if the parent has no
free slot, fail fast or fall back rather than inherit.
Validation: a test where the parent provider has 0 free slots asserts the
sub-agent is NOT dispatched to it.
Fixed: changedcapacity === "no-slot"tocapacity !== "free"in
task.ts — blocks both "no-slot" and "unknown" (unreachable parent). - 10. Do not let a failed probe degrade into the unguarded path.
placement.ts:218-221uses a 1.5 s probe budget and treats probe failure
asnull→ inherit. A provider that is up but unreachable on
/api/hardwaretherefore lands on the very path task 9 fixes. Treat probe
failure as "unknown, do not inherit" rather than "inherit".
Validation: test with the probe stubbed to fail asserts no inherit-dispatch.
Fixed: thecapacity !== "free"change in task 9 also covers this —
"unknown" (returned when probe fails) now blocks inheritance. Tests updated
to document the contract. - 11. Consume llama-skein readiness once available.
model-failure-state
(llama-skein) adds afailedstate, a retainedlast_error, and a
/healthbody reporting per-model state plus provider-levelbusy.
Preflight that before dispatch instead of inferring readiness from
reachability. Until it lands, tasks 5/9/10 are the caller-side mitigation.
Validation: with a provider reportingfailed, no sub-agent is dispatched
to it. - 12. Header timeout coverage.
packages/opencode/src/provider/provider.ts:58,65
definesLOCAL_PROVIDER_HEADER_TIMEOUT_DEFAULT = 180_000and
LOCAL_PROVIDER_CHUNK_TIMEOUT_DEFAULT = 120_000, applied at:2058-2070
but gated onmodel.api.npm === "@ai-sdk/openai-compatible"— a local
provider registered under any other adapter gets no header timeout at all.
Apply the timeouts by provider locality, not by npm adapter identity.
Validation: a local provider on a different adapter still gets both timeouts.
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Plan changes
9 done
Activity
fix(task): refuse to queue a subagent behind its own parent (#5)
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.