Skip to content

fix(fx-dev): make a finished wait detectable and forbid double-backgrounding - #27

Merged
fx merged 3 commits into
mainfrom
fix/wait-completion-contract
Sep 12, 2026
Merged

fix(fx-dev): make a finished wait detectable and forbid double-backgrounding#27
fx merged 3 commits into
mainfrom
fix/wait-completion-contract

Conversation

@fx

@fx fx commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Why

Port of fx/skills#6 into this repo's copy of the fx-dev skills. Same two production failures, both wait-shaped:

  1. A wait loop written as while pgrep -f "codex review" matched its own command line, so it waited on itself and spun for 13 minutes after the reviewed process had already finished.
  2. & was added inside a call the host had already backgrounded. The wrapper returned in milliseconds, that instant return was read as the review completing, and a truncated log was read as a finished review.

The pgrep rule already existed in background-waits.md, and codex-review/SKILL.md already pointed at it. It was violated anyway — so more prose was not the fix.

The root cause was structural. run-codex-review.sh was the only script any skill launches as a background wait with no completion contract. Its three siblings (wait-for-ci-checks.sh, wait-for-copilot-review.sh, wait-for-coderabbit-review.sh) all emit STATUS=<state> as their last stdout line under an EXIT trap. Without that sentinel a truncated log is indistinguishable from a finished one (failure 2), and with no completion signal to wait on, an agent invents one (failure 1).

codex-review/SKILL.md was also handing readers the loaded gun: its Diagnosing a stalled review section — the one you open precisely when a wait seems hung — contained pgrep -f 'codex review', the exact self-matching pattern background-waits.md forbids.

What changed

1. run-codex-review.sh gains a STATUS= completion contractCOMPLETED, DRY_RUN, ERROR. Not the siblings' five states: PENDING cannot occur (no budget, nothing times out), NOT_CONFIGURED cannot occur (no configuration question), and TERMINAL_PASS must not exist — it would be read as "no findings", the exact misread the header was written to prevent. STATUS is decoupled from the exit code: on COMPLETED the code is codex's own, reported as CODEX_EXIT=<n> and given no meaning here. Documented exit-3 semantics unchanged.

2. The self-matching pgrep is gone from the stalled-review diagnosis, replaced by a pointer to the log-mtime check background-waits.md already owns. The genuinely diagnostic rollout step is kept.

3. The double-background rule lands once, in background-waits.md § The rule, adjacent to the launch template it is a mutation of.

Port notes

Written against this repo's conventions, not the source's: [SKILL_BASE_DIR]/skills/... rather than [SKILLS_DIR]/..., literal .claude/team/waits paths, and fx-dev:-prefixed skill names. No [SKILL_BASE_DIR] definition notes were added, since this repo does not use them.

This repo's background-waits.md is a narrower variant than the source's — no host-adapters.md to reference, and different anchor wording ("Never background a wait without the redirect"). The rule was rewritten in this file's own idiom, naming run_in_background: true directly instead of the source's per-host phrasing, and both § cross-references resolve inside this file. Reconciling the two variants is deliberately out of scope.

Before this change run-codex-review.sh differed from the source on exactly three lines, all skill-name spellings. After it, that is still the only divergence — verified by diff.

Version bumps are in the first commit because .githooks/pre-commit compares against merge-base HEAD main and rejects any commit touching plugins/<name>/ without the bump already present. Every commit ran the hook unbypassed.

Design notes for review

&, nohup and disown are forbidden for three different reasons, not one. Verified: & forks and returns at once; nohup runs in the foreground and passes the exit status through unchanged, adding only SIGHUP immunity plus a nohup.out redirect that engages solely when stdout is a terminal — which the mandatory > log 2>&1 already precludes; disown backgrounds nothing, but drops the job from the shell's table, after which wait <pid> returns immediately with status 0 (measured: 0 ms and rc 0 while the child was still running, versus 2000 ms and rc 7 for the same tracked job), fabricating a clean finish.

The reap's bounded kill -0 poll is not a hand-rolled wait. background-waits.md § What this does NOT govern blesses exactly this: a bounded teardown wait inside a single command with no external completion to wait for. pgrep -P <pid> filters on parent PID, so unlike the forbidden pgrep -f <pattern> it cannot self-match — verified.

pgrep is a soft dependency. Without it the reap no-ops and the late-write hazard returns, degrading quietly: the STATUS= line and the 128+N exit are unaffected.

Upstream review

The source PR went through four Codex iterations. Nine findings: seven fixed, two classified immaterial (a PID-reuse race requiring the kernel to cycle the whole PID space in microseconds, and a residual single-assignment gap that is not closable in bash — trap dispatch happens between commands, and trap '' discards signals rather than queueing them). One Copilot thread disputed the disown claim; it was tested, the claim held, and the thread was resolved with the measurements. Three of the nine findings were false shell-behaviour claims in the new text, which is why every such claim now carries its verifying test.

