diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 373b76ba1..22b7b2e5b 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -622,16 +622,35 @@ function resolveIdentity( // act on. The state also gets its own failure kind, so a consumer can branch // on the missing binding without reading details.binding_kind: the receipt // exists and is well-formed here, which is not what identity_mismatch means. + // + // A Turn whose explicit choice the guard already deferred is a different + // state with a different repair: rebinding through `--todo-id` re-enters the + // same preemption and defers again, while the guard's argument-less reentry + // binds the preemption it is actually holding. Naming the wrong command + // costs the caller a turn, so the repair is chosen from the retained + // selection the guard recorded. + const deferredSelectionTodoId = normalizeTodoId( + receiptDetails.pending_action_selection_todo_id, + ); + const repair = deferredSelectionTodoId === null + ? "rebind it through the guard's same-turn reconciliation, then settle: " + + "quota should-run --turn-instance-id " + + `${turnInstanceId} --todo-id ${identity.todo_id ?? ""}` + : "the guard deferred this turn's explicit selection instead of binding " + + "it, so rerun the guard for the same turn without --todo-id (which " + + "binds the preemption it is holding), then settle with the identity it " + + `returns: quota should-run --turn-instance-id ${turnInstanceId}`; return failedIdentity( "the quota should-run receipt for this turn carries no settlement binding " + - `yet (turn_instance_id ${turnInstanceId}); rebind it through the guard's ` + - "same-turn reconciliation, then settle: quota should-run --turn-instance-id " + - `${turnInstanceId} --todo-id ${identity.todo_id ?? ""}`, + `yet (turn_instance_id ${turnInstanceId}); ${repair}`, "receipt_unbound", { binding_kind: "unbound", requested_binding_kind: identity.binding_kind, turn_instance_id: turnInstanceId, + ...(deferredSelectionTodoId === null + ? {} + : { deferred_selection_todo_id: deferredSelectionTodoId }), }, ); } diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index f639b155f..1a2bf1b2e 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -63,6 +63,12 @@ async function fixture(options: { * turn but carries no settlement binding. */ guardUnbound?: boolean; + /** + * Commit the guard's own deferred explicit selection for this Turn: the + * receipt retains the chosen Todo but still carries no settlement binding, + * because the guard bound the preemption only on argument-less reentry. + */ + guardDeferred?: boolean; writeback?: boolean; spend?: boolean; completion?: boolean; @@ -106,6 +112,25 @@ async function fixture(options: { }, }]; const runs: Record[] = []; + if (options.guardDeferred) { + events.push({ + schema_version: "loopx_rollout_event_v0", + event_id: "event-guard-deferred", + event_kind: "quota_should_run", + goal_id: goalId, + agent_id: agentId, + run_id: turnId, + status: "action_selection_deferred", + details: { + pending_action_selection_todo_id: todoId, + pending_action_selection_state: "deferred", + pending_action_selection_reason: "autonomous_replan", + settlement_effect_id: "", + todo_id: "", + replan_obligation_id: "", + }, + }); + } if (options.writeback) { events.push({ schema_version: "loopx_rollout_event_v0", @@ -388,6 +413,40 @@ test("names the unbound same-turn receipt and the repair instead of a mismatch", }); }); +test("names the argument-less guard reentry for a deferred explicit selection", async () => { + // A deferred explicit selection is also identity-less, but its repair is not + // "rebind with --todo-id": that re-enters the same preemption and defers + // again, which is how a caller ends up looping instead of settling. The + // retained selection tells the two unbound states apart, so the refusal can + // name the reentry that actually binds the preemption. + const runtimeRoot = await fixture({ guardUnbound: true, guardDeferred: true }); + + const result = await readQuotaSettlement(request(runtimeRoot)); + + const failure = (result.settlement as any).result.failure; + // Both unbound states share the receipt's own failure kind; the deferred + // selection is told apart by the repair text and the retained selection. + assert.equal(failure.kind, "receipt_unbound"); + assert.match(failure.reason, /carries no settlement binding yet/); + assert.match( + failure.reason, + new RegExp( + `quota should-run --turn-instance-id ${turnId}(?! --todo-id)`, + ), + ); + assert.match(failure.reason, /without --todo-id/); + assert.doesNotMatch( + failure.reason, + new RegExp(`--todo-id ${todoId}`), + ); + assert.deepEqual(failure.details, { + binding_kind: "unbound", + requested_binding_kind: "todo", + turn_instance_id: turnId, + deferred_selection_todo_id: todoId, + }); +}); + test("still reports a receipt bound to another work item as a mismatch", async () => { // The unbound state must not swallow the case where the receipt was bound and // the caller asked for something else: that is a real conflict, and its repair