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