Skip to content

Commit 8d11cbc

Browse files
schicklingclaude
andauthored
Resolve an errored Codex turn as the reported error (#270)
A turn that ends because the thread reported `systemError` or `notLoaded` fell through `observe_turn_completed`'s catch-all and was recorded as a conflicting turn. That state is false — one turn was live, not two — and every subsequent transition preserves it, so the agent accepted no native delivery until an idle status arrived. A usage limit is account-wide, so one ordinary error silently mutes every agent on the account. `turn/completed` reports one turn's lifecycle and carries no thread status. It now resolves only the states a completed turn actually resolves, and the match is exhaustive so a new observed state cannot arrive as a conflict it never was. Closes #264 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 898ad13 commit 8d11cbc

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

docs/vrs/01-ding/02-codex/spec.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ ownership and narrowing the contents were deliberately separated so that the
8383
move carried no behavior change; narrowing is outstanding, and is recorded as a
8484
known limit below rather than described as done.
8585

86+
## Failed turns
87+
88+
This section describes the control protocol named in the opening paragraph, not the screen.
89+
90+
A turn that ends because the thread reported an error resolves to that error, held. The thread
91+
status is the only signal that reports a thread condition, so the turn's own completion neither
92+
clears the condition nor stands as evidence of a second live turn. Delivery is gated for as long
93+
as the hold stands: a thread that has just reported a system error, or that is not loaded, is not
94+
a thread st2 sends into. Only a later thread status releases it — `idle` directly, or `active`
95+
followed by the turn that starts under it — never turn traffic alone.
96+
8697
## Known limits
8798

8899
- Recognition depends on exact styled sequences, so a renderer change that

src/codex_app_server.rs

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,20 +829,28 @@ impl CodexControlState {
829829
CodexObservedState::Active { turn_id: current } if current == turn_id => {
830830
CodexObservedState::Idle
831831
}
832-
CodexObservedState::Held {
833-
reason: CodexHoldReason::Review | CodexHoldReason::Compaction,
834-
..
835-
} => self.observed.clone(),
836832
CodexObservedState::AwaitingStatus
837833
| CodexObservedState::Held {
838834
reason: CodexHoldReason::ActiveWithoutTurn,
839835
..
840836
} => CodexObservedState::Idle,
837+
// Every other hold is owned by a signal that is not the turn lifecycle. A completion
838+
// is not evidence that a review or a compaction ended, that the thread reloaded, or
839+
// that a reported system error cleared, so it does not speak for them. Only the
840+
// signal that minted the hold releases it.
841841
CodexObservedState::Held {
842-
reason: CodexHoldReason::ConflictingTurn,
842+
reason:
843+
CodexHoldReason::Review
844+
| CodexHoldReason::Compaction
845+
| CodexHoldReason::ConflictingTurn
846+
| CodexHoldReason::NotLoaded
847+
| CodexHoldReason::SystemError,
843848
..
844849
} => self.observed.clone(),
845-
_ => CodexObservedState::Held {
850+
// A completion for a turn other than the one believed live is the only evidence here
851+
// that two turns exist. This match stays exhaustive so a new observed state cannot
852+
// silently arrive as a conflict it never was.
853+
CodexObservedState::Active { .. } => CodexObservedState::Held {
846854
reason: CodexHoldReason::ConflictingTurn,
847855
turn_id: None,
848856
},
@@ -3871,6 +3879,144 @@ mod tests {
38713879
));
38723880
}
38733881

3882+
#[test]
3883+
fn an_errored_turn_completes_into_the_named_error_not_a_conflicting_turn() {
3884+
// Replays the captured terminal-error ordering (#264): a usage limit emits
3885+
// `thread/status/changed -> systemError` immediately before the failed turn's
3886+
// `turn/completed`. That completion reports one turn's lifecycle and carries no thread
3887+
// status, so it is not evidence the thread recovered, and it is not evidence of a second
3888+
// live turn either. The honest resolution is the condition the thread itself reported,
3889+
// still held, until a thread status says otherwise.
3890+
for (status, reason) in [
3891+
("systemError", CodexHoldReason::SystemError),
3892+
("notLoaded", CodexHoldReason::NotLoaded),
3893+
] {
3894+
let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap();
3895+
let mut state = CodexControlState::new(&runtime, "thread-main".into());
3896+
state.subscribed = true;
3897+
state
3898+
.observe(&json!({
3899+
"method": "turn/started",
3900+
"params": { "threadId": "thread-main", "turn": { "id": "turn-1" } }
3901+
}))
3902+
.unwrap();
3903+
assert!(
3904+
state
3905+
.observe(&json!({
3906+
"method": "thread/status/changed",
3907+
"params": { "threadId": "thread-main", "status": { "type": status } }
3908+
}))
3909+
.unwrap()
3910+
);
3911+
assert_eq!(
3912+
state.observed(),
3913+
&CodexObservedState::Held {
3914+
reason,
3915+
turn_id: None
3916+
}
3917+
);
3918+
3919+
// The completion of the turn that just failed changes nothing. st2 never believed a
3920+
// second turn was live, so it must not begin reporting one.
3921+
assert!(
3922+
!state
3923+
.observe(&json!({
3924+
"method": "turn/completed",
3925+
"params": { "threadId": "thread-main", "turn": { "id": "turn-1" } }
3926+
}))
3927+
.unwrap()
3928+
);
3929+
assert_eq!(
3930+
state.observed(),
3931+
&CodexObservedState::Held {
3932+
reason,
3933+
turn_id: None
3934+
}
3935+
);
3936+
3937+
// The hold gates delivery deliberately: a thread that just reported an error is not
3938+
// a thread st2 sends into, and the unread head stays unread.
3939+
let tmp = tempfile::tempdir().unwrap();
3940+
let config = delivery_config(tmp.path());
3941+
let filename =
3942+
message::send_to_inbox(&config.inbox, "h.sender", Some("held"), None, &[], "body")
3943+
.unwrap();
3944+
let mut delivery = inbox_delivery(tmp.path(), config.clone());
3945+
assert_eq!(delivery.maybe_request(&state).unwrap(), None);
3946+
assert!(config.inbox.join(&filename).is_file());
3947+
3948+
// The gate is released by the signal that owns it: the next thread status.
3949+
assert!(
3950+
state
3951+
.observe(&json!({
3952+
"method": "thread/status/changed",
3953+
"params": { "threadId": "thread-main", "status": { "type": "idle" } }
3954+
}))
3955+
.unwrap()
3956+
);
3957+
assert_eq!(state.observed(), &CodexObservedState::Idle);
3958+
assert!(delivery.maybe_request(&state).unwrap().is_some());
3959+
}
3960+
3961+
// The truthful hold is not a new wedge. It has strictly more exits than the conflict it
3962+
// replaces: `active` does not preserve a system error the way it preserves a conflicting
3963+
// turn, so a thread that simply resumes work is live again on its next turn.
3964+
let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap();
3965+
let mut state = CodexControlState::new(&runtime, "thread-main".into());
3966+
state.subscribed = true;
3967+
for message in [
3968+
json!({
3969+
"method": "turn/started",
3970+
"params": { "threadId": "thread-main", "turn": { "id": "turn-1" } }
3971+
}),
3972+
json!({
3973+
"method": "thread/status/changed",
3974+
"params": { "threadId": "thread-main", "status": { "type": "systemError" } }
3975+
}),
3976+
json!({
3977+
"method": "turn/completed",
3978+
"params": { "threadId": "thread-main", "turn": { "id": "turn-1" } }
3979+
}),
3980+
] {
3981+
state.observe(&message).unwrap();
3982+
}
3983+
assert_eq!(
3984+
state.observed(),
3985+
&CodexObservedState::Held {
3986+
reason: CodexHoldReason::SystemError,
3987+
turn_id: None
3988+
}
3989+
);
3990+
state
3991+
.observe(&json!({
3992+
"method": "thread/status/changed",
3993+
"params": {
3994+
"threadId": "thread-main",
3995+
"status": { "type": "active", "activeFlags": [] }
3996+
}
3997+
}))
3998+
.unwrap();
3999+
assert_eq!(
4000+
state.observed(),
4001+
&CodexObservedState::Held {
4002+
reason: CodexHoldReason::ActiveWithoutTurn,
4003+
turn_id: None
4004+
}
4005+
);
4006+
state
4007+
.observe(&json!({
4008+
"method": "turn/started",
4009+
"params": { "threadId": "thread-main", "turn": { "id": "turn-2" } }
4010+
}))
4011+
.unwrap();
4012+
assert_eq!(
4013+
state.observed(),
4014+
&CodexObservedState::Active {
4015+
turn_id: "turn-2".into()
4016+
}
4017+
);
4018+
}
4019+
38744020
#[test]
38754021
fn persisted_control_state_is_bound_to_the_exact_runtime_incarnation() {
38764022
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)