Test plan

Run against this repo's copy with a codex shim; no real review was invoked.

  • bash -n on every bundled script parses
  • No-args → STATUS=ERROR, exit 3
  • Directory passed as the scope prompt → STATUS=ERROR, exit 4 via the trap (exited 1 before this change — inside codex's reserved range)
  • CODEX_REVIEW_DRY_RUN=1STATUS=DRY_RUN, exit 0
  • codex exits 1 through a healthy teeCODEX_EXIT=1, STATUS=COMPLETED, exit 1 (not inverted to ERROR)
  • Unwritable CODEX_REVIEW_OUTWARNING naming the path, STATUS=COMPLETED — a tee failure is attributed to tee, not to codex
  • toml_key quoting intact (mcp_servers."acme.review".enabled=false)
  • Late-write regression, 3 runs: SIGTERM mid-review → exit 143, tail STATUS=ERROR, zero review lines after the sentinel
  • No runnable process check remains in codex-review/SKILL.md (only the prohibition prose reference)
  • jq empty valid on both version files
  • Pre-commit hook passed unbypassed on all three commits

Not done: shellcheck is not installed on this machine, so validation is bash -n plus the behavioural suite.

fx added 3 commits September 11, 2026 17:07
… runner

The runner's stdout gave the caller nothing to branch on: a log that stopped
growing mid-review was indistinguishable from a finished one, so a truncated
capture was read as a completed review.

Every exit path now ends in a STATUS= sentinel — COMPLETED, DRY_RUN, or ERROR —
written last, after the EXIT trap terminates and confirms the death of every
descendant that inherited the log's file descriptor. Without that reap, a codex
child outliving a signalled runner appends review prose after the sentinel and
the log has no STATUS= tail at all.

STATUS is decoupled from the exit code: COMPLETED passes codex's own status
through unchanged and reports it separately as CODEX_EXIT=, because a non-zero
codex exit does not distinguish a failed reviewer from an opinionated one.

The pipeline runs under set +e with PIPESTATUS captured by the very next
statement; any command in between resets the array.
`pgrep -f 'codex review'` matches the watching command's own command line, so
the check never reports the process gone. One stood here and spun for 13 minutes
after the reviewed process had already finished.

background-waits.md already owns this check and forbids the pattern outright;
point at it rather than keeping a second, broken copy that can diverge again.
…wait looks like

An `&` added to a launch run_in_background: true had already backgrounded made
the wrapper return in milliseconds; the instant return was read as the review
completing and a truncated log was reported as a finished review.

Forbid `&`, `nohup` and `disown` on an already-backgrounded launch, each with
the distinct way it breaks — `&` returns the wrapper's status, `nohup` buys
nothing, `disown` makes `wait` report status 0 for a live job — and state the
completion test the failure actually needed: judge a wait by its log's tail, not
by how fast the call came back.
Copilot AI lite review requested due to automatic review settings September 12, 2026 00:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The Bash signal/trap and process-reaping changes are intricate enough that they warrant a final human verification pass in a real runtime environment.

Pull request overview

This PR ports upstream fixes into this repo’s fx-dev skills to make codex-review background waits reliably detectable and to prevent “instant return” false-completion caused by double-backgrounding, while also removing a known self-matching pgrep -f diagnosis footgun.

Changes:

  • Adds a strict stdout completion sentinel (STATUS= + CODEX_EXIT=) to run-codex-review.sh, including signal-path handling to prevent post-sentinel log writes.
  • Updates codex-review/SKILL.md to remove the self-matching process check and to document the new STATUS= contract as the authoritative branching mechanism.
  • Documents the “never double-background” rule in background-waits.md and bumps the fx-dev plugin + marketplace metadata versions.
File summaries
File Description
plugins/fx-dev/skills/dev/references/background-waits.md Adds an explicit rule forbidding &/nohup/disown when run_in_background: true is already used, and clarifies “instant return ≠ completion”.
plugins/fx-dev/skills/codex-review/SKILL.md Removes the self-matching pgrep -f stalled-review check and adds guidance to branch on the log’s final STATUS= line.
plugins/fx-dev/skills/codex-review/scripts/run-codex-review.sh Implements the STATUS= completion contract and ensures the sentinel remains the log tail (including descendant reaping on abnormal exits).
plugins/fx-dev/.claude-plugin/plugin.json Bumps fx-dev version to 5.2.2.
.claude-plugin/marketplace.json Bumps marketplace metadata version to 0.27.2.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@fx
fx merged commit f3fed7a into main Sep 12, 2026
3 checks passed
@fx
fx deleted the fix/wait-completion-contract branch September 12, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants