From d75ebf7072aea34463928e7888fbecb436b15d61 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:10:08 +0800 Subject: [PATCH 1/2] fix(quota): name the retained selection and the replan obligation that owns the Turn A Turn whose receipt is bound to an autonomous replan obligation cannot hand its settlement to another Todo. The refusal reported that case as a plain selection conflict, calling the retained pending selection "the projection's current selection", and published neither the retained selection nor the obligation that owns the Turn, so a caller could not branch on either without parsing prose. The conflict now states that the requested Todo cannot replace the retained pending selection, names the autonomous replan obligation that owns the Turn, and publishes `retained_selection`, `retained_selection_todo_id` and `receipt_replan_obligation_id` in the failure payload. The recommended action points at settling that obligation instead of rebinding the retained selection. This is the last branch of the explicit-selection preflight that still carried no typed retention facts; the eligibility predicates are unchanged. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/quota_action_selection.py | 2 + loopx/cli_commands/quota_failure_report.py | 7 +++ loopx/control_plane/quota/error_codes.py | 25 ++++++++++ .../test_quota_action_selection_conflict.py | 49 +++++++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index 452c255425..8021c36666 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -177,6 +177,8 @@ def _requested_quota_action_selection_preflight( requested_todo_id=requested_todo_id, selected_todo_id=receipt_pending_action_todo_id, qualification_state="retained_selection", + receipt_replan_obligation_id=receipt_bound_replan_obligation_id, + retained_selection=True, ) 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..c509fb9368 100644 --- a/loopx/control_plane/quota/error_codes.py +++ b/loopx/control_plane/quota/error_codes.py @@ -89,6 +89,8 @@ 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, + retained_selection: bool = False, ) -> None: self.kind = kind self.requested_todo_id = requested_todo_id @@ -98,6 +100,8 @@ 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 = retained_selection if kind is QuotaActionSelectionConflictKind.UNQUALIFIED: reason = ( "the current projection carries no typed action-selection " @@ -135,6 +139,21 @@ def __init__( else "" ) ) + elif 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" + ) else: reason = ( f"requested Todo {requested_todo_id or '(none)'} is neither the " @@ -148,6 +167,12 @@ def __init__( "the prior Turn named in the reason is the blocker, settle that " "Turn first and then rerun this Turn" ) + elif 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" + ) 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, + } From 9779cff7764ee9ab2011ac19d759d5000f581135 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:21:57 +0800 Subject: [PATCH 2/2] fix(quota): distinguish retained selection from replan identity Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/quota_action_selection.py | 5 ++-- loopx/control_plane/quota/error_codes.py | 28 +++++++++++++++---- .../test_selection_replan_reentry.py | 22 +++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index 8021c36666..be785309e9 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -176,9 +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, - retained_selection=True, ) if admitted: return None diff --git a/loopx/control_plane/quota/error_codes.py b/loopx/control_plane/quota/error_codes.py index c509fb9368..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" @@ -90,7 +90,6 @@ def __init__( admission_must_attempt: bool | None = None, admission_delivery_allowed: bool | None = None, receipt_replan_obligation_id: str | None = None, - retained_selection: bool = False, ) -> None: self.kind = kind self.requested_todo_id = requested_todo_id @@ -101,7 +100,11 @@ def __init__( 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 = retained_selection + 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 " @@ -139,7 +142,7 @@ def __init__( else "" ) ) - elif retained_selection: + 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 @@ -154,6 +157,13 @@ def __init__( 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 " @@ -167,12 +177,18 @@ def __init__( "the prior Turn named in the reason is the blocker, settle that " "Turn first and then rerun this Turn" ) - elif retained_selection: + 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_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"]