Files
spoon/apps/agent-worker/src/worker.ts
T

2042 lines
61 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import {
access,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';
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 { normalizeCodexJsonLine } from './agent-events';
import { prepareCodexWorkspaceFiles } from './codex-runtime';
import { env } from './env';
import {
cloneRepository,
commitAndPush,
getStatus,
getWorktreeDiff,
run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import {
abortOpenCodeSession,
createOpenCodeSession,
promptOpenCodeSession,
replyOpenCodePermission,
} from './opencode-session';
import { createRedactor, truncate } from './redact';
import {
listWorkspaceContainerNames,
runExecInContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamExecInContainer,
} from './runtime/docker';
import {
acquireUserBox,
releaseUserBox,
runningBoxUsernames,
} from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
runtime?: 'openai_direct' | 'opencode';
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
envFilePath?: string;
materializeEnvFile?: boolean;
baseBranch: string;
workBranch: string;
forkOwner: string;
forkRepo: string;
upstreamOwner: string;
upstreamRepo: string;
};
spoon: { name: string };
openai: {
apiKey?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
aiProviderProfile?: {
id: string;
name: string;
provider:
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json' | 'none';
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
github: { installationId?: string };
agentSettings?: {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
autoDetectCommands?: boolean;
} | null;
secrets: { name: string; value: string }[];
};
type ActiveWorkspace = {
claim: Claim;
// Host path of the persistent per-user home (mounted at `containerHome`).
// Equal to `homeDir`; kept as `workdir` for the container mount source.
workdir: string;
homeDir: string;
username: string;
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
containerHome: string;
containerRepo: string;
repoDir: string;
// Phase 2: the per-user box container this thread execs into.
boxName: string;
githubToken: string;
redact: (value: string) => string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
containerName?: string;
containerId?: string;
opencodePassword?: string;
opencodeSession?: OpenCodeSession;
codexSessionId?: string;
agentTurnActive?: boolean;
resolveTurn?: () => void;
lastRecordedDiffSignature?: string;
// Captures the most recent Codex `error`/`turn.failed` event for the active
// turn so the failure surfaces the real reason instead of a generic
// "no assistant response" message.
codexTurnError?: string;
};
type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>();
const appendEvent = async (
jobId: Id<'agentJobs'>,
level: 'debug' | 'info' | 'warn' | 'error',
phase:
| 'queued'
| 'clone'
| 'plan'
| 'edit'
| 'install'
| 'check'
| 'test'
| 'commit'
| 'push'
| 'pr'
| 'cleanup',
message: string,
metadata?: string,
) =>
await client.mutation(api.agentJobs.appendEvent, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
level,
phase,
message,
metadata,
});
const updateStatus = async (
jobId: Id<'agentJobs'>,
status:
| 'queued'
| 'claimed'
| 'preparing'
| 'running'
| 'checks_running'
| 'changes_ready'
| 'draft_pr_opened'
| 'failed'
| 'cancelled'
| 'timed_out',
extra?: { error?: string; summary?: string },
) =>
await client.mutation(api.agentJobs.updateStatus, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
status,
...extra,
});
const addArtifact = async (args: {
jobId: Id<'agentJobs'>;
kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
title: string;
content: string;
contentType:
| 'text/markdown'
| 'text/plain'
| 'application/json'
| 'text/x-diff';
}) =>
await client.mutation(api.agentJobs.addArtifact, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const completeWithDraftPr = async (args: {
jobId: Id<'agentJobs'>;
commitSha: string;
pullRequestUrl: string;
pullRequestNumber: number;
summary: string;
}) =>
await client.mutation(api.agentJobs.completeWithDraftPr, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const applyMaintenanceDecision = async (
jobId: Id<'agentJobs'>,
decision: MaintenanceDecision,
) =>
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
...decision,
});
const markWorkspaceActive = async (args: {
jobId: Id<'agentJobs'>;
opencodeSessionId?: string;
containerId?: string;
workspaceUrl?: string;
}) =>
await client.mutation(api.agentJobs.markWorkspaceActive, {
workerToken: env.workerToken,
workerId: env.workerId,
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
...args,
});
const markWorkspaceStopped = async (
jobId: Id<'agentJobs'>,
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
) =>
await client.mutation(api.agentJobs.markWorkspaceStopped, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
workspaceStatus,
});
const appendMessage = async (args: {
jobId: Id<'agentJobs'>;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
status: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.appendMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const updateMessage = async (args: {
messageId: Id<'agentJobMessages'>;
content?: string;
status?: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.updateMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setRuntimeSession = async (args: {
jobId: Id<'agentJobs'>;
agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
opencodeSessionId?: string;
codexSessionId?: string;
containerId?: string;
}) =>
await client.mutation(api.agentJobs.setRuntimeSession, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setCodexSessionId = async (
jobId: Id<'agentJobs'>,
codexSessionId: string,
) =>
await client.mutation(api.agentJobs.setCodexSessionId, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
codexSessionId,
});
const createInteractionRequest = async (args: {
jobId: Id<'agentJobs'>;
runtime: 'opencode' | 'codex';
externalRequestId: string;
kind: 'question' | 'permission' | 'tool_confirmation';
title: string;
body: string;
options?: string[];
metadata?: string;
}) =>
await client.mutation(api.agentJobs.createInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const patchInteractionRequest = async (args: {
interactionId: Id<'agentInteractionRequests'>;
status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
response?: string;
metadata?: string;
}) =>
await client.mutation(api.agentJobs.patchInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>;
path: string;
source: 'user' | 'agent' | 'command';
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
diff?: string;
}) =>
await client.mutation(api.agentJobs.recordWorkspaceChange, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const commandToShell = (command: string) => ['bash', '-lc', command];
const workspaceContainerName = (jobId: string) =>
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
const collectJsonStringValues = (value?: string): string[] => {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
const values: string[] = [];
const visit = (item: unknown) => {
if (typeof item === 'string') {
if (item.length >= 12) values.push(item);
return;
}
if (Array.isArray(item)) {
item.forEach(visit);
return;
}
if (item && typeof item === 'object') {
Object.values(item).forEach(visit);
}
};
visit(parsed);
return values;
} catch {
return [];
}
};
const providerEnvironment = (
claim: Claim,
workspaceRoot?: string,
): Record<string, string> => {
if (isCodexLoginProfile(claim)) {
if (!workspaceRoot) {
throw new Error('Codex auth profiles require a prepared workspace.');
}
return {
CODEX_HOME: path.join(workspaceRoot, '.codex'),
HOME: workspaceRoot,
XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'),
XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'),
};
}
const profile = claim.aiProviderProfile;
const secret = profile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No AI provider credential is configured for this job.');
}
const baseUrl: Record<string, string> = profile?.baseUrl
? { OPENAI_BASE_URL: profile.baseUrl }
: {};
if (!profile || profile.provider === 'openai') {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
if (profile.provider === 'openrouter') {
return { OPENROUTER_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'requesty') {
return { REQUESTY_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'cloudflare_ai_gateway') {
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
}
if (
profile.provider === 'litellm' ||
profile.provider === 'custom_openai_compatible'
) {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
throw new Error('Unsupported AI provider profile.');
};
const opencodeModel = (claim: Claim) => {
const profile = claim.aiProviderProfile;
const model = profile?.model ?? claim.openai.model;
if (model.includes('/')) return model;
if (!profile) return `openai/${model}`;
if (
profile.provider === 'custom_openai_compatible' ||
profile.provider === 'cloudflare_ai_gateway'
) {
return model;
}
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
return `${profile.provider}/${model}`;
};
const codexModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
const codexModelArgs = (claim: Claim) =>
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error('Codex auth JSON is not valid JSON.');
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Codex auth profile is missing auth.json contents.');
}
await prepareCodexWorkspaceFiles({
workdir: workspace.workdir,
repoDir: workspace.repoDir,
});
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await writeJsonFile(codexAuthPath, secret);
// Also seed OpenCode's auth location with the saved JSON for forward
// compatibility if this profile later runs through OpenCode directly.
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await writeJsonFile(openCodeAuthPath, secret);
await appendEvent(
workspace.claim.job._id,
'info',
'clone',
'Prepared Codex auth JSON for the isolated workspace.',
);
};
const agentFailurePrefix = (claim: Claim) =>
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
const handleAgentEvent = async (args: {
workspace: ActiveWorkspace;
event: NormalizedAgentEvent;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, event, assistantMessageId, assistantContent } = args;
const jobId = workspace.claim.job._id;
if (event.kind === 'assistant_delta') {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'streaming',
metadata: event.externalMessageId
? JSON.stringify({ externalMessageId: event.externalMessageId })
: undefined,
});
return;
}
if (event.kind === 'assistant_completed') {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
if (event.content) {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
}
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'completed',
});
return;
}
if (event.kind === 'session') {
if (workspace.runtimeMode === 'codex_exec') {
workspace.codexSessionId = event.sessionId;
await setCodexSessionId(jobId, event.sessionId);
}
return;
}
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
const detail = event.kind === 'tool_started' ? event.input : event.output;
await appendMessage({
jobId,
role: 'tool',
status: event.kind === 'tool_started' ? 'streaming' : 'completed',
content: truncate(
`${event.name}${detail ? `\n\n${detail}` : ''}`,
20_000,
),
metadata: JSON.stringify({
kind: event.kind,
externalMessageId: event.externalMessageId,
}),
});
return;
}
if (event.kind === 'file_edited') {
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId,
path: event.path,
source: 'agent',
changeType: await fileChangedType(workspace.repoDir, event.path),
diff: truncate(diff.output, 50_000),
});
await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
return;
}
if (event.kind === 'command_executed') {
await appendEvent(
jobId,
event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
'check',
event.command,
event.output ? truncate(event.output, 10_000) : undefined,
);
return;
}
if (
event.kind === 'permission_requested' ||
event.kind === 'question_requested'
) {
await createInteractionRequest({
jobId,
runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
externalRequestId: event.externalRequestId,
kind: event.kind === 'permission_requested' ? 'permission' : 'question',
title: event.title,
body: truncate(event.body, 20_000),
options: event.kind === 'question_requested' ? event.options : undefined,
metadata: event.metadata,
});
await appendMessage({
jobId,
role: 'system',
status: 'completed',
content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
metadata: JSON.stringify({ kind: event.kind }),
});
return;
}
if (event.kind === 'status') {
await appendEvent(
jobId,
'debug',
'plan',
event.status,
event.metadata ? truncate(event.metadata, 10_000) : undefined,
);
return;
}
// event.kind === 'error'
// Record the real Codex failure reason on the workspace so the turn can
// surface it (Codex can emit `error`/`turn.failed` events and still exit 0
// in some versions, which otherwise looks like an empty response).
workspace.codexTurnError = event.message;
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;
}
>();
// Reading through a function boundary prevents TypeScript from narrowing the
// field to `undefined` after the synchronous reset in `runCodexTurn`; it is set
// asynchronously by the stream event handler.
const readCodexTurnError = (workspace: ActiveWorkspace) =>
workspace.codexTurnError;
const runCodexTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspace.runtimeMode = 'codex_exec';
workspace.codexTurnError = undefined;
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'codex_exec',
codexSessionId: workspace.codexSessionId,
});
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
const outputFileHostPath = path.join(
workspace.workdir,
'.codex',
outputFileName,
);
const outputFileContainerPath = path.posix.join(
workspace.containerHome,
'.codex',
outputFileName,
);
const command = workspace.codexSessionId
? [
'codex',
'exec',
'resume',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
workspace.codexSessionId,
prompt,
]
: [
'codex',
'exec',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
'--cd',
workspace.containerRepo,
prompt,
];
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamExecInContainer({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: {
...aiEnv,
...secretEnv,
},
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeCodexJsonLine(line)) {
await handleAgentEvent({
workspace,
event,
assistantMessageId,
assistantContent,
});
}
},
onStderrLine: async (line) => {
const trimmed = line.trim();
if (
trimmed &&
trimmed !== 'Reading additional input from stdin...' &&
!trimmed.includes('`[features].codex_hooks` is deprecated')
) {
await appendEvent(
workspace.claim.job._id,
'info',
'plan',
truncate(trimmed, 10_000),
);
}
},
});
await appendEvent(
workspace.claim.job._id,
'info',
'plan',
`Codex CLI exited with code ${result.exitCode}; captured output length ${result.output.length}; assistant length ${assistantContent.value.length}.`,
);
if (result.exitCode !== 0) {
throw new Error(`codex failed:\n${result.output}`);
}
if (!assistantContent.value.trim()) {
try {
const lastMessage = await readFile(outputFileHostPath, 'utf8');
if (lastMessage.trim()) {
assistantContent.value = truncate(
workspace.redact(lastMessage.trim()),
40_000,
);
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'streaming',
});
await appendEvent(
workspace.claim.job._id,
'info',
'plan',
`Recovered assistant response from Codex output file ${outputFileName}.`,
);
}
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
await appendEvent(
workspace.claim.job._id,
'warn',
'plan',
`Codex output file ${outputFileName} was not created.`,
);
}
}
// Codex can report a failure via a JSON `error`/`turn.failed` event while
// still exiting 0. If the turn produced no assistant text but did report an
// error, surface that real reason rather than a generic empty response.
// Read through a helper so it is not narrowed away by the reset above (the
// field is mutated asynchronously inside the stream handler).
const codexTurnError = readCodexTurnError(workspace);
if (!assistantContent.value.trim() && codexTurnError) {
throw new Error(`codex failed:\n${codexTurnError}`);
}
};
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}`,
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
].join('\n');
if (claim.job.jobType === 'maintenance_review') {
return `${base}
You are reviewing upstream changes for a maintained fork.
Determine whether the upstream commits can be safely applied.
If the fork has no relevant customizations, recommend sync.
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
Do not claim tests passed unless commands were run.
End with a JSON maintenance decision in this exact shape:
{
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
"risk": "low" | "medium" | "high" | "unknown",
"summary": "string",
"ignoredCommitShas": ["string"],
"ignoredReason": "string",
"recommendedAction": "string",
"requiresUserApproval": true
}
User/system request:
${claim.job.prompt}`;
}
if (claim.job.jobType === 'conflict_resolution') {
return `${base}
You are resolving upstream merge conflicts in a maintained fork.
Preserve fork customizations unless the user explicitly removed that upstream behavior.
Prefer small, reviewable changes.
Produce a draft PR rather than committing to main.
Request:
${claim.job.prompt}`;
}
return `${base}
You are working on a maintained fork managed by Spoon.
Make the requested change only.
Preserve fork-specific customizations.
Do not commit secrets.
Use selected environment variables only for running/building/testing.
Open a draft PR only when instructed by Spoon.
Request:
${claim.job.prompt}`;
};
type MaintenanceDecision = {
decision:
| 'sync'
| 'ignore'
| 'open_review_pr'
| 'manual_review'
| 'conflict_resolution'
| 'unknown';
risk: 'low' | 'medium' | 'high' | 'unknown';
summary: string;
ignoredCommitShas: string[];
ignoredReason: string;
recommendedAction: string;
requiresUserApproval: boolean;
};
const parseMaintenanceDecision = (
output: string,
): MaintenanceDecision | null => {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
Boolean,
) as string[];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(
candidate.trim(),
) as Partial<MaintenanceDecision>;
if (
parsed.decision &&
parsed.risk &&
typeof parsed.summary === 'string'
) {
return {
decision: parsed.decision,
risk: parsed.risk,
summary: parsed.summary,
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
? parsed.ignoredCommitShas.filter(
(sha): sha is string => typeof sha === 'string',
)
: [],
ignoredReason:
typeof parsed.ignoredReason === 'string'
? parsed.ignoredReason
: '',
recommendedAction:
typeof parsed.recommendedAction === 'string'
? parsed.recommendedAction
: parsed.summary,
requiresUserApproval: parsed.requiresUserApproval ?? true,
};
}
} catch {
// Try the next candidate.
}
}
return null;
};
const fileExists = async (filePath: string) => {
try {
await access(filePath);
return true;
} catch {
return false;
}
};
const runProjectCommand = async (args: {
command: string;
phase: 'install' | 'check' | 'test';
claim: Claim;
boxName: string;
containerHome: string;
containerCwd: string;
repoDir: string;
redact: (value: string) => string;
}) => {
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
const secretEnv = Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result =
env.runtime === 'docker'
? await runExecInContainer({
containerName: args.boxName,
command: commandToShell(args.command),
containerCwd: args.containerCwd,
environment: { HOME: args.containerHome, ...secretEnv },
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
})
: await run('bash', ['-lc', args.command], {
cwd: args.repoDir,
env: secretEnv,
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
});
await addArtifact({
jobId: args.claim.job._id,
kind: args.phase === 'test' ? 'test_output' : 'summary',
title: args.command,
content: truncate(result.output, 100_000),
contentType: 'text/plain',
});
if (result.exitCode !== 0) {
throw new Error(`${args.command} failed:\n${result.output}`);
}
};
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const resolveWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) {
throw new Error('Agent workspace is not active on this worker.');
}
return workspace;
};
const safeWorkspacePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
const root = path.resolve(repoDir);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to access path outside repository: ${filePath}`);
}
return resolved;
};
const fileChangedType = async (repoDir: string, filePath: string) => {
const status = await run('git', ['status', '--short', '--', filePath], {
cwd: repoDir,
redact: (value) => value,
timeoutMs: 60_000,
});
const code = status.output.trim().slice(0, 2);
if (code.includes('D')) return 'deleted' as const;
if (code.includes('A') || code.includes('?')) return 'added' as const;
if (code.includes('R')) return 'renamed' as const;
return 'modified' as const;
};
const sensitiveWorkspacePath = (filePath: string) => {
const parts = filePath.split('/');
if (parts.includes('.git') || parts.includes('.codex')) return true;
const name = parts.at(-1) ?? filePath;
if (name === '.env') return true;
if (name.startsWith('.env.') && name !== '.env.example') return true;
return false;
};
const changedFilesFromStatus = async (
repoDir: string,
redact: (value: string) => string,
) => {
const status = await run('git', ['status', '--short'], {
cwd: repoDir,
redact,
timeoutMs: 60_000,
});
if (status.exitCode !== 0) return [];
return status.output
.split('\n')
.map((line) => {
if (line.length < 4) return null;
const code = line.slice(0, 2);
const rawPath = line.slice(3).trim();
if (!rawPath) return null;
const filePath = rawPath.includes(' -> ')
? rawPath.split(' -> ').at(-1)?.trim()
: rawPath;
if (!filePath || sensitiveWorkspacePath(filePath)) return null;
const changeType = code.includes('D')
? 'deleted'
: code.includes('R')
? 'renamed'
: code.includes('A') || code.includes('?')
? 'added'
: 'modified';
return {
path: filePath,
changeType,
};
})
.filter(
(
value,
): value is {
path: string;
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
} => Boolean(value),
);
};
const recordChangedFiles = async (
workspace: ActiveWorkspace,
source: 'agent' | 'command',
diff: string,
) => {
const changes = await changedFilesFromStatus(
workspace.repoDir,
workspace.redact,
);
const signature = JSON.stringify({ diff, changes });
if (signature === workspace.lastRecordedDiffSignature) return;
workspace.lastRecordedDiffSignature = signature;
for (const change of changes) {
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: change.path,
source,
changeType: change.changeType,
diff: truncate(diff, 50_000),
});
}
if (changes.length > 0) {
await appendEvent(
workspace.claim.job._id,
'info',
'edit',
`Workspace has ${changes.length} changed file${
changes.length === 1 ? '' : 's'
}.`,
JSON.stringify(changes),
);
}
};
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
const { claim, repoDir } = workspace;
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
await mkdir(path.dirname(envPath), { recursive: true });
const content = `${claim.secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
.join('\n')}\n`;
await writeFile(envPath, content);
await appendEvent(
claim.job._id,
'info',
'clone',
`Materialized selected secrets into ${claim.job.envFilePath}.`,
);
};
const detectPackageCommands = async (
repoDir: string,
): Promise<{
packageManager?: string;
scripts?: string[];
install?: string;
check?: string;
test?: string;
build?: string;
}> => {
const packageJsonPath = path.join(repoDir, 'package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
packageManager?: string;
scripts?: Record<string, string>;
};
const scripts = packageJson.scripts ?? {};
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
const detectedPackageManager = (await fileExists(
path.join(repoDir, 'bun.lock'),
))
? 'bun'
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
? 'pnpm'
: (await fileExists(path.join(repoDir, 'yarn.lock')))
? 'yarn'
: 'npm';
const packageManager = declaredPackageManager ?? detectedPackageManager;
const runScript = (script: string) =>
packageManager === 'npm'
? `npm run ${script}`
: `${packageManager} run ${script}`;
return {
packageManager,
scripts: Object.keys(scripts).sort(),
install: `${packageManager} install`,
check: scripts.typecheck
? runScript('typecheck')
: scripts.lint
? runScript('lint')
: undefined,
test: scripts.test
? packageManager === 'npm'
? 'npm test'
: `${packageManager} test`
: undefined,
build: scripts.build ? runScript('build') : undefined,
};
} catch {
return {};
}
};
const buildPrBody = (args: {
prompt: string;
summary: string;
commands: string[];
limitations: string[];
}) => `## Spoon agent request
${args.prompt}
## Summary
${args.summary}
## Validation
${
args.commands.length
? args.commands.map((command) => `- \`${command}\``).join('\n')
: '- No validation commands were requested by the agent.'
}
## Limitations
${
args.limitations.length
? args.limitations.map((item) => `- ${item}`).join('\n')
: '- No limitations reported.'
}
Generated by Spoon.`;
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
const status = await run('git', ['status', '--short'], {
cwd: workspace.repoDir,
redact: workspace.redact,
timeoutMs: 60_000,
});
const envLine = status.output
.split('\n')
.find((line) => /\s\.env(?:$|[./-])/.test(line));
if (envLine) {
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
}
};
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-') || 'x';
export const planWorkdirCleanup = (args: {
root: string;
rootEntries: { name: string; isDirectory: boolean }[];
homesEntries: Record<string, { name: string; isDirectory: boolean }[]>;
codeLeaves: Record<string, string[]>;
activeWorkdirs: Set<string>;
runningBoxUsernames: Set<string>;
}): { removeDirs: string[] } => {
const removeDirs: string[] = [];
for (const entry of args.rootEntries) {
if (!entry.isDirectory || entry.name.startsWith('.')) continue;
if (entry.name === 'homes') continue;
const abs = path.resolve(args.root, entry.name);
if (args.activeWorkdirs.has(abs)) continue;
removeDirs.push(abs);
}
for (const [username, leaves] of Object.entries(args.codeLeaves)) {
if (args.runningBoxUsernames.has(username)) continue;
for (const leaf of leaves) removeDirs.push(leaf);
}
return { removeDirs };
};
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const secretValues = [
claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '',
...collectJsonStringValues(claim.aiProviderProfile?.secret),
...claim.secrets.map((secret) => secret.value),
].filter(Boolean);
const redact = createRedactor(secretValues);
let acquiredBoxUser: string | undefined;
try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
await updateStatus(jobId, 'preparing');
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
if (!claim.github.installationId) {
throw new Error('GitHub installation ID is missing.');
}
const githubToken = await getInstallationToken(claim.github.installationId);
// Resolve the persistent per-user home and lay the checkout out as
// ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
// thread shows up as a folder in one home.
const userEnv = await fetchUserEnvironment(jobId);
const username = userEnv?.username ?? 'user';
const homeDir = path.resolve(env.workdir, 'homes', username);
const containerHome = path.posix.join('/home', username);
const spoonSlug = slugify(claim.spoon.name);
const branchSlug = slugify(claim.job.workBranch);
const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
const containerRepo = path.posix.join(
containerHome,
'Code',
spoonSlug,
branchSlug,
);
// Start (or reuse) the persistent per-user box that this thread — and the
// terminal — exec into. It mounts the home, so the clone below is visible.
const boxName = await acquireUserBox({
username,
workdir: homeDir,
containerHome,
});
acquiredBoxUser = username;
const repoDir = await cloneRepository({
workdir: checkoutParent,
dirName: branchSlug,
token: githubToken,
owner: claim.job.forkOwner,
repo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
redact,
timeoutMs: env.jobTimeoutMs,
});
const workspace: ActiveWorkspace = {
claim,
workdir: homeDir,
homeDir,
username,
containerHome,
containerRepo,
repoDir,
boxName,
githubToken,
redact,
};
if (userEnv) {
await appendEvent(
jobId,
'info',
'clone',
'Applying your dotfiles and environment.',
);
await materializeUserHome({
homeDir,
containerHome,
boxName,
userEnv,
redact,
});
}
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
}
await materializeEnvFile(workspace);
const detected = await detectPackageCommands(repoDir);
await addArtifact({
jobId,
kind: 'summary',
title: 'Detected project commands',
content: JSON.stringify(detected, null, 2),
contentType: 'application/json',
});
activeWorkspaces.set(jobId, workspace);
await markWorkspaceActive({ jobId });
await updateStatus(jobId, 'running', {
summary: 'Workspace is active.',
});
await appendMessage({
jobId,
role: 'system',
status: 'completed',
content:
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
});
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
await appendEvent(
jobId,
'info',
'plan',
`Worker runtime ${env.workerId} build ${env.buildSha} (${env.buildCreatedAt}).`,
);
await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
recordUserMessage: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
jobId,
'error',
'cleanup',
truncate(redact(message), 20_000),
);
await addArtifact({
jobId,
kind: 'error',
title: 'Failure',
content: truncate(redact(message), 50_000),
contentType: 'text/plain',
});
await updateStatus(
jobId,
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
{ error: truncate(redact(message), 10_000) },
);
await markWorkspaceStopped(
jobId,
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
).catch((stopError: unknown) => {
console.error(stopError);
});
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
}
};
export const listWorkspaceTree = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const buildNode = async (
absolutePath: string,
relativePath: string,
): Promise<FileTreeNode | null> => {
const basename = path.basename(absolutePath);
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
return null;
}
const stats = await stat(absolutePath);
if (stats.isDirectory()) {
const entries = await readdir(absolutePath);
const children = (
await Promise.all(
entries
.sort((a, b) => a.localeCompare(b))
.map((entry) =>
buildNode(
path.join(absolutePath, entry),
path.join(relativePath, entry),
),
),
)
).filter((node): node is FileTreeNode => Boolean(node));
return {
name: relativePath ? basename : workspace.claim.job.forkRepo,
path: relativePath,
type: 'directory',
children,
};
}
return { name: basename, path: relativePath, type: 'file' };
};
return await buildNode(workspace.repoDir, '');
};
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
return await readFile(target, 'utf8');
};
export const writeWorkspaceFile = async (
jobId: string,
filePath: string,
content: string,
) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: filePath,
source: 'user',
changeType: await fileChangedType(workspace.repoDir, filePath),
diff: truncate(diff.output, 50_000),
});
await appendEvent(
workspace.claim.job._id,
'info',
'edit',
`Saved ${filePath}.`,
);
return { success: true };
};
export const getWorkspaceDiff = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
return diff.output;
};
export const runWorkspaceCommand = async (jobId: string, command: string) => {
const workspace = resolveWorkspace(jobId);
await updateStatus(workspace.claim.job._id, 'checks_running');
await runProjectCommand({
command,
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
boxName: workspace.boxName,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
repoDir: workspace.repoDir,
redact: workspace.redact,
});
await updateStatus(workspace.claim.job._id, 'running');
await recordChangedFiles(
workspace,
'command',
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
);
return { success: true };
};
// Non-throwing accessor for the interactive terminal: returns the active
// workspace's mount info, or null when the workspace is not active here.
export const getTerminalWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) return null;
return {
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerRepo: workspace.containerRepo,
username: workspace.username,
secrets: workspace.claim.secrets,
};
};
export const getWorkspaceAgentStatus = (jobId: string) => {
const workspace = resolveWorkspace(jobId);
return {
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
opencodeSessionId: workspace.opencodeSession?.sessionId,
codexSessionId: workspace.codexSessionId,
containerId: workspace.containerId,
active: Boolean(workspace.agentTurnActive),
};
};
export const abortWorkspaceAgent = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
if (workspace.opencodeSession) {
await abortOpenCodeSession(workspace.opencodeSession);
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
await appendEvent(
workspace.claim.job._id,
'warn',
'cleanup',
'Agent turn aborted.',
);
return { success: true };
}
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex agent turns cannot be aborted from Spoon yet.');
}
return { success: true };
};
export const replyToInteraction = async (
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 };
};
export const sendWorkspaceMessage = async (
jobId: string,
prompt: string,
options: { recordUserMessage?: boolean } = {},
) => {
const workspace = resolveWorkspace(jobId);
const { claim, redact } = workspace;
if (workspace.agentTurnActive) {
throw new Error('Wait for the current agent turn to finish or abort it.');
}
if (options.recordUserMessage ?? true) {
await appendMessage({
jobId: claim.job._id,
role: 'user',
status: 'completed',
content: prompt,
});
}
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
let assistantMessageId: Id<'agentJobMessages'> | undefined;
try {
workspace.agentTurnActive = true;
assistantMessageId = await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'streaming',
content: '',
});
const assistantContent = { value: '' };
if (isCodexLoginProfile(claim)) {
await appendEvent(
claim.job._id,
'info',
'plan',
'Starting Codex CLI turn with the configured login profile.',
);
await runCodexTurn({
workspace,
prompt,
assistantMessageId,
assistantContent,
});
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({
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}`);
}
}
if (isCodexLoginProfile(claim)) {
if (!assistantContent.value.trim()) {
console.error(
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
);
throw new Error(
workspace.codexTurnError
? `Codex failed: ${workspace.codexTurnError}`
: 'Codex completed without producing an assistant response.',
);
}
await updateMessage({
messageId: assistantMessageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
}
workspace.agentTurnActive = false;
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(assistantContent.value);
if (decision) {
await addArtifact({
jobId: claim.job._id,
kind: 'summary',
title: 'Maintenance decision',
content: JSON.stringify(decision, null, 2),
contentType: 'application/json',
});
await applyMaintenanceDecision(claim.job._id, decision);
} else {
await updateStatus(claim.job._id, 'changes_ready', {
summary:
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
});
}
}
const diff = await getWorktreeDiff(workspace.repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await recordChangedFiles(workspace, 'agent', diff.output);
} catch (error) {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
const message = error instanceof Error ? error.message : String(error);
console.error(`Agent turn failed for job ${claim.job._id}: ${message}`);
await appendEvent(
claim.job._id,
'error',
'cleanup',
truncate(redact(message), 20_000),
);
if (assistantMessageId) {
await updateMessage({
messageId: assistantMessageId,
status: 'failed',
content: truncate(redact(message), 40_000),
});
} else {
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'failed',
content: truncate(redact(message), 40_000),
});
}
throw error;
}
};
export const openWorkspacePullRequest = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact } = workspace;
await ensureNoEnvFilesStaged(workspace);
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes are ready for a draft PR.');
}
const diff = await getWorktreeDiff(repoDir, redact);
const settings = claim.agentSettings;
const detected = await detectPackageCommands(repoDir);
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: 'Interactive Spoon agent workspace changes.',
commands: [installCommand, checkCommand, testCommand].filter(
(command): command is string => Boolean(command),
),
limitations: ['Review the draft PR before merging.'],
});
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Final Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await addArtifact({
jobId: claim.job._id,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
});
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
redact,
timeoutMs: env.jobTimeoutMs,
});
if (!claim.github.installationId) {
throw new Error('GitHub installation ID is missing.');
}
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
});
await completeWithDraftPr({
jobId: claim.job._id,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: 'Draft PR opened from interactive workspace.',
});
await markWorkspaceStopped(claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
// The persistent per-user home + ~/Code checkouts survive across sessions;
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
};
};
export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
// The persistent per-user home + ~/Code checkouts survive across sessions;
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return { success: true };
};
export const getWorkerHealth = async () => {
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
jobId,
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
containerName: workspace.containerName,
workdir: workspace.workdir,
agentTurnActive: Boolean(workspace.agentTurnActive),
}));
const jobContainers = await listWorkspaceContainerNames('spoon-agent-job-');
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
return {
ok: true,
buildSha: env.buildSha,
buildCreatedAt: env.buildCreatedAt,
workerId: env.workerId,
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)),
);
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[] = [];
try {
const rootDirents = await readdir(root, { withFileTypes: true });
const rootEntries = rootDirents.map((d) => ({
name: d.name,
isDirectory: d.isDirectory(),
}));
const homesRoot = path.join(root, 'homes');
const homesEntries: Record<
string,
{ name: string; isDirectory: boolean }[]
> = {};
const codeLeaves: Record<string, string[]> = {};
const homesDirents = await readdir(homesRoot, {
withFileTypes: true,
}).catch(() => []);
for (const userDir of homesDirents) {
if (!userDir.isDirectory()) continue;
const username = userDir.name;
const codeRoot = path.join(homesRoot, username, 'Code');
homesEntries[username] = [];
const leaves: string[] = [];
const spoons = await readdir(codeRoot, { withFileTypes: true }).catch(
() => [],
);
for (const spoonDir of spoons) {
if (!spoonDir.isDirectory()) continue;
const spoonRoot = path.join(codeRoot, spoonDir.name);
const branches = await readdir(spoonRoot, {
withFileTypes: true,
}).catch(() => []);
for (const branchDir of branches) {
if (branchDir.isDirectory()) {
leaves.push(path.join(spoonRoot, branchDir.name));
}
}
}
codeLeaves[username] = leaves;
}
const { removeDirs } = planWorkdirCleanup({
root,
rootEntries,
homesEntries,
codeLeaves,
activeWorkdirs,
runningBoxUsernames: runningBoxUsernames(),
});
for (const target of removeDirs) {
await rm(target, { recursive: true, force: true });
removedWorkdirs.push(target);
}
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
return { success: true, removedContainers, removedWorkdirs, boxContainers };
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {
try {
const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
workerId: env.workerId,
workerToken: env.workerToken,
});
if (!claim) {
await sleep(env.pollMs);
continue;
}
await runClaim(claim);
} catch (error) {
console.error(error);
await sleep(env.pollMs);
}
}
};