From 2261ad01517f5b5f1c44c03654e0f425feef9d65 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:42:51 +0800 Subject: [PATCH 1/3] fix(quota): classify unbound action selection preflight Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/protocols/turn-envelope-v0.md | 23 ++ loopx/cli_commands/quota.py | 144 +++++++-- .../control_plane/quota/should_run_packet.py | 22 ++ .../work_items/action_portfolio.py | 2 + .../work_items/action_portfolio.ts | 11 +- .../test_quota_settlement_cli.py | 282 +++++++++++++++++- .../control_plane_ts/action_portfolio.test.ts | 17 ++ 7 files changed, 475 insertions(+), 26 deletions(-) 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/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 5b460b427d..68889afdf9 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -94,6 +94,8 @@ ) from ..todos.contract import ( normalize_todo_claimed_by, + normalize_todo_id, + normalize_todo_task_class, ) from ..todos.todo_semantics import ( todo_item_is_actionable_open as projection_todo_item_is_actionable_open, @@ -553,9 +555,28 @@ def _resolve_agent_lane_delivery_route( return prepared.guarded_agent_lane_next_action if prepared.requested_action_todo_id is not None: + requested_item = next( + ( + item + for item in prepared.agent_todo_planning_source_items + if normalize_todo_id(item.get("todo_id")) + == prepared.requested_action_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 + ) qualification = qualify_action_selection( requested_todo_id=prepared.requested_action_todo_id, candidate=prepared.requested_action_candidate, + requested_task_class=requested_task_class, should_run=should_run, normal_delivery_allowed=normal_delivery_allowed, delivery_preemptions=delivery_preemptions, @@ -1281,6 +1302,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/work_items/action_portfolio.py b/loopx/control_plane/work_items/action_portfolio.py index c60ed46a3a..0bb420ad17 100644 --- a/loopx/control_plane/work_items/action_portfolio.py +++ b/loopx/control_plane/work_items/action_portfolio.py @@ -100,6 +100,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 +115,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, 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/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 2ddd1828cd..fcca107600 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") @@ -2972,6 +2993,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: From b1381349df2481f0bc6ca0340681254085af3fd9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:02:05 +0800 Subject: [PATCH 3/3] fix(quota): select late same-turn actions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/quota/should_run_prepare.py | 7 +- .../test_quota_settlement_cli.py | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) 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/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 27735142e6..0eb884a936 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2473,6 +2473,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)