Repository navigation
Skein pool: one placement layer for local hosts and live agents #88
Description
Proposal
Why
Three planned changes describe the same machine from three angles and none of them has
moved since July:
provider-slot-leases(0/15) — stop concurrent opencode instances double-booking a
single-slot host.fleet-instance-presence(5/23) — see running instances/sessions, their status, and
delegate to them.peer-messaging(7/9) — send a message to another live session.
Since then claude-code-peer-source and claude-peer-messaging shipped A2A: every
opencode and Claude Code session on the machine is discoverable (peers,
opencode agents --json) and messageable (send_peer_message), from any directory. That
already delivers peer-messaging entirely and the roster half of
fleet-instance-presence. What is left of all three is one question, asked from the
sub-agent flow:
Which local hosts and which live agents can take work for me right now, and how do I
hand it off without two of us landing on the same GPU slot?
Today task.ts answers only half of it. LocalPlacement.pick places a sub-agent on an
idle llama-skein host (slot-aware, context-aware, role-aware — ctx-aware-subagent-placement,
role-placement-policy, provider-capacity-truth all shipped), but:
- Its slot reservations live in one process. Four opencode processes on one machine each
see z4 asin_flight: 0, slots_total: 1and all four dispatch (2026-07-25). - It never considers the other pool: a live, idle opencode or Claude Code session that
already has a model, a context and a warm cache. When the local GPU cannot host another
session, the parent inherits its own cloud model and pays twice, while an idle peer sits
there. - Nothing shows held capacity.
peersshows who is idle; nothing shows that host X's
single slot is already promised to someone.
What is already true about multi-slot context (checked 2026-09-18)
The "parallel slots split the context → endless compaction loop" concern is handled at the
source and does not need a new opencode mechanism:
- llama-skein
internal/fit/fit.go:505-512dividesmax_safe_ctxby--parallel— the
figure it advertises is the per-request share. - llama-skein's
promptguardpre-flight rejects any prompt over that share with a 413
(prompt_over_max_safe_ctx) carrying the ceiling in a header. - opencode adopts
max_safe_ctxaslimit.contexton discovery
(provider.ts:1698-1720) and re-adopts the header value on that 413
(adjustLocalContextOnOverflow).
The 483 "compaction overflow" log lines all belong to one session on 2026-07-16, before
this chain existed. The residual gap is staleness: opencode learns the per-slot ctx at
discovery and on 413, but not when a host's --parallel is changed while a session is
running. Placement below re-probes /api/fit per pick anyway; the fix is to let that fresh
max_safe_ctx update the live limit.context too (task 1.3), not a new subsystem.
What Changes
1. Two pools, one pick
LocalPlacement.pick gains a second candidate kind next to host:
host { providerID, modelID } — spawn a new sub-agent session on a llama-skein host
peer { owner, instanceID, sessionID, name } — delegate the task to a live idle agent over A2A
A peer candidate is any roster entry (session/peers.ts + presence-claude.ts) that is
idle, not the caller, and — for opencode peers — reports canPrompt. Its "model" is the
model it is already running; its "context" is that model's limit.context minus its
current usage; it needs no slot because it already holds one. Eligibility is the same
gate hosts pass: enough context for the estimated prompt, allowed by the role's
placement.
Scoring keeps the existing terms and adds one tier: a role may say prefer: "peers" /
"hosts" / a mixed ordered list, using the hostRankFor mechanism that already exists.
Default: hosts first (a fresh session is a cleaner sandbox), peers when no host has a free
slot. Nothing changes for a single instance with no peers.
2. Delegation over A2A
Delegating to a peer reuses send_peer_message's transport (opencode → synthetic prompt,
Claude Code → UDS envelope) with a structured task envelope: task id, instructions,
reply-to (the parent's own peer address), deadline. The peer runs it as a normal prompt in
its own session and replies with send_peer_message back to the parent, which lands as
the sub-agent result the way a task tool result does today. If no reply arrives by the
deadline the parent re-places or inherits — the same fallback pick already has.
This is the "existing agent reuse" that fleet-instance-presence 4.7 described, with
A2A as the handoff instead of a Supervisor API that was never built.
3. Leases where the slot lives
provider-slot-leases proposed a three-tier lease store (SQLite / skein claims / memory).
The evidence says put it in one place: the host. llama-skein already owns slots_total
and in_flight and every opencode instance on every machine already asks it before
placing. Add to llama-skein:
POST /api/slots/lease { holder, ttl_s } → { lease_id, expires_at } 409 when none free
DELETE /api/slots/lease/{id}
GET /api/hardware inference: { slots_total, in_flight, leased, holders[] }
A lease counts against free slots exactly like an in-flight request, expires on its own,
and is released when the sub-agent finishes (the release handle task.ts already holds).
opencode's in-process reservation stays as the sub-millisecond TOCTOU guard within one
process; the lease is the cross-process, cross-host truth. Hosts running an older
llama-skein without the endpoint fall back to today's behaviour — the pool must not depend
on it.
4. Roster shows capacity
peers / opencode agents --json grow a capacity block per host, alongside the agents:
● z4 host qwen3.8-27b slots 1/1 leased by opencode:A2A feature (0:42 left)
● rocky host ornith-35b slots 0/1 in flight
○ exopen-5e claude idle · can take work
so "who can I hand this to" is answerable by a human and by the model from the same view.
Supersedes
provider-slot-leases— leases move to llama-skein (§3); the tiered store is dropped.peer-messaging— shipped asclaude-peer-messaging'ssend_peer_message; the two
unchecked tasks (3.1, 3.2) are covered by §2's deadline/unreachable handling.fleet-instance-presence— Phases 1–2 shipped (roster); Phase 3 (stall detection) and
4.1–4.3 (remote cancel) move to their own small change if still wanted; 4.5–4.7 are §1–2
here.
ctx-aware-subagent-placement task 5 (wall-clock ceiling on a queued local sub-agent)
becomes this change's task 2.4 — the deadline is the same mechanism for hosts and peers.
Non-Goals
- No Supervisor process, no consensus, no distributed lock manager.
- No cross-host agent discovery beyond what A2A does on one machine; cross-host capacity
comes from llama-skein hosts, which are already reachable. - Not changing how a Claude Code peer runs the task — it receives a prompt, nothing more.
Impact
packages/opencode/src/local/placement.ts—peercandidate kind, lease acquisition.packages/opencode/src/tool/task.ts— delegate path, reply correlation, deadline.packages/opencode/src/tool/peers.ts,cli/cmd/agents.ts— capacity block.packages/opencode/src/peer/— task envelope (shared with Claude Code codec).- llama-skein — lease endpoint and
leased/holdersin/api/hardware; regenerate the
opencode client (bun run build:llama-skein-client). fork/manifest.json— register every touched upstream file.
Tasks
Phase 1: Context truth stays fresh
- 1.1 Confirmed at the source instead: llama-skein
internal/fit/fit.go:505-512divides
max_safe_ctxby--parallel; opencode adopts it aslimit.context
(provider.ts:1698). No host currently runs--parallel > 1to measure against. - 1.2 Reproduce the stale case: raise
--parallelon a running host mid-session and
show opencode keeps the oldlimit.contextuntil a 413 - 1.3
pickreturns the chosen model's freshmaxSafeCtx;task.tswrites it through
Provider.setModelContextLimit(..., "keep")(context only, ceiling untouched)
Phase 2: Peers as a placement pool
- 2.1
peer/delegate.tspickPeer: idle only, Claude Code peers first, opencode peers
only when their model is not on a local host (that host is already ahost
candidate); unit tests. Peers are the fallback after every host is full — not
scored against hosts, since a peer holds no slot opencode can measure. - 2.2 Task envelope:
[peer-task <id>]header with reply address, reply tool and
deadline; reply marker[peer-task-result <id>]; same text over both transports
(Claude UDS envelope, opencode synthetic prompt); tests - 2.3
task.tsdelegates when the parent host isno-slotand no idle host exists
(experimental.peer_delegation, default on); replies are intercepted in the Claude
sidecardeliverand insend_peer_messageand settled as the task result;
background/foreground/timeout paths unchanged - 2.4 Deadline: the existing
SUBAGENT_TASK_TIMEOUT_MSbounds the delegated wait too;
expiry surfaces as a task error the parent is notified of (absorbs
ctx-aware-subagent-placementtask 5 for the delegated path) - 2.5 Live: local parent on a full host + idle Claude Code peer → task lands on the peer,
reply arrives as the task result; same with an idle opencode peer on a cloud model
Phase 3: Leases on the host (llama-skein)
- 3.1 llama-skein:
POST/DELETE /api/slots/lease, TTL expiry, leases counted in
inference.free;leased/holdersin/api/hardware; OpenAPI + tests - 3.2 opencode: acquire a lease when placing on a host that supports it, release with
the existing handle; fall back silently on 404 - 3.3 Live: two opencode processes fan out onto one single-slot host — exactly one
places there, the other goes elsewhere or inherits
Phase 4: Roster shows capacity
- 4.1
LocalPlacement.hostCapacity:peerstool lists local hosts with free/total slots,
in-process reservations and loaded model;opencode agents --jsonreturns
{ agents, hosts }; TUI Peers block gets a Host/Slots table. (leased/holders wait
for Phase 3.) Claude peers now reportcanPrompttruthfully (alive + messaging on). - 4.2
bun typecheck(opencode, tui) clean;bun test test/tool/task test/peer test/agent test/local/placement167 pass
Phase 5: Bookkeeping
- 5.1 Archive
provider-slot-leases,peer-messaging; trimfleet-instance-presence
to Phase 3 + 4.1–4.3 or archive it; tickctx-aware-subagent-placementtask 5 with
a pointer here
Plan changes
8 done
Activity
chore(specsync): link skein-pool to #88
chore(specsync): link skein-pool to #88
chore(specsync): link skein-pool to #88