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
+44 -18
View File
@@ -36,11 +36,12 @@ import {
import { createRedactor, truncate } from './redact';
import {
listWorkspaceContainerNames,
runInJobContainer,
runExecInContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamInJobContainer,
streamExecInContainer,
} from './runtime/docker';
import { acquireUserBox, releaseUserBox } from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = {
@@ -104,6 +105,8 @@ type ActiveWorkspace = {
containerHome: string;
containerRepo: string;
repoDir: string;
// Phase 2: the per-user box container this thread execs into.
boxName: string;
githubToken: string;
redact: (value: string) => string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
@@ -760,9 +763,8 @@ const runCodexTurn = async (args: {
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamInJobContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
const result = await streamExecInContainer({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: {
@@ -1006,27 +1008,29 @@ const runProjectCommand = async (args: {
command: string;
phase: 'install' | 'check' | 'test';
claim: Claim;
workdir: string;
boxName: string;
containerHome: string;
containerCwd: string;
repoDir: string;
redact: (value: string) => string;
}) => {
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
const secretEnv = Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir: args.workdir,
? await runExecInContainer({
containerName: args.boxName,
command: commandToShell(args.command),
environment: Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
),
containerCwd: args.containerCwd,
environment: { HOME: args.containerHome, ...secretEnv },
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
})
: await run('bash', ['-lc', args.command], {
cwd: args.repoDir,
env: Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
),
env: secretEnv,
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
});
@@ -1292,6 +1296,7 @@ const runClaim = async (claim: Claim) => {
...claim.secrets.map((secret) => secret.value),
].filter(Boolean);
const redact = createRedactor(secretValues);
let acquiredBoxUser: string | undefined;
try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
@@ -1320,6 +1325,15 @@ const runClaim = async (claim: Claim) => {
branchSlug,
);
// Start (or reuse) the persistent per-user box that this thread — and the
// terminal — exec into. It mounts the home, so the clone below is visible.
const boxName = await acquireUserBox({
username,
workdir: homeDir,
containerHome,
});
acquiredBoxUser = username;
const repoDir = await cloneRepository({
workdir: checkoutParent,
dirName: branchSlug,
@@ -1339,6 +1353,7 @@ const runClaim = async (claim: Claim) => {
containerHome,
containerRepo,
repoDir,
boxName,
githubToken,
redact,
};
@@ -1349,7 +1364,13 @@ const runClaim = async (claim: Claim) => {
'clone',
'Applying your dotfiles and environment.',
);
await materializeUserHome({ homeDir, containerHome, userEnv, redact });
await materializeUserHome({
homeDir,
containerHome,
boxName,
userEnv,
redact,
});
}
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
@@ -1412,6 +1433,7 @@ const runClaim = async (claim: Claim) => {
).catch((stopError: unknown) => {
console.error(stopError);
});
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
}
};
@@ -1497,7 +1519,9 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
command,
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
workdir: workspace.workdir,
boxName: workspace.boxName,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
repoDir: workspace.repoDir,
redact: workspace.redact,
});
@@ -1831,7 +1855,8 @@ export const openWorkspacePullRequest = async (jobId: string) => {
}
activeWorkspaces.delete(jobId);
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
@@ -1847,7 +1872,8 @@ export const stopWorkspace = async (jobId: string) => {
}
activeWorkspaces.delete(jobId);
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return { success: true };
};