Skip to content

Commit 992108d

Browse files
authored
Merge pull request #4890 from loopx-project/codex/quota-deferred-selection-obligation-0922
fix(quota): name the argument-less reentry for a deferred selection
2 parents 912ffc3 + be6e04c commit 992108d

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎loopx/control_plane/quota/settlement_readback.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -622,16 +622,35 @@ function resolveIdentity(
622622
// act on. The state also gets its own failure kind, so a consumer can branch
623623
// on the missing binding without reading details.binding_kind: the receipt
624624
// exists and is well-formed here, which is not what identity_mismatch means.
625+
//
626+
// A Turn whose explicit choice the guard already deferred is a different
627+
// state with a different repair: rebinding through `--todo-id` re-enters the
628+
// same preemption and defers again, while the guard's argument-less reentry
629+
// binds the preemption it is actually holding. Naming the wrong command
630+
// costs the caller a turn, so the repair is chosen from the retained
631+
// selection the guard recorded.
632+
const deferredSelectionTodoId = normalizeTodoId(
633+
receiptDetails.pending_action_selection_todo_id,
634+
);
635+
const repair = deferredSelectionTodoId === null
636+
? "rebind it through the guard's same-turn reconciliation, then settle: " +
637+
"quota should-run --turn-instance-id " +
638+
`${turnInstanceId} --todo-id ${identity.todo_id ?? "<todo_id>"}`
639+
: "the guard deferred this turn's explicit selection instead of binding " +
640+
"it, so rerun the guard for the same turn without --todo-id (which " +
641+
"binds the preemption it is holding), then settle with the identity it " +
642+
`returns: quota should-run --turn-instance-id ${turnInstanceId}`;
625643
return failedIdentity(
626644
"the quota should-run receipt for this turn carries no settlement binding " +
627-
`yet (turn_instance_id ${turnInstanceId}); rebind it through the guard's ` +
628-
"same-turn reconciliation, then settle: quota should-run --turn-instance-id " +
629-
`${turnInstanceId} --todo-id ${identity.todo_id ?? "<todo_id>"}`,
645+
`yet (turn_instance_id ${turnInstanceId}); ${repair}`,
630646
"receipt_unbound",
631647
{
632648
binding_kind: "unbound",
633649
requested_binding_kind: identity.binding_kind,
634650
turn_instance_id: turnInstanceId,
651+
...(deferredSelectionTodoId === null
652+
? {}
653+
: { deferred_selection_todo_id: deferredSelectionTodoId }),
635654
},
636655
);
637656
}

‎tests/control_plane_ts/quota_settlement_readback.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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, /carries no settlement binding yet/);
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, /without --todo-id/);
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+
391450
test("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

Comments
 (0)