@@ -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