Repository navigation
fix(codex): resolve the current turn from the rollout when the Stop payload carries no turn_id - #1534
Merged
Merged
Conversation
…ayload carries no turn_id Codex added turn_id to StopCommandInput in rust-v0.117.0 (0.116.0-alpha.12), after the hooks engine shipped in rust-v0.114.0, so stable rust-v0.114.0, v0.115.0 and v0.116.0 fire the Stop hook with no turn_id at all. #1169 made resolveCodexStopPlan fail closed on that payload, which silently disabled plan review on those versions. When the payload omits the field, derive the turn from the rollout instead: the id of the last id-carrying turn_context/task_started marker, then the same first-marker-anchored scan, so mid-turn compaction safety and per-turn plan filtering are unchanged. A rollout with no turn markers still fails closed, and so does a payload that carries turn_id as a blank string (truncated or foreign, refused before the file is read). Using the fallback writes one line to stderr, unconditionally — stdout is the hook's JSON decision channel, and Codex only reads a Stop hook's stderr on exit code 2, which this hook never uses. Docs: minimum-Codex-version table in the Codex troubleshooting guide and a turn-resolution section in AGENTS.md.
This was referenced Sep 15, 2026
backnotprop
added a commit
that referenced
this pull request
Sep 15, 2026
…fallback path (#1539) The `stop_hook_active` guard in `findPlanInTurn` anchors on the `<hook_prompt>` USER message Codex records when a Stop hook blocks. That item only arrived in rust-v0.117.0 (`build_hook_prompt_message`): rust-v0.114.0, v0.115.0 and v0.116.0 — the exact versions #1534's rollout turn-id fallback newly enables — record the continuation as `DeveloperInstructions::new(continuation_prompt)`, i.e. a developer-role message, and then continue the SAME turn with `stop_hook_active = true`. The guard was therefore inert there: after "Request changes", if the model answered without emitting a fresh `<proposed_plan>`, the next Stop resolved the same turn, found no boundary, and re-served the unchanged denied plan for review. Codex's continuation loop has no iteration cap, so denying again just repeated it. Generalize the boundary: the `<hook_prompt>` item stays authoritative, and the last developer-role message in the turn is accepted as the boundary only on the rollout-fallback path (a Stop payload with no `turn_id`), so every Codex that sends `turn_id` keeps byte-identical behaviour. Also: - Correct the troubleshooting version table. Hooks became default-enabled at rust-v0.124.0 (`0.124.0-alpha.3`, `Stage::Stable` + `default_enabled: true`), not 0.125.0, and the `codex_hooks` -> `hooks` key rename landed at rust-v0.129.0 (`0.129.0-alpha.5`), not 0.131.0 — verified against `codex-rs/features/src/lib.rs` at each tag, with 0.128.0 still on `codex_hooks`. The derived prose now names the real codex_hooks-only range. - Fail closed instead of throwing on a non-string `turn_id`: the coercion now lives inside `resolveCodexStopPlan` rather than only at the CLI call site, and the debug breadcrumb says the id was present but unusable rather than missing.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release-QA finding: on Codex builds that ship the hooks engine but send a Stop payload without
turn_id, plan review silently stops — no UI, no message, exit 0.v0.27.14opened a review on the same session.The Codex evidence
Checked against
~/oss-agents/codex(pulled today):codex-rs/hooks/src/schema.rs→StopCommandInputrust-v0.113.0codex-rs/hooks/crate at allrust-v0.114.0turn_idrust-v0.115.0turn_idrust-v0.116.0turn_idrust-v0.117.0pub turn_id: String— "Codex extension: expose the active turn id to internal turn-scoped hooks"HEAD(a8964cb1ba)v0.117.0So the hooks engine shipped in
rust-v0.114.0and the field landed three minor versions later;#1169(551b908) assumed it had always been there (apps/hook/server/codex-session.ts:497-498: "Codex itself always sends one").The rollouts at those tags do carry the id, which is what makes the fallback possible:
rust-v0.116.0:codex-rs/core/src/codex.rs:956—TurnContext::to_turn_context_item()writesturn_id: Some(self.sub_id.clone())on everyturn_contextrust-v0.114.0:codex-rs/core/src/codex.rs:846— same line, so the earliest affected tag is covered toorust-v0.116.0:codex-rs/protocol/src/protocol.rs:2496-2498—TurnContextItem.turn_idThe fallback rule
resolveCodexStopPlannow resolves the turn in two tiers:options.turnIdwhen the payload carries the field — byte-identical behavior to before on Codex ≥ 0.117.turn_context/task_startedmarker in the file (findLatestRolloutTurnId). One rule covers both shapes the owner described — a turn still in flight owns the last markers by construction (a completed turn's markers can only be followed by a newer turn's), and when Stop fires at turn end the last marker is that turn's own.The resolved id then runs the unchanged
#1169machinery:findTurnStartIndexanchors on that turn's first marker, so mid-turn compaction (which re-emitsturn_contextwith the same id) still cannot hide a plan produced before the compaction point, andgetPlanItemText's per-turn filtering is untouched.Still fails closed:
missing-turn-marker;turn_idas a blank string →missing-turn-id, refused before the file is read, as the finding asked. The tier is keyed on the field being absent (options.turnId !== undefined), andindex.tsmaps a present-but-non-string value to""rather than collapsing it to "absent", so a truncated or foreign payload can never take the fallback.Using the fallback writes one line to stderr, unconditionally (
logCodexStopTurnIdFallback) — stdout is the hook's JSON decision channel, and Codex only reads a Stop hook's stderr at exit code 2 (rust-v0.116.0:codex-rs/hooks/src/events/stop.rs:198-216), which this hook never uses, so the line is a captured breadcrumb and never becomes agent input.Docs
apps/marketing/src/content/docs/guides/troubleshooting.md— a "Minimum Codex version" subsection with a verified version table (rust-v0.114.0hooks engine,codex_hooksoff by default →rust-v0.117.0turn_id→rust-v0.125.0default-on (features/src/lib.rs:756-760) →rust-v0.131.0key renamed tohookswithcodex_hooksas a legacy alias (features/src/lib.rs:844-848,features/src/legacy.rs:45)), plus the explicit statement that aturn_id-less Codex is handled by the rollout fallback.AGENTS.md(CLAUDE.mdis a symlink to it) — a "Codex Stop hook: which turn the plan belongs to" section under Plan Review Flow documenting both tiers and the fail-closed cases.Tests
apps/hook/server/codex-session.test.ts— newCodex Stop turn id fallbackdescribe (6 tests):turn_id+ rollout with one turn → plan resolved,fallbackTurnIdreportedturn_id+ compaction-re-emittedturn_context→ the plan written before compaction is resolvedturn_id+ two turns where only the older one has a plan →null(stale plan never re-reviewed)missing-turn-markerturn_id→ unchanged: the payload-named turn wins and no fallback is reportedTwo existing tests were updated because they pinned the contract this PR deliberately changes: the
missing-turn-iddiagnostic test now covers blank-but-present ids (the case that still fails closed), and the "does not scrape an assistant proposed plan for a Stop event without a turn id" test gained a newer turn so it keeps guarding the real#1169risk — an older, already-decided turn's plan is still invisible — rather than the old blanket refusal.bun test apps/hook/server/→ 262 pass, 0 fail (14 files).bun run typecheck→ clean.bunx tsc --noEmiton a scoped throwaway config overserver/{index,codex-session,codex-session.test}.tswithbun-types→ no errors in the touched files (the 5"exit" is not assignable to "memoryPressure"hits are pre-existing node/bun-types clashes inpackages/aiandpackages/server).Follow-up, deliberately not in this PR
scripts/install.shis untouched. The related finding — the installer writes[features] hooks = true, which Codex only understands fromrust-v0.131.0, and its awk normalizer rewrites a workingcodex_hooks = trueinto it (scripts/install.sh:1096-1152) — is pre-existing (the key changed in6e3efe8e, already an ancestor ofv0.27.14) and wants its own change: write both keys and make the awk additive, or gate the Codex leg on acodex --versionprobe. The troubleshooting doc added here tells affected users what to write in the meantime.