Skip to content

[Bug]: a missing settlement binding is reported against the current Turn even when an earlier Turn owes its closeout #6110

Closed

Description

@hua03

A missing settlement binding is reported against the current Turn even when an earlier Turn owes its closeout

Affected version: release 20261010T034436Z — loopx/control_plane/quota/settlement_readback.ts, the receipt_unbound refusal in resolveIdentity().
Impact: one unsettled Turn wedges every later Turn, and the message that explains it points at the wrong Turn. refresh-state and quota spend-slot keep failing with receipt_unbound for the current Turn, whose suggested repair (rebind through the guard) only replays, so an operator has no way forward from the message alone. Recovery is only possible after reading unsettled_host_turn_recovery.prior_turn_instance_id out of the guard payload by hand.

What happens

The documented wake order runs the guard before a work item is chosen, so a guard can commit a receipt with no settlement binding — that state is already handled on purpose (receipt_unbound + a named rebind command). There is a third state with the same receipt shape:

  1. Turn A runs quota should-run --turn-instance-id A (no --todo-id) and is then settled under a different id (or not settled at all).
  2. Turn A now owes durable_writeback_receipt + quota_spend_receipt.
  3. Every later Turn's guard answers unsettled_host_turn_recovery:
{"state": "recovery_required",
 "reason_code": "required_closeout_receipt_missing",
 "prior_turn_instance_id": "2026-10-10T09:44:29Z",
 "binding_id": "todo_06ebeb784d00",
 "missing_receipts": ["durable_writeback_receipt", "quota_spend_receipt"]}
  1. refresh-state / spend-slot for the current Turn fail with:
the quota should-run receipt for this turn carries no settlement binding yet
(turn_instance_id 2026-10-10T09:44:43Z); rebind it through the guard's same-turn
reconciliation, then settle: quota should-run --turn-instance-id
2026-10-10T09:44:43Z --todo-id todo_06ebeb784d00
  1. Executing that instruction returns the recovery decision again (status: replayed), and never binds. The command that actually works is in the guard's compiled contract, not in any error:
interaction_contract.cli_channel.next_cli_actions = [
  "... never fabricate progress to clear recovery",
  "loopx ... quota should-run ... --turn-instance-id 2026-10-10T09:44:29Z --todo-id todo_06ebeb784d00",
  "after the original Turn is legally settled, rerun the current Turn below; recovery itself does not spend quota",
  "loopx ... quota should-run ... --turn-instance-id <current-turn> --todo-id ..."]

The guard's own receipt_unbound comment already states the principle ("Naming the wrong command costs the caller a turn"), and this state is exactly that cost: the refusal names the current Turn's rebind, which cannot bind while an earlier Turn is owed.

Reproduction

  1. quota should-run --turn-instance-id A --todo-id <todo> (records the Turn), then settle a different Turn id (or settle nothing).
  2. quota should-run --turn-instance-id B --todo-id <todo> → unsettled_host_turn_recovery, settlement_binding_owed: true.
  3. refresh-state / quota spend-slot for B → the receipt_unbound refusal above, and repeating the suggested rebind only replays.

Verified against a live goal: the owed Turn was …09:44:29Z, the reporting Turn …09:44:43Z; settling the prior Turn with its own id cleared recovery in three commands.

Local fix (branch fix/quota-receipt-unbound-recovery-hint, commit 52dc3aa3a)

Keep the existing refusal and repair, and add the state it cannot cover: the guard can replay the recovery instead of binding, and the repair for that is the Turn named by the guard's own compiled actions.

const RECOVERY_ACTIONS_REF = "interaction_contract.cli_channel.next_cli_actions";
const RECOVERY_ACTIONS_LABEL = `the guard's ${RECOVERY_ACTIONS_REF}`;
...
const recoveryNote =
  "; if that guard replayed at unsettled_host_turn_recovery instead of " +
  "binding, an earlier Turn of this goal owes its closeout: settle the Turn " +
  `named by ${RECOVERY_ACTIONS_LABEL} first, then settle this Turn`;
return failedIdentity(..., "receipt_unbound",
  { binding_kind: "unbound", requested_binding_kind: identity.binding_kind,
    turn_instance_id: turnInstanceId,
    recovery_actions_ref: RECOVERY_ACTIONS_REF, ... });

tests/control_plane_ts/quota_settlement_readback.test.ts asserts the new sentence and detail field for both unbound states; node --experimental-strip-types --test tests/control_plane_ts/quota_settlement_readback.test.ts → 125 pass, and the adjacent settlement suites (turn_settlement, quota_settlement_plan, causal_blocked_wait, unsettled_host_turn_recovery) → 100 pass.

Follow-up options (not in that patch)

  • Name the prior Turn id in the failure details by reusing preflightPriorHostTurnCloseout() (it already returns candidate.prior_turn_instance_id + missing_receipts). readQuotaSettlementFromRequest is synchronous and also called from readQuotaSettlementFromSnapshot, which two other modules consume, so this needs a deliberate async seam rather than a local edit.
  • Consider surfacing a typed hint when a guard commits a receipt with no binding at all, since that is the entry point to this state and it is currently silent.

Activity

  1. added a commit that references this issue on Oct 11, 2026
    090c14c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions