Hold native delivery while a Codex thread waits on a human - #274
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
`ThreadStatus`'s `active` arm carries a required `activeFlags` array, and `observe_thread_status` took only the status word, discarding it at all three call sites. Parse the field, map a known human-blocking flag onto an explicit hold, and release the same turn when the flag clears. Closes #265 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fe7a33d to
04e00b8
Compare
Rebased onto
|
Why
ThreadStatus'sactivearm carries a requiredactiveFlags: ThreadActiveFlag[], whereThreadActiveFlagis exactly"waitingOnApproval" | "waitingOnUserInput".observe_thread_statustook
status: &str— one word — so all three call sites (:702,:724,:732) read.../status/typeand discarded the rest of the status object at the parse site. st2 therefore couldnot distinguish "the model is working" from "the model has stopped and is waiting for a human".
The delivery consequence follows from st2's own code: an approval happens mid-turn, so the session is
Active { turn_id }, andmaybe_requestmapsActivetoCodexDeliveryMethod::Steer(
:415-419). st2 steers a[DING]into a session that is sitting on an approval dialog.That consequence is inferred, not observed — see
## Limits.What
One localized change to
observe_thread_statusand its three call sites.human_blocking_flag(status: Option<&Value>) -> Option<CodexHoldReason>reads
activeFlagsfrom the status object and returns the first flag that means "blocked on ahuman". It is called at all three sites, passing the status object each already had in hand.
CodexHoldReasonvariants,WaitingOnApprovalandWaitingOnUserInput. A flag arriving whileActive { turn_id }becomesHeld { reason, turn_id: Some(turn_id) }.{"type":"active","activeFlags":[]}arriving on aflagged hold returns to
Active { turn_id }. This is the case a naive fix gets wrong: the old"active"arm preserved state only forActiveandHeld{Review|Compaction|ConflictingTurn}, soa cleared flag would have fallen through to
Held{ActiveWithoutTurn, turn_id: None}— turn IDlost, and no second
turn/startedarrives mid-turn to restore it. Native delivery would havestalled for the rest of the turn.
active, and a missing or malformed array reads as noflag rather than failing the frame.
observe's error propagates via?(:1944), so a strictaccessor on a schema surprise would kill the control watcher.
Review,CompactionandConflictingTurnkeep their own turn IDs, which the turn-lifecycle handlers depend on.
maybe_requestis not modified. It already returnsNonefor everyHeldreason(
:420-421), so the behavioural half is one more input mapped onto the existing, invariant-pinnedhold path — the same path
ReviewandCompactiontake.Out of scope and untouched:
observe_turn_completed(#264), theitem/started/item/completedhandling (#266), and
SUPPORTED_CODEX_CLI_VERSIONS(#267).Impact
st2 declines to steer into a Codex session that has stopped for a human, and resumes steering the
same turn once the human answers.
Why this is fail-closed, not fail-open. Declining is not dropping. A hold makes
maybe_requestreturn
None, which is the already-tested behaviour forReviewandCompaction(
review_compaction_and_dnd_hold_the_unread_fifo_head): the unread FIFO head stays in the inbox, nodelivery ownership is recorded, no paste or archive happens, and the same head is delivered on the
next poll once the state clears. The new test asserts exactly that round trip — held twice with the
message still on disk, then
turn/steerwithexpectedTurnId: turn-1after release. The failuredirection the change introduces is deferral; the failure direction it removes is an unobservable
steer into a modal. No new delivery path is created and no
Deliveredclassification is relaxed.One deliberate edge, pinned but not fixed here: a turn that completes while still flagged falls into
the pre-existing
ConflictingTurncatch-all inobserve_turn_completedand is released by the nextidle status. That function is #264's region, so it was read, not edited; the test asserts only that
the flagged state cannot decay into a steerable turn.
INVARIANTS.mdis unchanged. Per the issue's "or is documented as deliberately unpinned" branch,the steerability rule stays a code comment (
:160-163): a single hold-reason mapping is not the tierCLAUDE.mdreserves rows for, andINVARIANTS.mdis a shared file three sibling PRs are near. Thelocal proof is the named test below.
Checks
Schema evidence, generated locally against the two versions st2 actually supports:
activeFlagsis present, required, and identically shaped on 0.145.0 and 0.146.0 — the completeSUPPORTED_CODEX_CLI_VERSIONSset. The issue's "unverified on a supported version" caveat isretired; #267 is unaffected either way, and
:38is untouched.Test, written before the fix and confirmed failing against it:
It covers the four cases the issue lists —
[],["waitingOnApproval"],["waitingOnUserInput"],an unknown value — plus a mixed array,
thread/startedcarrying the field before any turn is known,a status arm with no
activeFlagsat all, the clear-and-release round trip, and the deliveryassertion.
Delivery path, since the behavioural half is included:
Full suite, honestly reported:
The same 13 fail identically on the unmodified base commit
f177520in this worktree, so the setsare equal and this change adds none of them. They are environment- and concurrency-bound (real PTY,
systemd scopes, cgroups, and sibling agents running the full suite on the same machine):
classification_only_and_nested_agent_filename_changes_are_exact,canonical_agents_freeze_the_admitted_route_across_post_boot_catalog_mutation,clean_path_supports_help_validate_env_and_doctor,exec_task_survives_transport_cgroup_cascade,managed_agent_color_contract_crosses_systemd_scope,pty_task_survives_transport_cgroup_cascade,st2_down_tears_down_a_spec_fleet,st2_up_boots_a_specs_team,st2_up_once_atomically_respawns_a_hard_killed_agent,st2_up_spec_supervises_and_respawns_a_killed_agent,targeted_once_real_pty_preserves_sibling_generation_across_selected_lifecycle,tracked_product_surface_contains_only_native_names,up_materialize_only_writes_the_overlay_without_needing_pty.cargo fmt --checkis dirty repo-wide onmain(438 diffs). Every hunk this branch adds isrustfmt-clean; the remaining diffs in
src/codex_app_server.rsare at lines this branch does nottouch. No Darwin run.
Limits
activeFlags. The one live attempt saw"activeFlags":[]because the account hit its ChatGPTusage limit before an approval was reachable, and a re-attempt on this branch hit the same limit —
the error payload names 2026-08-20 as the reset. That a session sitting on an approval prompt
reports
activewith["waitingOnApproval"]follows from the enum's names and the field'splacement inside the
activearm. It is schema evidence, not wire evidence. Nothing in this PRshould be read as a claim that the steer-into-dialog behaviour was observed.
supported versions, so the field is no longer "unverified on a supported version". What remains
unverified is that 0.145.0 and 0.146.0 populate it in the situation the flag names — the same
gap as the point above, on every version. The field is required on
ActiveThreadStatuson allthree, so its presence is not in question.
fail-open edge: a future codex-cli flag meaning "blocked" would read as plain
activeuntilsomeone classifies it. The control is the version gate — the comment at
:35-37requires adelivery-critical schema comparison before any new version is admitted, and that comparison is
where a new flag value has to be classified. Erring the other way would let one new benign flag
silently stop native delivery fleet-wide.
activeFlagsis persisted as a derived hold, not as the raw array. The delivery-relevantcontent of the array is whether a known human-blocking flag is present, and the hold reason names
which one. If both flags are ever set at once, only the first is reported.
CodexControlStateis#[serde(deny_unknown_fields)](:192) andno field is added here, but two
CodexHoldReasonvariants are. An older binary reading a recordcontaining
"reason":"waitingOnApproval"failsserde_json::from_sliceinload_current_control_state(:2231) — an unknown enum variant is a hard error, not an ignoredkey, and the schema string
st2.codex-control-state.v1is unchanged so nothing catches itearlier. The blast radius is bounded by the record itself: it is keyed to
runtime_incarnation, so only the exact same incarnation reads it back, and today no calleroutside this module's tests reads it. Stated, not solved.
requestApproval/requestUserInput/elicitationmethods still fall throughobserve's_ => return Ok(false).activeFlagssays that an agent is blocked; those say what on, and pairing themwith
serverRequest/resolvedis what would make blocked dwell time measurable. Not in this PR.Related
observe_thread_statustakes only the status word, so a requiredThreadStatusfield is parsed and discarded at all three call sites #265.activeFlagsneeds nothing from it, but:38is that PR's region and is untouched here.exitedReviewModeis never matched, soHeld{Review}clears only when the thread reports idle #266 — sibling defects in the same file; their regions are untouched.Ready for review.