Reuse finished OpenCode subagent sandboxes via lease pool - #17
Reuse finished OpenCode subagent sandboxes via lease pool#17gpambrozio wants to merge 7 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| async function handlePluginEvent(state, event) { | ||
| if (event?.type === "session.created" || event?.type === "session.updated") { |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
Done in 43ed756 — the created/updated branch now returns early when state.disposed is set, matching the idle branch.
| 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); |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
|
|
||
| const safeID = safeSessionID(sessionID); | ||
| const parentID = | ||
| event.properties?.info?.parentID ?? state.sessionParents.get(safeID); |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
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.
| } | ||
| } | ||
|
|
||
| while (state.freeSandboxes.length > 0) { |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
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.
| if (typeof id !== "string" || !id) return; | ||
| const parentID = | ||
| typeof info.parentID === "string" && info.parentID ? info.parentID : null; | ||
| state.sessionParents.set(safeSessionID(id), parentID); |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
Done in 43ed756 — recordSessionParent 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.
| } | ||
| if (!state.sandboxRoot) return; | ||
|
|
||
| await Promise.all( |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
Done in 43ed756 — quarantineAndRemoveSandbox 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): |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
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.
…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>
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):parentIDtracked fromsession.created/session.updatedevents — release their sandbox to a free pool onsession.idle. Main sessions never release on idle.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.quarantineAndRemoveSandboxhelper); 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 --checkclean.No version bumps in this PR — release bumps/tags follow the
update-pluginflow after merge.🤖 Generated with Claude Code