refactor(worker): delete per-job OpenCode container path, host_port split, and dead docker helpers

Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
This commit is contained in:
Gabriel Brown
2026-07-11 10:26:58 -04:00
parent ff0425cf4b
commit c9ee2e6cde
6 changed files with 172 additions and 763 deletions
+1 -13
View File
@@ -27,25 +27,14 @@ export const env = {
'docker',
containerVolumeOptions:
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
: 'network',
jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
// Interactive terminal: image for the persistent shell container (defaults to
// the job image), the secret shared with the Next app for verifying terminal
// tokens, and how long an idle terminal container survives before cleanup.
terminalImage:
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
'spoon-agent-job:latest',
// Secret shared with the Next app for verifying interactive terminal tokens.
terminalSecret:
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
// How long a per-user box container survives with no active jobs/terminals.
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
@@ -60,7 +49,6 @@ export const env = {
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
githubAppId: requiredEnv('GITHUB_APP_ID'),
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
-128
View File
@@ -1,128 +0,0 @@
import type { OpencodeClient } from '@opencode-ai/sdk';
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events';
export type OpenCodeSession = {
client: OpencodeClient;
sessionId: string;
close: () => void;
};
const basicAuth = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const modelParts = (model: string) => {
const [rawProviderId, ...rest] = model.split('/');
const providerID =
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
const modelID = rest.length > 0 ? rest.join('/') : model;
return {
providerID,
modelID,
};
};
export const createOpenCodeSession = async (args: {
baseUrl: string;
password: string;
directory: string;
title: string;
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
}) => {
const abortController = new AbortController();
const client = createOpencodeClient({
baseUrl: args.baseUrl,
directory: args.directory,
headers: {
authorization: basicAuth('opencode', args.password),
},
});
const created = await client.session.create({
query: { directory: args.directory },
body: { title: args.title },
});
if (!created.data) {
throw new Error('OpenCode session could not be created.');
}
const sessionId = created.data.id;
void (async () => {
const events = await client.event.subscribe({
signal: abortController.signal,
query: { directory: args.directory },
onSseEvent: (event) => {
for (const normalized of normalizeOpenCodeEvent(event.data)) {
void args.onEvent(normalized);
}
},
onSseError: (error) => {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
},
});
for await (const event of events.stream) {
for (const normalized of normalizeOpenCodeEvent(event)) {
await args.onEvent(normalized);
}
}
})().catch((error: unknown) => {
if (!abortController.signal.aborted) {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
});
return {
client,
sessionId,
close: () => abortController.abort(),
} satisfies OpenCodeSession;
};
export const promptOpenCodeSession = async (args: {
session: OpenCodeSession;
prompt: string;
model: string;
directory: string;
}) => {
const model = modelParts(args.model);
const result = await args.session.client.session.promptAsync({
path: { id: args.session.sessionId },
query: { directory: args.directory },
body: {
model,
parts: [{ type: 'text', text: args.prompt }],
},
});
if (result.error) {
throw new Error('OpenCode prompt was rejected.');
}
};
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
await session.client.session.abort({
path: { id: session.sessionId },
});
};
export const replyOpenCodePermission = async (args: {
session: OpenCodeSession;
permissionId: string;
response: 'once' | 'always' | 'reject';
directory: string;
}) => {
const result = await args.session.client.postSessionIdPermissionsPermissionId(
{
path: { id: args.session.sessionId, permissionID: args.permissionId },
query: { directory: args.directory },
body: { response: args.response },
},
);
if (result.error) {
throw new Error('OpenCode permission response was rejected.');
}
};
-188
View File
@@ -103,138 +103,6 @@ export const jobWorkspaceVolumeSpec = (
: `${source}:${containerHome}`;
};
export const runInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const result = await execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return normalizeRunResult(result, result.all, args.redact);
};
export const startWorkspaceContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
publishTcpPort?: number;
}) => {
await ensureJobImagePulled();
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
reject: false,
});
const result = await execa(
containerRuntime(),
[
'run',
'-d',
'--name',
args.containerName,
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...(args.publishTcpPort
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
: []),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
{ all: true, stdin: 'ignore' },
);
return {
containerId: result.stdout.trim(),
containerName: args.containerName,
hostPort: args.publishTcpPort
? await getPublishedPort(args.containerName, args.publishTcpPort)
: undefined,
};
};
const getPublishedPort = async (
containerName: string,
containerPort: number,
) => {
const result = await execa(
containerRuntime(),
['port', containerName, `${containerPort}/tcp`],
{ all: true, reject: false, stdin: 'ignore' },
);
const output = result.all.trim();
const match = /:(\d+)\s*$/.exec(output);
if (!match?.[1]) {
throw new Error(
`Could not determine published port for ${containerName}:${containerPort}.`,
);
}
return match[1];
};
export const execInWorkspaceContainer = async (args: {
containerName: string;
command: string[];
environment?: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
const result = await execa(
containerRuntime(),
[
'exec',
...(args.environment ? environmentArgs(args.environment) : []),
args.containerName,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
// Shared line-streaming + result normalization for a started subprocess
// (used by both `docker run` and `docker exec` paths).
type StreamingSubprocess = {
@@ -296,51 +164,6 @@ const streamSubprocess = async (
return normalizeRunResult(result, output.join(''), redact);
};
export const streamInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const subprocess = execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
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) =>
@@ -533,17 +356,6 @@ export const stopWorkspaceContainer = async (containerName: string) => {
});
};
export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa(containerRuntime(), ['inspect', containerName], {
all: true,
reject: false,
});
return {
exists: result.exitCode === 0,
output: result.all,
};
};
export const listWorkspaceContainerNames = async (prefix: string) => {
const result = await execa(
containerRuntime(),
+87 -312
View File
@@ -1,4 +1,3 @@
import { randomBytes } from 'node:crypto';
import {
access,
mkdir,
@@ -15,7 +14,6 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events';
import type { OpenCodeSession } from './opencode-session';
import type { AdapterWorkspace, Claim } from './runtime/provider';
import type { BoxHandle } from './user-container';
import { getAdapter } from './runtime/agent-runtime';
@@ -31,25 +29,15 @@ import {
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import { createHeartbeatController } from './heartbeat-controller';
import {
abortOpenCodeSession,
createOpenCodeSession,
promptOpenCodeSession,
replyOpenCodePermission,
} from './opencode-session';
import { assertContainedRealPath } from './path-containment';
import { createRedactor, truncate } from './redact';
import {
listWorkspaceContainerNames,
runExecInContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
} from './runtime/docker';
import {
collectJsonStringValues,
isCodexLoginProfile,
opencodeModel,
providerEnvironment,
} from './runtime/provider';
import { acquireUserBox, runningBoxUsernames } from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
@@ -60,10 +48,6 @@ type ActiveWorkspace = AdapterWorkspace & {
boxHandle: BoxHandle;
githubToken: string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
containerName?: string;
containerId?: string;
opencodePassword?: string;
opencodeSession?: OpenCodeSession;
agentTurnActive?: boolean;
cancelRequested?: boolean;
resolveTurn?: () => void;
@@ -332,12 +316,6 @@ const recordWorkspaceChange = async (args: {
const commandToShell = (command: string) => ['bash', '-lc', command];
const workspaceContainerName = (jobId: string) =>
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
const agentFailurePrefix = (claim: Claim) =>
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
const handleAgentEvent = async (args: {
workspace: ActiveWorkspace;
event: NormalizedAgentEvent;
@@ -466,121 +444,6 @@ const handleAgentEvent = async (args: {
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
};
const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
if (workspace.opencodeSession) return workspace.opencodeSession;
const containerName = workspaceContainerName(workspace.claim.job._id);
const password = randomBytes(24).toString('hex');
const aiEnv = providerEnvironment(workspace.claim);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
containerName,
environment: {
...aiEnv,
...secretEnv,
OPENCODE_SERVER_PASSWORD: password,
OPENCODE_SERVER_USERNAME: 'opencode',
},
command: ['opencode', 'serve', '--hostname', '0.0.0.0', '--port', '4096'],
publishTcpPort: env.containerAccess === 'host_port' ? 4096 : undefined,
});
const baseUrl =
env.containerAccess === 'host_port'
? `http://127.0.0.1:${container.hostPort}`
: `http://${containerName}:4096`;
workspace.containerName = container.containerName;
workspace.containerId = container.containerId;
workspace.opencodePassword = password;
workspace.runtimeMode = 'opencode_server';
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
containerId: container.containerId,
});
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const session = await createOpenCodeSession({
baseUrl,
password,
directory: workspace.containerRepo,
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(
workspace.claim.job._id,
);
if (!messageId) return;
await handleAgentEvent({
workspace,
event,
assistantMessageId: messageId,
assistantContent: workspaceCurrentContent.get(
workspace.claim.job._id,
) ?? {
value: '',
},
});
},
});
workspace.opencodeSession = session;
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
opencodeSessionId: session.sessionId,
containerId: container.containerId,
});
return session;
} catch (error) {
lastError = error;
await sleep(500);
}
}
throw lastError instanceof Error
? lastError
: new Error('OpenCode server did not become ready.');
};
const workspaceCurrentMessage = new Map<string, Id<'agentJobMessages'>>();
const workspaceCurrentContent = new Map<
string,
{
value: string;
}
>();
const runOpenCodeTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspaceCurrentMessage.set(workspace.claim.job._id, assistantMessageId);
workspaceCurrentContent.set(workspace.claim.job._id, assistantContent);
const session = await ensureOpenCodeSession(workspace);
const turnDone = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
workspace.resolveTurn = undefined;
reject(new Error('OpenCode turn timed out.'));
}, env.jobTimeoutMs);
workspace.resolveTurn = () => {
clearTimeout(timeout);
resolve();
};
});
await promptOpenCodeSession({
session,
prompt,
model: opencodeModel(workspace.claim),
directory: workspace.containerRepo,
});
await turnDone;
};
const systemPromptForJob = (claim: Claim) => {
const base = [
`Spoon: ${claim.spoon.name}`,
@@ -746,8 +609,6 @@ const runProjectCommand = async (args: {
}
};
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const resolveWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) {
@@ -1298,20 +1159,16 @@ export const getTerminalWorkspace = (jobId: string) => {
export const getWorkspaceAgentStatus = (jobId: string) => {
const workspace = resolveWorkspace(jobId);
return {
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
opencodeSessionId: workspace.opencodeSession?.sessionId,
runtimeMode: workspace.runtime,
codexSessionId: workspace.codexSessionId,
containerId: workspace.containerId,
opencodeSessionId: workspace.opencodeSessionId,
claudeSessionId: workspace.claudeSessionId,
active: Boolean(workspace.agentTurnActive),
};
};
const abortWorkspaceAgentForWorkspace = async (workspace: ActiveWorkspace) => {
if (workspace.opencodeSession) {
await abortOpenCodeSession(workspace.opencodeSession);
} else if (workspace.runtime === 'codex' && workspace.turnMarker) {
await getAdapter(workspace.runtime).abort(workspace);
}
await getAdapter(workspace.runtime).abort(workspace);
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
@@ -1328,44 +1185,16 @@ export const abortWorkspaceAgent = async (jobId: string) =>
await abortWorkspaceAgentForWorkspace(resolveWorkspace(jobId));
export const replyToInteraction = async (
jobId: string,
args: {
_jobId: string,
_args: {
interactionId: Id<'agentInteractionRequests'>;
externalRequestId: string;
response: string;
},
) => {
const workspace = resolveWorkspace(jobId);
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex interaction replies are not supported yet.');
}
if (!workspace.opencodeSession) {
throw new Error('OpenCode session is not active.');
}
const mapped =
args.response === 'reject'
? 'reject'
: args.response === 'always'
? 'always'
: 'once';
await replyOpenCodePermission({
session: workspace.opencodeSession,
permissionId: args.externalRequestId,
response: mapped,
directory: workspace.containerRepo,
});
await patchInteractionRequest({
interactionId: args.interactionId,
status: mapped === 'reject' ? 'rejected' : 'approved',
response: mapped,
});
await appendMessage({
jobId: workspace.claim.job._id,
role: 'system',
status: 'completed',
content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`,
});
return { success: true };
// Every runtime now runs its CLI non-interactively inside the box, so no
// runtime emits an interaction request that expects a reply.
throw new Error('Interactive replies are not supported by exec-based runtimes.');
};
export const sendWorkspaceMessage = async (
@@ -1398,118 +1227,80 @@ export const sendWorkspaceMessage = async (
content: '',
});
const assistantContent = { value: '' };
if (isCodexLoginProfile(claim)) {
const messageId = assistantMessageId;
await appendEvent(
claim.job._id,
'info',
'plan',
'Starting Codex CLI turn with the configured login profile.',
);
workspace.runtimeMode = 'codex_exec';
await setRuntimeSession({
jobId: claim.job._id,
agentRuntimeMode: 'codex_exec',
codexSessionId: workspace.codexSessionId,
});
if (workspace.cancelRequested) {
throw new Error('Workspace cancellation requested.');
}
const onEvent = (event: NormalizedAgentEvent) =>
handleAgentEvent({
workspace,
event,
assistantMessageId: messageId,
assistantContent,
});
const adapter = getAdapter(workspace.runtime);
const result = await adapter.runTurn(workspace, prompt, onEvent);
if (result.sessionId) {
await persistRuntimeSession(workspace, result.sessionId);
}
const recoveredText = result.finalMessage
? truncate(redact(result.finalMessage), 40_000)
: undefined;
const outcome = resolveTurnOutcome({
assistantText: assistantContent.value,
recoveredText,
error: result.error,
runtime: workspace.runtime,
});
// Persist any recovered finalMessage text so the user still sees it, even
// when the turn ultimately failed with partial output.
if (outcome.text !== assistantContent.value) {
assistantContent.value = outcome.text;
await updateMessage({
messageId,
content: assistantContent.value,
status: 'streaming',
});
}
// A hard failure (nonzero exit / failure event) surfaces the turn as
// failed even when partial assistant text streamed — the catch path marks
// the message failed and appends the error event.
if (outcome.failure) {
if (!assistantContent.value.trim()) {
console.error(
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
);
}
throw new Error(outcome.failure);
}
await updateMessage({
messageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
console.log(
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
);
} else if (env.runtime === 'docker') {
await appendEvent(
claim.job._id,
'info',
'plan',
'Starting OpenCode server turn with the configured API provider.',
);
await runOpenCodeTurn({
// Every runtime now runs its CLI inside the per-user box via its adapter
// (Codex `codex exec`, OpenCode `opencode run`); there is no per-job side
// container. `runtimeMode` keeps its legacy label per runtime for the UI.
const messageId = assistantMessageId;
const runtimeMode =
workspace.runtime === 'codex' ? 'codex_exec' : 'opencode_server';
workspace.runtimeMode = runtimeMode;
await appendEvent(
claim.job._id,
'info',
'plan',
`Starting ${workspace.runtime} turn in the workspace box.`,
);
await setRuntimeSession({
jobId: claim.job._id,
agentRuntimeMode: runtimeMode,
codexSessionId: workspace.codexSessionId,
opencodeSessionId: workspace.opencodeSessionId,
});
if (workspace.cancelRequested) {
throw new Error('Workspace cancellation requested.');
}
const onEvent = (event: NormalizedAgentEvent) =>
handleAgentEvent({
workspace,
prompt,
assistantMessageId,
event,
assistantMessageId: messageId,
assistantContent,
});
} else {
const aiEnv = providerEnvironment(claim);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await run(
'bash',
[
'-lc',
`opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
],
{
cwd: workspace.repoDir,
env: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
},
);
await updateMessage({
messageId: assistantMessageId,
status: result.exitCode === 0 ? 'completed' : 'failed',
content: truncate(result.output, 40_000),
});
if (result.exitCode !== 0) {
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
}
const adapter = getAdapter(workspace.runtime);
const result = await adapter.runTurn(workspace, prompt, onEvent);
if (result.sessionId) {
await persistRuntimeSession(workspace, result.sessionId);
}
const recoveredText = result.finalMessage
? truncate(redact(result.finalMessage), 40_000)
: undefined;
const outcome = resolveTurnOutcome({
assistantText: assistantContent.value,
recoveredText,
error: result.error,
runtime: workspace.runtime,
});
// Persist any recovered finalMessage text so the user still sees it, even
// when the turn ultimately failed with partial output.
if (outcome.text !== assistantContent.value) {
assistantContent.value = outcome.text;
await updateMessage({
messageId,
content: assistantContent.value,
status: 'streaming',
});
}
// A hard failure (nonzero exit / failure event) surfaces the turn as failed
// even when partial assistant text streamed — the catch path marks the
// message failed and appends the error event. This applies to every runtime,
// so a nonzero-exit OpenCode turn with partial text also surfaces as failed.
if (outcome.failure) {
if (!assistantContent.value.trim()) {
console.error(
`${workspace.runtime} completed without producing an assistant response for job ${claim.job._id}.`,
);
}
throw new Error(outcome.failure);
}
await updateMessage({
messageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
console.log(
`${workspace.runtime} turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
);
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(assistantContent.value);
if (decision) {
@@ -1579,8 +1370,10 @@ const teardownActiveWorkspace = async (args: {
workspaceStatus?: 'stopped' | 'expired' | 'failed';
}) => {
const { jobId, workspace } = args;
const containerName = workspace.containerName;
if (args.abortAgent) workspace.cancelRequested = true;
// The per-user box is shared across a user's threads and reference-counted by
// the box registry, so teardown only aborts the turn and releases the box
// handle — there is no per-job container to stop or agent session to close.
return await teardownWorkspace({
stopHeartbeat: () => stopHeartbeat(jobId),
removeActive: () => activeWorkspaces.delete(jobId),
@@ -1598,10 +1391,6 @@ const teardownActiveWorkspace = async (args: {
: undefined,
markStopped: async () =>
await markWorkspaceStopped(workspace.claim.job._id, args.workspaceStatus),
closeAgent: () => workspace.opencodeSession?.close(),
stopContainer: containerName
? async () => await stopWorkspaceContainer(containerName)
: undefined,
release: () => workspace.boxHandle.release(),
});
};
@@ -1717,12 +1506,11 @@ export const stopWorkspace = async (jobId: string) => {
export const getWorkerHealth = async () => {
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
jobId,
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
containerName: workspace.containerName,
runtimeMode: workspace.runtime,
boxName: workspace.boxName,
workdir: workspace.workdir,
agentTurnActive: Boolean(workspace.agentTurnActive),
}));
const jobContainers = await listWorkspaceContainerNames('spoon-agent-job-');
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
return {
ok: true,
@@ -1732,40 +1520,27 @@ export const getWorkerHealth = async () => {
convexUrl: env.convexUrl,
runtime: env.runtime,
containerRuntime: env.containerRuntime,
containerAccess: env.containerAccess,
jobImage: env.jobImage,
workdir: env.workdir,
hostWorkdir: env.hostWorkdir,
network: env.network,
httpPort: env.httpPort,
maxConcurrentJobs: env.maxConcurrentJobs,
jobTimeoutMs: env.jobTimeoutMs,
activeWorkspaceCount: active.length,
activeWorkspaces: active,
workspaceContainers: jobContainers,
boxContainers,
};
};
export const cleanupOrphanedWorkspaces = async () => {
const activeContainers = new Set(
[...activeWorkspaces.values()]
.map((workspace) => workspace.containerName)
.filter((value): value is string => Boolean(value)),
);
// Per-user boxes are shared and reference-counted, so cleanup no longer force
// removes containers here — it only reclaims orphaned workdirs and reports the
// live boxes. (Job containers no longer exist.)
const activeWorkdirs = new Set(
[...activeWorkspaces.values()].map((workspace) =>
path.resolve(workspace.workdir),
),
);
const removedContainers: string[] = [];
for (const containerName of await listWorkspaceContainerNames(
'spoon-agent-job-',
)) {
if (activeContainers.has(containerName)) continue;
await stopWorkspaceContainer(containerName);
removedContainers.push(containerName);
}
const root = path.resolve(env.workdir);
const removedWorkdirs: string[] = [];
@@ -1827,7 +1602,7 @@ export const cleanupOrphanedWorkspaces = async () => {
}
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
return { success: true, removedContainers, removedWorkdirs, boxContainers };
return { success: true, removedWorkdirs, boxContainers };
};
export const startWorker = async () => {
@@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
),
mutation: vi.fn(),
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
streamExecInContainer: vi.fn(),
}));
@@ -23,7 +22,6 @@ vi.mock('../../src/env', () => ({
env: {
buildCreatedAt: 'test',
buildSha: 'test',
containerAccess: 'network',
convexUrl: 'http://convex.test',
jobTimeoutMs: 1000,
runtime: 'docker',
@@ -42,19 +40,11 @@ vi.mock('../../src/github', () => ({
getInstallationToken: vi.fn(),
openDraftPullRequest: vi.fn(),
}));
vi.mock('../../src/opencode-session', () => ({
abortOpenCodeSession: vi.fn(),
createOpenCodeSession: vi.fn(),
promptOpenCodeSession: vi.fn(),
replyOpenCodePermission: vi.fn(),
}));
vi.mock('../../src/runtime/docker', () => ({
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
runExecInContainer: vi.fn(),
startWorkspaceContainer: vi.fn(),
stopWorkspaceContainer: mocks.stopWorkspaceContainer,
streamExecInContainer: mocks.streamExecInContainer,
}));
vi.mock('../../src/user-container', () => ({
@@ -86,105 +76,85 @@ afterEach(async () => {
});
describe('maintenance workspace cleanup', () => {
test.each(['mark', 'container'] as const)(
'releases and removes the workspace when %s teardown fails',
async (failingStep) => {
const worker = await load();
const release = vi.fn();
const close = vi.fn();
const jobId = `maintenance-${failingStep}`;
mocks.mutation.mockImplementation((reference) => {
if (
failingStep === 'mark' &&
getFunctionName(reference) === 'agentJobs:markWorkspaceStopped'
) {
return Promise.reject(new Error('status teardown failed'));
}
return Promise.resolve('message-1');
});
mocks.stopWorkspaceContainer.mockImplementation(() =>
failingStep === 'container'
? Promise.reject(new Error('container teardown failed'))
: Promise.resolve(),
test('releases and removes the workspace when status teardown fails', async () => {
const worker = await load();
const release = vi.fn();
const jobId = 'maintenance-mark';
mocks.mutation.mockImplementation((reference) => {
if (getFunctionName(reference) === 'agentJobs:markWorkspaceStopped') {
return Promise.reject(new Error('status teardown failed'));
}
return Promise.resolve('message-1');
});
mocks.streamExecInContainer.mockImplementation(async (args) => {
await args.onStdoutLine(
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify(decision),
},
}),
);
mocks.streamExecInContainer.mockImplementation(async (args) => {
await args.onStdoutLine(
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify(decision),
},
}),
);
return { exitCode: 0, output: '' };
});
worker._setActiveWorkspaceForTests(jobId, {
claim: {
job: {
_id: jobId as Id<'agentJobs'>,
prompt: 'Review maintenance changes',
jobType: 'maintenance_review',
baseBranch: 'main',
workBranch: 'spoon/maintenance',
forkOwner: 'team',
forkRepo: 'spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'spoon',
},
spoon: { name: 'Spoon' },
openai: {
model: 'gpt-5',
reasoningEffort: 'medium',
},
aiProviderProfile: {
id: 'profile-1',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
return { exitCode: 0, output: '' };
});
worker._setActiveWorkspaceForTests(jobId, {
claim: {
job: {
_id: jobId as Id<'agentJobs'>,
prompt: 'Review maintenance changes',
jobType: 'maintenance_review',
baseBranch: 'main',
workBranch: 'spoon/maintenance',
forkOwner: 'team',
forkRepo: 'spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'spoon',
},
workdir: '/tmp/workspace',
homeDir: '/tmp/workspace',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/workspace/Code/spoon',
boxName: 'spoon-box-tester',
boxHandle: { boxName: 'spoon-box-tester', release },
githubToken: 'github-token',
redact: (value: string) => value,
runtime: 'codex',
containerName: 'spoon-agent-job-maintenance',
opencodeSession: {
client: {} as never,
sessionId: 'session-1',
close,
spoon: { name: 'Spoon' },
openai: {
model: 'gpt-5',
reasoningEffort: 'medium',
},
});
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
aiProviderProfile: {
id: 'profile-1',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
},
workdir: '/tmp/workspace',
homeDir: '/tmp/workspace',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/workspace/Code/spoon',
boxName: 'spoon-box-tester',
boxHandle: { boxName: 'spoon-box-tester', release },
githubToken: 'github-token',
redact: (value: string) => value,
runtime: 'codex',
});
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
).resolves.toBeUndefined();
await expect(
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
expect(mocks.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
expect(
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
).toBe(false);
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
'Agent workspace is not active on this worker.',
);
expect(consoleError).toHaveBeenCalledTimes(1);
},
);
expect(release).toHaveBeenCalledTimes(1);
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
expect(
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
).toBe(false);
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
'Agent workspace is not active on this worker.',
);
expect(consoleError).toHaveBeenCalledTimes(1);
});
});
@@ -31,18 +31,17 @@ type WorkerHealth = {
convexUrl: string;
runtime: string;
containerRuntime: string;
containerAccess: string;
jobImage: string;
workdir: string;
network?: string;
httpPort: number;
activeWorkspaceCount: number;
workspaceContainers: string[];
boxContainers: string[];
};
type CleanupResult = {
removedContainers: string[];
removedWorkdirs: string[];
boxContainers: string[];
};
export const WorkerHealthPanel = () => {
@@ -103,9 +102,7 @@ export const WorkerHealthPanel = () => {
});
if (!response.ok) throw new Error(await response.text());
const result = (await response.json()) as CleanupResult;
toast.success(
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
);
toast.success(`Cleaned ${result.removedWorkdirs.length} workdirs.`);
await refreshHealth();
} catch (error) {
console.error(error);
@@ -165,9 +162,7 @@ export const WorkerHealthPanel = () => {
{health.ok ? 'healthy' : 'unhealthy'}
</Badge>
<Badge variant='outline'>{health.workerId}</Badge>
<Badge variant='outline'>
{health.containerRuntime} / {health.containerAccess}
</Badge>
<Badge variant='outline'>{health.containerRuntime}</Badge>
</div>
<dl className='grid gap-3 text-sm md:grid-cols-2'>
<div>
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
</div>
</dl>
<div>
<p className='text-muted-foreground text-sm'>
Workspace containers
</p>
<p className='text-muted-foreground text-sm'>Box containers</p>
<p className='mt-1 font-mono text-sm'>
{health.workspaceContainers.length
? health.workspaceContainers.join(', ')
{health.boxContainers.length
? health.boxContainers.join(', ')
: 'none'}
</p>
</div>
@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {
<div>
<p className='text-sm font-medium'>Orphaned worker resources</p>
<p className='text-muted-foreground text-sm'>
Remove inactive Spoon job containers and inactive directories
under the configured worker workdir.
Remove inactive directories under the configured worker workdir.
</p>
</div>
<Button