diff --git a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md index 062438e5da..56756b508b 100644 --- a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md +++ b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md @@ -14,15 +14,22 @@ advancement work remains active. - The heartbeat receipt's `todo_id` remains the only settlement Todo. Only that Todo may be used by `refresh-state` and `quota spend-slot`. -- `quota monitor-poll --todo-id ` may record one auxiliary, no-spend - observation in the same Turn only when the requested Todo is a due - `continuous_monitor` visible to the same Agent. +- `quota monitor-poll --todo-id ` may record auxiliary, no-spend + observations in the same Turn only when each requested Todo is a due + `continuous_monitor` visible to the same Agent. Admission checks the Todo + authority as well as the bounded decision projection, so a due monitor is + not rejected merely because it falls outside the compact list. - The monitor receipt records both `settlement_todo_id` and the observed monitor `todo_id`. They may differ; this never grants a second delivery or quota-spend identity. -- A receipt-bound monitor remains strict: another monitor cannot be substituted. - Reusing the same Turn for a different observation conflicts with the existing - monitor-poll effect digest. +- Each auxiliary observation has an operation identity scoped by the monitor + Todo, or by a digest of `target_key` when there is no Todo id. Exact retries + replay that observation; changed content conflicts only with the same monitor + identity; another due monitor receives an independent no-spend receipt. + Shipped turn-only receipts remain replayable for their original monitor. +- A receipt-bound monitor remains strict: another monitor cannot be substituted + for the Turn's settlement Todo. Multiple auxiliary receipts never change the + already-bound settlement identity. - After an unchanged auxiliary observation, the original advancement Todo remains selected. A material observation may create its independently routed successor through the existing monitor contract, but it still does not @@ -30,10 +37,10 @@ advancement work remains active. ### Acceptance -The CLI path must prove that a due monitor can update its cadence and replay -idempotently without spending quota, while a guard replay continues to select -the original advancement Todo. Existing wrong-Todo tests for receipt-bound -monitor Turns must remain passing. +The CLI path must prove that multiple due monitors can each update their cadence +and replay idempotently in one settlement Turn without spending quota, while a +guard replay continues to select the original advancement Todo. Existing +wrong-Todo tests for receipt-bound monitor Turns must remain passing. ## 中文 @@ -47,18 +54,23 @@ monitor Turns must remain passing. - heartbeat 回执中的 `todo_id` 始终是唯一结算 Todo;只有它可用于 `refresh-state` 与 `quota spend-slot`。 -- 仅当请求对象是同一 Agent 可见且已到期的 `continuous_monitor` 时, - `quota monitor-poll --todo-id ` 才可在同一 Turn 写入一次辅助、 - 不计费的观察回执。 +- 仅当每个请求对象都是同一 Agent 可见且已到期的 `continuous_monitor` 时, + `quota monitor-poll --todo-id ` 才可在同一 Turn 写入辅助、不计费 + 的观察回执。准入同时检查 Todo 权威源与有界决策投影,不能仅因到期 monitor + 位于精简列表之外就拒绝它。 - 监控回执同时记录 `settlement_todo_id` 与被观察的 monitor `todo_id`。 二者允许不同,但不会因此产生第二个交付或配额结算身份。 -- 若 heartbeat 本身绑定的是 monitor,仍保持严格身份,不能换成另一个 monitor; - 同一 Turn 也不能用不同观察内容覆盖既有 monitor-poll effect。 +- 每个辅助观察按 monitor Todo 建立操作身份;没有 Todo id 时,按 + `target_key` 摘要建立身份。精确重试只重放该观察;同一 monitor 下内容变化 + 只与该 monitor 冲突;另一个到期 monitor 获得独立的不计费回执。已发布的 + Turn-only 旧回执仍可对原 monitor 重放。 +- 若 heartbeat 本身绑定的是 monitor,仍保持严格身份,不能把另一个 monitor + 替换为本 Turn 的结算 Todo;多个辅助回执也绝不改变既有结算身份。 - 辅助观察无变化后,原 advancement Todo 继续保持选中;若观察发生重大变化, 可按既有 monitor 契约创建独立路由的 successor,但仍不替换本 Turn 的结算身份。 ### 验收 -CLI 端到端测试必须证明:到期 monitor 能更新周期并幂等重放、全程不消耗配额; -随后重放 guard 仍选择原 advancement Todo。同时,receipt-bound monitor Turn 的 -错误 Todo 替换测试必须继续通过。 +CLI 端到端测试必须证明:多个到期 monitor 能在同一结算 Turn 中分别更新周期并 +幂等重放、全程不消耗配额;随后重放 guard 仍选择原 advancement Todo。同时, +receipt-bound monitor Turn 的错误 Todo 替换测试必须继续通过。 diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 403e088e8b..d7e2bc707d 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -71,6 +71,29 @@ 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 unbound selection that no longer qualifies is a preflight outcome, not a +settlement-identity conflict. The full quota response preserves the TypeScript +`action_selection_qualification_v0` result and returns +`quota_action_selection_deferred` or `quota_action_selection_rejected`, including +the exact current preemption or eligibility reason. An existing identity-less +receipt is replayed without mutation; a first-call rejection reports +`heartbeat_receipt.status=not_committed` and writes no receipt event. The agent +can therefore refresh the current portfolio with the same Turn id and re-enter +deterministically. A receipt already bound to a different Todo or autonomous +replan obligation remains a hard `heartbeat_receipt_identity_conflict`. +When a due monitor is visible only as auxiliary context for an advancement lane, +the typed reason is +`auxiliary_monitor_not_selectable_in_advancement_lane`. The agent selects a +current advancement Todo or retries after the monitor becomes the hard lane; +this state is never reported as a receipt write failure. + +A selection may also qualify while repository delivery is temporarily blocked +by the peer workspace guard. In that case the response and bound receipt keep +the selected Todo, `effective_action=agent_workspace_repair`, and the typed +worktree recovery instruction. Moving to an independent worktree and rerunning +the guard with the same Turn id resumes the selected Todo; the wrapper must not +rewrite this recoverable state as a settlement-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..6bbf950b06 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -25,6 +25,7 @@ quota_error_code, ) from ..control_plane.quota.heartbeat_receipt import ( + HEARTBEAT_RECEIPT_SCHEMA_VERSION, fail_heartbeat_receipt, find_heartbeat_receipt, heartbeat_receipt_settlement_replan_obligation_id, @@ -379,17 +380,17 @@ def _heartbeat_quota_action_selection_bindings( return existing, todo_id, replan_obligation_id -def _require_requested_quota_action_selection( - payload: Mapping[str, object], +def _apply_requested_quota_action_selection_preflight( + payload: dict[str, object], *, requested_todo_id: str | None, receipt_bound_todo_id: str | None, receipt_bound_replan_obligation_id: str | None, -) -> None: +) -> bool: if not requested_todo_id or ( receipt_bound_todo_id or receipt_bound_replan_obligation_id ): - return + return False selected_todo = payload.get("selected_todo") selected_todo_id = ( normalize_todo_id(selected_todo.get("todo_id")) @@ -415,25 +416,109 @@ def _require_requested_quota_action_selection( agent_channel: Mapping[str, object] = ( agent_channel_value if isinstance(agent_channel_value, Mapping) else {} ) - pending_selection_qualified = ( + pending_selection_delivery_qualified = ( selection_binding == "pending_action_selection" and payload.get("normal_delivery_allowed") is True ) + pending_selection_workspace_repair_qualified = ( + selection_binding == "pending_action_selection" + and payload.get("workspace_repair_allowed") is True + and payload.get("effective_action") == "agent_workspace_repair" + and execution_obligation.get("kind") == "agent_workspace_repair" + and execution_obligation.get("must_attempt_work") is True + and agent_channel.get("must_attempt") is True + and agent_channel.get("delivery_allowed") is False + ) 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) + selected_todo_id == requested_todo_id + and payload.get("ok") is True + and payload.get("should_run") is True + and ( + pending_selection_delivery_qualified + or pending_selection_workspace_repair_qualified + or exact_current_obligation_qualified + ) ): - raise HeartbeatReceiptIdentityConflictError( - "explicit action selection must name one currently projected " - "agent-scoped, capability-ready Todo" + return False + + qualification_value = payload.get("action_selection_qualification") + if not isinstance(qualification_value, Mapping): + raise RuntimeError("requested action selection lacks typed qualification") + qualification = qualification_value + qualification_state = str(qualification.get("state") or "") + if qualification_state not in {"deferred", "rejected"}: + raise RuntimeError( + "requested action selection qualification conflicts with its projection" ) + qualification_reason = str( + qualification.get("reason") or "candidate_not_currently_eligible" + ) + deferred = qualification_state == "deferred" + auxiliary_monitor = ( + qualification_reason + == "auxiliary_monitor_not_selectable_in_advancement_lane" + ) + error_code = ( + "quota_action_selection_deferred" + if deferred + else "quota_action_selection_rejected" + ) + payload.update( + { + "ok": False, + "decision": "skip", + "should_run": False, + "effective_action": error_code, + "state": error_code, + "waiting_on": "codex", + "status": error_code, + "error_code": error_code, + "reason": ( + "explicit action selection was deferred by the current " + f"delivery frontier: {qualification_reason}" + if deferred + else "explicit action selection is not currently eligible: " + f"{qualification_reason}" + ), + "recommended_action": ( + "handle the current delivery preemption, then rerun quota " + "should-run with the same --turn-instance-id; omit --todo-id " + "first when a refreshed action portfolio is needed" + if deferred + else "the due monitor is visible as auxiliary context, not an " + "independently selectable action in the current advancement lane; " + "choose a current advancement Todo, or rerun after the monitor " + "becomes the hard lane" + if auxiliary_monitor + else "rerun quota should-run with the same --turn-instance-id " + "without --todo-id, then choose a currently eligible Todo" + ), + } + ) + return True + + +def _attach_uncommitted_action_selection_receipt( + payload: dict[str, object], + *, + turn_instance_id: str, +) -> None: + """Expose an accurate non-durable receipt for a rejected preflight.""" + + payload["heartbeat_receipt"] = { + "schema_version": HEARTBEAT_RECEIPT_SCHEMA_VERSION, + "turn_instance_id": turn_instance_id, + "status": "not_committed", + "stall_observation": "not_evaluated", + "reason_code": str( + payload.get("error_code") or "quota_action_selection_rejected" + ), + } def _commit_requested_action_selection( @@ -544,6 +629,7 @@ def handle_quota_command( heartbeat_receipt_existing_status = "replayed" heartbeat_receipt_existing_appended = False heartbeat_receipt_ready = False + action_selection_preflight_failed = False heartbeat_stall_observation = "not_evaluated" detail_sections: frozenset[str] = frozenset() context: QuotaCommandContext | None = None @@ -636,14 +722,20 @@ 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( - payload, - requested_todo_id=_requested_quota_action_todo_id(args), - receipt_bound_todo_id=receipt_bound_todo_id, - receipt_bound_replan_obligation_id=(receipt_bound_replan_obligation_id), + action_selection_preflight_failed = ( + _apply_requested_quota_action_selection_preflight( + payload, + requested_todo_id=_requested_quota_action_todo_id(args), + receipt_bound_todo_id=receipt_bound_todo_id, + receipt_bound_replan_obligation_id=( + receipt_bound_replan_obligation_id + ), + ) ) if heartbeat_turn_id: - if heartbeat_receipt_existing: + if action_selection_preflight_failed: + heartbeat_receipt_ready = True + elif heartbeat_receipt_existing: ( heartbeat_receipt_existing, heartbeat_receipt_existing_status, @@ -826,7 +918,21 @@ def handle_quota_command( replan_obligation_id=rollout_replan_obligation_id, ) if heartbeat_turn_id and args.quota_command == "should-run": - if not heartbeat_receipt_ready: + if action_selection_preflight_failed: + if heartbeat_receipt_existing: + render_existing_heartbeat_receipt_payload( + payload, + receipt=heartbeat_receipt_existing, + turn_instance_id=heartbeat_turn_id, + status="replayed", + appended=False, + ) + else: + _attach_uncommitted_action_selection_receipt( + payload, + turn_instance_id=heartbeat_turn_id, + ) + elif not heartbeat_receipt_ready: prior_reason = str(payload.get("reason") or "").strip() fail_heartbeat_receipt( payload, diff --git a/loopx/control_plane/quota/monitor_poll.py b/loopx/control_plane/quota/monitor_poll.py index 6189bcf4dd..7d047f61c5 100644 --- a/loopx/control_plane/quota/monitor_poll.py +++ b/loopx/control_plane/quota/monitor_poll.py @@ -109,7 +109,7 @@ def _vision_wait_state(before: dict[str, Any]) -> dict[str, Any]: return _mapping(projection.get("vision_wait_state")) -def _registry_due_monitor( +def resolve_due_monitor_candidate( *, registry_path: Path | None, runtime_root: Path | None, @@ -152,7 +152,7 @@ def _decision_packet( ) -> dict[str, Any]: lane = _mapping(before.get("work_lane_contract")) due_candidates = _due_monitor_candidates(before) - registry_due = _registry_due_monitor( + registry_due = resolve_due_monitor_candidate( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, @@ -374,7 +374,11 @@ def _find_monitor_poll_turn( goal_id: str, agent_id: str, turn_instance_id: str, + todo_id: str | None = None, + target_key: str | None = None, ) -> dict[str, Any] | None: + normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None + normalized_target_key = str(target_key or "").strip() or None try: lines = index_path.read_text(encoding="utf-8").splitlines() except OSError: @@ -391,6 +395,15 @@ def _find_monitor_poll_turn( and str(row.get("goal_id") or "") == goal_id and str(row.get("agent_id") or "") == agent_id and str(row.get("turn_instance_id") or "") == turn_instance_id + and ( + normalized_todo_id is None + or normalize_todo_id(row.get("todo_id")) == normalized_todo_id + ) + and ( + normalized_target_key is None + or str(row.get("target_key") or "").strip() + == normalized_target_key + ) ): return row return None @@ -402,8 +415,10 @@ def find_quota_monitor_poll_turn( goal_id: str, agent_id: str, turn_instance_id: str, + todo_id: str | None = None, + target_key: str | None = None, ) -> dict[str, Any] | None: - """Return the persisted monitor observation for one heartbeat turn.""" + """Return the latest matching monitor observation for one heartbeat turn.""" normalized_turn_id = normalize_turn_instance_id(turn_instance_id) if not normalized_turn_id: @@ -413,7 +428,54 @@ def find_quota_monitor_poll_turn( goal_id=goal_id, agent_id=agent_id, turn_instance_id=normalized_turn_id, + todo_id=todo_id, + target_key=target_key, + ) + + +def _persisted_monitor_effect_id(record: Mapping[str, Any] | None) -> str | None: + if not isinstance(record, Mapping): + return None + metadata = record.get("quota_monitor_poll_commit") + if not isinstance(metadata, Mapping): + return None + return str(metadata.get("effect_id") or "").strip() or None + + +def _monitor_poll_effect_id( + *, + runtime_root: Path, + goal_id: str, + agent_id: str, + turn_instance_id: str | None, + todo_id: str | None, + target_key: str | None, +) -> str: + if not turn_instance_id: + return f"quota-monitor-poll:{goal_id}:{uuid.uuid4().hex}" + + # Reuse a shipped turn-only receipt for an exact monitor identity. This + # preserves crash recovery across upgrades while allowing later monitors + # in the same settlement Turn to receive their own effect identity. + existing = find_quota_monitor_poll_turn( + runtime_root, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_instance_id, + todo_id=todo_id, + target_key=None if todo_id else target_key, ) + existing_effect_id = _persisted_monitor_effect_id(existing) + if existing_effect_id: + return existing_effect_id + + base = f"quota-monitor-poll:{goal_id}:{agent_id}:{turn_instance_id}" + if todo_id: + return f"{base}:todo:{todo_id}" + if target_key: + target_digest = hashlib.sha256(target_key.encode("utf-8")).hexdigest() + return f"{base}:target:sha256:{target_digest}" + return base def _status_with_monitor_poll( @@ -639,10 +701,13 @@ def record_quota_monitor_poll_for_decision( runtime_root = Path(str(raw_runtime_root)).expanduser() index_path = runtime_root / "goals" / goal_id / "runs" / "index.jsonl" decision_agent_id = quota_decision_agent_id(before) - effect_id = ( - f"quota-monitor-poll:{goal_id}:{decision_agent_id}:{normalized_turn_id}" - if normalized_turn_id - else f"quota-monitor-poll:{goal_id}:{uuid.uuid4().hex}" + effect_id = _monitor_poll_effect_id( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=decision_agent_id, + turn_instance_id=normalized_turn_id, + todo_id=safe_todo_id, + target_key=safe_target_key, ) if execute and (safe_todo_id or safe_target_key): from ..scheduler.provider_monitor_poll import ( diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 5b460b427d..ea7efd522e 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -114,7 +114,7 @@ from ..turn_driver.delivery_continuity import evaluate_delivery_route from ..work_items.action_portfolio import ( build_quota_planning_packet, - qualify_action_selection, + qualify_action_selection_from_inventory, ) from ..work_items.execution_obligation import build_execution_obligation from ..work_items.goal_route_hint import build_goal_route_hint @@ -553,9 +553,10 @@ def _resolve_agent_lane_delivery_route( return prepared.guarded_agent_lane_next_action if prepared.requested_action_todo_id is not None: - qualification = qualify_action_selection( + qualification = qualify_action_selection_from_inventory( requested_todo_id=prepared.requested_action_todo_id, candidate=prepared.requested_action_candidate, + source_items=prepared.agent_todo_planning_source_items, should_run=should_run, normal_delivery_allowed=normal_delivery_allowed, delivery_preemptions=delivery_preemptions, @@ -1281,6 +1282,7 @@ def _build_quota_should_run_payload( _attach_truthy_fields( payload, agent_lane_next_action=public_agent_lane_next_action, + action_selection_qualification=prepared.action_selection_qualification, ) selected_todo_projection = ( None diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index f5874b4e97..159e938af6 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -97,6 +97,7 @@ from ..work_items.capability_monitor_fallback import ( build_capability_gate_with_monitor_fallback, ) +from ..work_items.planning_inventory import quota_runnable_action_candidates from ..work_items.primary_action import protocol_action_text as _protocol_action_text from ..work_items.work_lane import ( lark_inbox_reply_due_work_lane_contract, @@ -783,7 +784,11 @@ def _prepare_quota_should_run_item( requested_action_candidate = ( build_explicit_advancement_next_action( agent_identity=agent_identity, - agent_todo_items=agent_todo_source_items, + agent_todo_items=quota_runnable_action_candidates( + agent_id=agent_frontier_id or "", + agent_todo_summary=agent_todo_summary, + capability_gate=capability_gate, + ), available_capabilities=effective_available_capabilities, todo_id=requested_action_todo_id, selection_binding="pending_action_selection", diff --git a/loopx/control_plane/work_items/action_portfolio.py b/loopx/control_plane/work_items/action_portfolio.py index c60ed46a3a..c301eda3fc 100644 --- a/loopx/control_plane/work_items/action_portfolio.py +++ b/loopx/control_plane/work_items/action_portfolio.py @@ -10,6 +10,7 @@ ACTION_PORTFOLIO_SELECTION_REQUEST_SCHEMA, ACTION_PORTFOLIO_SELECTION_RESULT_SCHEMA, ) +from ..todos.contract import normalize_todo_id, normalize_todo_task_class from .planning_inventory import ( build_quota_planning_inventory_request, compact_planning_candidate, @@ -100,6 +101,7 @@ def qualify_action_selection( *, requested_todo_id: str, candidate: Mapping[str, Any] | None, + requested_task_class: str | None, should_run: bool, normal_delivery_allowed: bool, delivery_preemptions: list[str], @@ -114,6 +116,7 @@ def qualify_action_selection( "schema_version": ACTION_SELECTION_QUALIFICATION_REQUEST_SCHEMA_VERSION, "requested_todo_id": requested_todo_id, "candidate": compact_candidate, + "requested_task_class": requested_task_class, "should_run": should_run, "normal_delivery_allowed": normal_delivery_allowed, "delivery_preemptions": delivery_preemptions, @@ -126,3 +129,41 @@ def qualify_action_selection( ): raise RuntimeError("TypeScript action-selection qualification shape mismatch") return dict(result) + + +def qualify_action_selection_from_inventory( + *, + requested_todo_id: str, + candidate: Mapping[str, Any] | None, + source_items: list[dict[str, Any]], + should_run: bool, + normal_delivery_allowed: bool, + delivery_preemptions: list[str], +) -> dict[str, Any]: + """Resolve the requested task class before invoking the typed reducer.""" + + requested_item = next( + ( + item + for item in source_items + if normalize_todo_id(item.get("todo_id")) == requested_todo_id + ), + None, + ) + requested_task_class = ( + normalize_todo_task_class( + requested_item.get("task_class"), + text=str(requested_item.get("text") or ""), + action_kind=requested_item.get("action_kind"), + ) + if requested_item is not None + else None + ) + return qualify_action_selection( + requested_todo_id=requested_todo_id, + candidate=candidate, + requested_task_class=requested_task_class, + should_run=should_run, + normal_delivery_allowed=normal_delivery_allowed, + delivery_preemptions=delivery_preemptions, + ) diff --git a/loopx/control_plane/work_items/action_portfolio.ts b/loopx/control_plane/work_items/action_portfolio.ts index 632d7a17bc..db12209669 100644 --- a/loopx/control_plane/work_items/action_portfolio.ts +++ b/loopx/control_plane/work_items/action_portfolio.ts @@ -356,11 +356,20 @@ export function qualifyActionSelection(value: unknown): JsonObject { const shouldRun = request.should_run === true; const normalDeliveryAllowed = request.normal_delivery_allowed === true; if (rawCandidate === null || rawCandidate === undefined) { + const requestedTaskClass = request.requested_task_class === null || + request.requested_task_class === undefined + ? null + : requireNonEmptyString( + request.requested_task_class, + "action_selection_qualification_request.requested_task_class", + ); return { schema_version: ACTION_SELECTION_QUALIFICATION_SCHEMA_VERSION, state: "rejected", requested_todo_id: requestedTodoId, - reason: "candidate_not_currently_eligible", + reason: requestedTaskClass === "continuous_monitor" + ? "auxiliary_monitor_not_selectable_in_advancement_lane" + : "candidate_not_currently_eligible", }; } const candidate = actionCandidate( diff --git a/loopx/quota.py b/loopx/quota.py index 899a979e28..e8623c4d0d 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -36,6 +36,7 @@ build_quota_monitor_poll_event as build_quota_monitor_poll_event, find_quota_monitor_poll_turn, record_quota_monitor_poll_for_decision, + resolve_due_monitor_candidate, ) from .control_plane.quota.recent_runs import ( goal_latest_run as _goal_latest_run, @@ -1080,6 +1081,22 @@ def record_quota_monitor_poll( normalized_receipt_todo_id = ( normalize_todo_id(receipt_bound_todo_id) if receipt_bound_todo_id else None ) + raw_runtime_root = status_payload.get("runtime_root") + runtime_root = ( + Path(str(raw_runtime_root)).expanduser() if raw_runtime_root else None + ) + resolved_monitor = resolve_due_monitor_candidate( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=safe_goal_id, + todo_id=normalized_requested_todo_id, + target_key=target_key, + ) + normalized_observation_todo_id = normalized_requested_todo_id or ( + normalize_todo_id(resolved_monitor.get("todo_id")) + if resolved_monitor + else None + ) def should_run(current_status: dict[str, Any]) -> dict[str, Any]: decision_status = current_status @@ -1110,8 +1127,8 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: before = should_run(status_payload) if ( normalized_receipt_todo_id - and normalized_requested_todo_id - and normalized_requested_todo_id != normalized_receipt_todo_id + and normalized_observation_todo_id + and normalized_observation_todo_id != normalized_receipt_todo_id ): selected = ( before.get("selected_todo") @@ -1136,19 +1153,24 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: auxiliary_due_monitor = any( isinstance(candidate, Mapping) and normalize_todo_id(candidate.get("todo_id")) - == normalized_requested_todo_id + == normalized_observation_todo_id and candidate.get("task_class") == TODO_TASK_CLASS_MONITOR and normalize_todo_claimed_by(candidate.get("claimed_by")) in {None, normalized_agent_id} for candidate in candidate_values ) - raw_runtime_root = status_payload.get("runtime_root") + auxiliary_registry_due = bool( + resolved_monitor + and normalize_todo_claimed_by(resolved_monitor.get("claimed_by")) + in {None, normalized_agent_id} + ) existing_observation = ( find_quota_monitor_poll_turn( Path(str(raw_runtime_root)).expanduser(), goal_id=safe_goal_id, agent_id=normalized_agent_id or "", turn_instance_id=str(turn_instance_id or ""), + todo_id=normalized_observation_todo_id, ) if raw_runtime_root and agent_id and turn_instance_id else None @@ -1156,7 +1178,7 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: auxiliary_replay = bool( isinstance(existing_observation, Mapping) and normalize_todo_id(existing_observation.get("todo_id")) - == normalized_requested_todo_id + == normalized_observation_todo_id and normalize_todo_id( existing_observation.get("settlement_todo_id") ) @@ -1167,16 +1189,22 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: == normalized_receipt_todo_id and selected.get("task_class") == TODO_TASK_CLASS_ADVANCEMENT and selected.get("selection_binding") == "heartbeat_receipt" - and (auxiliary_due_monitor or auxiliary_replay) + and ( + auxiliary_due_monitor + or auxiliary_registry_due + or auxiliary_replay + ) ) if not auxiliary_observation_allowed: raise HeartbeatReceiptIdentityConflictError( "turn-scoped monitor-poll Todo conflicts with the committed " "heartbeat receipt: expected settlement Todo " f"{normalized_receipt_todo_id}, requested observation Todo " - f"{normalized_requested_todo_id}" + f"{normalized_observation_todo_id}" ) - effective_todo_id = normalized_requested_todo_id or normalized_receipt_todo_id + effective_todo_id = normalized_observation_todo_id or ( + normalized_receipt_todo_id if not target_key else None + ) return record_quota_monitor_poll_for_decision( before, status_payload, diff --git a/tests/control_plane/test_quota_monitor_poll_runtime.py b/tests/control_plane/test_quota_monitor_poll_runtime.py index 5387b8e8a7..be3da145ac 100644 --- a/tests/control_plane/test_quota_monitor_poll_runtime.py +++ b/tests/control_plane/test_quota_monitor_poll_runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any from loopx.control_plane.effect_runtime import EffectRuntimeRejected @@ -100,3 +101,49 @@ def native(_method: str, _params: dict[str, Any]) -> dict[str, Any]: render_markdown=lambda _record: "unused", ) assert "capability_retry" not in prose_only + + +def test_turn_monitor_effect_identity_replays_legacy_receipt_then_scopes_new_todo( + tmp_path, +) -> None: + goal_id = "monitor-runtime-fixture" + agent_id = "codex-main-control" + turn_id = "turn-legacy-monitor-identity" + runs = tmp_path / "goals" / goal_id / "runs" + runs.mkdir(parents=True) + legacy_effect_id = f"quota-monitor-poll:{goal_id}:{agent_id}:{turn_id}" + (runs / "index.jsonl").write_text( + json.dumps( + { + "classification": monitor_poll.QUOTA_MONITOR_POLL_CLASSIFICATION, + "goal_id": goal_id, + "agent_id": agent_id, + "turn_instance_id": turn_id, + "todo_id": "todo_monitor_legacy", + "target_key": "legacy-target", + "quota_monitor_poll_commit": {"effect_id": legacy_effect_id}, + } + ) + + "\n", + encoding="utf-8", + ) + + assert monitor_poll._monitor_poll_effect_id( + runtime_root=tmp_path, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_id, + todo_id="todo_monitor_legacy", + target_key="legacy-target", + ) == legacy_effect_id + assert monitor_poll._monitor_poll_effect_id( + runtime_root=tmp_path, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_id, + todo_id="todo_monitor_new", + target_key="new-target", + ) == ( + f"quota-monitor-poll:{goal_id}:{agent_id}:{turn_id}:" + "todo:todo_monitor_new" + ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 270fff727d..f19bcc752c 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -299,14 +299,18 @@ def _configure_runtime_capability_reentry_fixture(project: Path) -> None: ) -def _append_newly_due_monitor(project: Path) -> None: +def _append_newly_due_monitor( + project: Path, + *, + priority: str = "P0-monitor", +) -> None: state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state_text = state_path.read_text(encoding="utf-8") state_path.write_text( state_text.replace( "## Agent Todo\n\n", "## Agent Todo\n\n" - "- [ ] [P0-monitor] Observe the newly due public target.\n" + f"- [ ] [{priority}] Observe the newly due public target.\n" f" \n" + for todo_id, target_key in monitors + ) + state_path.write_text( + state_text.replace("## Agent Todo\n\n", f"## Agent Todo\n\n{rows}"), + encoding="utf-8", + ) + + def _append_blocking_user_gate(project: Path) -> None: state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state_text = state_path.read_text(encoding="utf-8") @@ -2584,6 +2609,70 @@ def test_agent_can_select_eligible_todo_outside_bounded_suggestions( assert _heartbeat_receipt_count(runtime, turn_instance_id) == 2 +def test_same_turn_can_select_eligible_todo_created_after_unbound_receipt( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + turn_instance_id = "turn-agent-selection-after-todo-create" + late_todo_id = "todo_fixture_late_alternative" + guard_args = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + ) + first_rc, first = _run_cli(registry_path, runtime, *guard_args) + assert first_rc == 0, first + assert "settlement_identity" not in first["heartbeat_receipt"] + + state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + state_text = state_path.read_text(encoding="utf-8") + state_path.write_text( + state_text.replace( + "## Agent Todo\n\n", + "## Agent Todo\n\n" + "- [ ] [P1] Advance the newly created same-Turn alternative.\n" + f" \n", + ), + encoding="utf-8", + ) + reentry_rc, reentry = _run_cli(registry_path, runtime, *guard_args) + assert reentry_rc == 0, reentry + assert late_todo_id in { + item["todo_id"] + for item in reentry["action_portfolio"]["suggested_actions"] + } + + selected_rc, selected = _run_cli( + registry_path, + runtime, + *guard_args, + "--todo-id", + late_todo_id, + ) + assert selected_rc == 0, selected + assert selected["action_selection_qualification"]["state"] == "qualified" + assert selected["selected_todo"]["todo_id"] == late_todo_id + assert selected["selected_todo"]["selection_binding"] == ( + "heartbeat_receipt" + ) + assert selected["heartbeat_receipt"]["status"] == "upgraded" + assert selected["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ( + late_todo_id + ) + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 2 + + def test_agent_selection_rejects_unprojected_todo(tmp_path: Path) -> None: project, runtime, registry_path = _write_fixture(tmp_path) _configure_selectable_alternative(project) @@ -2613,7 +2702,15 @@ 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_qualification"] == { + "schema_version": "action_selection_qualification_v0", + "state": "rejected", + "requested_todo_id": "todo_not_projected", + "reason": "candidate_not_currently_eligible", + } + assert invalid["heartbeat_receipt"]["status"] == "replayed" + assert invalid["rollout_event"]["appended"] is False assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 @@ -2658,10 +2755,50 @@ 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_qualification"]["reason"] == ( + "candidate_not_currently_eligible" + ) + assert blocked["heartbeat_receipt"]["status"] == "replayed" assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 +def test_first_call_rejected_selection_does_not_commit_a_false_receipt( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + turn_instance_id = "turn-agent-selection-first-call-rejected" + + rejected_rc, rejected = _run_cli( + registry_path, + runtime, + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + "--todo-id", + "todo_not_projected", + ) + + assert rejected_rc == 1, rejected + assert rejected["error_code"] == "quota_action_selection_rejected" + assert rejected["heartbeat_receipt"] == { + "schema_version": "heartbeat_quota_receipt_v0", + "turn_instance_id": turn_instance_id, + "status": "not_committed", + "stall_observation": "not_evaluated", + "reason_code": "quota_action_selection_rejected", + } + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 0 + + def test_first_call_agent_selection_is_qualified_before_receipt_commit( tmp_path: Path, ) -> None: @@ -2697,6 +2834,109 @@ def test_first_call_agent_selection_is_qualified_before_receipt_commit( assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 +def test_pending_selection_preserves_workspace_repair_then_reenters_same_turn( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["goals"][0]["coordination"]["registered_agents"].append( + "codex-settlement-peer" + ) + registry_path.write_text( + json.dumps(registry, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + _initialize_git_checkout(project) + subprocess.run( + [ + "git", + "-c", + "user.name=LoopX Test", + "-c", + "user.email=loopx-test@example.invalid", + "commit", + "--quiet", + "--allow-empty", + "-m", + "fixture", + ], + cwd=project, + check=True, + ) + turn_instance_id = "turn-pending-selection-workspace-repair" + guard_args = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + ) + + first_rc, first = _run_cli( + registry_path, + runtime, + *guard_args, + cwd=project, + ) + repair_rc, repair = _run_cli( + registry_path, + runtime, + *guard_args, + "--todo-id", + ALTERNATIVE_TODO_ID, + cwd=project, + ) + + assert first_rc == 0, first + assert "settlement_identity" not in first["heartbeat_receipt"] + assert repair_rc == 0, repair + assert repair.get("error_code") != "heartbeat_receipt_identity_conflict" + assert repair["effective_action"] == "agent_workspace_repair" + assert repair["workspace_repair_allowed"] is True + assert repair["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID + assert repair["selected_todo"]["selection_binding"] == "heartbeat_receipt" + assert repair["execution_obligation"]["kind"] == "agent_workspace_repair" + assert repair["interaction_contract"]["agent_channel"]["primary_action"] == ( + "create or switch to an independent worktree/branch, then rerun quota " + "guard before file edits" + ) + assert repair["heartbeat_receipt"]["status"] == "upgraded" + assert repair["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ( + ALTERNATIVE_TODO_ID + ) + + linked_worktree = tmp_path / "linked-worktree" + subprocess.run( + ["git", "worktree", "add", "--quiet", "--detach", str(linked_worktree)], + cwd=project, + check=True, + ) + resumed_rc, resumed = _run_cli( + registry_path, + runtime, + *guard_args, + "--todo-id", + ALTERNATIVE_TODO_ID, + cwd=linked_worktree, + ) + + assert resumed_rc == 0, resumed + assert resumed["effective_action"] == "normal_run" + assert resumed["normal_delivery_allowed"] is True + assert resumed["workspace_repair_allowed"] is False + assert resumed["selected_todo"]["todo_id"] == ALTERNATIVE_TODO_ID + assert resumed["selected_todo"]["selection_binding"] == "heartbeat_receipt" + assert resumed["heartbeat_receipt"]["status"] == "replayed" + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 2 + + def test_pending_action_selection_does_not_preempt_newly_due_monitor( tmp_path: Path, ) -> None: @@ -2734,13 +2974,123 @@ 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" + assert selected["action_selection_qualification"]["reason"] == ( + "blocking_work_lane" + ) + assert selected["heartbeat_receipt"]["status"] == "replayed" + assert selected["rollout_event"]["appended"] is False events = _heartbeat_receipt_events(runtime, turn_instance_id) assert len(events) == 1 assert not events[0]["details"].get("todo_id") assert not events[0]["details"].get("settlement_effect_id") +def test_pending_action_selection_reports_autonomous_replan_preemption( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + turn_instance_id = "turn-pending-selection-replan-preemption" + guard_args = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + ) + first_rc, first = _run_cli(registry_path, runtime, *guard_args) + assert first_rc == 0, first + assert first["decision"] == "run" + assert "settlement_identity" not in first["heartbeat_receipt"] + + _append_surface_only_runs( + runtime, + count=AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD, + ) + selected_rc, selected = _run_cli( + registry_path, + runtime, + *guard_args, + "--todo-id", + ALTERNATIVE_TODO_ID, + ) + + assert selected_rc == 1, selected + assert selected["error_code"] == "quota_action_selection_deferred" + assert selected["action_selection_qualification"] == { + "schema_version": "action_selection_qualification_v0", + "state": "deferred", + "requested_todo_id": ALTERNATIVE_TODO_ID, + "reason": "autonomous_replan", + "delivery_preemptions": ["autonomous_replan", "delivery_not_allowed"], + } + assert selected["heartbeat_receipt"]["status"] == "replayed" + assert selected["rollout_event"]["appended"] is False + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 + + +def test_due_monitor_auxiliary_context_has_typed_selection_rejection( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + _append_newly_due_monitor(project, priority="P2-monitor") + turn_instance_id = "turn-auxiliary-due-monitor-selection" + guard_args = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + "--available-capability", + "network", + "--available-capability", + "external_evidence_poll", + ) + first_rc, first = _run_cli(registry_path, runtime, *guard_args) + assert first_rc == 0, first + assert first["selected_todo"]["todo_id"] == TODO_ID + assert first["work_lane_contract"]["lane"] == "advancement_task" + assert first["work_lane_contract"]["monitor_policy"] == ( + "material_transition_only" + ) + assert "settlement_identity" not in first["heartbeat_receipt"] + + selected_rc, selected = _run_cli( + registry_path, + runtime, + *guard_args, + "--todo-id", + DUE_MONITOR_TODO_ID, + ) + + assert selected_rc == 1, selected + assert selected["error_code"] == "quota_action_selection_rejected" + assert selected["action_selection_qualification"]["state"] == "rejected" + assert selected["action_selection_qualification"]["reason"] == ( + "auxiliary_monitor_not_selectable_in_advancement_lane" + ) + assert selected["recommended_action"].startswith( + "the due monitor is visible as auxiliary context" + ) + assert selected["heartbeat_receipt"]["status"] == "replayed" + assert selected["rollout_event"]["appended"] is False + assert _heartbeat_receipt_count(runtime, turn_instance_id) == 1 + + def test_pending_action_selection_can_bind_exact_newly_due_monitor( tmp_path: Path, ) -> None: @@ -2843,6 +3193,135 @@ def test_pending_action_selection_can_bind_exact_newly_due_monitor( assert _spend_run_count(runtime) == 0 +def test_receipt_bound_advancement_turn_records_multiple_due_monitors( + tmp_path: Path, +) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + monitors = [ + ("todo_fixture_monitor_alpha", "monitor-alpha"), + ("todo_fixture_monitor_beta", "monitor-beta"), + ("todo_fixture_monitor_gamma", "monitor-gamma"), + ] + _append_due_monitors(project, monitors) + turn_instance_id = "turn-multiple-auxiliary-monitors" + guard_args = ( + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--scan-path", + str(project), + "--available-capability", + "network", + "--available-capability", + "external_evidence_poll", + ) + first_rc, first = _run_cli(registry_path, runtime, *guard_args) + + assert first_rc == 0, first + assert first["selected_todo"]["todo_id"] == TODO_ID + assert first["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ( + TODO_ID + ) + projected_due = [ + *(first["work_lane_contract"].get("monitor_due_items") or []), + *(first["agent_todo_summary"].get("monitor_due_items") or []), + ] + projected_due_ids = { + item["todo_id"] for item in projected_due if item.get("todo_id") + } + assert projected_due_ids < {todo_id for todo_id, _ in monitors} + + poll_args: list[tuple[str, ...]] = [] + for index, (todo_id, target_key) in enumerate(monitors): + args = ( + "quota", + "monitor-poll", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + turn_instance_id, + "--todo-id", + todo_id, + *(() if index == 2 else ("--target-key", target_key)), + "--result-hash", + f"unchanged-{target_key}", + "--available-capability", + "network", + "--available-capability", + "external_evidence_poll", + "--execute", + "--scan-path", + str(project), + ) + poll_args.append(args) + poll_rc, poll = _run_cli(registry_path, runtime, *args) + assert poll_rc == 0, poll + assert poll["settlement_todo_id"] == TODO_ID + assert poll["todo_id"] == todo_id + assert poll["target_key"] == target_key + assert poll["material_change"] is False + assert poll["replayed"] is False + + for args in poll_args: + replay_rc, replay = _run_cli(registry_path, runtime, *args) + assert replay_rc == 0, replay + assert replay["settlement_todo_id"] == TODO_ID + assert replay["replayed"] is True + assert replay["appended"] is False + + changed_args = list(poll_args[1]) + changed_args[changed_args.index("unchanged-monitor-beta")] = ( + "changed-monitor-beta" + ) + conflict_rc, conflict = _run_cli( + registry_path, + runtime, + *changed_args, + ) + assert conflict_rc == 1, conflict + assert conflict["error_code"] == "heartbeat_receipt_identity_conflict" + assert conflict["conflict_fields"] == ["result_hash"] + + index_path = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + observation_rows = [ + json.loads(line) + for line in index_path.read_text(encoding="utf-8").splitlines() + if json.loads(line).get("classification") == "quota_monitor_poll" + ] + effect_ids = { + row["quota_monitor_poll_commit"]["effect_id"] + for row in observation_rows + } + assert len(observation_rows) == 3 + assert effect_ids == { + f"quota-monitor-poll:{GOAL_ID}:{AGENT_ID}:{turn_instance_id}:todo:{todo_id}" + for todo_id, _ in monitors + } + assert { + row["todo_id"] for row in observation_rows + } == {todo_id for todo_id, _ in monitors} + assert all( + row["settlement_todo_id"] == TODO_ID for row in observation_rows + ) + assert _spend_run_count(runtime) == 0 + + settled_rc, settled = _run_cli(registry_path, runtime, *guard_args) + assert settled_rc == 0, settled + assert settled["selected_todo"]["todo_id"] == TODO_ID + assert settled["heartbeat_receipt"]["settlement_identity"]["todo_id"] == ( + TODO_ID + ) + + def test_pending_action_selection_does_not_commit_after_new_user_gate( tmp_path: Path, ) -> None: @@ -2876,7 +3355,12 @@ 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" + assert selected["action_selection_qualification"]["reason"] == ( + "delivery_not_allowed" + ) + assert selected["heartbeat_receipt"]["status"] == "replayed" + assert selected["rollout_event"]["appended"] is False events = _heartbeat_receipt_events(runtime, turn_instance_id) assert len(events) == 1 assert not events[0]["details"].get("todo_id") diff --git a/tests/control_plane_ts/action_portfolio.test.ts b/tests/control_plane_ts/action_portfolio.test.ts index 8aedf8bf8b..96763824e9 100644 --- a/tests/control_plane_ts/action_portfolio.test.ts +++ b/tests/control_plane_ts/action_portfolio.test.ts @@ -372,3 +372,20 @@ test("pending selection rejects a Todo absent from the current eligible set", () reason: "candidate_not_currently_eligible", }); }); + +test("pending selection explains an auxiliary monitor outside the advancement lane", () => { + assert.deepEqual(qualifyActionSelection({ + schema_version: ACTION_SELECTION_QUALIFICATION_REQUEST_SCHEMA_VERSION, + requested_todo_id: "todo_monitor001", + candidate: null, + requested_task_class: "continuous_monitor", + should_run: true, + normal_delivery_allowed: true, + delivery_preemptions: [], + }), { + schema_version: "action_selection_qualification_v0", + state: "rejected", + requested_todo_id: "todo_monitor001", + reason: "auxiliary_monitor_not_selectable_in_advancement_lane", + }); +});