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
+6 -6
View File
@@ -7,7 +7,7 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
import { runInJobContainer } from './runtime/docker';
import { runExecInContainer } from './runtime/docker';
const client = new ConvexHttpClient(env.convexUrl);
@@ -51,10 +51,11 @@ const safeHomeJoin = (homeDir: string, relPath: string) => {
export const materializeUserHome = async (args: {
homeDir: string;
containerHome: string;
boxName: string;
userEnv: UserEnvironment;
redact: (value: string) => string;
}): Promise<void> => {
const { homeDir, containerHome, userEnv, redact } = args;
const { homeDir, containerHome, boxName, userEnv, redact } = args;
await mkdir(homeDir, { recursive: true });
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
@@ -95,11 +96,10 @@ export const materializeUserHome = async (args: {
]
.filter(Boolean)
.join('\n');
await runInJobContainer({
workdir: homeDir,
containerHome,
containerCwd: containerHome,
await runExecInContainer({
containerName: boxName,
command: ['bash', '-lc', script],
containerCwd: containerHome,
environment: { HOME: containerHome },
redact,
timeoutMs: env.jobTimeoutMs,