@@ -63,6 +63,12 @@ async function fixture(options: {
6363 * turn but carries no settlement binding.
6464 */
6565 guardUnbound ?: boolean ;
66+ /**
67+ * Commit the guard's own deferred explicit selection for this Turn: the
68+ * receipt retains the chosen Todo but still carries no settlement binding,
69+ * because the guard bound the preemption only on argument-less reentry.
70+ */
71+ guardDeferred ?: boolean ;
6672 writeback ?: boolean ;
6773 spend ?: boolean ;
6874 completion ?: boolean ;
@@ -106,6 +112,25 @@ async function fixture(options: {
106112 } ,
107113 } ] ;
108114 const runs : Record < string , unknown > [ ] = [ ] ;
115+ if ( options . guardDeferred ) {
116+ events . push ( {
117+ schema_version : "loopx_rollout_event_v0" ,
118+ event_id : "event-guard-deferred" ,
119+ event_kind : "quota_should_run" ,
120+ goal_id : goalId ,
121+ agent_id : agentId ,
122+ run_id : turnId ,
123+ status : "action_selection_deferred" ,
124+ details : {
125+ pending_action_selection_todo_id : todoId ,
126+ pending_action_selection_state : "deferred" ,
127+ pending_action_selection_reason : "autonomous_replan" ,
128+ settlement_effect_id : "" ,
129+ todo_id : "" ,
130+ replan_obligation_id : "" ,
131+ } ,
132+ } ) ;
133+ }
109134 if ( options . writeback ) {
110135 events . push ( {
111136 schema_version : "loopx_rollout_event_v0" ,
@@ -388,6 +413,40 @@ test("names the unbound same-turn receipt and the repair instead of a mismatch",
388413 } ) ;
389414} ) ;
390415
416+ test ( "names the argument-less guard reentry for a deferred explicit selection" , async ( ) => {
417+ // A deferred explicit selection is also identity-less, but its repair is not
418+ // "rebind with --todo-id": that re-enters the same preemption and defers
419+ // again, which is how a caller ends up looping instead of settling. The
420+ // retained selection tells the two unbound states apart, so the refusal can
421+ // name the reentry that actually binds the preemption.
422+ const runtimeRoot = await fixture ( { guardUnbound : true , guardDeferred : true } ) ;
423+
424+ const result = await readQuotaSettlement ( request ( runtimeRoot ) ) ;
425+
426+ const failure = ( result . settlement as any ) . result . failure ;
427+ // Both unbound states share the receipt's own failure kind; the deferred
428+ // selection is told apart by the repair text and the retained selection.
429+ assert . equal ( failure . kind , "receipt_unbound" ) ;
430+ assert . match ( failure . reason , / c a r r i e s n o s e t t l e m e n t b i n d i n g y e t / ) ;
431+ assert . match (
432+ failure . reason ,
433+ new RegExp (
434+ `quota should-run --turn-instance-id ${ turnId } (?! --todo-id)` ,
435+ ) ,
436+ ) ;
437+ assert . match ( failure . reason , / w i t h o u t - - t o d o - i d / ) ;
438+ assert . doesNotMatch (
439+ failure . reason ,
440+ new RegExp ( `--todo-id ${ todoId } ` ) ,
441+ ) ;
442+ assert . deepEqual ( failure . details , {
443+ binding_kind : "unbound" ,
444+ requested_binding_kind : "todo" ,
445+ turn_instance_id : turnId ,
446+ deferred_selection_todo_id : todoId ,
447+ } ) ;
448+ } ) ;
449+
391450test ( "still reports a receipt bound to another work item as a mismatch" , async ( ) => {
392451 // The unbound state must not swallow the case where the receipt was bound and
393452 // the caller asked for something else: that is a real conflict, and its repair
0 commit comments