Update formatting on worker
Build and Push Spoon Images / quality (push) Successful in 1m27s
Build and Push Spoon Images / build-images (push) Successful in 7m13s

This commit is contained in:
Gabriel Brown
2026-06-24 08:40:52 -04:00
parent 5f7d56369f
commit 65aae85369
11 changed files with 130 additions and 93 deletions
+27 -10
View File
@@ -71,7 +71,8 @@ const textFromPart = (part: Record<string, unknown>) => {
};
const commandString = (value: unknown) => {
if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' ');
if (Array.isArray(value))
return value.map((part) => stringify(part)).join(' ');
return stringify(value);
};
@@ -82,8 +83,7 @@ const toolNameFromRecord = (record: Record<string, unknown> | null) =>
record?.toolName ??
record?.name ??
record?.function ??
(stringify(record?.type).toLowerCase().includes('exec') ||
record?.command
(stringify(record?.type).toLowerCase().includes('exec') || record?.command
? 'Command'
: record?.type) ??
'tool',
@@ -132,7 +132,9 @@ const recordLooksLikeTool = (
recordType.includes('exec_command') ||
recordType.includes('command') ||
recordType.includes('mcp') ||
Boolean(record?.tool ?? record?.tool_name ?? record?.name ?? record?.command)
Boolean(
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
)
);
};
@@ -153,7 +155,10 @@ const normalizeCodexMsgEvent = (
);
if (sessionId) events.push({ kind: 'session', sessionId });
}
if (msgType === 'agent_message_delta' || msgType === 'agent_reasoning_delta') {
if (
msgType === 'agent_message_delta' ||
msgType === 'agent_reasoning_delta'
) {
const delta = stringify(msg.delta ?? msg.text);
if (delta) events.push({ kind: 'assistant_delta', content: delta });
}
@@ -177,7 +182,11 @@ const normalizeCodexMsgEvent = (
output: toolOutputFromRecord(msg),
});
}
if (msgType === 'error' || msgType === 'turn_failed' || msgType === 'task_error') {
if (
msgType === 'error' ||
msgType === 'turn_failed' ||
msgType === 'task_error'
) {
const message = stringify(msg.message ?? msg.error ?? msg);
if (isCodexConfigWarning(message)) {
events.push({ kind: 'status', status: message });
@@ -354,7 +363,8 @@ export const normalizeOpenCodeEvent = (
const event = asRecord(input);
if (!event) return [];
const type = stringify(event.type);
const properties = asRecord(event.properties) ?? asRecord(event.data) ?? event;
const properties =
asRecord(event.properties) ?? asRecord(event.data) ?? event;
const events: NormalizedAgentEvent[] = [];
const sessionId = properties.sessionID ?? properties.sessionId;
if (typeof sessionId === 'string' && type.includes('session')) {
@@ -408,7 +418,8 @@ export const normalizeOpenCodeEvent = (
}
if (type === 'file.edited') {
const file = properties.file;
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
if (typeof file === 'string')
events.push({ kind: 'file_edited', path: file });
}
if (type === 'command.executed') {
events.push({
@@ -422,7 +433,9 @@ export const normalizeOpenCodeEvent = (
kind: 'permission_requested',
externalRequestId: stringify(properties.permissionID ?? properties.id),
title: 'Permission requested',
body: stringify(properties.permission ?? properties.message ?? properties),
body: stringify(
properties.permission ?? properties.message ?? properties,
),
metadata: stringify(properties),
});
}
@@ -443,7 +456,11 @@ export const normalizeOpenCodeEvent = (
});
}
if (events.length === 0 && type) {
events.push({ kind: 'status', status: type, metadata: stringify(properties) });
events.push({
kind: 'status',
status: type,
metadata: stringify(properties),
});
}
return events;
};
+2 -1
View File
@@ -25,7 +25,8 @@ export const env = {
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
'docker',
containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerVolumeOptions:
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
+1 -2
View File
@@ -155,8 +155,7 @@ export const getWorktreeDiff = async (
if (diff.output.trim()) untrackedDiffs.push(diff.output);
}
return {
exitCode:
trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
output: [trackedDiff.output, ...untrackedDiffs]
.filter((part) => part.trim())
.join('\n'),
+8 -6
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { OpencodeClient } from '@opencode-ai/sdk';
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events';
@@ -115,11 +115,13 @@ export const replyOpenCodePermission = async (args: {
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 },
});
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.');
}
+12 -19
View File
@@ -1,5 +1,5 @@
import { execa } from 'execa';
import path from 'node:path';
import { execa } from 'execa';
import { env } from '../env';
@@ -134,15 +134,9 @@ export const startWorkspaceContainer = async (args: {
publishTcpPort?: number;
}) => {
await ensureJobImagePulled();
await execa(
containerRuntime(),
[
'rm',
'-f',
args.containerName,
],
{ reject: false },
);
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
reject: false,
});
const result = await execa(
containerRuntime(),
[
@@ -177,7 +171,10 @@ export const startWorkspaceContainer = async (args: {
};
};
const getPublishedPort = async (containerName: string, containerPort: number) => {
const getPublishedPort = async (
containerName: string,
containerPort: number,
) => {
const result = await execa(
containerRuntime(),
['port', containerName, `${containerPort}/tcp`],
@@ -317,14 +314,10 @@ export const stopWorkspaceContainer = async (containerName: string) => {
};
export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa(
containerRuntime(),
['inspect', containerName],
{
all: true,
reject: false,
},
);
const result = await execa(containerRuntime(), ['inspect', containerName], {
all: true,
reject: false,
});
return {
exists: result.exitCode === 0,
output: result.all,
+15 -14
View File
@@ -128,8 +128,9 @@ export const startWorkerServer = () => {
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
return;
}
const interactionMatch =
/^interactions\/([^/]+)\/reply$/.exec(route.action);
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
route.action,
);
if (request.method === 'POST' && interactionMatch?.[1]) {
const body = await parseJson<{
externalRequestId?: string;
@@ -166,21 +167,21 @@ export const startWorkerServer = () => {
return;
}
sendJson(response, 404, { error: 'Not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
);
const status =
message === 'Unauthorized'
? 401
: message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
const status =
message === 'Unauthorized'
? 401
: message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
})();
});
attachTerminalServer(server);
+2 -1
View File
@@ -1,7 +1,8 @@
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws';
import Docker from 'dockerode';
import { WebSocketServer, type WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import { env } from './env';
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
+50 -36
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import {
access,
mkdir,
@@ -7,7 +8,6 @@ import {
stat,
writeFile,
} from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';
@@ -15,6 +15,7 @@ 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 {
codexContainerRepo,
@@ -30,7 +31,6 @@ import {
run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import type { OpenCodeSession } from './opencode-session';
import {
abortOpenCodeSession,
createOpenCodeSession,
@@ -432,7 +432,7 @@ const opencodeModel = (claim: Claim) => {
const codexModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? model.split('/').at(-1) ?? model : model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
const codexModelArgs = (claim: Claim) =>
@@ -531,8 +531,7 @@ const handleAgentEvent = async (args: {
return;
}
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
const detail =
event.kind === 'tool_started' ? event.input : event.output;
const detail = event.kind === 'tool_started' ? event.input : event.output;
await appendMessage({
jobId,
role: 'tool',
@@ -647,22 +646,25 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const session = await createOpenCodeSession({
baseUrl,
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);
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: '',
},
assistantContent: workspaceCurrentContent.get(
workspace.claim.job._id,
) ?? {
value: '',
},
});
},
});
@@ -1338,8 +1340,8 @@ const runClaim = async (claim: Claim) => {
await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
recordUserMessage: false,
});
} 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',
@@ -1491,7 +1493,12 @@ export const abortWorkspaceAgent = async (jobId: string) => {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.');
await appendEvent(
workspace.claim.job._id,
'warn',
'cleanup',
'Agent turn aborted.',
);
return { success: true };
}
if (workspace.runtimeMode === 'codex_exec') {
@@ -1605,15 +1612,22 @@ export const sendWorkspaceMessage = async (
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,
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,
},
redact,
timeoutMs: env.jobTimeoutMs,
});
);
await updateMessage({
messageId: assistantMessageId,
status: result.exitCode === 0 ? 'completed' : 'failed',
@@ -1634,15 +1648,15 @@ export const sendWorkspaceMessage = async (
: 'Codex completed without producing an assistant response.',
);
}
await updateMessage({
messageId: assistantMessageId,
await updateMessage({
messageId: assistantMessageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
}
workspace.agentTurnActive = false;
if (claim.job.jobType === 'maintenance_review') {
});
workspace.agentTurnActive = false;
}
workspace.agentTurnActive = false;
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(assistantContent.value);
if (decision) {
await addArtifact({
@@ -1660,7 +1674,7 @@ export const sendWorkspaceMessage = async (
});
}
}
const diff = await getWorktreeDiff(workspace.repoDir, redact);
const diff = await getWorktreeDiff(workspace.repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
@@ -1669,11 +1683,11 @@ export const sendWorkspaceMessage = async (
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);
} 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,
@@ -271,7 +271,8 @@ describe('agent event normalization', () => {
externalRequestId: 'perm-1',
title: 'Permission requested',
body: 'Run bun test?',
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
metadata:
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
});
expect(
@@ -1,4 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
@@ -3,7 +3,6 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
type TestWorkspace = {
@@ -53,7 +52,9 @@ const writeConfig = async (
config: Record<string, unknown> | string,
) => {
const content =
typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`;
typeof config === 'string'
? config
: `${JSON.stringify(config, null, 2)}\n`;
await writeFile(configPath(workspace), content);
};