fix(skill): pipe-форма и rollout-UUID fallback для переносимости
- Зачем: - форма `codex exec ... - < file` exit=1 с пустым stderr, а `--json` stdout оказывается пустым в части Claude Code песочниц; без обхода скилл не может захватить session ID и теряет resume на раундах 2-5. - Что: - SKILL.md Step 4/7 переведены на `cat file | codex exec ... -` как canonical; добавлен two-tier захват session ID (primary = first JSONL line, secondary = UUID из имени `~/.codex/sessions/**/rollout-*.jsonl` с mtime > CODEX_SESSIONS_BEFORE). - README.md обновлены permissions (pipe-форма, `find rollout-*`, `date +%s`) и добавлен troubleshooting про env-specific пустой JSONL. - docs/DESIGN.md: §2.1 описывает обе формы, §2.2 фиксирует env-specific suppression, §2.3 добавляет filesystem-recovery путь, §4.1 переделан на two-tier decision, добавлены §4.13 (canonical pipe) и §6.6 (lesson от 2026-04-17), в §8 новая строка с окружением yantar-k8s. - Проверка: - DESIGN.md §7.1 smoke test (обновлён на pipe-форму + проверка filesystem secondary path). - Оба окружения (reference WSL2 + containerized sandbox) должны давать валидный session ID и успешный resume.
This commit is contained in:
+194
-30
@@ -145,14 +145,30 @@ codex exec [OPTIONS] [PROMPT]
|
||||
codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]
|
||||
```
|
||||
|
||||
Both accept PROMPT either as a trailing argument or as stdin (pass `-`
|
||||
as the PROMPT argument, then redirect from a file). The skill uses the
|
||||
stdin form to avoid shell quoting issues on long XML prompts:
|
||||
Both accept PROMPT either as a trailing argument or as stdin. Two stdin
|
||||
shapes are accepted by codex itself:
|
||||
|
||||
```bash
|
||||
# A. stdin redirect from file
|
||||
codex exec ... - < /tmp/codex-prompt-*.md
|
||||
|
||||
# B. pipe through cat
|
||||
cat /tmp/codex-prompt-*.md | codex exec ... -
|
||||
```
|
||||
|
||||
**Both shapes work in some environments; only (B) works reliably in all
|
||||
observed Claude Code sandboxes.** In at least one sandbox configuration,
|
||||
form (A) exits 1 with empty stderr (no codex error message) while form
|
||||
(B) produces an identical `-o` review file. We do not have a root-cause
|
||||
diagnosis for (A)'s failure — it is consistent across codex 0.120.0 and
|
||||
0.121.0 in the affected environment, so it is not a codex version issue.
|
||||
See `§6.6` for the observation log.
|
||||
|
||||
The skill therefore uses form (B) as canonical (`§4.1` captures the
|
||||
decision). Long XML prompts still need file delivery to avoid shell
|
||||
quoting issues, so the file is written via the Write tool and fed
|
||||
through `cat | ... -` rather than embedded as a command-line argument.
|
||||
|
||||
### §2.2. Output streams
|
||||
|
||||
Two output modes, with different stream semantics:
|
||||
@@ -190,6 +206,16 @@ head -1 /tmp/a.out # expect {"type":"thread.started",...}
|
||||
wc -c /tmp/a.err # expect 0
|
||||
```
|
||||
|
||||
**Environment-specific suppression.** In at least one observed Claude
|
||||
Code sandbox, `--json` stdout is empty (0 bytes) when redirected to a
|
||||
file, even though the `-o` path is populated correctly and the process
|
||||
exits 0. The `-o` file has the review, stderr is empty, only stdout
|
||||
JSONL is missing. This is reproducible across codex 0.120.0 and 0.121.0
|
||||
in that environment and not reproducible in the reference environment.
|
||||
Root cause is not diagnosed; see `§6.6` for the observation log. The
|
||||
skill handles it with a secondary filesystem-based session-id capture
|
||||
path (`§4.1b`) so resume still works.
|
||||
|
||||
**`-o FILE` flag:**
|
||||
|
||||
Writes the final agent message to FILE as plain text, *regardless* of
|
||||
@@ -211,6 +237,24 @@ The UUID is the session / thread id and is accepted verbatim by
|
||||
`--ephemeral` disables persistence. Not used by the skill — resume
|
||||
requires persistence.
|
||||
|
||||
**Session-id recovery from the filesystem.** Because the UUID is a
|
||||
deterministic suffix of the filename, session id can be recovered from
|
||||
disk after the fact, independent of whether `--json` emitted the
|
||||
`thread.started` event to stdout. The skill uses this as a secondary
|
||||
capture path (`§4.1b`) when stdout is empty.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
BEFORE=$(date +%s)
|
||||
echo "respond PONG" | codex exec -m gpt-5.4 -s read-only \
|
||||
--skip-git-repo-check -o /tmp/x.md - >/dev/null 2>&1
|
||||
find ~/.codex/sessions -name 'rollout-*.jsonl' -newermt "@${BEFORE}" \
|
||||
| sort | tail -1 | xargs -n1 basename \
|
||||
| grep -oE '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}'
|
||||
# expect: a UUID, and that UUID accepted by `codex exec resume`
|
||||
```
|
||||
|
||||
### §2.4. Resume semantics
|
||||
|
||||
```
|
||||
@@ -372,35 +416,67 @@ Each decision below follows the same template:
|
||||
- **Chosen because** — the load-bearing argument.
|
||||
- **Trade-offs accepted** — what we gave up.
|
||||
|
||||
### §4.1. Use `--json` for session ID capture
|
||||
### §4.1. Two-tier session ID capture (`--json` primary, rollout filename secondary)
|
||||
|
||||
- **Decision.** Every `codex exec` and `codex exec resume` invocation
|
||||
uses `--json` and redirects stdout to `/tmp/codex-stdout-${REVIEW_ID}.jsonl`.
|
||||
Session ID is parsed from the first JSONL event's `thread_id` field.
|
||||
- **Where in SKILL.md.** Step 4 (launch), Step 7 (resume).
|
||||
- **Context.** Earlier skill versions parsed `session id:` from
|
||||
stderr. That line was reliable on the Codex side but unreliable on
|
||||
the Claude side because the Bash tool truncates output at ~30 KB,
|
||||
retaining the tail. Long reasoning pushed the early metadata block
|
||||
(including the session id) out of the retained window on high-effort
|
||||
reviews.
|
||||
uses `--json` with stdout redirected to
|
||||
`/tmp/codex-stdout-${REVIEW_ID}.jsonl`. Session ID capture tries
|
||||
**primary first, then secondary**:
|
||||
- **Primary** (`§4.1a`): parse `thread_id` from the first line of
|
||||
JSONL stdout.
|
||||
- **Secondary** (`§4.1b`): if stdout is empty, extract the UUID from
|
||||
the filename of the newest `~/.codex/sessions/**/rollout-*.jsonl`
|
||||
with `mtime > CODEX_SESSIONS_BEFORE` (a timestamp captured
|
||||
immediately before the exec).
|
||||
- **Where in SKILL.md.** Step 4 (launch), Step 7 (resume). Both tiers
|
||||
live inline in each Step.
|
||||
- **Context.** The primary path covers the reference environment
|
||||
cleanly (JSONL events reliably land in the redirected file). In at
|
||||
least one Claude Code sandbox, the JSONL stdout is suppressed (0
|
||||
bytes) even on exit 0 and a populated `-o` review file (`§2.2`,
|
||||
`§6.6`). Without a secondary path, the skill cannot resume in that
|
||||
environment — every round would need a fresh `codex exec`, wasting
|
||||
tokens on project re-reads. The filesystem path was previously
|
||||
rejected (see "Alternatives considered" below) but the rejection
|
||||
only applies when it is the *primary* capture; as a *secondary*
|
||||
fallback its failure modes are acceptable.
|
||||
- **Alternatives considered.**
|
||||
- *Keep parsing stderr.* Rejected: same truncation problem.
|
||||
- *Keep parsing `session id:` from stderr.* Rejected: Bash tool
|
||||
truncates output at ~30 KB from the head (§3.1); long reasoning
|
||||
traces pushed the session-id line out of the retained window.
|
||||
(Historical reason for moving to `--json` in the first place.)
|
||||
- *Redirect stderr to a file, Read via Read tool.* Rejected: an
|
||||
earlier skill version did exactly this and a diagnostic dump still
|
||||
reported empty stderr files, suggesting a fragility we did not
|
||||
want to investigate further. Worse, stderr's session-id line is
|
||||
not in a stable absolute position.
|
||||
- *Extract UUID from the latest `~/.codex/sessions/**/rollout-*.jsonl`
|
||||
filename.* Rejected: fragile against parallel codex invocations;
|
||||
filesystem race.
|
||||
- **Chosen because.** In JSON mode the `thread_id` is in the *first
|
||||
line* of stdout, at an offset of <100 bytes from the file start. Read
|
||||
tool retrieves it independent of Bash truncation. The format is
|
||||
documented and enforced by Codex.
|
||||
- **Trade-offs accepted.** Human-readable review is no longer in stdout
|
||||
(it went to `-o` only). That is fine for the skill — in fact it is
|
||||
*load-bearing* for the show-review gate (§4.9).
|
||||
reported empty stderr files. §3.2 says Read bypasses Bash
|
||||
truncation, so this might have been viable — but since `§4.1a`
|
||||
covers the reference environment and `§4.1b` covers the sandboxed
|
||||
one, adding a third path is not worth the complexity.
|
||||
- *Use only the filesystem path as the single source.* Rejected:
|
||||
relies on a filesystem race window against any parallel codex
|
||||
invocation in the same second. As a secondary (only consulted
|
||||
when stdout is empty) the race is rare, and when it does hit,
|
||||
one of the Step 7 post-resume checks (`§4.8`) catches the wrong
|
||||
session and routes to the standard fallback (`§4.11`).
|
||||
- *Drop `--json` entirely and use plain-text stdout.* Rejected:
|
||||
`--json` makes stdout machine-readable only, which is *load-
|
||||
bearing* for the show-review gate (`§4.9`). Plain-text stdout
|
||||
would re-expose the "Opus sees the review in Bash result, skips
|
||||
the user-visible show step" failure mode.
|
||||
- **Chosen because.** Two-tier keeps primary cheap and documented on
|
||||
the Codex side (the `thread.started` event is in the CLI contract),
|
||||
while the secondary isolates the skill from env-specific stdout
|
||||
quirks we cannot control (`§6.6`). Neither tier alone covers both
|
||||
observed environments; together they do.
|
||||
- **Trade-offs accepted.**
|
||||
- Human-readable review is no longer in stdout (it went to `-o`
|
||||
only) — load-bearing for `§4.9`.
|
||||
- Secondary path introduces a filesystem race against parallel
|
||||
codex invocations (§9.1 scope). Mitigated by the pre-exec
|
||||
timestamp (`CODEX_SESSIONS_BEFORE`) narrowing the window to
|
||||
"files created between the two timestamps" rather than "newest
|
||||
anywhere".
|
||||
- Extra permission surface: `Bash(find ...)` is now in the
|
||||
recommended permissions list.
|
||||
|
||||
### §4.2. Capture `REPO_ROOT` at Step 2, substitute literally
|
||||
|
||||
@@ -654,6 +730,36 @@ Each decision below follows the same template:
|
||||
rounds, retry budget is inconsistently available. Rules section of
|
||||
`SKILL.md` states this explicitly.
|
||||
|
||||
### §4.13. Canonical prompt delivery via `cat file | codex exec -`
|
||||
|
||||
- **Decision.** The skill feeds prompts to `codex exec` via
|
||||
`cat /tmp/codex-prompt-*.md | timeout 600 codex exec ... -` instead
|
||||
of `codex exec ... - < /tmp/codex-prompt-*.md`.
|
||||
- **Where in SKILL.md.** Step 4 (launch), Step 7 (resume), Step 7
|
||||
fresh-exec fallback.
|
||||
- **Context.** Both shapes are accepted by codex itself (`§2.1`). In
|
||||
the reference environment they are interchangeable. In at least one
|
||||
Claude Code sandbox, the `- < file` form exits 1 with empty stderr
|
||||
— codex never actually runs, and without a stderr error line the
|
||||
skill has nothing to diagnose. The `cat | pipe` form is unaffected
|
||||
in the same sandbox and produces identical `-o` output.
|
||||
- **Alternatives considered.**
|
||||
- *Keep `- < file` as canonical.* Rejected: verified-broken in one
|
||||
target environment.
|
||||
- *Branch by environment (detect sandbox, switch form).* Rejected:
|
||||
over-complicated for a Pareto case. `cat | pipe` is universal.
|
||||
- *Use the trailing-argument form (`codex exec ... "$(cat file)"`).*
|
||||
Rejected: shell quoting on long XML prompts is the exact problem
|
||||
file delivery solves.
|
||||
- **Chosen because.** `cat | pipe` works in every observed environment
|
||||
at no extra cost. The only visible artifact is in the permission
|
||||
rule (`Bash(cat /tmp/codex-prompt-* | timeout 600 codex exec *)`)
|
||||
which is still prefix-matchable.
|
||||
- **Trade-offs accepted.** One extra process (`cat`) per codex launch
|
||||
— negligible. The pipeline exit code semantics are `$?` = last
|
||||
command (codex), which matches what the skill already checks; no
|
||||
`pipefail` needed.
|
||||
|
||||
---
|
||||
|
||||
## §5. Rejected ideas
|
||||
@@ -781,6 +887,56 @@ decision is built on top of it. This document's `§2` and `§7` are
|
||||
structured so future contributors can replicate the verification in
|
||||
minutes, not hours.
|
||||
|
||||
### §6.6. 2026-04-17: Environment-specific stdout suppression, not a version bug
|
||||
|
||||
**Claim trajectory.** An agent running in a different Claude Code
|
||||
sandbox reported three issues with the skill:
|
||||
|
||||
1. `codex exec ... - < /tmp/prompt.md` exits 1 with empty stderr.
|
||||
2. `cat file | codex exec --json ... -` exits 0 with review file OK
|
||||
but `/tmp/codex-stdout-*.jsonl` empty (0 bytes).
|
||||
3. Same symptoms under `timeout 120 bash -c '...'` wrapper.
|
||||
|
||||
Initial hypothesis: codex version bug. The agent was on 0.120.0,
|
||||
reference environment on 0.121.0. Upgrading the agent to 0.121.0 left
|
||||
all three symptoms unchanged. Therefore: not a codex version issue.
|
||||
|
||||
**Reality.** In the reference environment (WSL2, 0.121.0) all three
|
||||
shapes produce non-empty JSONL stdout and EXIT=0. In the agent's
|
||||
environment (containerized, 0.121.0) they do not. Same codex binary
|
||||
version, different outputs. The `codex --version` command itself
|
||||
prints to stdout in the reference env but not in the agent's env —
|
||||
further evidence of environment-level stdout interception or
|
||||
suppression rather than codex misbehavior.
|
||||
|
||||
**Root cause.** Not diagnosed. Plausible hypotheses: Node.js stdout
|
||||
buffering interaction with the sandbox's process-wrapping (short
|
||||
writes lost on exit), or a sandbox-level stdout tee that discards
|
||||
output, or a libuv/file-descriptor interaction specific to the
|
||||
container environment. None confirmed.
|
||||
|
||||
**Mitigation.** The skill cannot control the environment, so the
|
||||
skill adapted: the filesystem-based session-id recovery path (`§4.1b`)
|
||||
was promoted from "rejected alternative" to "secondary capture".
|
||||
Both tiers coexist — primary for reference-env performance, secondary
|
||||
for affected-env correctness. The `cat | pipe` form (`§4.13`) was
|
||||
chosen as canonical prompt delivery because it works in both envs
|
||||
while `- < file` fails in the affected one.
|
||||
|
||||
**Impact on the design.** The §4.1 rejection of filesystem-UUID
|
||||
parsing was reread: it was correct as a reason to reject *primary*
|
||||
reliance on it (race hazards against parallel codex), but not as a
|
||||
reason to reject it as *secondary*. Re-reading old rejections with a
|
||||
specific role in mind is sometimes more useful than re-verifying
|
||||
claims.
|
||||
|
||||
**Lesson (augmenting §6.5).** When a single-source report reproduces
|
||||
after verification, the follow-up question is still "what exactly did
|
||||
I verify?" The first verification (running §7.1 in the reference
|
||||
environment) proved the skill contract worked — in that environment.
|
||||
It did not prove the contract worked universally. Contract verification
|
||||
is env-specific until demonstrated otherwise.
|
||||
|
||||
---
|
||||
|
||||
## §7. Smoke test protocol
|
||||
@@ -797,6 +953,7 @@ the repo root. Expected outputs are in comments.
|
||||
```bash
|
||||
REVIEW_ID=$(date +%s)-$(printf '%08d' $RANDOM)
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
CODEX_SESSIONS_BEFORE=$(date +%s)
|
||||
cat > /tmp/codex-prompt-${REVIEW_ID}.md <<'EOF'
|
||||
<role>
|
||||
You are a senior adversarial reviewer of implementation plans.
|
||||
@@ -809,18 +966,24 @@ End the LAST line with exactly: VERDICT: APPROVED
|
||||
</output_format>
|
||||
EOF
|
||||
|
||||
timeout 300 codex exec --json \
|
||||
cat /tmp/codex-prompt-${REVIEW_ID}.md | timeout 300 codex exec --json \
|
||||
-m gpt-5.4 -c model_reasoning_effort=low \
|
||||
-s read-only -C "${REPO_ROOT}" \
|
||||
-o /tmp/codex-review-${REVIEW_ID}.md \
|
||||
- < /tmp/codex-prompt-${REVIEW_ID}.md \
|
||||
- \
|
||||
> /tmp/codex-stdout-${REVIEW_ID}.jsonl \
|
||||
2>/tmp/codex-stderr-${REVIEW_ID}.txt
|
||||
|
||||
echo "EXIT=$?" # expect 0
|
||||
head -1 /tmp/codex-stdout-${REVIEW_ID}.jsonl # expect {"type":"thread.started","thread_id":"..."}
|
||||
head -1 /tmp/codex-stdout-${REVIEW_ID}.jsonl # reference env: thread.started; affected env: empty
|
||||
wc -c /tmp/codex-stderr-${REVIEW_ID}.txt # expect 0
|
||||
grep -E '^VERDICT:' /tmp/codex-review-${REVIEW_ID}.md # expect VERDICT: APPROVED
|
||||
|
||||
# Verify the filesystem secondary path also works (§4.1b)
|
||||
find ~/.codex/sessions -name 'rollout-*.jsonl' -newermt "@${CODEX_SESSIONS_BEFORE}" \
|
||||
| sort | tail -1 | xargs -r -n1 basename \
|
||||
| grep -oE '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}'
|
||||
# expect: one UUID. If head -1 stdout had thread_id, this UUID should match.
|
||||
```
|
||||
|
||||
### §7.2. Resume with cd prefix
|
||||
@@ -932,6 +1095,7 @@ If §7.1–§7.5 do not produce the expected outputs:
|
||||
| Date | Codex CLI | Claude Code | Verifier | Notes |
|
||||
|------|-----------|-------------|----------|-------|
|
||||
| 2026-04-17 | 0.121.0 | current at time of refactor | initial author | All §2 facts verified; §7 smoke test passes end to end. Initial commit of this document. |
|
||||
| 2026-04-17 | 0.121.0 | containerized sandbox (yantar-k8s) | external agent + lead | §7.1 `- < file` form fails EXIT=1 with empty stderr. `cat \| pipe` form works for `-o` review, but `--json` stdout is empty. Filesystem secondary session-id capture (§4.1b) verified functional: UUID extracted from rollout filename successfully resumes. Not a version issue (reproduced on 0.120.0 and 0.121.0). Root cause undiagnosed — see §6.6. Skill adapted: `§4.1` now two-tier, `§4.13` switches canonical form to `cat \| pipe`. |
|
||||
|
||||
When you re-verify (either during routine maintenance or when
|
||||
triggered by §7.7), add a row. Keep the log chronological.
|
||||
|
||||
Reference in New Issue
Block a user