Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions loopx/control_plane/quota/settlement_readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "<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 ?? "<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 }),
},
);
}
Expand Down
59 changes: 59 additions & 0 deletions tests/control_plane_ts/quota_settlement_readback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,6 +112,25 @@ async function fixture(options: {
},
}];
const runs: Record<string, unknown>[] = [];
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",
Expand Down Expand Up @@ -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
Expand Down
Loading