Skip to content

Reuse finished OpenCode subagent sandboxes via lease pool - #17

Open
gpambrozio wants to merge 7 commits into
mainfrom
opencode-subagent-sandbox-reuse
Open

Reuse finished OpenCode subagent sandboxes via lease pool#17
gpambrozio wants to merge 7 commits into
mainfrom
opencode-subagent-sandbox-reuse

Conversation

@gpambrozio

Copy link
Copy Markdown
Owner

Problem

In the OpenCode plugin path, every session that runs a shell command gets its own Xcode build sandbox — and OpenCode subagents are child sessions with fresh session IDs. Each subagent that compiled created a new multi-GB DerivedData/SPM sandbox that was only removed at plugin dispose, so disk usage grew monotonically with the number of subagents spawned.

Solution

A per-plugin-instance lease pool in common/opencode-plugin.js (synced to all plugin packages):

  • Release: child (subagent) sessions — identified via parentID tracked from session.created/session.updated events — release their sandbox to a free pool on session.idle. Main sessions never release on idle.
  • Lease: sessions lease in shell.env, preferring the sandbox they held before, then the most recently freed pool entry (revalidated with the existing identity checks; invalid entries discarded), then a fresh directory. The fresh-path fallback disambiguates with a random suffix if the deterministic name is another session's live sandbox — pooling decouples directory names from sessions, so the old deterministic name is no longer collision-free.
  • Reclaim: deleting a main session drains the free pool from disk (via the existing quarantine + remove machinery, extracted into a shared quarantineAndRemoveSandbox helper); plugin dispose also removes pooled sandboxes.

Disk usage is now bounded by peak subagent concurrency instead of total subagent count, and reused sandboxes keep warm DerivedData/SPM caches (directories are never renamed, so embedded absolute paths stay valid).

Testing

Seven new integration tests in tests/test_opencode_plugin.py (reuse-after-idle, parallel isolation, main-idle no-release, previous-sandbox preference, invalid-entry discard, drain-on-main-deletion with live-lease survival, subagent-deletion drain-gate pin, no-double-free, dispose pool cleanup, re-prompted-subagent collision regression). Full suite: 97/97 passing; scripts/sync-plugin-common.py --check clean.

No version bumps in this PR — release bumps/tags follow the update-plugin flow after merge.

🤖 Generated with Claude Code

gpambrozio and others added 5 commits July 23, 2026 09:37
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gpa-agent gpa-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid lease-pool design — the synchronous pool mutations, identity revalidation on lease, and the leased-vs-free drain gating all check out, and the new tests pin the important invariants (97/97 passing). One event-ordering race worth a look before merge, plus a handful of minor hardening notes.

Comment thread common/opencode-plugin.js
}

async function handlePluginEvent(state, event) {
if (event?.type === "session.created" || event?.type === "session.updated") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: nit: this branch skips the state.disposed guard the idle branch has, so post-dispose session.created/updated events repopulate sessionParents after detachOwnedSandboxes cleared it. Memory-only, but a one-line guard closes it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756 — the created/updated branch now returns early when state.disposed is set, matching the idle branch.

Comment thread common/opencode-plugin.js Outdated
const safeID = safeSessionID(rawSessionID);
// Only subagent (child) sessions release on idle. A main session going
// idle is just the end of a turn; it keeps its sandbox for the next one.
if (state.sessionParents.get(safeID)) releaseSandboxToPool(state, safeID);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Major: A stale session.idle processed after the session was re-prompted will pool a live sandbox: shell.env re-confirms the existing lease and the command starts writing, then the delayed idle releases the path — another session can lease it (two live sessions sharing one sandbox), or a main-session delete can rm -rf it mid-build. Event delivery can lag behind (e.g. a session.deleted handler awaiting a multi-GB rm), so the window is real. Consider stamping a lease generation into the owned entry and ignoring a release whose generation was superseded — and a regression test for idle-arriving-after-re-lease.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed756, with one deviation from the suggested mechanism. A lease generation can't order the release correctly because session.idle carries no emission ordering — at processing time, a generation bumped by the re-prompt's shell.env is indistinguishable from one bumped by the pre-idle turn. Instead the release is gated on real-time in-flight state: tool.execute.before/after (which are direct hook calls, not queued events) track active bash commands per session, and an idle processed while a command is in flight skips the release entirely — the re-prompted turn's own idle performs it later. A missed after degrades to "sandbox stays leased" (pre-pool behavior), never to a premature release. Residual window acknowledged: a stale idle processed after the re-prompt but before its first command has no in-flight marker; the release then pools the sandbox, but the same session's next shell.env re-leases it preferentially, and nothing is running during that gap, so a steal costs cache warmth, not correctness. The drain-blocking lag that widens this window is also gone (detached rm, per your other comment). Regression test: test_stale_idle_with_inflight_bash_does_not_pool_the_sandbox covers both the skip and the subsequent normal release.

Comment thread common/opencode-plugin.js

const safeID = safeSessionID(sessionID);
const parentID =
event.properties?.info?.parentID ?? state.sessionParents.get(safeID);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: consider: a session.deleted for a session this instance never tracked (created before the plugin loaded) resolves parentID to undefined and is treated as a main-session delete, draining warm caches that may belong to a still-live main. Only free entries are touched so it's safe, but state.sessionParents.has(safeID) could distinguish known-main from unknown.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756 — the drain now requires state.sessionParents.has(safeID) (captured before the map cleanup), so a deletion for a session this instance never observed no longer drains the pool. The drain test registers main-1 first, as production mains would be via their session.updated events.

Comment thread common/opencode-plugin.js
}
}

