From 8d93c042d1a5666de3522f2d6d3a6d754951d51e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:54:11 +0800 Subject: [PATCH] fix(quota): name the argument-less reentry for a deferred selection A Turn whose explicit --todo-id selection the guard deferred commits a receipt that still carries no settlement binding, because the guard binds the autonomous replan preemption only on argument-less reentry. The refusal named a rebind with --todo-id as its repair, and that call re-enters the same preemption and defers again, so a caller following the message could not settle the Turn at all. The read model already receives the receipt details, so the refusal can tell the two unbound states apart from the selection the guard retained and name the reentry that actually binds the preemption, keeping the retained Todo in its typed details. The pre-selection repair text is unchanged. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../quota/settlement_readback.ts | 25 +++++++- .../quota_settlement_readback.test.ts | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index c59be74f2..dba6250e1 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -616,16 +616,35 @@ function resolveIdentity( // binder rather than two -- which means the caller has to be told the state // and the exact repair instead of being handed a binding mismatch it cannot // act on. + // + // 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}`, "identity_mismatch", { 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 17ad1ec43..62a4462e4 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", @@ -386,6 +411,38 @@ 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; + assert.equal(failure.kind, "identity_mismatch"); + 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