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
+23 -76
View File
@@ -5,69 +5,12 @@ import Docker from 'dockerode';
import { WebSocketServer } from 'ws';
import { env } from './env';
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
import { verifyTerminalToken } from './terminal-token';
import { acquireUserBox, releaseUserBox } from './user-container';
import { getTerminalWorkspace } from './worker';
const TERMINAL_IMAGE = env.terminalImage;
const IDLE_STOP_MS = env.terminalIdleMs;
const docker = new Docker();
const containerName = (jobId: string) =>
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
const sessions = new Map<string, Session>();
const ensureTerminalContainer = async (
jobId: string,
workdir: string,
containerHome: string,
) => {
const name = containerName(jobId);
const container = docker.getContainer(name);
const info = await container.inspect().catch(() => null);
if (info?.State.Running) return container;
if (info && !info.State.Running) {
await container.remove({ force: true }).catch(() => undefined);
}
const suffix = containerVolumeSuffix();
const source = hostWorkspacePath(workdir);
const created = await docker.createContainer({
name,
Image: TERMINAL_IMAGE,
Cmd: ['sleep', 'infinity'],
WorkingDir: containerHome,
Tty: false,
Labels: { 'spoon.agent.terminal': jobId },
HostConfig: {
Binds: [`${source}:${containerHome}${suffix ? `:${suffix}` : ''}`],
NetworkMode: env.network,
Memory: 4 * 1024 * 1024 * 1024,
AutoRemove: false,
},
});
await created.start();
return created;
};
const stopTerminalContainer = async (jobId: string) => {
await docker
.getContainer(containerName(jobId))
.remove({ force: true })
.catch(() => undefined);
sessions.delete(jobId);
};
const scheduleIdleStop = (jobId: string) => {
const session = sessions.get(jobId);
if (!session || session.connections > 0) return;
session.idleTimer = setTimeout(() => {
void stopTerminalContainer(jobId);
}, IDLE_STOP_MS);
};
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
@@ -75,21 +18,27 @@ const bridge = async (ws: WebSocket, jobId: string) => {
return;
}
const session = sessions.get(jobId) ?? { connections: 0 };
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleTimer = undefined;
session.connections += 1;
sessions.set(jobId, session);
// Hold the per-user box open while this terminal is connected; the agent and
// the terminal share the exact same container (Phase 2).
let boxName: string;
try {
boxName = await acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
});
} catch (error) {
ws.close(
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return;
}
let stream: Duplex | undefined;
let exec: Docker.Exec | undefined;
try {
const container = await ensureTerminalContainer(
jobId,
workspace.workdir,
workspace.containerHome,
);
exec = await container.exec({
exec = await docker.getContainer(boxName).exec({
// Reattach a persistent tmux session across reconnects when tmux is
// available; otherwise fall back to a plain login shell.
Cmd: [
@@ -114,8 +63,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
session.connections -= 1;
scheduleIdleStop(jobId);
releaseUserBox(workspace.username);
return;
}
@@ -151,13 +99,12 @@ const bridge = async (ws: WebSocket, jobId: string) => {
activeStream.write(data);
});
let released = false;
const cleanup = () => {
if (released) return;
released = true;
activeStream.end();
const current = sessions.get(jobId);
if (current) {
current.connections = Math.max(0, current.connections - 1);
scheduleIdleStop(jobId);
}
releaseUserBox(workspace.username);
};
ws.on('close', cleanup);
ws.on('error', cleanup);