Update stuff
Build and Push Spoon Images / quality (push) Successful in 2m28s
Build and Push Spoon Images / build-images (push) Successful in 9m53s

This commit is contained in:
Gabriel Brown
2026-06-23 22:27:23 -04:00
parent 4fee7bf50d
commit 980a2c07e8
10 changed files with 136 additions and 24 deletions
+2
View File
@@ -53,6 +53,8 @@ jobs:
printf '%s\n' "$DOTENV_PROD" > "$env_file" printf '%s\n' "$DOTENV_PROD" > "$env_file"
CI_ENV_FILE="$env_file" ./scripts/build-next-app production CI_ENV_FILE="$env_file" ./scripts/build-next-app production
- name: Build agent images - name: Build agent images
env:
SPOON_BUILD_SHA: ${{ gitea.sha }}
run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
- name: Tag and push images - name: Tag and push images
run: | run: |
+4
View File
@@ -61,6 +61,10 @@
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env - Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`, loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
and expects `spoon-agent-job:latest` to exist locally. and expects `spoon-agent-job:latest` to exist locally.
- Containerized production workers that control the host Docker socket must set
`SPOON_AGENT_HOST_WORKDIR` to the host-side path backing
`SPOON_AGENT_WORKDIR`. Docker bind mount source paths are resolved on the host,
not inside the worker container.
- `bun smoke:agent-container` checks that the local job image has Node, npm, - `bun smoke:agent-container` checks that the local job image has Node, npm,
Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available. Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned - Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
+2 -1
View File
@@ -475,7 +475,7 @@ not call Infisical.
<summary><strong>Convex, storage, and runtime</strong></summary> <summary><strong>Convex, storage, and runtime</strong></summary>
| Variable | Used for | | Variable | Used for |
| ----------------------------------- | ----------------------------------------------- | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL | | `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex | | `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin | | `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
@@ -494,6 +494,7 @@ not call Infisical.
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit | | `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout | | `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory | | `SPOON_AGENT_WORKDIR` | Worker work directory |
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
| `SPOON_AGENT_NETWORK` | Optional job container network | | `SPOON_AGENT_NETWORK` | Optional job container network |
</details> </details>
+3
View File
@@ -12,6 +12,8 @@ const requiredEnv = (name: string) => {
}; };
export const env = { export const env = {
buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development',
buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown',
convexUrl: convexUrl:
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ?? process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
process.env.CONVEX_SELF_HOSTED_URL?.trim() ?? process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
@@ -31,6 +33,7 @@ export const env = {
jobImage: jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest', process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work', workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
network: process.env.SPOON_AGENT_NETWORK?.trim(), network: process.env.SPOON_AGENT_NETWORK?.trim(),
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000), pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921), httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
+15 -2
View File
@@ -1,4 +1,5 @@
import { execa } from 'execa'; import { execa } from 'execa';
import path from 'node:path';
import { env } from '../env'; import { env } from '../env';
@@ -17,13 +18,25 @@ const networkArgs = () => (env.network ? ['--network', env.network] : []);
const containerRuntime = () => env.containerRuntime; const containerRuntime = () => env.containerRuntime;
const hostWorkspacePath = (workdir: string) => {
if (!env.hostWorkdir) return workdir;
const workerRoot = path.resolve(env.workdir);
const resolvedWorkdir = path.resolve(workdir);
const relative = path.relative(workerRoot, resolvedWorkdir);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return workdir;
}
return path.join(env.hostWorkdir, relative);
};
export const jobWorkspaceVolumeSpec = (workdir: string) => { export const jobWorkspaceVolumeSpec = (workdir: string) => {
const volumeOptions = const volumeOptions =
env.containerVolumeOptions ?? env.containerVolumeOptions ??
(containerRuntime().endsWith('podman') ? 'Z' : undefined); (containerRuntime().endsWith('podman') ? 'Z' : undefined);
const source = hostWorkspacePath(workdir);
return volumeOptions return volumeOptions
? `${workdir}:/workspace:${volumeOptions}` ? `${source}:/workspace:${volumeOptions}`
: `${workdir}:/workspace`; : `${source}:/workspace`;
}; };
export const runInJobContainer = async (args: { export const runInJobContainer = async (args: {
+3
View File
@@ -167,6 +167,9 @@ export const startWorkerServer = () => {
sendJson(response, 404, { error: 'Not found' }); sendJson(response, 404, { error: 'Not found' });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
);
const status = const status =
message === 'Unauthorized' message === 'Unauthorized'
? 401 ? 401
+70
View File
@@ -696,6 +696,17 @@ const runCodexTurn = async (args: {
agentRuntimeMode: 'codex_exec', agentRuntimeMode: 'codex_exec',
codexSessionId: workspace.codexSessionId, 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(
codexContainerWorkspace,
'.codex',
outputFileName,
);
const command = workspace.codexSessionId const command = workspace.codexSessionId
? [ ? [
'codex', 'codex',
@@ -704,6 +715,8 @@ const runCodexTurn = async (args: {
'--json', '--json',
...codexModelArgs(workspace.claim), ...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
workspace.codexSessionId, workspace.codexSessionId,
prompt, prompt,
] ]
@@ -713,6 +726,8 @@ const runCodexTurn = async (args: {
'--json', '--json',
...codexModelArgs(workspace.claim), ...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
'--cd', '--cd',
codexContainerRepo, codexContainerRepo,
prompt, prompt,
@@ -756,9 +771,48 @@ const runCodexTurn = async (args: {
} }
}, },
}); });
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) { if (result.exitCode !== 0) {
throw new Error(`codex failed:\n${result.output}`); 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.`,
);
}
}
}; };
const runOpenCodeTurn = async (args: { const runOpenCodeTurn = async (args: {
@@ -1249,6 +1303,12 @@ const runClaim = async (claim: Claim) => {
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.', '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', 'Interactive workspace is ready.');
await appendEvent(
jobId,
'info',
'plan',
`Worker runtime ${env.workerId} build ${env.buildSha} (${env.buildCreatedAt}).`,
);
await sendWorkspaceMessage(jobId, systemPromptForJob(claim), { await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
recordUserMessage: false, recordUserMessage: false,
@@ -1488,6 +1548,9 @@ export const sendWorkspaceMessage = async (
assistantMessageId, assistantMessageId,
assistantContent, assistantContent,
}); });
console.log(
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
);
} else if (env.runtime === 'docker') { } else if (env.runtime === 'docker') {
await appendEvent( await appendEvent(
claim.job._id, claim.job._id,
@@ -1526,6 +1589,9 @@ export const sendWorkspaceMessage = async (
} }
if (isCodexLoginProfile(claim)) { if (isCodexLoginProfile(claim)) {
if (!assistantContent.value.trim()) { if (!assistantContent.value.trim()) {
console.error(
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
);
throw new Error( throw new Error(
'Codex completed without producing an assistant response.', 'Codex completed without producing an assistant response.',
); );
@@ -1570,6 +1636,7 @@ export const sendWorkspaceMessage = async (
workspace.resolveTurn?.(); workspace.resolveTurn?.();
workspace.resolveTurn = undefined; workspace.resolveTurn = undefined;
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`Agent turn failed for job ${claim.job._id}: ${message}`);
await appendEvent( await appendEvent(
claim.job._id, claim.job._id,
'error', 'error',
@@ -1692,6 +1759,8 @@ export const getWorkerHealth = async () => {
const containerNames = await listWorkspaceContainerNames('spoon-agent-job-'); const containerNames = await listWorkspaceContainerNames('spoon-agent-job-');
return { return {
ok: true, ok: true,
buildSha: env.buildSha,
buildCreatedAt: env.buildCreatedAt,
workerId: env.workerId, workerId: env.workerId,
convexUrl: env.convexUrl, convexUrl: env.convexUrl,
runtime: env.runtime, runtime: env.runtime,
@@ -1699,6 +1768,7 @@ export const getWorkerHealth = async () => {
containerAccess: env.containerAccess, containerAccess: env.containerAccess,
jobImage: env.jobImage, jobImage: env.jobImage,
workdir: env.workdir, workdir: env.workdir,
hostWorkdir: env.hostWorkdir,
network: env.network, network: env.network,
httpPort: env.httpPort, httpPort: env.httpPort,
maxConcurrentJobs: env.maxConcurrentJobs, maxConcurrentJobs: env.maxConcurrentJobs,
+6
View File
@@ -1,5 +1,11 @@
FROM docker.io/oven/bun:1.3.10 FROM docker.io/oven/bun:1.3.10
ARG SPOON_BUILD_SHA=development
ARG SPOON_BUILD_CREATED_AT=unknown
ENV SPOON_BUILD_SHA=${SPOON_BUILD_SHA}
ENV SPOON_BUILD_CREATED_AT=${SPOON_BUILD_CREATED_AT}
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
bash \ bash \
+8 -1
View File
@@ -3,6 +3,8 @@ set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}" RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
BUILD_SHA="${SPOON_BUILD_SHA:-$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)}"
BUILD_CREATED_AT="${SPOON_BUILD_CREATED_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
if [[ -z "$RUNTIME" ]]; then if [[ -z "$RUNTIME" ]]; then
if command -v podman >/dev/null 2>&1; then if command -v podman >/dev/null 2>&1; then
@@ -15,5 +17,10 @@ if [[ -z "$RUNTIME" ]]; then
fi fi
fi fi
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR" "$RUNTIME" build \
--build-arg "SPOON_BUILD_SHA=$BUILD_SHA" \
--build-arg "SPOON_BUILD_CREATED_AT=$BUILD_CREATED_AT" \
-f "$ROOT_DIR/docker/agent-worker.Dockerfile" \
-t spoon-agent-worker:latest \
"$ROOT_DIR"
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR" "$RUNTIME" build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
+3
View File
@@ -48,11 +48,14 @@
"SPOON_AGENT_MAX_CONCURRENT_JOBS", "SPOON_AGENT_MAX_CONCURRENT_JOBS",
"SPOON_AGENT_JOB_TIMEOUT_MS", "SPOON_AGENT_JOB_TIMEOUT_MS",
"SPOON_AGENT_WORKDIR", "SPOON_AGENT_WORKDIR",
"SPOON_AGENT_HOST_WORKDIR",
"SPOON_AGENT_NETWORK", "SPOON_AGENT_NETWORK",
"SPOON_AGENT_POLL_MS", "SPOON_AGENT_POLL_MS",
"SPOON_AGENT_WORKER_URL", "SPOON_AGENT_WORKER_URL",
"SPOON_AGENT_WORKER_HTTP_PORT", "SPOON_AGENT_WORKER_HTTP_PORT",
"SPOON_AGENT_WORKER_INTERNAL_TOKEN", "SPOON_AGENT_WORKER_INTERNAL_TOKEN",
"SPOON_BUILD_SHA",
"SPOON_BUILD_CREATED_AT",
"SKIP_E2E", "SKIP_E2E",
"BASE_URL", "BASE_URL",
"NETWORK", "NETWORK",