while (state.freeSandboxes.length > 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: consider: entries that fail revalidation (here and in the preferred branch above) are dropped without markPendingSandboxCleanup, so a transient inspect failure (EMFILE, momentary root-revalidation error) orphans a multi-GB directory for the rest of the process lifetime — this instance's sweep already ran, and other instances skip dirs with a live owner. Marking them pending would let a successor instance in this process reclaim them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756 — both discard branches in leaseSandboxPath now call markPendingSandboxCleanup(entry.path), so a successor instance in this process reclaims entries dropped on transient inspect failures.

Comment thread common/opencode-plugin.js Outdated
if (typeof id !== "string" || !id) return;
const parentID =
typeof info.parentID === "string" && info.parentID ? info.parentID : null;
state.sessionParents.set(safeSessionID(id), parentID);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: might be worth guarding against partial session.updated payloads: an info carrying id but no parentID flips a known child to main (null), so it stops releasing on idle and its deletion can drain the pool. The session.deleted handler already treats a missing parentID defensively — same idea here: skip the overwrite when parentID is absent and an entry already exists.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756recordSessionParent now skips the overwrite when parentID is absent and an entry already exists, so a partial payload can't downgrade a known child. Covered by test_partial_session_update_keeps_child_parentage.

Comment thread common/opencode-plugin.js
}
if (!state.sandboxRoot) return;

await Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: nit: draining several multi-GB sandboxes via in-process fs.promises.rm blocks serialized event delivery for the duration — which is exactly the lag that widens the stale-idle window flagged above. The detached /bin/rm pattern from detachManagedSandbox might fit here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756quarantineAndRemoveSandbox gained a detach option and the drain now uses the detached /bin/rm path (detachManagedSandbox), so multi-GB removals no longer stall event delivery. The quarantine rename still happens in-handler (fast), so drained paths leave the namespace before the handler returns; the drain test polls for the detached removal.


self.assertFalse(result["staleExists"])

def test_subagent_sandbox_is_reused_after_idle(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor: consider adding three cases: a stale idle arriving after a re-lease (the race flagged in handlePluginEvent), parentage registered via session.created (every pool test uses session.updated only), and a session.updated without parentID on a known child not corrupting its mapping.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 43ed756 — all three added: test_stale_idle_with_inflight_bash_does_not_pool_the_sandbox (skip while in flight, normal release after completion), test_session_created_event_registers_parentage (session.created-only registration), and test_partial_session_update_keeps_child_parentage. Suite is 100/100.

gpambrozio and others added 2 commits July 23, 2026 11:28
…detached drain

- Gate idle-release on in-flight bash commands (tool.execute.before/after
  tracking) so a stale session.idle processed after a re-prompt cannot pool
  a sandbox a running command is writing to
- Drain the free pool only for sessions observed as parentless, not for
  sessions this instance never tracked
- Drain via detached /bin/rm to keep event delivery unblocked
- Mark discarded pool entries pending so successor instances reclaim them
- Never downgrade a known child session on a partial session.updated
- Guard session.created/updated tracking against a disposed instance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When $TMPDIR/xbt-sandbox-debug exists, every pool decision (lease
reuse/fresh/discard, release, refused release with reason, drain) is
appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file.

Motivated by a field report where sequential subagents each got fresh
sandboxes in one process while an identical setup pooled correctly in
the next; the post-mortem evidence was destroyed with the process, so
the pool now records its own decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants