Phase 2: single per-user box container for every thread

Every thread (agent turns + terminal + project commands) now execs into one
persistent per-user container (spoon-box-{username}) instead of ephemeral
docker run --rm — so the agent and terminal share the exact same running
environment, filesystem, and in-session installs.

- docker.ts: ensureUserContainer (persistent box) + streamExecInContainer/
  runExecInContainer (docker exec, streaming) sharing a factored streamSubprocess
- user-container.ts: reference-counted box lifecycle (held while any thread
  workspace is active or a terminal is connected; idle-reaped after
  SPOON_AGENT_BOX_IDLE_MS, default 30m)
- worker.ts: runClaim acquires the box; codex turn + runProjectCommand exec into
  it; release on stop/PR/failure
- terminal.ts: execs into the shared box (dockerode TTY) instead of a per-job
  container; materializeUserHome runs the dotfiles setup in the box
- Verified: agent + terminal run in the same box, share fs, dotfiles + tmux load
This commit is contained in:
Gabriel Brown
2026-06-24 10:30:40 -04:00
parent c103430c7d
commit c6b27063a4
6 changed files with 281 additions and 152 deletions
+40
View File
@@ -0,0 +1,40 @@
import { env } from './env';
import {
ensureUserContainer,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
// Phase 2: one persistent "box" container per user that all of their threads
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
// up while any thread workspace is active or a terminal is connected, and is
// reaped after an idle period once nothing holds it.
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
const boxes = new Map<string, Box>();
export const acquireUserBox = async (args: {
username: string;
workdir: string;
containerHome: string;
}): Promise<string> => {
const name = await ensureUserContainer(args);
const box = boxes.get(args.username) ?? { refs: 0 };
if (box.idleTimer) {
clearTimeout(box.idleTimer);
box.idleTimer = undefined;
}
box.refs += 1;
boxes.set(args.username, box);
return name;
};
export const releaseUserBox = (username: string) => {
const box = boxes.get(username);
if (!box) return;
box.refs = Math.max(0, box.refs - 1);
if (box.refs > 0) return;
box.idleTimer = setTimeout(() => {
void stopWorkspaceContainer(userContainerName(username));
boxes.delete(username);
}, env.boxIdleMs);
};