diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 403e088e8b..35171932c9 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -71,6 +71,33 @@ only a qualified request upgrades the identity-less receipt. A newly due hard lane leaves the receipt unbound, and only the resulting receipt-bound envelope is a delivery contract. +An explicit selection that is not admitted returns the shared TypeScript +`action_selection_qualification_v0` result as `action_selection` in the quota +packet. `--turn-envelope` also retains this typed failed-preflight packet, +including its exact re-entry command, instead of rendering an executable Turn. +`quota_action_selection_rejected` +means the requested candidate is not currently eligible; +`quota_action_selection_deferred` preserves the current preemption reason, +such as `autonomous_replan`. A displayed runnable Todo alone does not override +`normal_delivery_allowed=false`. These are preflight outcomes, not heartbeat +receipt identity conflicts: no receipt is created or upgraded, and no quota is +spent. The returned `recommended_action` and `next_cli_actions[0]` re-enter the +current guard with the same registry, runtime, Goal, Agent, Turn and scheduler +context, without the refused `--todo-id`. Follow that guard's actual obligation +before retrying selection. Repeated unsuccessful preflight leaves existing +receipts unchanged; a genuinely conflicting committed identity still fails +with `heartbeat_receipt_identity_conflict`. + +显式选择未获准时,quota 的 `action_selection` 保留 TypeScript 类型化结果; +`--turn-envelope` 同样返回这个失败预检载荷,保留完整重入命令,不渲染可执行 Turn。 +其中 `quota_action_selection_rejected` 表示当前候选不满足资格;`quota_action_selection_deferred` 保留 `autonomous_replan` 等 +真实抢占原因。列表中显示可执行 Todo,并不能覆盖 `normal_delivery_allowed=false`。 +此时不创建或升级回执、不扣额度,也不伪报回执身份冲突。返回命令保留 registry、 +runtime、Goal、Agent、Turn 和调度上下文,去掉被拒的 `--todo-id`,先重新进入 guard +并处理其真实义务,再重试选择。重复失败不改变已有回执;已提交身份的真实冲突仍 +返回 `heartbeat_receipt_identity_conflict`。能力验证通过后的重试仍需重新声明实际 +可用的能力,不因错误诊断或列表显示获得额外权限。 + Portfolio v2 preserves v1's selection policy, candidate ordering, and settlement rules, and adds an optional `continuation_hint` to each suggested action. The default quota producer and Turn controller now require v2. The diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 2ab95b1e9c..4e31c8e711 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -20,6 +20,7 @@ from ..control_plane.quota.effect_program import SettlementIdentity from ..control_plane.quota.error_codes import ( HeartbeatReceiptIdentityConflictError, + QuotaActionSelectionNotAdmitted, QuotaCommandValidationError, QuotaIdentityPreconditionError, quota_error_code, @@ -77,6 +78,11 @@ build_lark_operator_inbox_urgency_projector, dispatch_goal_lark_turn_start_hooks, ) +from .quota_action_selection import ( + commit_requested_action_selection, + reject_action_selection, + require_requested_quota_action_selection, +) from .quota_context import ( QuotaCommandContext, prepare_quota_command_context, @@ -379,79 +385,6 @@ def _heartbeat_quota_action_selection_bindings( return existing, todo_id, replan_obligation_id -def _require_requested_quota_action_selection( - payload: Mapping[str, object], - *, - requested_todo_id: str | None, - receipt_bound_todo_id: str | None, - receipt_bound_replan_obligation_id: str | None, -) -> None: - if not requested_todo_id or ( - receipt_bound_todo_id or receipt_bound_replan_obligation_id - ): - return - selected_todo = payload.get("selected_todo") - selected_todo_id = ( - normalize_todo_id(selected_todo.get("todo_id")) - if isinstance(selected_todo, Mapping) - else None - ) - selection_binding = ( - selected_todo.get("selection_binding") - if isinstance(selected_todo, Mapping) - else None - ) - execution_obligation_value = payload.get("execution_obligation") - execution_obligation: Mapping[str, object] = ( - execution_obligation_value - if isinstance(execution_obligation_value, Mapping) - else {} - ) - interaction_value = payload.get("interaction_contract") - interaction: Mapping[str, object] = ( - interaction_value if isinstance(interaction_value, Mapping) else {} - ) - agent_channel_value = interaction.get("agent_channel") - agent_channel: Mapping[str, object] = ( - agent_channel_value if isinstance(agent_channel_value, Mapping) else {} - ) - pending_selection_qualified = ( - selection_binding == "pending_action_selection" - and payload.get("normal_delivery_allowed") is True - ) - exact_current_obligation_qualified = ( - selection_binding != "pending_action_selection" - and execution_obligation.get("must_attempt_work") is True - and agent_channel.get("must_attempt") is True - ) - if ( - selected_todo_id != requested_todo_id - or payload.get("ok") is not True - or payload.get("should_run") is not True - or not (pending_selection_qualified or exact_current_obligation_qualified) - ): - raise HeartbeatReceiptIdentityConflictError( - "explicit action selection must name one currently projected " - "agent-scoped, capability-ready Todo" - ) - - -def _commit_requested_action_selection( - payload: Mapping[str, object], - *, - requested_todo_id: str | None, -) -> None: - """Project the exact requested selection only after receipt reconciliation.""" - - selected_todo = payload.get("selected_todo") - if ( - requested_todo_id - and isinstance(selected_todo, dict) - and normalize_todo_id(selected_todo.get("todo_id")) == requested_todo_id - ): - selected_todo["selection_binding"] = "heartbeat_receipt" - - def _dispatch_quota_turn_start_hooks( args: argparse.Namespace, *, @@ -509,7 +442,6 @@ def _attach_turn_start_hook_dispatch( payload["turn_start_capability_hook_dispatch"] = dict(dispatch) - def _render_turn_envelope_payload( payload: dict[str, object], scheduler_context: object, @@ -521,6 +453,13 @@ def _render_turn_envelope_payload( renderer rejection keeps the typed diagnostic itself (with the skip reason) instead of masking it with a crash (issue #3687). """ + if payload.get("error_code") in { + "quota_action_selection_rejected", "quota_action_selection_deferred", + }: + # This is a failed preflight, not an executable Turn. Keep its typed + # reason and exact re-entry command rather than truncating the command + # or replacing it with the unbound replan's settlement actions. + return payload try: return build_turn_envelope( payload, @@ -531,6 +470,7 @@ def _render_turn_envelope_payload( degraded["turn_envelope_skipped"] = str(envelope_error)[:200] return degraded + def handle_quota_command( args: argparse.Namespace, *, @@ -547,6 +487,7 @@ def handle_quota_command( heartbeat_stall_observation = "not_evaluated" detail_sections: frozenset[str] = frozenset() context: QuotaCommandContext | None = None + selection_not_admitted = False try: turn_start_hook_dispatch, turn_start_mutated = _dispatch_quota_turn_start_hooks( args, @@ -636,7 +577,7 @@ def handle_quota_command( turn_start_hook_dispatch=turn_start_hook_dispatch, ) _attach_turn_start_hook_dispatch(payload, turn_start_hook_dispatch) - _require_requested_quota_action_selection( + require_requested_quota_action_selection( payload, requested_todo_id=_requested_quota_action_todo_id(args), receipt_bound_todo_id=receipt_bound_todo_id, @@ -797,6 +738,13 @@ def handle_quota_command( payload = build_quota_plan(status_payload, mode=args.quota_command) if cache_metadata: payload["status_projection_cache"] = cache_metadata + except QuotaActionSelectionNotAdmitted: + # The preflight did not accept any Todo or replan settlement identity. + selection_not_admitted = True + assert context is not None + payload = reject_action_selection( + payload, args=args, registry_path=registry_path, context=context, + ) except QuotaCommandValidationError as exc: # Only typed CLI validation diagnostics are public-safe by contract. payload = _quota_validation_failure_payload( @@ -812,7 +760,7 @@ def handle_quota_command( runtime_root_arg=runtime_root_arg, error=exc, ) - if _should_log_quota(args.quota_command, payload): + if not selection_not_admitted and _should_log_quota(args.quota_command, payload): spend_turn_instance_id = _effective_spend_turn_instance_id( payload, heartbeat_turn_id=heartbeat_turn_id, @@ -846,7 +794,7 @@ def handle_quota_command( status=heartbeat_receipt_existing_status, appended=heartbeat_receipt_existing_appended, ) - _commit_requested_action_selection( + commit_requested_action_selection( payload, requested_todo_id=_requested_quota_action_todo_id(args), ) @@ -913,7 +861,7 @@ def handle_quota_command( if rollout_event.get("appended") else "replayed", ) - _commit_requested_action_selection( + commit_requested_action_selection( payload, requested_todo_id=_requested_quota_action_todo_id(args), ) diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py new file mode 100644 index 0000000000..4b7b6dfac0 --- /dev/null +++ b/loopx/cli_commands/quota_action_selection.py @@ -0,0 +1,161 @@ +"""CLI transport for typed explicit selection admission and receipt recovery.""" + +from __future__ import annotations + +import argparse +import shlex +from collections.abc import Mapping +from pathlib import Path + +from ..control_plane.quota.error_codes import ( + HeartbeatReceiptIdentityConflictError, + QuotaActionSelectionNotAdmitted, +) +from ..control_plane.scheduler.execution_context import render_scheduler_execution_args +from ..control_plane.todos.contract import normalize_todo_id +from .quota_context import QuotaCommandContext + + +def require_requested_quota_action_selection( + payload: dict[str, object], + *, + requested_todo_id: str | None, + receipt_bound_todo_id: str | None, + receipt_bound_replan_obligation_id: str | None, +) -> None: + if not requested_todo_id or ( + receipt_bound_todo_id or receipt_bound_replan_obligation_id + ): + return + qualification = payload.get("action_selection") + selected_todo = payload.get("selected_todo") + selected_todo_id = ( + normalize_todo_id(selected_todo.get("todo_id")) + if isinstance(selected_todo, Mapping) + else None + ) + selection_binding = ( + selected_todo.get("selection_binding") + if isinstance(selected_todo, Mapping) + else None + ) + execution_obligation_value = payload.get("execution_obligation") + execution_obligation: Mapping[str, object] = ( + execution_obligation_value + if isinstance(execution_obligation_value, Mapping) + else {} + ) + interaction_value = payload.get("interaction_contract") + interaction: Mapping[str, object] = ( + interaction_value if isinstance(interaction_value, Mapping) else {} + ) + agent_channel_value = interaction.get("agent_channel") + agent_channel: Mapping[str, object] = ( + agent_channel_value if isinstance(agent_channel_value, Mapping) else {} + ) + pending_selection_qualified = ( + selection_binding == "pending_action_selection" + and payload.get("normal_delivery_allowed") is True + ) + exact_current_obligation_qualified = ( + selection_binding != "pending_action_selection" + and execution_obligation.get("must_attempt_work") is True + and agent_channel.get("must_attempt") is True + ) + if ( + selected_todo_id != requested_todo_id + or payload.get("ok") is not True + or payload.get("should_run") is not True + or not (pending_selection_qualified or exact_current_obligation_qualified) + ): + if isinstance(qualification, Mapping) and qualification.get("state") in { + "rejected", + "deferred", + }: + raise QuotaActionSelectionNotAdmitted(str(qualification["reason"])) + raise HeartbeatReceiptIdentityConflictError( + "explicit action selection must name one currently projected " + "agent-scoped, capability-ready Todo" + ) + if exact_current_obligation_qualified: + # Exact due-monitor selection uses the existing obligation route, + # rather than the advancement-only candidate qualifier. + payload.pop("action_selection", None) + + +def commit_requested_action_selection( + payload: Mapping[str, object], + *, + requested_todo_id: str | None, +) -> None: + """Project the exact requested selection only after receipt reconciliation.""" + + selected_todo = payload.get("selected_todo") + if ( + requested_todo_id + and isinstance(selected_todo, dict) + and normalize_todo_id(selected_todo.get("todo_id")) == requested_todo_id + ): + selected_todo["selection_binding"] = "heartbeat_receipt" + + +def reject_action_selection( + payload: dict[str, object], + *, + args: argparse.Namespace, + registry_path: Path, + context: QuotaCommandContext, +) -> dict[str, object]: + """Keep the failed preflight typed and re-enter before any settlement.""" + qualification = payload["action_selection"] + payload.update( + ok=False, + should_run=False, + normal_delivery_allowed=False, + error_code=f"quota_action_selection_{qualification['state']}", + reason=str(qualification["reason"]), + ) + command = shlex.join( + [ + "loopx", + "--registry", + str(registry_path), + "--runtime-root", + str(context.runtime_root), + "--format", + "json", + "quota", + "should-run", + "--goal-id", + args.goal_id, + "--agent-id", + args.agent_id, + *( + ["--turn-instance-id", context.heartbeat_turn_id] + if context.heartbeat_turn_id + else [] + ), + *[ + token + for capability in (args.available_capabilities or []) + for token in ("--available-capability", capability) + ], + ] + ) + render_scheduler_execution_args( + scheduler_execution_context=context.scheduler_context + ) + payload["recommended_action"] = command + payload.pop("selected_todo", None) + payload.pop("action_portfolio", None) + payload["interaction_contract"]["cli_channel"] = { + "next_cli_actions": [command], + "spend_allowed_now": False, + "spend_after_validation": False, + "spend_policy": "re-enter the current guard before delivery or settlement", + } + payload["interaction_contract"]["agent_channel"].update( + delivery_allowed=False, + selection_required=False, + primary_action="re-enter the current guard before selecting work", + ) + return payload diff --git a/loopx/control_plane/quota/error_codes.py b/loopx/control_plane/quota/error_codes.py index 3cac31974c..802c13493f 100644 --- a/loopx/control_plane/quota/error_codes.py +++ b/loopx/control_plane/quota/error_codes.py @@ -12,6 +12,10 @@ class HeartbeatReceiptIdentityConflictError(ValueError): """Public-safe diagnostic for a same-turn settlement identity conflict.""" +class QuotaActionSelectionNotAdmitted(ValueError): + """A typed selection preflight result, before any receipt is committed.""" + + class QuotaIdentityPrecondition(StrEnum): """Typed identity admission preconditions for scoped quota decisions.""" diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 5b460b427d..d39f9b15ba 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -549,9 +549,6 @@ def _resolve_agent_lane_delivery_route( ) -> dict[str, Any] | None: """Project candidates once, then let TypeScript own their delivery route.""" - if isinstance(prepared.guarded_agent_lane_next_action, dict): - return prepared.guarded_agent_lane_next_action - if prepared.requested_action_todo_id is not None: qualification = qualify_action_selection( requested_todo_id=prepared.requested_action_todo_id, @@ -561,6 +558,13 @@ def _resolve_agent_lane_delivery_route( delivery_preemptions=delivery_preemptions, ) prepared.action_selection_qualification = qualification + if isinstance(prepared.guarded_agent_lane_next_action, dict): + # Workspace/scope guards can change admission after initial selection. + # Keep the selected identity frozen, but report the final qualification. + return prepared.guarded_agent_lane_next_action + + if prepared.requested_action_todo_id is not None: + qualification = prepared.action_selection_qualification or {} if qualification.get("state") != "qualified": return None if not isinstance(prepared.requested_action_candidate, dict): @@ -1250,6 +1254,8 @@ def _build_quota_should_run_payload( } if payload["safe_bypass_policy"] is None: payload.pop("safe_bypass_policy") + if (prepared.action_selection_qualification or {}).get("state") in {"rejected", "deferred"}: + payload["action_selection"] = prepared.action_selection_qualification payload = attach_task_orchestration_payload( payload, prepared.task_orchestration_contract, diff --git a/tests/control_plane/test_explicit_successor_selection.py b/tests/control_plane/test_explicit_successor_selection.py new file mode 100644 index 0000000000..058eebe314 --- /dev/null +++ b/tests/control_plane/test_explicit_successor_selection.py @@ -0,0 +1,280 @@ +"""Real monitor-created work must survive selection preflight and retry.""" + +from __future__ import annotations + +import json +import shlex + +import pytest + +from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) +from test_monitor_followthrough_contract import ( + AGENT_ID, + GOAL_ID, + _add_monitor, + _write_fixture, +) +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.control_plane.quota.heartbeat_receipt import find_heartbeat_receipt +from loopx.control_plane.testing.canary_harness import run_json_cli, run_json_cli_result +from loopx.todos import list_goal_todos + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("waiting_count", [0, 24]) +def test_monitor_successor_selection_preserves_admission_and_retry( + tmp_path, + monkeypatch, + provider, + waiting_count, +): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, state = _write_fixture(tmp_path) + with state.open("a") as stream: + for index in range(waiting_count): + stream.write( + f"- [ ] [P1] Validate pending synthetic input {index}.\n" + f" \n" + ) + if waiting_count: + stream.write( + "- [ ] [P1] Wait for dependency.\n" + " \n" + ) + monitor = _add_monitor( + registry, + text="Observe a public revision", + target_key="public-revision", + next_due_at="2000-01-01T00:00:00Z", + ) + if provider != "legacy": + items = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, role="agent")[ + "todos" + ] + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, + todos=items, + leases=[], + handoff_mode="soft_claim", + ) + initialize_canonical_authority( + runtime, GOAL_ID, projection, state_path=state, provider=provider + ) + result = run_json_cli( + "quota", + "monitor-poll", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--runtime-profile", + "generic_cli", + "--todo-id", + monitor["todo_id"], + "--result-hash", + "revision-a", + "--material-change", + "--next-agent-todo", + "Validate the observed revision", + "--next-action-kind", + "validate", + "--next-claimed-by", + AGENT_ID, + "--next-continuation-policy", + "same_agent_non_delivery", + "--execute", + registry_path=registry, + runtime_root=runtime, + ) + successor = result["todo_writeback"]["next_todos"][0]["todo_id"] + args = ( + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--codex-app", + ) + default = run_json_cli(*args, registry_path=registry, runtime_root=runtime) + assert default["selected_todo"]["todo_id"] == successor + selection_args = ( + *args, + "--todo-id", + successor, + "--turn-instance-id", + "select-successor", + ) + code, selected = run_json_cli_result( + *selection_args, registry_path=registry, runtime_root=runtime + ) + deferred = provider != "legacy" and waiting_count > 0 + assert default["normal_delivery_allowed"] is not deferred + if not deferred: + assert code == 0, selected + assert selected["selected_todo"]["todo_id"] == successor + assert ( + selected["interaction_contract"]["agent_channel"]["delivery_allowed"] + is True + ) + replay = run_json_cli( + *selection_args, registry_path=registry, runtime_root=runtime + ) + assert replay["heartbeat_receipt"]["status"] == "replayed" + assert ( + replay["heartbeat_receipt"]["event_id"] + == selected["heartbeat_receipt"]["event_id"] + ) + else: + assert waiting_count > 0 + assert default["effective_action"] == "autonomous_replan_required" + assert code == 1, selected + assert selected["error_code"] == "quota_action_selection_deferred" + assert selected["action_selection"]["state"] == "deferred" + assert selected["action_selection"]["reason"] == "autonomous_replan" + assert str(tmp_path) not in json.dumps(selected["action_selection"]) + assert "heartbeat_receipt" not in selected + assert ( + find_heartbeat_receipt( + runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + turn_instance_id="select-successor", + ) + is None + ) + assert not selected["interaction_contract"]["cli_channel"][ + "spend_after_validation" + ] + assert "settlement_plan" not in selected["interaction_contract"]["cli_channel"] + code, retry = run_json_cli_result( + *selection_args, registry_path=registry, runtime_root=runtime + ) + assert code == 1 + assert retry["action_selection"] == selected["action_selection"] + assert retry["recommended_action"] == selected["recommended_action"] + envelope_code, envelope = run_json_cli_result( + *selection_args, + "--turn-envelope", + registry_path=registry, + runtime_root=runtime, + ) + assert envelope_code == 1 + assert envelope["action_selection"] == selected["action_selection"] + assert envelope["interaction_contract"]["cli_channel"]["next_cli_actions"] == [ + selected["recommended_action"] + ] + # Re-entry follows the real gate and binds its semantic replan obligation. + command = shlex.split(retry["recommended_action"]) + assert "--todo-id" not in command + assert command[command.index("--turn-instance-id") + 1] == "select-successor" + reentry = run_json_cli( + *command[1:], registry_path=registry, runtime_root=runtime + ) + assert reentry["effective_action"] == "autonomous_replan_required" + assert ( + reentry["heartbeat_receipt"]["semantic_replan_obligation_id"] + == (reentry["autonomous_replan_obligation"]["obligation_id"]) + ) + assert reentry["normal_delivery_allowed"] is False + + +def test_canonical_add_after_pending_guard_reports_final_boundary(tmp_path): + from test_quota_settlement_cli import ( + AGENT_ID as agent_id, + GOAL_ID as goal_id, + _write_fixture as write_fixture, + _configure_selectable_alternative, + ) + + project, runtime, registry = write_fixture(tmp_path) + _configure_selectable_alternative(project) + state = project / f".codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md" + items = list_goal_todos(registry_path=registry, goal_id=goal_id, role="agent")[ + "todos" + ] + projection = build_todo_runtime_shadow_projection( + goal_id=goal_id, + todos=items, + leases=[], + handoff_mode="soft_claim", + ) + initialize_canonical_authority(runtime, goal_id, projection, state_path=state) + args = ( + "quota", + "should-run", + "--goal-id", + goal_id, + "--agent-id", + agent_id, + "--codex-app", + "--turn-instance-id", + "add-after-guard", + ) + initial = run_json_cli(*args, registry_path=registry, runtime_root=runtime) + assert ( + initial["interaction_contract"]["agent_channel"]["selection_required"] is True + ) + assert "settlement_identity" not in initial["heartbeat_receipt"] + receipt = find_heartbeat_receipt( + runtime, goal_id=goal_id, agent_id=agent_id, turn_instance_id="add-after-guard" + ) + added = run_json_cli( + "todo", + "add", + "--goal-id", + goal_id, + "--role", + "agent", + "--task-class", + "advancement_task", + "--action-kind", + "implement", + "--claimed-by", + agent_id, + "--text", + "Validate the new canonical task", + "--task-repository", + "git:github.com/example/read-only-settlement-fixture", + "--required-write-scope", + "loopx/**", + registry_path=registry, + runtime_root=runtime, + ) + selection = (*args, "--todo-id", added["todo_id"]) + code, rejected = run_json_cli_result( + *selection, registry_path=registry, runtime_root=runtime + ) + assert code == 1 + assert rejected["error_code"] == "quota_action_selection_deferred" + assert rejected["action_selection"]["reason"] == "control_repair" + assert rejected["action_selection"]["requested_todo_id"] == added["todo_id"] + assert rejected["normal_delivery_allowed"] is False + assert "heartbeat_receipt" not in rejected + assert ( + find_heartbeat_receipt( + runtime, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id="add-after-guard", + ) + == receipt + ) + code, repeated = run_json_cli_result( + *selection, registry_path=registry, runtime_root=runtime + ) + assert code == 1 + assert repeated["action_selection"] == rejected["action_selection"] + command = shlex.split(repeated["recommended_action"]) + assert "--todo-id" not in command + guard = run_json_cli(*command[1:], registry_path=registry, runtime_root=runtime) + assert guard["normal_delivery_allowed"] is False + assert guard["workspace_repair_allowed"] or guard["self_repair_allowed"] diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 2ddd1828cd..cc58f341fb 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2477,7 +2477,8 @@ def test_agent_selection_rejects_unprojected_todo(tmp_path: Path) -> None: assert first_rc == 0, first assert invalid_rc != 0, invalid assert invalid["ok"] is False - assert invalid["error_code"] == "heartbeat_receipt_identity_conflict" + assert invalid["error_code"] == "quota_action_selection_rejected" + assert invalid["action_selection"]["reason"] == "candidate_not_currently_eligible" assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 @@ -2522,9 +2523,19 @@ def test_unsuggested_selection_revalidates_current_capability_readiness( assert first_rc == 0, first assert blocked_rc != 0, blocked - assert blocked["error_code"] == "heartbeat_receipt_identity_conflict" + assert blocked["error_code"] == "quota_action_selection_rejected" + assert blocked["action_selection"]["reason"] == "candidate_not_currently_eligible" assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 + ready_rc, ready = _run_cli( + registry_path, runtime, *guard_args, "--todo-id", + OUTSIDE_BOUNDED_PORTFOLIO_TODO_ID, "--available-capability", "network", + ) + assert ready_rc == 0, ready + assert ready["selected_todo"]["todo_id"] == OUTSIDE_BOUNDED_PORTFOLIO_TODO_ID + assert ready["heartbeat_receipt"]["status"] == "upgraded" + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 2 + def test_first_call_agent_selection_is_qualified_before_receipt_commit( tmp_path: Path, @@ -2598,7 +2609,7 @@ def test_pending_action_selection_does_not_preempt_newly_due_monitor( ) assert selected_rc == 1, selected - assert selected["error_code"] == "heartbeat_receipt_identity_conflict" + assert selected["error_code"] == "quota_action_selection_deferred" events = _heartbeat_receipt_events(runtime, turn_instance_id) assert len(events) == 1 assert not events[0]["details"].get("todo_id") @@ -2740,7 +2751,7 @@ def test_pending_action_selection_does_not_commit_after_new_user_gate( ) assert selected_rc == 1, selected - assert selected["error_code"] == "heartbeat_receipt_identity_conflict" + assert selected["error_code"] == "quota_action_selection_deferred" events = _heartbeat_receipt_events(runtime, turn_instance_id) assert len(events) == 1 assert not events[0]["details"].get("todo_id")