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:
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
@@ -225,6 +226,67 @@ export const execInWorkspaceContainer = async (args: {
|
||||
};
|
||||
};
|
||||
|
||||
// Shared line-streaming + result normalization for a started subprocess
|
||||
// (used by both `docker run` and `docker exec` paths).
|
||||
type StreamingSubprocess = {
|
||||
stdout: Readable | null;
|
||||
stderr: Readable | null;
|
||||
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||
|
||||
const streamSubprocess = async (
|
||||
subprocess: StreamingSubprocess,
|
||||
redact: (value: string) => string,
|
||||
onStdoutLine?: (line: string) => Promise<void>,
|
||||
onStderrLine?: (line: string) => Promise<void>,
|
||||
): Promise<CommandResult> => {
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
const output: string[] = [];
|
||||
let lineHandlers = Promise.resolve();
|
||||
const consume = async (
|
||||
chunk: Buffer,
|
||||
source: 'stdout' | 'stderr',
|
||||
handler?: (line: string) => Promise<void>,
|
||||
) => {
|
||||
output.push(chunk.toString('utf8'));
|
||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||
const lines = next.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? '';
|
||||
if (source === 'stdout') stdoutBuffer = remainder;
|
||||
else stderrBuffer = remainder;
|
||||
for (const line of lines) {
|
||||
if (handler) await handler(redact(line));
|
||||
}
|
||||
};
|
||||
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stdout', onStdoutLine),
|
||||
);
|
||||
});
|
||||
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stderr', onStderrLine),
|
||||
);
|
||||
});
|
||||
let result: Awaited<StreamingSubprocess>;
|
||||
try {
|
||||
result = await subprocess;
|
||||
} catch (error) {
|
||||
await lineHandlers;
|
||||
const outputText = output.join('');
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Container command failed.';
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||
};
|
||||
}
|
||||
await lineHandlers;
|
||||
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||
return normalizeRunResult(result, output.join(''), redact);
|
||||
};
|
||||
|
||||
export const streamInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
@@ -262,58 +324,110 @@ export const streamInJobContainer = async (args: {
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
const output: string[] = [];
|
||||
let lineHandlers = Promise.resolve();
|
||||
const consume = async (
|
||||
chunk: Buffer,
|
||||
source: 'stdout' | 'stderr',
|
||||
handler?: (line: string) => Promise<void>,
|
||||
) => {
|
||||
output.push(chunk.toString('utf8'));
|
||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||
const lines = next.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? '';
|
||||
if (source === 'stdout') stdoutBuffer = remainder;
|
||||
else stderrBuffer = remainder;
|
||||
for (const line of lines) {
|
||||
if (handler) {
|
||||
await handler(args.redact(line));
|
||||
}
|
||||
}
|
||||
};
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stdout', args.onStdoutLine),
|
||||
);
|
||||
});
|
||||
subprocess.stderr.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stderr', args.onStderrLine),
|
||||
);
|
||||
});
|
||||
let result: Awaited<typeof subprocess>;
|
||||
try {
|
||||
result = await subprocess;
|
||||
} catch (error) {
|
||||
await lineHandlers;
|
||||
const outputText = output.join('');
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Container command failed.';
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: args.redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||
};
|
||||
}
|
||||
await lineHandlers;
|
||||
if (stdoutBuffer && args.onStdoutLine) {
|
||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
||||
}
|
||||
if (stderrBuffer && args.onStderrLine) {
|
||||
await args.onStderrLine(args.redact(stderrBuffer));
|
||||
}
|
||||
return normalizeRunResult(result, output.join(''), args.redact);
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
args.onStdoutLine,
|
||||
args.onStderrLine,
|
||||
);
|
||||
};
|
||||
|
||||
// Per-user persistent "box" container that all of a user's threads exec into
|
||||
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||
export const userContainerName = (username: string) =>
|
||||
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
export const ensureUserContainer = async (args: {
|
||||
username: string;
|
||||
workdir: string;
|
||||
containerHome: string;
|
||||
}): Promise<string> => {
|
||||
await ensureJobImagePulled();
|
||||
const name = userContainerName(args.username);
|
||||
const inspect = await execa(
|
||||
containerRuntime(),
|
||||
['inspect', '-f', '{{.State.Running}}', name],
|
||||
{ reject: false, stdin: 'ignore' },
|
||||
);
|
||||
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||
// Not running: remove any stale container, then start fresh.
|
||||
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||
await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
name,
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs(),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
args.containerHome,
|
||||
env.jobImage,
|
||||
'sleep',
|
||||
'infinity',
|
||||
],
|
||||
{ stdin: 'ignore' },
|
||||
);
|
||||
return name;
|
||||
};
|
||||
|
||||
export const streamExecInContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
onStdoutLine?: (line: string) => Promise<void>;
|
||||
onStderrLine?: (line: string) => Promise<void>;
|
||||
}): Promise<CommandResult> => {
|
||||
const subprocess = execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
);
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
args.onStdoutLine,
|
||||
args.onStderrLine,
|
||||
);
|
||||
};
|
||||
|
||||
export const runExecInContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
);
|
||||
return normalizeRunResult(result, result.all, args.redact);
|
||||
};
|
||||
|
||||
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||
|
||||
Reference in New Issue
Block a user