Fix agent empty-response in prod: workdir mount, image freshness, error surfacing

- Pin codex@0.142.0 + opencode-ai@1.17.9 in the job image (was @latest,
  causing dev/prod drift)
- Worker now s the job image once per process so prod stops
  running a stale Codex
- Surface Codex error/turn.failed events instead of swallowing them, so the
  real failure reason is reported rather than 'no assistant response'
- Harden the Codex JSON parser to also handle the legacy msg-wrapped shape
- Fix the docker-in-docker workdir: bind-mount identical host:container path
  and set SPOON_AGENT_HOST_WORKDIR (named volume can't be mounted by sibling
  job containers)
- Add docs/compose.prod.yml as a documented reference deployment
This commit is contained in:
Gabriel Brown
2026-06-24 05:38:35 -04:00
parent 980a2c07e8
commit 9643cb197b
8 changed files with 315 additions and 8 deletions
+28 -1
View File
@@ -111,6 +111,10 @@ type ActiveWorkspace = {
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 = {
@@ -599,6 +603,11 @@ const handleAgentEvent = async (args: {
);
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));
};
@@ -683,6 +692,12 @@ const workspaceCurrentContent = new Map<
}
>();
// 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;
@@ -691,6 +706,7 @@ const runCodexTurn = async (args: {
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspace.runtimeMode = 'codex_exec';
workspace.codexTurnError = undefined;
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'codex_exec',
@@ -813,6 +829,15 @@ const runCodexTurn = async (args: {
);
}
}
// 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: {
@@ -1593,7 +1618,9 @@ export const sendWorkspaceMessage = async (
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
);
throw new Error(
'Codex completed without producing an assistant response.',
workspace.codexTurnError
? `Codex failed: ${workspace.codexTurnError}`
: 'Codex completed without producing an assistant response.',
);
}
await updateMessage({