From 980a2c07e84f837c23fb49527667a13c74d529e0 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 23 Jun 2026 22:27:23 -0400 Subject: [PATCH] Update stuff --- .gitea/workflows/build-next.yml | 2 + AGENTS.md | 4 ++ README.md | 43 +++++++-------- apps/agent-worker/src/env.ts | 3 ++ apps/agent-worker/src/runtime/docker.ts | 17 +++++- apps/agent-worker/src/server.ts | 3 ++ apps/agent-worker/src/worker.ts | 70 +++++++++++++++++++++++++ docker/agent-worker.Dockerfile | 6 +++ scripts/build-agent-images | 9 +++- turbo.json | 3 ++ 10 files changed, 136 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/build-next.yml b/.gitea/workflows/build-next.yml index 43acbf9..20dbcfc 100644 --- a/.gitea/workflows/build-next.yml +++ b/.gitea/workflows/build-next.yml @@ -53,6 +53,8 @@ jobs: printf '%s\n' "$DOTENV_PROD" > "$env_file" CI_ENV_FILE="$env_file" ./scripts/build-next-app production - name: Build agent images + env: + SPOON_BUILD_SHA: ${{ gitea.sha }} run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images - name: Tag and push images run: | diff --git a/AGENTS.md b/AGENTS.md index 98c173e..81ff9ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,10 @@ - Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`, 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, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available. - Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned diff --git a/README.md b/README.md index aa247b5..adbf62a 100644 --- a/README.md +++ b/README.md @@ -474,27 +474,28 @@ not call Infisical.
Convex, storage, and runtime -| Variable | Used for | -| ----------------------------------- | ----------------------------------------------- | -| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL | -| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex | -| `CONVEX_CLOUD_ORIGIN` | Convex backend origin | -| `CONVEX_SITE_ORIGIN` | Convex site-function origin | -| `CONVEX_SITE_URL` | Site URL seen by Convex Auth | -| `POSTGRES_URL` | Convex storage database URL | -| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth | -| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations | -| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next | -| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port | -| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy | -| `SPOON_AGENT_JOB_IMAGE` | Agent job container image | -| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented | -| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` | -| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev | -| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit | -| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout | -| `SPOON_AGENT_WORKDIR` | Worker work directory | -| `SPOON_AGENT_NETWORK` | Optional job container network | +| Variable | Used for | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL | +| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex | +| `CONVEX_CLOUD_ORIGIN` | Convex backend origin | +| `CONVEX_SITE_ORIGIN` | Convex site-function origin | +| `CONVEX_SITE_URL` | Site URL seen by Convex Auth | +| `POSTGRES_URL` | Convex storage database URL | +| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth | +| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations | +| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next | +| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port | +| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy | +| `SPOON_AGENT_JOB_IMAGE` | Agent job container image | +| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented | +| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` | +| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev | +| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit | +| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout | +| `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 |
diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index 816b9fb..913b6f0 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -12,6 +12,8 @@ const requiredEnv = (name: string) => { }; export const env = { + buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development', + buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown', convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ?? process.env.CONVEX_SELF_HOSTED_URL?.trim() ?? @@ -31,6 +33,7 @@ export const env = { jobImage: process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest', 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(), pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000), httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921), diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index eb339ce..b4f9b14 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -1,4 +1,5 @@ import { execa } from 'execa'; +import path from 'node:path'; import { env } from '../env'; @@ -17,13 +18,25 @@ const networkArgs = () => (env.network ? ['--network', env.network] : []); 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) => { const volumeOptions = env.containerVolumeOptions ?? (containerRuntime().endsWith('podman') ? 'Z' : undefined); + const source = hostWorkspacePath(workdir); return volumeOptions - ? `${workdir}:/workspace:${volumeOptions}` - : `${workdir}:/workspace`; + ? `${source}:/workspace:${volumeOptions}` + : `${source}:/workspace`; }; export const runInJobContainer = async (args: { diff --git a/apps/agent-worker/src/server.ts b/apps/agent-worker/src/server.ts index 778a8f5..8ac6e0f 100644 --- a/apps/agent-worker/src/server.ts +++ b/apps/agent-worker/src/server.ts @@ -167,6 +167,9 @@ export const startWorkerServer = () => { sendJson(response, 404, { error: 'Not found' }); } 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 diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index ca2dc8f..b0d712c 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -696,6 +696,17 @@ const runCodexTurn = async (args: { 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( + codexContainerWorkspace, + '.codex', + outputFileName, + ); const command = workspace.codexSessionId ? [ 'codex', @@ -704,6 +715,8 @@ const runCodexTurn = async (args: { '--json', ...codexModelArgs(workspace.claim), '--dangerously-bypass-approvals-and-sandbox', + '--output-last-message', + outputFileContainerPath, workspace.codexSessionId, prompt, ] @@ -713,6 +726,8 @@ const runCodexTurn = async (args: { '--json', ...codexModelArgs(workspace.claim), '--dangerously-bypass-approvals-and-sandbox', + '--output-last-message', + outputFileContainerPath, '--cd', codexContainerRepo, 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) { 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: { @@ -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.', }); 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, @@ -1488,6 +1548,9 @@ export const sendWorkspaceMessage = async ( 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, @@ -1526,6 +1589,9 @@ export const sendWorkspaceMessage = async ( } if (isCodexLoginProfile(claim)) { if (!assistantContent.value.trim()) { + console.error( + `Codex completed without producing an assistant response for job ${claim.job._id}.`, + ); throw new Error( 'Codex completed without producing an assistant response.', ); @@ -1570,6 +1636,7 @@ export const sendWorkspaceMessage = async ( 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', @@ -1692,6 +1759,8 @@ export const getWorkerHealth = async () => { const containerNames = await listWorkspaceContainerNames('spoon-agent-job-'); return { ok: true, + buildSha: env.buildSha, + buildCreatedAt: env.buildCreatedAt, workerId: env.workerId, convexUrl: env.convexUrl, runtime: env.runtime, @@ -1699,6 +1768,7 @@ export const getWorkerHealth = async () => { containerAccess: env.containerAccess, jobImage: env.jobImage, workdir: env.workdir, + hostWorkdir: env.hostWorkdir, network: env.network, httpPort: env.httpPort, maxConcurrentJobs: env.maxConcurrentJobs, diff --git a/docker/agent-worker.Dockerfile b/docker/agent-worker.Dockerfile index e7c51e9..f5367aa 100644 --- a/docker/agent-worker.Dockerfile +++ b/docker/agent-worker.Dockerfile @@ -1,5 +1,11 @@ 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 \ && apt-get install -y --no-install-recommends \ bash \ diff --git a/scripts/build-agent-images b/scripts/build-agent-images index 1c726a8..6453fb2 100755 --- a/scripts/build-agent-images +++ b/scripts/build-agent-images @@ -3,6 +3,8 @@ set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" 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 command -v podman >/dev/null 2>&1; then @@ -15,5 +17,10 @@ if [[ -z "$RUNTIME" ]]; then 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" diff --git a/turbo.json b/turbo.json index 447028e..63eaf4c 100644 --- a/turbo.json +++ b/turbo.json @@ -48,11 +48,14 @@ "SPOON_AGENT_MAX_CONCURRENT_JOBS", "SPOON_AGENT_JOB_TIMEOUT_MS", "SPOON_AGENT_WORKDIR", + "SPOON_AGENT_HOST_WORKDIR", "SPOON_AGENT_NETWORK", "SPOON_AGENT_POLL_MS", "SPOON_AGENT_WORKER_URL", "SPOON_AGENT_WORKER_HTTP_PORT", "SPOON_AGENT_WORKER_INTERNAL_TOKEN", + "SPOON_BUILD_SHA", + "SPOON_BUILD_CREATED_AT", "SKIP_E2E", "BASE_URL", "NETWORK",