Add features & update project
This commit is contained in:
+602
-59
@@ -7,12 +7,15 @@ import {
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
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 { normalizeCodexJsonLine } from './agent-events';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -22,8 +25,21 @@ import {
|
||||
run,
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import type { OpenCodeSession } from './opencode-session';
|
||||
import {
|
||||
abortOpenCodeSession,
|
||||
createOpenCodeSession,
|
||||
promptOpenCodeSession,
|
||||
replyOpenCodePermission,
|
||||
} from './opencode-session';
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import { runInJobContainer } from './runtime/docker';
|
||||
import {
|
||||
listWorkspaceContainerNames,
|
||||
runInJobContainer,
|
||||
startWorkspaceContainer,
|
||||
stopWorkspaceContainer,
|
||||
streamInJobContainer,
|
||||
} from './runtime/docker';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
@@ -81,6 +97,14 @@ type ActiveWorkspace = {
|
||||
repoDir: 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;
|
||||
};
|
||||
|
||||
type FileTreeNode = {
|
||||
@@ -225,6 +249,70 @@ const appendMessage = async (args: {
|
||||
...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;
|
||||
@@ -240,6 +328,9 @@ 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 isCodexLoginProfile = (claim: Claim) =>
|
||||
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
|
||||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
||||
@@ -373,20 +464,305 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
|
||||
);
|
||||
};
|
||||
|
||||
const agentCommand = (claim: Claim, prompt: string) => {
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
return commandToShell(
|
||||
`codex exec --model ${quoteShell(codexModel(claim))} --sandbox workspace-write ${quoteShell(prompt)}`,
|
||||
);
|
||||
}
|
||||
return commandToShell(
|
||||
`opencode run --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
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/repo',
|
||||
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 runCodexTurn = async (args: {
|
||||
workspace: ActiveWorkspace;
|
||||
prompt: string;
|
||||
assistantMessageId: Id<'agentJobMessages'>;
|
||||
assistantContent: { value: string };
|
||||
}) => {
|
||||
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
||||
workspace.runtimeMode = 'codex_exec';
|
||||
await setRuntimeSession({
|
||||
jobId: workspace.claim.job._id,
|
||||
agentRuntimeMode: 'codex_exec',
|
||||
codexSessionId: workspace.codexSessionId,
|
||||
});
|
||||
const command = workspace.codexSessionId
|
||||
? commandToShell(
|
||||
`codex exec resume --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`,
|
||||
)
|
||||
: commandToShell(
|
||||
`codex exec --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} --sandbox workspace-write ${quoteShell(prompt)}`,
|
||||
);
|
||||
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result = await streamInJobContainer({
|
||||
workdir: workspace.workdir,
|
||||
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) => {
|
||||
if (line.trim()) {
|
||||
await appendEvent(
|
||||
workspace.claim.job._id,
|
||||
'debug',
|
||||
'plan',
|
||||
truncate(line, 10_000),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`codex failed:\n${result.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
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/repo',
|
||||
});
|
||||
await turnDone;
|
||||
};
|
||||
|
||||
const systemPromptForJob = (claim: Claim) => {
|
||||
const base = [
|
||||
`Spoon: ${claim.spoon.name}`,
|
||||
@@ -759,8 +1135,8 @@ const runClaim = async (claim: Claim) => {
|
||||
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
||||
|
||||
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
jobId,
|
||||
'error',
|
||||
@@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
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/repo',
|
||||
});
|
||||
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) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const { claim, repoDir, redact, workdir } = workspace;
|
||||
const { claim, redact } = workspace;
|
||||
if (workspace.agentTurnActive) {
|
||||
throw new Error('Wait for the current agent turn to finish or abort it.');
|
||||
}
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'user',
|
||||
@@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
}
|
||||
const aiEnv = providerEnvironment(
|
||||
claim,
|
||||
env.runtime === 'docker' ? jobContainerWorkspace : workdir,
|
||||
);
|
||||
const secretEnv = Object.fromEntries(
|
||||
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const command = agentCommand(claim, prompt);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir,
|
||||
command,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run(
|
||||
'bash',
|
||||
command.slice(1),
|
||||
{
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
},
|
||||
);
|
||||
await appendMessage({
|
||||
workspace.agentTurnActive = true;
|
||||
const assistantMessageId = await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||
content: truncate(result.output, 40_000),
|
||||
status: 'streaming',
|
||||
content: '',
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
|
||||
const assistantContent = { value: '' };
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
await runCodexTurn({
|
||||
workspace,
|
||||
prompt,
|
||||
assistantMessageId,
|
||||
assistantContent,
|
||||
});
|
||||
} else if (env.runtime === 'docker') {
|
||||
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 (claim.job.jobType === 'maintenance_review') {
|
||||
const decision = parseMaintenanceDecision(result.output);
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
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,
|
||||
@@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
} else {
|
||||
await updateStatus(claim.job._id, 'changes_ready', {
|
||||
summary:
|
||||
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
const diff = await getWorktreeDiff(workspace.repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'diff',
|
||||
@@ -978,8 +1437,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
changeType: 'modified',
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
} catch (error) {
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
workspace.resolveTurn = undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
'error',
|
||||
@@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
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);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return {
|
||||
@@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
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);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
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 containerNames = await listWorkspaceContainerNames('spoon-agent-job-');
|
||||
return {
|
||||
ok: true,
|
||||
workerId: env.workerId,
|
||||
convexUrl: env.convexUrl,
|
||||
runtime: env.runtime,
|
||||
containerRuntime: env.containerRuntime,
|
||||
containerAccess: env.containerAccess,
|
||||
jobImage: env.jobImage,
|
||||
workdir: env.workdir,
|
||||
network: env.network,
|
||||
httpPort: env.httpPort,
|
||||
maxConcurrentJobs: env.maxConcurrentJobs,
|
||||
jobTimeoutMs: env.jobTimeoutMs,
|
||||
activeWorkspaceCount: active.length,
|
||||
activeWorkspaces: active,
|
||||
workspaceContainers: containerNames,
|
||||
};
|
||||
};
|
||||
|
||||
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 removedWorkdirs: string[] = [];
|
||||
const root = path.resolve(env.workdir);
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
const target = path.resolve(root, entry.name);
|
||||
if (activeWorkdirs.has(target)) continue;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
removedContainers,
|
||||
removedWorkdirs,
|
||||
};
|
||||
};
|
||||
|
||||
export const startWorker = async () => {
|
||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||
for (;;) {
|
||||
|
||||
Reference in New Issue
Block a user