From 0bc012ac2fe33a269958917ad85bbc91a1f89cf3 Mon Sep 17 00:00:00 2001 From: Wouter Hermans Date: Mon, 10 Aug 2026 11:56:12 +0200 Subject: [PATCH] fix(codex-implementer): stop lanes colliding in /tmp and stalling forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all observed repeatedly across parallel lanes on macOS. 1. `mktemp -t codex-spec.XXXXXX` does not do what it looks like on macOS. BSD mktemp treats -t's argument as a PREFIX, not a template, so the literal XXXXXX survives into the filename: /var/folders/.../codex-spec.XXXXXX.TJEjQBKZt2 Every lane on the host therefore produces a path matching the same `codex-spec.XXXXXX.*` glob. That is what makes cross-lane pickup possible — one lane recovered its spec path by globbing and executed a *different, concurrently running* lane's spec. The block sits directly under the words "never a fixed path (parallel lanes on fixed paths corrupt each other)", which is the right intent; the code just did not deliver it. Passing an explicit template as an operand substitutes the Xs on BSD and GNU alike. Switch to one private scratch dir per lane and put spec/final inside it. 2. Document how to carry the path across tool calls. `ls /tmp/codex-spec.* | tail -1` sorts by random suffix, not mtime. Parking it in a fixed sidecar (/tmp/.codex_spec_path) is worse — concurrent lanes clobber that deterministically rather than occasionally. 3. Lanes background `codex exec`, then end the turn "waiting for the completion notification". Nothing delivers that notification to a subagent, so the lane waits forever: 60-100k tokens across dozens of tool calls, empty working tree, no report, while the caller believes work is in flight. Relatedly, a 600 s shell wrapper is meaningless when the Bash tool call itself defaults to 120 s — the tool kills the command long before the documented cap applies. State both plainly: foreground the call, set the tool timeout to match, and never end a turn with codex still running. Verified: the new mktemp form yields distinct dirs on BSD mktemp with the Xs substituted, and two simulated concurrent lanes keep their own specs. Co-Authored-By: Claude Opus 5 (1M context) --- agents/codex-implementer.md | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/agents/codex-implementer.md b/agents/codex-implementer.md index 8782e9e..8cc4584 100644 --- a/agents/codex-implementer.md +++ b/agents/codex-implementer.md @@ -38,8 +38,15 @@ The prompt you receive should contain the standard five-part spec: **objective, 1. Write the spec to a unique prompt file — never inline shell quoting, never a fixed path (parallel lanes on fixed paths corrupt each other): ```bash -SPEC=$(mktemp -t codex-spec.XXXXXX) -FINAL=$(mktemp -t codex-final.XXXXXX) +# One private scratch dir per lane; every temp file lives inside it. +# NOTE: `mktemp -t codex-spec.XXXXXX` is wrong on macOS — BSD mktemp treats -t's +# argument as a PREFIX, not a template, so the literal XXXXXX survives into the +# name (codex-spec.XXXXXX.a1B2c3). Every lane then produces a path matching the +# same `codex-spec.XXXXXX.*` glob, which is what makes cross-lane pickup possible. +# Passing an explicit template as an operand substitutes the Xs on BSD and GNU alike. +TMP="${TMPDIR:-/tmp}"; LANE=$(mktemp -d "${TMP%/}/codex-lane.XXXXXX") +SPEC="$LANE/spec.md" +FINAL="$LANE/final.txt" cat > "$SPEC" << 'SPEC_EOF' This task runs in a dedicated implementation lane on the model and reasoning @@ -55,6 +62,15 @@ and include its actual output in your final message."] SPEC_EOF ``` +**Keep `$LANE` in one Bash call, and never re-derive it.** `$SPEC`/`$FINAL` only exist as +shell variables for the life of a single tool call. If you write the spec in one call and +invoke codex in another, do **not** recover the path with `ls /tmp/codex-spec.* | tail -1`: +that sorts by random suffix rather than mtime, so under concurrency it happily hands you a +*different lane's* spec and you implement someone else's task. Do not park the path in a +fixed sidecar file (`/tmp/.codex_spec_path`) either — two lanes overwrite that +deterministically, not just occasionally. Either do steps 1 and 2 in the same call, or echo +`$LANE` and copy the literal path forward. Delete the dir when you are done. + **Why the preamble is there.** `codex exec` loads the user's `~/.codex/AGENTS.md` on every invocation, and a rule written for one project governs every lane on the machine. If such a rule pins a specific model/effort or mandates an orchestration flow, codex will — correctly — @@ -66,7 +82,26 @@ only, and never overrides their other content. Observed live 2026-08-04. This is belt-and-braces, not a substitute for step 3 — the empty diff is what actually catches a refusal, whatever caused it. -2. Invoke codex non-interactively, sandboxed to the workspace, with reasoning effort pinned max: +2. Invoke codex non-interactively, sandboxed to the workspace, with reasoning effort pinned max. + +**Run codex in the foreground, and set your Bash tool's own timeout to cover it.** Two +failure modes, both observed repeatedly: + +- **Never background this call.** A lane that launches `codex exec` as a background task and + then ends its turn "waiting for the completion notification" waits forever — nothing is + wired to deliver that notification to a subagent. The symptom is a lane burning 60–100k + tokens across dozens of tool calls and returning with an empty working tree and no report, + while the caller assumes work is underway. If you catch yourself about to end a turn + without codex's exit status in hand, you have already failed: run it in the foreground. +- **The shell timeout is a lie unless the tool timeout matches it.** Wrapping codex in a + shell cap does nothing if your Bash tool call defaults to 120 s — the tool kills the + command first and the documented cap never applies. Set the tool-call timeout explicitly + to its 600000 ms maximum, and keep the shell cap **strictly below** it (540 s). Equal + values are a race: if the tool wins you lose the graceful `STATUS: timeout` report, + because only the shell timeout leaves codex's partial work legible. + +If codex exits non-zero, dies without writing `"$FINAL"`, or leaves an empty diff, that is a +result to report — not a reason to retry silently or to wait. ```bash # Portable timeout: macOS has no `timeout` unless coreutils is installed @@ -112,6 +147,7 @@ GAPS: [spec ambiguities, unfinished items, or "none"] ## Rules - One codex invocation per task unless the caller explicitly decomposed it. +- **Never end your turn with a codex process still running.** Foreground the call, collect its exit status, and report. "Waiting for a background notification" is a stall, not a state. - Never claim completion without re-running the verification yourself. "Codex said it works" is forbidden as evidence. - **An empty diff is never `complete`.** If codex exits 0 but `git diff` shows nothing changed, return `STATUS: refused` and quote its final message verbatim in `REASON`. A clean exit code is not evidence that work happened. - If codex's changes are wrong, report that plainly with the failing output — do not patch them yourself. Fix decisions belong to the caller.