diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index 86b1689..07e79fb 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -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( diff --git a/apps/agent-worker/src/opencode-session.ts b/apps/agent-worker/src/opencode-session.ts deleted file mode 100644 index 7b8ff1c..0000000 --- a/apps/agent-worker/src/opencode-session.ts +++ /dev/null @@ -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; -}) => { - 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.'); - } -}; diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index e390c9b..617c5d4 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -103,138 +103,6 @@ export const jobWorkspaceVolumeSpec = ( : `${source}:${containerHome}`; }; -export const runInJobContainer = async (args: { - workdir: string; - containerHome?: string; - containerCwd?: string; - command: string[]; - environment: Record; - redact: (value: string) => string; - timeoutMs: number; -}): Promise => { - 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; - 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; - redact: (value: string) => string; - timeoutMs: number; -}): Promise => { - 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; - redact: (value: string) => string; - timeoutMs: number; - onStdoutLine?: (line: string) => Promise; - onStderrLine?: (line: string) => Promise; -}): Promise => { - 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(), diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index b2bcbd8..6fee82c 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -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>(); -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((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 () => { diff --git a/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts b/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts index f77921d..dcf180c 100644 --- a/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts +++ b/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts @@ -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); + }); }); diff --git a/apps/next/src/components/settings/worker-health-panel.tsx b/apps/next/src/components/settings/worker-health-panel.tsx index 9bda7fa..fa76ae1 100644 --- a/apps/next/src/components/settings/worker-health-panel.tsx +++ b/apps/next/src/components/settings/worker-health-panel.tsx @@ -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'} {health.workerId} - - {health.containerRuntime} / {health.containerAccess} - + {health.containerRuntime}
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
-

- Workspace containers -

+

Box containers

- {health.workspaceContainers.length - ? health.workspaceContainers.join(', ') + {health.boxContainers.length + ? health.boxContainers.join(', ') : 'none'}

@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {

Orphaned worker resources

- Remove inactive Spoon job containers and inactive directories - under the configured worker workdir. + Remove inactive directories under the configured worker workdir.