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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user