Skip to content

observe_thread_status takes only the status word, so a required ThreadStatus field is parsed and discarded at all three call sites #265

Description

@schickling

Problem

Two facts, both checkable in this repository at f177520, with no capture required.

One — the field is parsed and thrown away. ThreadStatus's active arm carries a field named
activeFlags:

ThreadActiveFlag = { "enum": ["waitingOnApproval", "waitingOnUserInput"] }
{ "type": "active", "activeFlags": ["waitingOnApproval"] }

All three call sites read the type pointer and nothing else — src/codex_app_server.rs:702, :724,
:732 — all feeding observe_thread_status(status: &str) at :770, whose entire input is one
string. The rest of the status object is discarded at the parse site, and CodexControlState
(:189-201) has no field that could hold it.

Two — the delivery path never consults it. maybe_request selects a delivery method from
state.observed alone (src/codex_app_server.rs:415-419): Idle → Start, Active { turn_id } → Steer { turn_id }, everything else None. No flag, and no other property of the thread status,
reaches that decision.

Those two are the defect. What follows from them is stated separately below, because it is an
inference rather than an observation.

Fleet evidence

Across one catalog of 627 seats (#261, measured 2026-08-16), "working" and "stopped, waiting for
me to approve a command" are the same reading. Both are busy. An agent blocked on approval stays
blocked until somebody opens its pane, and at that seat count nobody opens them all.

What is measured, and what is not. The seat count is measured. The cost of not distinguishing
these two — how many seats sit blocked, and for how long — is not, and cannot be until the
distinction exists. That is the ordinary shape of an observability gap: the thing that would measure
it is the thing being asked for. It is stated that way rather than with a number we do not have.

Cause

observe_thread_status takes &str, so no caller can pass the rest of the status object without
changing the signature. CodexControlState (src/codex_app_server.rs:189-201) is additionally
#[serde(deny_unknown_fields)] (:190), so persisting the flags is a schema change to
st2.codex-control-state.v1 (:41, validated on load at :2243), not an additive field — an older
binary reading a newer record hard-errors rather than ignoring the new key. Whether that matters
depends on whether two binary versions ever read one host's state directory; it should be checked
before the field is added, not after.

What is proved, and what is not

Version scope first, because it bounds everything below. The activeFlags schema and the wire
capture both come from codex-cli 0.147.0.
SUPPORTED_CODEX_CLI_VERSIONS = ["codex-cli 0.145.0", "codex-cli 0.146.0"]
(src/codex_app_server.rs:38, enforced at :2101), so the field is unverified on the versions st2
actually supports.
No older build was available to check against. No claim is made here that
activeFlags exists on 0.145 or 0.146.

Proved — on 0.147.0 the field is on the wire, not merely in the schema:

{"method":"thread/status/changed",
 "params":{"threadId":"","status":{"type":"active","activeFlags":[]}}}

The array is present and empty because nothing was pending.

In-repo, and weaker than it looks. st2's own test fixtures carry the field at
src/codex_app_server.rs:3328, :3618 and :3715, all
"status": {"type": "active", "activeFlags": []}, on a tree whose supported versions are 0.145 and
0.146. That is evidence the field was known to st2's authors, and it is a reason to expect it on a
supported version. It is hand-authored test data, not a capture, and it does not establish what
0.145 or 0.146 emit.

Not proved — a populated activeFlags was never captured. The account hit its ChatGPT usage
limit, so no model turn ran and no approval was ever reachable. That ["waitingOnApproval"] appears
when an approval is pending follows from the enum's naming and the field's placement inside the
active arm; it is schema evidence, not wire evidence.

The consequence, stated as an inference

This does not follow from an observation. It chains three steps and the middle one is unobserved.

  1. A session sitting on an approval prompt reports status.type == "active". — Schema-supported,
    never observed.
    activeFlags exists only on the active arm, which is why it is the natural
    reading; nothing in any capture confirms it.
  2. observe_thread_status therefore yields Active { turn_id }, the state whose doc comment
    (src/codex_app_server.rs:160-163) says it is "the only state that permits turn/steer". —
    Follows from step 1 and the match arms at :773-790.
  3. maybe_request routes that to CodexDeliveryMethod::Steer (:415-419), so st2 injects a DING
    into a session whose next keystroke answers a modal. — Follows from step 2.

If step 1 is wrong and Codex reports approval-pending under some other status word, then
observe_thread_status's trailing _ arm makes it Held{SystemError} and maybe_request returns
None (:420-421). No steer — and a different defect, the fabricated system error described in
Issue 1.

The one run that decides it. One codex app-server session under --approval-policy untrusted,
driven until a real approval is pending, capturing the thread/status/changed frame at that moment.
That needs no new code. The capture account's usage limit resets 2026-08-20, per the error
payload quoted in Issue 1. Until that run exists, this section is an inference and is written as one.

Steps 1-3 decide how urgent this is. They do not decide whether it is a defect: a protocol field is
discarded at the parse site regardless, and maybe_request consults nothing but the state word
regardless. Both are fixable, and testable, without the capture.

The steerability rule is not an invariant today

Worth stating because it changes what "do not break it" means here. The rule at
src/codex_app_server.rs:160-163 is a code comment. No row in INVARIANTS.md covers it and no
named test proves it — all rows were checked. CLAUDE.md says to add a row "only when a genuinely
load-bearing invariant appears, and only once a real test proves it". This rule gates native
delivery, and this issue is the change that forces the question, so it is the natural moment.

Also on the floor: the server→client approval requests

Ten server→client JSON-RPC requests carry a method and therefore reach observe()'s match and fall
through _ => return Ok(false) (src/codex_app_server.rs:765):

item/commandExecution/requestApproval    item/fileChange/requestApproval
item/permissions/requestApproval         item/tool/requestUserInput
mcpServer/elicitation/request            applyPatchApproval / execCommandApproval (v1 legacy)

The exit edge is the serverRequest/resolved notification. activeFlags says that an agent is
blocked; these say what on, and the pairing is what would make blocked dwell time measurable.

This is not a deadlock. st2 attaches as an observer and the interactive TUI is the answering
client (module doc, src/codex_app_server.rs:1-10). An unanswered request does not stall the turn.

There is already a test injecting item/commandExecution/requestApproval
(src/codex_app_server.rs:3214) asserting only that it is not misparsed as a client response —
JSON-RPC request IDs are per-direction. That behavior must be preserved; extracting state here must
not start consuming these as responses.

Not proved. No live requestApproval frame was captured, for the same usage-limit reason.

One benign transit, noted so it is not rediscovered

From Idle, observe_thread_status("active") matches none of its explicit arms and falls to
Held{ActiveWithoutTurn} (:782-786), because thread/status/changed{active} always precedes
turn/started. Measured in both captures at 0.1 ms before turn/started corrects it. Harmless
as a live state; it matters only to a consumer that samples or records observed state.

Required behavior

  • activeFlags is parsed from thread/started and thread/status/changed and persisted.
  • Unknown future flag values degrade to plain active rather than erroring.
  • Steerability while a flag is set is decided explicitly and pinned by a test.

Acceptance

  • activeFlags is confirmed present on a supported version — 0.145.0 or 0.146.0 — or 0.147.0
    is admitted under the existing policy at src/codex_app_server.rs:35-37. Until one of those
    holds, this is a field observed only on a version st2 refuses to launch. See Issue 5.
  • A populated activeFlags is captured on the wire before implementation, resolving step 1 of
    the inference above.
  • Parsed at all three call sites (:702, :724, :732) and persisted in CodexControlState.
  • The deny_unknown_fields / schema-version consequence for a staggered binary upgrade is stated.
  • Cases covered: [], ["waitingOnApproval"], ["waitingOnUserInput"], unknown value.
  • The steerability rule becomes an INVARIANTS.md row with a named test, or is documented as
    deliberately unpinned.
  • The existing per-direction request-ID test stays green.

Version note, repeated because it bounds the whole issue. Every wire observation here is
codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS (:38) rejects. activeFlags is
unverified on 0.145.0 and 0.146.0 — no older build was available to check. The two
implementation facts in ## Problem are independent of all of this: they are properties of st2's own
code, not of any Codex version.


Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dingDING delivery: inbox notice into a running agent · Set: manualarea:driverHarness drivers: launch, MCP, app-server, native delivery · Set: manualharness:codexCodex-specific behavior · Set: manualtype:bugSomething broken or a regression · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions