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