Skip to content

fix(codex): release chats after a hook refuses a prompt - #28630

Closed
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/queue-codex-hook-refused
Closed

brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/queue-codex-hook-refused

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​92 $\color{#cf222e}{\Huge{\mathbf{−}}}$​45 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​47
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​99 $\color{#cf222e}{\Huge{\mathbf{−}}}$​40 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​59

ELI5

Codex lets people add prompt hooks: small scripts that check each message before Codex accepts it. If a hook blocked a message, the Orca chat stayed on "Working" forever, and any messages queued behind it never went out. The only way out was to stop or restart the chat.

After this change, the blocked message shows as not sent as soon as Codex finishes that turn, and the chat goes idle. When the hook gave a reason and Orca can tell which message it was about, the reason is shown on that message. A queued message the hook blocked comes back as a returned card, and the cards behind it wait until you act on it, the same as any other refused card.

What Changed

The problem. Orca counts a Codex message as sent only when Codex reports that it recorded the message in the conversation history. That report carries the message's id. A blocking hook runs before Codex records anything, so that report never arrives. Codex still finishes the turn normally. Before this PR, Orca kept a message from a normally finished turn pending in case the report came late. Because nothing ever cleared it, the chat kept owing work and read "Working" for good.

User-facing change.

  • A message a hook blocks shows as refused when its turn ends, in Orca's existing refusal wording. The chat goes idle and the next message can be sent.
  • A blocked queued card comes back as a returned card with that refusal. It holds the cards behind it until you send, edit or delete it.
  • The hook's own reason appears only when that turn refused exactly one message. A hook's report names a turn, not a message. If one turn refused two messages, Orca can't tell which reason goes with which, so both get the generic refusal. The reason is the hook's feedback or stop reason if there is one; otherwise its error, then its warning, then its status message.
  • No new banner, notice or other UI.

Mechanism. Codex records accepted input, and sends the report that names it, before it reports the turn finished. A failed turn does this after its error and before its end. This order holds in Codex 0.136 (Orca's minimum) and in later releases through current. So when a turn ends, every message bound to it with no report is settled:

  • an interrupted turn (Stop) withdraws it, and a queued card goes back to the paused queue (unchanged);
  • a completed or failed turn refuses it (new for completed turns).

When Codex answers a send after the turn it names has already ended, the send gets the same refusal, in the generic wording. A message Codex already reported recording stays sent. A failed turn that reports an error, and then records its accepted input before it ends, still marks that input sent: the error alone does not end a turn Codex opened. Both cases have tests.

The hook reason is kept per turn in the existing Codex dispatch bookkeeping and dropped when that turn ends. The list is capped at 64 entries, like the turn ends kept next to it.

Why

A turn that has ended can't still owe a message it never recorded. Settling that at the existing turn boundary removes the stuck state without a timer, a retry, a durable flag or a client-specific rule. The missing receipt decides the refusal, so a hook that gives no reason is handled too. The hook text is only used to explain the refusal. It never decides whether a message was delivered.

Considered and rejected: counting a message as sent when Codex answers the send request. Stop clears messages Codex accepted but had not yet recorded, so under that rule a message lost to a Stop would show as sent.

Linked Issue

Refs #27658 (closed; reproduced and verified here).

Visual Proof

N/A: no UI changes. The refused message and the returned card use the existing refusal display. The new regression test runs the real host, journal, queue and Codex adapter; only the Codex process is faked.

Testing

  • Added a regression test for the full flow: a hook blocks a queued card → the card comes back returned with the hook's reason → the card behind it keeps waiting → the chat owes no work. It fails on the PR's main base (the card never returns) and passes with the fix.
  • Added a unit test: when a blocked turn refuses two messages, both get the generic refusal with no hook reason. It fails if the one-message check is removed.
  • Updated or removed old tests that expected a completed turn to leave an unrecorded send pending. Existing tests still cover a failed turn whose error comes before its input is recorded, and a late answer whose report can't reopen a settled send.
  • 132 tests pass in six files on Linux (openclaw): the Codex turn-end settlement, dispatch echo and admission tests, the host settlement-order and turn-end tests, and the latest-request projection test.
  • orca-ci-checks on Linux passes oxlint, both code-quality audits, the changed-code gates, the ratchets and the other static gates; formatting is clean. The tc:node typecheck shows one error: structured-agent-session-message-options.ts:30 (missing now). That file isn't touched here. The error comes from this branch's main base (294927e0978) and is already fixed on main by fix(i18n,test): drop setup-only keys and fix the default-tab test #28543 left red #28603. The Bun-based typecheck runner and the localization check couldn't run there (no Bun installed); this PR touches neither.
  • Not run: rendered Electron, a real Codex hook, Windows/macOS, SSH and the phone. This is host logic only, and the tests cover it.

Review

Self-reviewed for: settlement order, thread and turn isolation, messages that were already sent, late answers, the failed-turn path, hook-reason attribution when one turn refuses several messages, and the cap on the hook-reason list.

Agent skill upstream boundary

  • Not applicable; no upstream skill material is copied.

Notes

Differences from the common pattern

  • Intended: a Codex message counts as sent when Codex reports that it recorded the message in history, not when Codex answers the send request. Stop clears messages Codex had accepted but not yet recorded, so counting the answer would show a lost message as sent. The cost is that a hook-blocked message stays pending until its turn ends, and this PR settles it at that point. The hook's reason is shown only when it can be tied to exactly one message.

Mixed versions. An older client on a newer host gets the existing providerRejected refusal record and its full sentence. A newer client on an older host keeps the old behavior until that host updates. This adds no new field, operation or capability. The phone reads the same refusal record.

SSH and folder workspaces. Only frames the execution host's Codex reports can settle a send. Losing the connection is never treated as a turn ending. Folder workspaces use the same host path. There is no platform-specific logic.

Author: Brennan Benson (X: @BrennanKB5)

Checklist

  • This PR is small and focused
  • I explained the problem, user-facing result, mechanism, and why this approach
  • Visual proof is N/A, with the reason and regression evidence above
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, phone, folder workspace and mixed-version impact considered
  • Full static checks are green; the base-commit typecheck error and the checks that couldn't run are listed above

@brennanb2025

Copy link
Copy Markdown
Contributor Author

A Codex prompt hook could block a message without recording it, then finish the turn. Orca kept waiting for that message, leaving the chat Working and blocking the next send.

At fad8436d32472b20da23d64970885e9552499508, the completed turn now refuses the unrecorded send, shows the hook's reason when available, and releases the chat. A refused message sent into an already running turn remains visible, while that turn keeps its successful result when its opening message was accepted. The fix uses the existing host settlement and request projection; queueing stays dark, with no new stored state or wire field.

Three review rounds, architecture checks, readiness and a separate resource audit are complete. The final trim passed 76 targeted tests, and all required CI checks passed. A test expecting acknowledgment after completion was corrected to Codex's actual ordered events. One opening-status concern assumed an order the real host does not produce; we removed that extra code and artificial test. No code finding remains open.

Hidden M4Air Electron QA used the real composer and an isolated deterministic Codex stand-in. The final head showed the blocked opening as Failed, accepted the next message as Done, and kept an accepted turn Done when its steered message was refused. The full-window screenshots below were inspected. Native chat was explicitly enabled for QA; real Codex, setup-script parity, other operating systems, SSH interruptions and phone interaction remain coverage gaps. Existing fresh-profile notices differ between screenshots, including the macOS notification-permission notice after refusal; no notice code changed.

Local checks still report the documented old-base clock argument, stale Spanish entries and local Bun runner mismatch. The PR remains draft; main has not been synchronized. The remaining step is the agreed single main sync and its final checks before merge.

Main: completed blocked turn remains Working.

Main stays Working

Final head: policy refusal and Failed status.

Final head refuses the blocked opening

Final head: the following accepted message finishes normally.

Following message accepted

Final head: refused steer with the accepted turn still Done.

Accepted turn stays Done after refused steer

A UserPromptSubmit hook's verdict carries no client submission id, so its reason is shown only
when the turn refuses exactly one unconfirmed send; otherwise the generic refusal stands. The
reason falls back from feedback/stop to error, warning, then the run's status message. The
hook-refused queued card returns with that reason and holds the card behind it.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this in favor of a root-cause refactor instead of another handler.

Why. This PR stops a chat from staying on Working after a Codex hook blocks a prompt. It does that by adding one more turn-end rule to the code that matches Codex's answers and echoes in memory. That is the latest of several fixes with the same shape: a chat stays Working because a message's delivery was never settled. Each fix closed one more way a send can stay pending: a completed turn with no echo, an answer naming no turn, an idle signal arriving before a timeout was recorded, a lost write, and an evicted cache entry. The shared cause is that Working depends on whether a message was delivered, and delivery outcomes are set by event handlers instead of derived from saved facts.

What replaces it. A planned refactor makes Working come only from the running turn and the messages the agent still holds, and derives each message's outcome from the facts Codex itself reported. This PR's turn-end rule and its refused-steer display fix carry over into that work. The commits stay on this branch for reference.

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.

1 participant