From f74a67b50b1d32620abdf57cd4082d289cf267e4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:17:08 +0800 Subject: [PATCH] fix(quota): make an unreconcilable action selection self-diagnosing A guard bound with `--todo-id` whose selection cannot be reconciled with the current projection used to raise a bare RuntimeError. That surfaced as `quota_unexpected_collection_error` with the reason "quota collection failed" and a recommended action pointing at heartbeat receipt writeback, so the real cause was invisible to the Agent that hit it. The preflight now raises a typed `QuotaActionSelectionConflictError` that names the requested Todo, the projection's current selection, and the qualification state, and that carries its own `error_code` and `recommended_action`: - `kind=unqualified`: the projection carries no typed qualification at all; - `kind=conflict`: the requested Todo is neither the current selection nor deferred/rejected by it. `quota_error_code` returns `quota_action_selection_conflict`, and `quota_failure_payload` reports the conflict as its own `status` with a typed `action_selection_conflict` block (`kind`, `requested_todo_id`, `selected_todo_id`, `qualification_state`) instead of the generic collection failure. The recommended action is a real next read: rerun without `--todo-id` to see the current selection, then bind that Todo, a deferred Todo, or the Todo the recovery obligation must settle. The neighboring typed paths are unchanged: a Todo that is deferred or rejected by the delivery frontier keeps its existing typed payload, and a qualified selection for the requested Todo still passes the preflight. Covered by `tests/control_plane/test_quota_action_selection_conflict.py`: both raise kinds, the code mapping, the failure payload shape, and the non-conflict case that must keep returning False. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/quota.py | 15 ++- loopx/cli_commands/quota_failure_report.py | 18 +++ loopx/control_plane/quota/error_codes.py | 54 ++++++++ .../test_quota_action_selection_conflict.py | 117 ++++++++++++++++++ 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_quota_action_selection_conflict.py diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 17518c2a5e..e19539b78a 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -22,6 +22,8 @@ from ..control_plane.quota.effect_program import SettlementIdentity from ..control_plane.quota.error_codes import ( QuotaCommandValidationError, + QuotaActionSelectionConflictError, + QuotaActionSelectionConflictKind, ) from ..control_plane.quota.heartbeat_receipt import ( HEARTBEAT_RECEIPT_SCHEMA_VERSION, @@ -268,11 +270,18 @@ def _apply_requested_quota_action_selection_preflight( return False if not isinstance(qualification_value, Mapping): - raise RuntimeError("requested action selection lacks typed qualification") + raise QuotaActionSelectionConflictError( + QuotaActionSelectionConflictKind.UNQUALIFIED, + requested_todo_id=requested_todo_id, + selected_todo_id=selected_todo_id, + ) qualification_state = str(qualification.get("state") or "") if qualification_state not in {"deferred", "rejected"}: - raise RuntimeError( - "requested action selection qualification conflicts with its projection" + raise QuotaActionSelectionConflictError( + QuotaActionSelectionConflictKind.CONFLICT, + requested_todo_id=requested_todo_id, + selected_todo_id=selected_todo_id, + qualification_state=qualification_state, ) qualification_reason = str( qualification.get("reason") or "candidate_not_currently_eligible" diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index c3e97ea2a2..7384ed43c9 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -20,6 +20,7 @@ ) from ..control_plane.quota.error_codes import ( HeartbeatReceiptIdentityConflictError, + QuotaActionSelectionConflictError, QuotaCommandValidationError, QuotaIdentityPreconditionError, quota_error_code, @@ -127,6 +128,23 @@ def quota_failure_payload( **verbose_debug, **lock_timeout_fields, } + if isinstance(error, QuotaActionSelectionConflictError): + # The requested Todo could not be reconciled with the projection. Report + # the real conflict and the next read to make, rather than the generic + # "quota collection failed" and a pointer at receipt writeback. + payload.update( + { + "reason": str(error), + "status": "quota_action_selection_conflict", + "recommended_action": error.recommended_action, + "action_selection_conflict": { + "kind": error.kind.value, + "requested_todo_id": error.requested_todo_id, + "selected_todo_id": error.selected_todo_id, + "qualification_state": error.qualification_state, + }, + } + ) if isinstance(error, QuotaIdentityPreconditionError): payload.update( { diff --git a/loopx/control_plane/quota/error_codes.py b/loopx/control_plane/quota/error_codes.py index 3cac31974c..2f78cf5c1b 100644 --- a/loopx/control_plane/quota/error_codes.py +++ b/loopx/control_plane/quota/error_codes.py @@ -59,6 +59,58 @@ def __init__( super().__init__(reason) +class QuotaActionSelectionConflictKind(StrEnum): + """Why a requested ``--todo-id`` could not be reconciled with the projection.""" + + UNQUALIFIED = "unqualified" + CONFLICT = "conflict" + + +class QuotaActionSelectionConflictError(RuntimeError): + """Public-safe diagnostic for an unreconcilable requested action selection. + + 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. + """ + + error_code = "quota_action_selection_conflict" + + def __init__( + self, + kind: QuotaActionSelectionConflictKind, + *, + requested_todo_id: str | None, + selected_todo_id: str | None = None, + qualification_state: str | None = None, + ) -> None: + self.kind = kind + self.requested_todo_id = requested_todo_id + self.selected_todo_id = selected_todo_id + self.qualification_state = qualification_state + if kind is QuotaActionSelectionConflictKind.UNQUALIFIED: + reason = ( + "the current projection carries no typed action-selection " + "qualification, so the requested Todo " + f"{requested_todo_id or '(none)'} cannot be reconciled with the " + "delivery frontier" + ) + else: + reason = ( + f"requested Todo {requested_todo_id or '(none)'} is neither the " + "projection's current selection " + f"({selected_todo_id or 'none'}) nor deferred or rejected by it " + f"(qualification state: {qualification_state or 'absent'})" + ) + self.recommended_action = ( + "rerun `loopx quota should-run` without --todo-id to read the current " + "selection, then bind that Todo, a deferred Todo, or the Todo the " + "recovery obligation must settle" + ) + super().__init__(reason) + + def quota_error_code(exc: BaseException) -> str: if isinstance(exc, json.JSONDecodeError): return "quota_state_invalid_json" @@ -66,6 +118,8 @@ def quota_error_code(exc: BaseException) -> str: return "quota_invalid_arguments" if isinstance(exc, QuotaIdentityPreconditionError): return exc.error_code + if isinstance(exc, QuotaActionSelectionConflictError): + return exc.error_code if isinstance(exc, HeartbeatReceiptIdentityConflictError): return "heartbeat_receipt_identity_conflict" if isinstance(exc, PermissionError): diff --git a/tests/control_plane/test_quota_action_selection_conflict.py b/tests/control_plane/test_quota_action_selection_conflict.py new file mode 100644 index 0000000000..5bd0e8cd1a --- /dev/null +++ b/tests/control_plane/test_quota_action_selection_conflict.py @@ -0,0 +1,117 @@ +"""An unreconcilable `--todo-id` guard names its own cause and next read.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest + +from loopx.cli_commands.quota import _apply_requested_quota_action_selection_preflight +from loopx.cli_commands.quota_failure_report import quota_failure_payload +from loopx.control_plane.quota.error_codes import ( + QuotaActionSelectionConflictError, + QuotaActionSelectionConflictKind, + quota_error_code, +) + + +REQUESTED_TODO_ID = "todo_requested_selection" +SELECTED_TODO_ID = "todo_projected_selection" + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "ok": True, + "should_run": True, + "effective_action": "normal_run", + "selected_todo": None, + "execution_obligation": {"must_attempt_work": True}, + "interaction_contract": {"agent_channel": {"must_attempt": True}}, + } + payload.update(overrides) + return payload + + +def _raise(payload: dict[str, object]) -> QuotaActionSelectionConflictError: + with pytest.raises(QuotaActionSelectionConflictError) as raised: + _apply_requested_quota_action_selection_preflight( + payload, + requested_todo_id=REQUESTED_TODO_ID, + receipt_bound_todo_id=None, + receipt_bound_replan_obligation_id=None, + ) + return raised.value + + +def _qualified_for(todo_id: str) -> dict[str, object]: + return { + "schema_version": "action_selection_qualification_v0", + "state": "qualified", + "requested_todo_id": REQUESTED_TODO_ID, + "selected_todo": {"todo_id": todo_id}, + } + + +def test_conflicting_qualification_names_requested_and_selected_todo() -> None: + error = _raise( + _payload(action_selection_qualification=_qualified_for(SELECTED_TODO_ID)) + ) + + assert error.kind is QuotaActionSelectionConflictKind.CONFLICT + assert error.error_code == "quota_action_selection_conflict" + assert REQUESTED_TODO_ID in str(error) + assert SELECTED_TODO_ID in str(error) + assert "qualified" in str(error) + assert quota_error_code(error) == "quota_action_selection_conflict" + + +def test_missing_qualification_is_typed_rather_than_unexplained() -> None: + error = _raise(_payload(selected_todo={"todo_id": SELECTED_TODO_ID})) + + assert error.kind is QuotaActionSelectionConflictKind.UNQUALIFIED + assert REQUESTED_TODO_ID in str(error) + assert "no typed action-selection qualification" in str(error) + + +def test_a_qualified_selection_for_the_requested_todo_is_not_a_conflict() -> None: + is_conflict = _apply_requested_quota_action_selection_preflight( + _payload(action_selection_qualification=_qualified_for(REQUESTED_TODO_ID)), + requested_todo_id=REQUESTED_TODO_ID, + receipt_bound_todo_id=None, + receipt_bound_replan_obligation_id=None, + ) + + assert is_conflict is False + + +def test_failure_payload_reports_the_conflict_instead_of_collection_failure() -> None: + error = _raise( + _payload(action_selection_qualification=_qualified_for(SELECTED_TODO_ID)) + ) + 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["error_code"] == "quota_action_selection_conflict" + assert payload["status"] == "quota_action_selection_conflict" + assert payload["reason"] != "quota collection failed" + assert payload["reason"] == str(error) + assert "heartbeat receipt writeback" not in str(payload["recommended_action"]) + assert payload["action_selection_conflict"] == { + "kind": "conflict", + "requested_todo_id": REQUESTED_TODO_ID, + "selected_todo_id": SELECTED_TODO_ID, + "qualification_state": "qualified", + }