diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index 452c255425..be785309e9 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -176,7 +176,10 @@ def _requested_quota_action_selection_preflight( QuotaActionSelectionConflictKind.CONFLICT, requested_todo_id=requested_todo_id, selected_todo_id=receipt_pending_action_todo_id, - qualification_state="retained_selection", + qualification_state=( + "retained_selection" if receipt_pending_action_todo_id else None + ), + receipt_replan_obligation_id=receipt_bound_replan_obligation_id, ) if admitted: return None diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index 62f5cc3e16..8c7936441d 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -158,6 +158,13 @@ def quota_failure_payload( "agent_must_attempt": error.admission_must_attempt, "delivery_allowed": error.admission_delivery_allowed, } + if error.retained_selection: + selection_conflict["retained_selection"] = True + selection_conflict["retained_selection_todo_id"] = error.selected_todo_id + if error.receipt_replan_obligation_id: + selection_conflict["receipt_replan_obligation_id"] = ( + error.receipt_replan_obligation_id + ) payload.update( { "reason": str(error), diff --git a/loopx/control_plane/quota/error_codes.py b/loopx/control_plane/quota/error_codes.py index 9554e82c4f..e8296e7bb0 100644 --- a/loopx/control_plane/quota/error_codes.py +++ b/loopx/control_plane/quota/error_codes.py @@ -72,8 +72,8 @@ class QuotaActionSelectionConflictError(RuntimeError): A guard bound to a ``--todo-id`` has to agree with the current projection. When it cannot, this error names what was requested, what the projection - currently selects, and what the caller should do next, so the failure is not - reported as an opaque quota collection failure. + selects or the Turn retains, and what the caller should do next, so the + failure is not reported as an opaque quota collection failure. """ error_code = "quota_action_selection_conflict" @@ -89,6 +89,7 @@ def __init__( unsettled_repair: str | None = None, admission_must_attempt: bool | None = None, admission_delivery_allowed: bool | None = None, + receipt_replan_obligation_id: str | None = None, ) -> None: self.kind = kind self.requested_todo_id = requested_todo_id @@ -98,6 +99,12 @@ def __init__( self.unsettled_repair = unsettled_repair self.admission_must_attempt = admission_must_attempt self.admission_delivery_allowed = admission_delivery_allowed + self.receipt_replan_obligation_id = receipt_replan_obligation_id + self.retained_selection = bool( + kind is QuotaActionSelectionConflictKind.CONFLICT + and receipt_replan_obligation_id + and selected_todo_id + ) if kind is QuotaActionSelectionConflictKind.UNQUALIFIED: reason = ( "the current projection carries no typed action-selection " @@ -135,6 +142,28 @@ def __init__( else "" ) ) + elif self.retained_selection: + # The Turn's receipt is bound to an autonomous replan obligation, so + # the requested Todo cannot replace the selection that Turn already + # retains. The default conflict sentence calls that id "the + # projection's current selection", which is not what the caller is + # up against: name the retained selection and the obligation that + # owns the Turn. + reason = ( + f"requested Todo {requested_todo_id or '(none)'} cannot replace " + "the retained pending selection " + f"{selected_todo_id or 'none'} on this Turn: the Turn's receipt is " + "bound to the autonomous replan obligation " + f"{receipt_replan_obligation_id or '(unnamed)'}, which owns its " + "settlement" + ) + elif receipt_replan_obligation_id: + reason = ( + f"requested Todo {requested_todo_id or '(none)'} cannot replace " + "this Turn's settlement identity: it is bound to the autonomous " + f"replan obligation {receipt_replan_obligation_id}, and no pending " + "Todo selection is retained" + ) else: reason = ( f"requested Todo {requested_todo_id or '(none)'} is neither the " @@ -148,6 +177,18 @@ def __init__( "the prior Turn named in the reason is the blocker, settle that " "Turn first and then rerun this Turn" ) + elif self.retained_selection: + self.recommended_action = ( + "settle the autonomous replan obligation that owns this Turn, or " + "rerun `loopx quota should-run` without --todo-id to read the " + "selection the Turn retains; do not rebind the retained selection" + ) + elif receipt_replan_obligation_id: + self.recommended_action = ( + "settle the autonomous replan obligation that owns this Turn, " + "then start a fresh Turn and rerun `loopx quota should-run` to " + "select a Todo; do not rebind this Turn" + ) else: self.recommended_action = ( "rerun `loopx quota should-run` without --todo-id to read the current " diff --git a/tests/control_plane/test_quota_action_selection_conflict.py b/tests/control_plane/test_quota_action_selection_conflict.py index 2893d5e90f..4dfde67741 100644 --- a/tests/control_plane/test_quota_action_selection_conflict.py +++ b/tests/control_plane/test_quota_action_selection_conflict.py @@ -230,3 +230,52 @@ def test_refused_delivery_boundary_is_published_as_a_typed_admission_fact() -> N "agent_must_attempt": True, "delivery_allowed": False, } + + +def test_retained_selection_names_the_replan_obligation_that_owns_the_turn() -> None: + """A receipt-bound replan Turn cannot hand its settlement to another Todo.""" + + retained_todo_id = "todo_retained_selection" + replan_obligation_id = "replan-retained-selection-fixture" + with pytest.raises(QuotaActionSelectionConflictError) as raised: + _requested_quota_action_selection_preflight( + _payload(selected_todo={"todo_id": retained_todo_id}), + requested_todo_id=REQUESTED_TODO_ID, + receipt_bound_todo_id=None, + receipt_bound_replan_obligation_id=replan_obligation_id, + receipt_pending_action_todo_id=retained_todo_id, + receipt_identity_upgraded=True, + ) + error = raised.value + + assert error.kind is QuotaActionSelectionConflictKind.CONFLICT + assert error.retained_selection is True + assert error.receipt_replan_obligation_id == replan_obligation_id + assert retained_todo_id in str(error) + assert replan_obligation_id in str(error) + assert "retained pending selection" in str(error) + assert "own" in str(error.recommended_action) + + args = argparse.Namespace( + quota_command="should-run", + goal_id="quota-conflict-fixture", + agent_id="agent-fixture", + runtime_root=None, + verbose=False, + ) + payload = quota_failure_payload( + args, + registry_path=Path("/tmp/quota-conflict-registry.json"), + runtime_root_arg=None, + error=error, + ) + + assert payload["action_selection_conflict"] == { + "kind": "conflict", + "requested_todo_id": REQUESTED_TODO_ID, + "selected_todo_id": retained_todo_id, + "qualification_state": "retained_selection", + "retained_selection": True, + "retained_selection_todo_id": retained_todo_id, + "receipt_replan_obligation_id": replan_obligation_id, + } diff --git a/tests/control_plane/test_selection_replan_reentry.py b/tests/control_plane/test_selection_replan_reentry.py index eb591d10d5..39cac7e75e 100644 --- a/tests/control_plane/test_selection_replan_reentry.py +++ b/tests/control_plane/test_selection_replan_reentry.py @@ -152,6 +152,20 @@ def test_deferred_selection_recovers_same_turn_and_settles_once(tmp_path, bindin assert settled["heartbeat_receipt"]["settlement_identity"] == identity rc, conflict = _run_cli(registry, runtime, *guard, "--todo-id", "todo_another_selection") assert rc == 1 and conflict["ok"] is False + if binding == "autonomous_replan": + facts = conflict["action_selection_conflict"] + assert facts["receipt_replan_obligation_id"] == identity[ + "replan_obligation_id" + ] + if selection_deferred: + assert facts["retained_selection"] is True + assert facts["retained_selection_todo_id"] == selected_id + else: + assert "retained_selection" not in facts + assert "retained_selection_todo_id" not in facts + assert facts["qualification_state"] is None + assert "no pending Todo selection" in conflict["reason"] + assert "selection the Turn retains" not in conflict["recommended_action"] assert _heartbeat_receipt_count(runtime, turn) == expected_after_resume assert _spend_run_count(runtime) == 1 @@ -201,3 +215,11 @@ def test_reentry_never_replaces_retained_selection_with_recommended_todo(tmp_pat "--replan-obligation-id" in action and "--todo-id" not in action for action in resumed["interaction_contract"]["cli_channel"]["next_cli_actions"] ) + rc, conflict = _run_cli( + registry, runtime, *guard, "--todo-id", SELECTED_REPLAN_TODO_ID + ) + assert rc == 1, conflict + facts = conflict["action_selection_conflict"] + assert facts["retained_selection"] is True + assert facts["retained_selection_todo_id"] == retained_todo_id + assert facts["receipt_replan_obligation_id"] == identity["replan_obligation_id"]