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',
|
'docker',
|
||||||
containerVolumeOptions:
|
containerVolumeOptions:
|
||||||
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||||
containerAccess:
|
|
||||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
|
||||||
? 'host_port'
|
|
||||||
: 'network',
|
|
||||||
jobImage:
|
jobImage:
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||||
// Interactive terminal: image for the persistent shell container (defaults to
|
// Secret shared with the Next app for verifying interactive terminal tokens.
|
||||||
// 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',
|
|
||||||
terminalSecret:
|
terminalSecret:
|
||||||
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
process.env.SPOON_WORKER_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.
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||||
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
// 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_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
process.env.SPOON_WORKER_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),
|
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||||
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
|
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}`;
|
: `${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
|
// Shared line-streaming + result normalization for a started subprocess
|
||||||
// (used by both `docker run` and `docker exec` paths).
|
// (used by both `docker run` and `docker exec` paths).
|
||||||
type StreamingSubprocess = {
|
type StreamingSubprocess = {
|
||||||
@@ -296,51 +164,6 @@ const streamSubprocess = async (
|
|||||||
return normalizeRunResult(result, output.join(''), redact);
|
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
|
// 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.
|
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||||
export const userContainerName = (username: string) =>
|
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) => {
|
export const listWorkspaceContainerNames = async (prefix: string) => {
|
||||||
const result = await execa(
|
const result = await execa(
|
||||||
containerRuntime(),
|
containerRuntime(),
|
||||||
|
|||||||
+32
-257
@@ -1,4 +1,3 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
mkdir,
|
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 { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
import type { OpenCodeSession } from './opencode-session';
|
|
||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import { getAdapter } from './runtime/agent-runtime';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
@@ -31,25 +29,15 @@ import {
|
|||||||
} from './git';
|
} from './git';
|
||||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||||
import { createHeartbeatController } from './heartbeat-controller';
|
import { createHeartbeatController } from './heartbeat-controller';
|
||||||
import {
|
|
||||||
abortOpenCodeSession,
|
|
||||||
createOpenCodeSession,
|
|
||||||
promptOpenCodeSession,
|
|
||||||
replyOpenCodePermission,
|
|
||||||
} from './opencode-session';
|
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import { createRedactor, truncate } from './redact';
|
import { createRedactor, truncate } from './redact';
|
||||||
import {
|
import {
|
||||||
listWorkspaceContainerNames,
|
listWorkspaceContainerNames,
|
||||||
runExecInContainer,
|
runExecInContainer,
|
||||||
startWorkspaceContainer,
|
|
||||||
stopWorkspaceContainer,
|
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
import {
|
import {
|
||||||
collectJsonStringValues,
|
collectJsonStringValues,
|
||||||
isCodexLoginProfile,
|
isCodexLoginProfile,
|
||||||
opencodeModel,
|
|
||||||
providerEnvironment,
|
|
||||||
} from './runtime/provider';
|
} from './runtime/provider';
|
||||||
import { acquireUserBox, runningBoxUsernames } from './user-container';
|
import { acquireUserBox, runningBoxUsernames } from './user-container';
|
||||||
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||||
@@ -60,10 +48,6 @@ type ActiveWorkspace = AdapterWorkspace & {
|
|||||||
boxHandle: BoxHandle;
|
boxHandle: BoxHandle;
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||||
containerName?: string;
|
|
||||||
containerId?: string;
|
|
||||||
opencodePassword?: string;
|
|
||||||
opencodeSession?: OpenCodeSession;
|
|
||||||
agentTurnActive?: boolean;
|
agentTurnActive?: boolean;
|
||||||
cancelRequested?: boolean;
|
cancelRequested?: boolean;
|
||||||
resolveTurn?: () => void;
|
resolveTurn?: () => void;
|
||||||
@@ -332,12 +316,6 @@ const recordWorkspaceChange = async (args: {
|
|||||||
|
|
||||||
const commandToShell = (command: string) => ['bash', '-lc', command];
|
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: {
|
const handleAgentEvent = async (args: {
|
||||||
workspace: ActiveWorkspace;
|
workspace: ActiveWorkspace;
|
||||||
event: NormalizedAgentEvent;
|
event: NormalizedAgentEvent;
|
||||||
@@ -466,121 +444,6 @@ const handleAgentEvent = async (args: {
|
|||||||
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
|
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 systemPromptForJob = (claim: Claim) => {
|
||||||
const base = [
|
const base = [
|
||||||
`Spoon: ${claim.spoon.name}`,
|
`Spoon: ${claim.spoon.name}`,
|
||||||
@@ -746,8 +609,6 @@ const runProjectCommand = async (args: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
||||||
|
|
||||||
const resolveWorkspace = (jobId: string) => {
|
const resolveWorkspace = (jobId: string) => {
|
||||||
const workspace = activeWorkspaces.get(jobId);
|
const workspace = activeWorkspaces.get(jobId);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -1298,20 +1159,16 @@ export const getTerminalWorkspace = (jobId: string) => {
|
|||||||
export const getWorkspaceAgentStatus = (jobId: string) => {
|
export const getWorkspaceAgentStatus = (jobId: string) => {
|
||||||
const workspace = resolveWorkspace(jobId);
|
const workspace = resolveWorkspace(jobId);
|
||||||
return {
|
return {
|
||||||
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
runtimeMode: workspace.runtime,
|
||||||
opencodeSessionId: workspace.opencodeSession?.sessionId,
|
|
||||||
codexSessionId: workspace.codexSessionId,
|
codexSessionId: workspace.codexSessionId,
|
||||||
containerId: workspace.containerId,
|
opencodeSessionId: workspace.opencodeSessionId,
|
||||||
|
claudeSessionId: workspace.claudeSessionId,
|
||||||
active: Boolean(workspace.agentTurnActive),
|
active: Boolean(workspace.agentTurnActive),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const abortWorkspaceAgentForWorkspace = async (workspace: ActiveWorkspace) => {
|
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.agentTurnActive = false;
|
||||||
workspace.resolveTurn?.();
|
workspace.resolveTurn?.();
|
||||||
workspace.resolveTurn = undefined;
|
workspace.resolveTurn = undefined;
|
||||||
@@ -1328,44 +1185,16 @@ export const abortWorkspaceAgent = async (jobId: string) =>
|
|||||||
await abortWorkspaceAgentForWorkspace(resolveWorkspace(jobId));
|
await abortWorkspaceAgentForWorkspace(resolveWorkspace(jobId));
|
||||||
|
|
||||||
export const replyToInteraction = async (
|
export const replyToInteraction = async (
|
||||||
jobId: string,
|
_jobId: string,
|
||||||
args: {
|
_args: {
|
||||||
interactionId: Id<'agentInteractionRequests'>;
|
interactionId: Id<'agentInteractionRequests'>;
|
||||||
externalRequestId: string;
|
externalRequestId: string;
|
||||||
response: string;
|
response: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const workspace = resolveWorkspace(jobId);
|
// Every runtime now runs its CLI non-interactively inside the box, so no
|
||||||
if (workspace.runtimeMode === 'codex_exec') {
|
// runtime emits an interaction request that expects a reply.
|
||||||
throw new Error('Codex interaction replies are not supported yet.');
|
throw new Error('Interactive replies are not supported by exec-based runtimes.');
|
||||||
}
|
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendWorkspaceMessage = async (
|
export const sendWorkspaceMessage = async (
|
||||||
@@ -1398,19 +1227,24 @@ export const sendWorkspaceMessage = async (
|
|||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
const assistantContent = { value: '' };
|
const assistantContent = { value: '' };
|
||||||
if (isCodexLoginProfile(claim)) {
|
// 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 messageId = assistantMessageId;
|
||||||
|
const runtimeMode =
|
||||||
|
workspace.runtime === 'codex' ? 'codex_exec' : 'opencode_server';
|
||||||
|
workspace.runtimeMode = runtimeMode;
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
claim.job._id,
|
claim.job._id,
|
||||||
'info',
|
'info',
|
||||||
'plan',
|
'plan',
|
||||||
'Starting Codex CLI turn with the configured login profile.',
|
`Starting ${workspace.runtime} turn in the workspace box.`,
|
||||||
);
|
);
|
||||||
workspace.runtimeMode = 'codex_exec';
|
|
||||||
await setRuntimeSession({
|
await setRuntimeSession({
|
||||||
jobId: claim.job._id,
|
jobId: claim.job._id,
|
||||||
agentRuntimeMode: 'codex_exec',
|
agentRuntimeMode: runtimeMode,
|
||||||
codexSessionId: workspace.codexSessionId,
|
codexSessionId: workspace.codexSessionId,
|
||||||
|
opencodeSessionId: workspace.opencodeSessionId,
|
||||||
});
|
});
|
||||||
if (workspace.cancelRequested) {
|
if (workspace.cancelRequested) {
|
||||||
throw new Error('Workspace cancellation requested.');
|
throw new Error('Workspace cancellation requested.');
|
||||||
@@ -1446,13 +1280,14 @@ export const sendWorkspaceMessage = async (
|
|||||||
status: 'streaming',
|
status: 'streaming',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A hard failure (nonzero exit / failure event) surfaces the turn as
|
// A hard failure (nonzero exit / failure event) surfaces the turn as failed
|
||||||
// failed even when partial assistant text streamed — the catch path marks
|
// even when partial assistant text streamed — the catch path marks the
|
||||||
// the message failed and appends the error event.
|
// 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 (outcome.failure) {
|
||||||
if (!assistantContent.value.trim()) {
|
if (!assistantContent.value.trim()) {
|
||||||
console.error(
|
console.error(
|
||||||
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
`${workspace.runtime} completed without producing an assistant response for job ${claim.job._id}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error(outcome.failure);
|
throw new Error(outcome.failure);
|
||||||
@@ -1464,52 +1299,8 @@ export const sendWorkspaceMessage = async (
|
|||||||
});
|
});
|
||||||
workspace.agentTurnActive = false;
|
workspace.agentTurnActive = false;
|
||||||
console.log(
|
console.log(
|
||||||
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
|
`${workspace.runtime} 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({
|
|
||||||
workspace,
|
|
||||||
prompt,
|
|
||||||
assistantMessageId,
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workspace.agentTurnActive = false;
|
|
||||||
if (claim.job.jobType === 'maintenance_review') {
|
if (claim.job.jobType === 'maintenance_review') {
|
||||||
const decision = parseMaintenanceDecision(assistantContent.value);
|
const decision = parseMaintenanceDecision(assistantContent.value);
|
||||||
if (decision) {
|
if (decision) {
|
||||||
@@ -1579,8 +1370,10 @@ const teardownActiveWorkspace = async (args: {
|
|||||||
workspaceStatus?: 'stopped' | 'expired' | 'failed';
|
workspaceStatus?: 'stopped' | 'expired' | 'failed';
|
||||||
}) => {
|
}) => {
|
||||||
const { jobId, workspace } = args;
|
const { jobId, workspace } = args;
|
||||||
const containerName = workspace.containerName;
|
|
||||||
if (args.abortAgent) workspace.cancelRequested = true;
|
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({
|
return await teardownWorkspace({
|
||||||
stopHeartbeat: () => stopHeartbeat(jobId),
|
stopHeartbeat: () => stopHeartbeat(jobId),
|
||||||
removeActive: () => activeWorkspaces.delete(jobId),
|
removeActive: () => activeWorkspaces.delete(jobId),
|
||||||
@@ -1598,10 +1391,6 @@ const teardownActiveWorkspace = async (args: {
|
|||||||
: undefined,
|
: undefined,
|
||||||
markStopped: async () =>
|
markStopped: async () =>
|
||||||
await markWorkspaceStopped(workspace.claim.job._id, args.workspaceStatus),
|
await markWorkspaceStopped(workspace.claim.job._id, args.workspaceStatus),
|
||||||
closeAgent: () => workspace.opencodeSession?.close(),
|
|
||||||
stopContainer: containerName
|
|
||||||
? async () => await stopWorkspaceContainer(containerName)
|
|
||||||
: undefined,
|
|
||||||
release: () => workspace.boxHandle.release(),
|
release: () => workspace.boxHandle.release(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1717,12 +1506,11 @@ export const stopWorkspace = async (jobId: string) => {
|
|||||||
export const getWorkerHealth = async () => {
|
export const getWorkerHealth = async () => {
|
||||||
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
|
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
|
||||||
jobId,
|
jobId,
|
||||||
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
runtimeMode: workspace.runtime,
|
||||||
containerName: workspace.containerName,
|
boxName: workspace.boxName,
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
agentTurnActive: Boolean(workspace.agentTurnActive),
|
agentTurnActive: Boolean(workspace.agentTurnActive),
|
||||||
}));
|
}));
|
||||||
const jobContainers = await listWorkspaceContainerNames('spoon-agent-job-');
|
|
||||||
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1732,40 +1520,27 @@ export const getWorkerHealth = async () => {
|
|||||||
convexUrl: env.convexUrl,
|
convexUrl: env.convexUrl,
|
||||||
runtime: env.runtime,
|
runtime: env.runtime,
|
||||||
containerRuntime: env.containerRuntime,
|
containerRuntime: env.containerRuntime,
|
||||||
containerAccess: env.containerAccess,
|
|
||||||
jobImage: env.jobImage,
|
jobImage: env.jobImage,
|
||||||
workdir: env.workdir,
|
workdir: env.workdir,
|
||||||
hostWorkdir: env.hostWorkdir,
|
hostWorkdir: env.hostWorkdir,
|
||||||
network: env.network,
|
network: env.network,
|
||||||
httpPort: env.httpPort,
|
httpPort: env.httpPort,
|
||||||
maxConcurrentJobs: env.maxConcurrentJobs,
|
|
||||||
jobTimeoutMs: env.jobTimeoutMs,
|
jobTimeoutMs: env.jobTimeoutMs,
|
||||||
activeWorkspaceCount: active.length,
|
activeWorkspaceCount: active.length,
|
||||||
activeWorkspaces: active,
|
activeWorkspaces: active,
|
||||||
workspaceContainers: jobContainers,
|
|
||||||
boxContainers,
|
boxContainers,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cleanupOrphanedWorkspaces = async () => {
|
export const cleanupOrphanedWorkspaces = async () => {
|
||||||
const activeContainers = new Set(
|
// Per-user boxes are shared and reference-counted, so cleanup no longer force
|
||||||
[...activeWorkspaces.values()]
|
// removes containers here — it only reclaims orphaned workdirs and reports the
|
||||||
.map((workspace) => workspace.containerName)
|
// live boxes. (Job containers no longer exist.)
|
||||||
.filter((value): value is string => Boolean(value)),
|
|
||||||
);
|
|
||||||
const activeWorkdirs = new Set(
|
const activeWorkdirs = new Set(
|
||||||
[...activeWorkspaces.values()].map((workspace) =>
|
[...activeWorkspaces.values()].map((workspace) =>
|
||||||
path.resolve(workspace.workdir),
|
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 root = path.resolve(env.workdir);
|
||||||
const removedWorkdirs: string[] = [];
|
const removedWorkdirs: string[] = [];
|
||||||
@@ -1827,7 +1602,7 @@ export const cleanupOrphanedWorkspaces = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
||||||
return { success: true, removedContainers, removedWorkdirs, boxContainers };
|
return { success: true, removedWorkdirs, boxContainers };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startWorker = async () => {
|
export const startWorker = async () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
|
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
|
||||||
),
|
),
|
||||||
mutation: vi.fn(),
|
mutation: vi.fn(),
|
||||||
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
|
|
||||||
streamExecInContainer: vi.fn(),
|
streamExecInContainer: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ vi.mock('../../src/env', () => ({
|
|||||||
env: {
|
env: {
|
||||||
buildCreatedAt: 'test',
|
buildCreatedAt: 'test',
|
||||||
buildSha: 'test',
|
buildSha: 'test',
|
||||||
containerAccess: 'network',
|
|
||||||
convexUrl: 'http://convex.test',
|
convexUrl: 'http://convex.test',
|
||||||
jobTimeoutMs: 1000,
|
jobTimeoutMs: 1000,
|
||||||
runtime: 'docker',
|
runtime: 'docker',
|
||||||
@@ -42,19 +40,11 @@ vi.mock('../../src/github', () => ({
|
|||||||
getInstallationToken: vi.fn(),
|
getInstallationToken: vi.fn(),
|
||||||
openDraftPullRequest: 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', () => ({
|
vi.mock('../../src/runtime/docker', () => ({
|
||||||
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
|
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
|
||||||
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
|
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
|
||||||
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
||||||
runExecInContainer: vi.fn(),
|
runExecInContainer: vi.fn(),
|
||||||
startWorkspaceContainer: vi.fn(),
|
|
||||||
stopWorkspaceContainer: mocks.stopWorkspaceContainer,
|
|
||||||
streamExecInContainer: mocks.streamExecInContainer,
|
streamExecInContainer: mocks.streamExecInContainer,
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/user-container', () => ({
|
vi.mock('../../src/user-container', () => ({
|
||||||
@@ -86,27 +76,16 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('maintenance workspace cleanup', () => {
|
describe('maintenance workspace cleanup', () => {
|
||||||
test.each(['mark', 'container'] as const)(
|
test('releases and removes the workspace when status teardown fails', async () => {
|
||||||
'releases and removes the workspace when %s teardown fails',
|
|
||||||
async (failingStep) => {
|
|
||||||
const worker = await load();
|
const worker = await load();
|
||||||
const release = vi.fn();
|
const release = vi.fn();
|
||||||
const close = vi.fn();
|
const jobId = 'maintenance-mark';
|
||||||
const jobId = `maintenance-${failingStep}`;
|
|
||||||
mocks.mutation.mockImplementation((reference) => {
|
mocks.mutation.mockImplementation((reference) => {
|
||||||
if (
|
if (getFunctionName(reference) === 'agentJobs:markWorkspaceStopped') {
|
||||||
failingStep === 'mark' &&
|
|
||||||
getFunctionName(reference) === 'agentJobs:markWorkspaceStopped'
|
|
||||||
) {
|
|
||||||
return Promise.reject(new Error('status teardown failed'));
|
return Promise.reject(new Error('status teardown failed'));
|
||||||
}
|
}
|
||||||
return Promise.resolve('message-1');
|
return Promise.resolve('message-1');
|
||||||
});
|
});
|
||||||
mocks.stopWorkspaceContainer.mockImplementation(() =>
|
|
||||||
failingStep === 'container'
|
|
||||||
? Promise.reject(new Error('container teardown failed'))
|
|
||||||
: Promise.resolve(),
|
|
||||||
);
|
|
||||||
mocks.streamExecInContainer.mockImplementation(async (args) => {
|
mocks.streamExecInContainer.mockImplementation(async (args) => {
|
||||||
await args.onStdoutLine(
|
await args.onStdoutLine(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -159,12 +138,6 @@ describe('maintenance workspace cleanup', () => {
|
|||||||
githubToken: 'github-token',
|
githubToken: 'github-token',
|
||||||
redact: (value: string) => value,
|
redact: (value: string) => value,
|
||||||
runtime: 'codex',
|
runtime: 'codex',
|
||||||
containerName: 'spoon-agent-job-maintenance',
|
|
||||||
opencodeSession: {
|
|
||||||
client: {} as never,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
close,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const consoleError = vi
|
const consoleError = vi
|
||||||
.spyOn(console, 'error')
|
.spyOn(console, 'error')
|
||||||
@@ -175,8 +148,6 @@ describe('maintenance workspace cleanup', () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
expect(release).toHaveBeenCalledTimes(1);
|
||||||
expect(close).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
|
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
|
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
|
||||||
@@ -185,6 +156,5 @@ describe('maintenance workspace cleanup', () => {
|
|||||||
'Agent workspace is not active on this worker.',
|
'Agent workspace is not active on this worker.',
|
||||||
);
|
);
|
||||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,18 +31,17 @@ type WorkerHealth = {
|
|||||||
convexUrl: string;
|
convexUrl: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
containerRuntime: string;
|
containerRuntime: string;
|
||||||
containerAccess: string;
|
|
||||||
jobImage: string;
|
jobImage: string;
|
||||||
workdir: string;
|
workdir: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
httpPort: number;
|
httpPort: number;
|
||||||
activeWorkspaceCount: number;
|
activeWorkspaceCount: number;
|
||||||
workspaceContainers: string[];
|
boxContainers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CleanupResult = {
|
type CleanupResult = {
|
||||||
removedContainers: string[];
|
|
||||||
removedWorkdirs: string[];
|
removedWorkdirs: string[];
|
||||||
|
boxContainers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkerHealthPanel = () => {
|
export const WorkerHealthPanel = () => {
|
||||||
@@ -103,9 +102,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const result = (await response.json()) as CleanupResult;
|
const result = (await response.json()) as CleanupResult;
|
||||||
toast.success(
|
toast.success(`Cleaned ${result.removedWorkdirs.length} workdirs.`);
|
||||||
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
|
|
||||||
);
|
|
||||||
await refreshHealth();
|
await refreshHealth();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -165,9 +162,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
{health.ok ? 'healthy' : 'unhealthy'}
|
{health.ok ? 'healthy' : 'unhealthy'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant='outline'>{health.workerId}</Badge>
|
<Badge variant='outline'>{health.workerId}</Badge>
|
||||||
<Badge variant='outline'>
|
<Badge variant='outline'>{health.containerRuntime}</Badge>
|
||||||
{health.containerRuntime} / {health.containerAccess}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||||
<div>
|
<div>
|
||||||
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>Box containers</p>
|
||||||
Workspace containers
|
|
||||||
</p>
|
|
||||||
<p className='mt-1 font-mono text-sm'>
|
<p className='mt-1 font-mono text-sm'>
|
||||||
{health.workspaceContainers.length
|
{health.boxContainers.length
|
||||||
? health.workspaceContainers.join(', ')
|
? health.boxContainers.join(', ')
|
||||||
: 'none'}
|
: 'none'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
Remove inactive Spoon job containers and inactive directories
|
Remove inactive directories under the configured worker workdir.
|
||||||
under the configured worker workdir.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user