diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 5dd21d6e6..ee173993c 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -1,5 +1,16 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + +from ..scheduler.execution_context import ( + NATIVE_GOAL_RUNTIME_PROFILES, + SchedulerExecutionContextResolution, + scheduler_runtime_profile_for_execution_context, +) +from ..todos.contract import normalize_todo_id +from .effect_program import SettlementStepKind, settlement_step_command + DEFAULT_SLOT_SPEND_SOURCE = "heartbeat" VISIBLE_GOAL_SLOT_SPEND_SOURCE = "visible-goal" @@ -9,3 +20,43 @@ "adapter", VISIBLE_GOAL_SLOT_SPEND_SOURCE, } + + +def quota_spend_source_for_execution_context( + value: Mapping[str, Any] | SchedulerExecutionContextResolution | None, +) -> str: + profile = scheduler_runtime_profile_for_execution_context(value) + if profile in NATIVE_GOAL_RUNTIME_PROFILES: + return VISIBLE_GOAL_SLOT_SPEND_SOURCE + return DEFAULT_SLOT_SPEND_SOURCE + + +def build_quota_spend_action( + goal_id: str, + *, + scoped_cli_args: str, + payload: Mapping[str, Any], + settlement_plan: Mapping[str, Any] | None, + scheduler_execution_context: ( + Mapping[str, Any] | SchedulerExecutionContextResolution | None + ) = None, +) -> str: + typed_command = settlement_step_command( + settlement_plan, + SettlementStepKind.QUOTA_SPEND, + ) + if typed_command: + return typed_command + selected_value = payload.get("selected_todo") + selected = selected_value if isinstance(selected_value, Mapping) else {} + todo_id = normalize_todo_id(selected.get("todo_id")) + source = quota_spend_source_for_execution_context(scheduler_execution_context) + todo_arg = ( + f" --todo-id {todo_id}" + if todo_id and source == DEFAULT_SLOT_SPEND_SOURCE + else "" + ) + return ( + f"loopx quota spend-slot --goal-id {goal_id} --slots 1 " + f"--source {source} --execute{todo_arg}{scoped_cli_args}" + ) diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 3f2c79fc5..e42247c11 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -15,6 +15,9 @@ settlement_binding_args, settlement_step_command, ) +from ..quota.spend_sources import ( + build_quota_spend_action, +) from ..scheduler.execution_context import ( SchedulerExecutionContextResolution, SchedulerRuntimeProfile, @@ -615,39 +618,13 @@ def _codex_app_settlement_plan( ).as_dict() -def _quota_spend_action( - goal_id: str, - *, - scoped_cli_args: str, - payload: dict[str, Any], - settlement_plan: Mapping[str, Any] | None, -) -> str: - typed_command = settlement_step_command( - settlement_plan, - SettlementStepKind.QUOTA_SPEND, - ) - if typed_command: - return typed_command - selected = ( - payload.get("selected_todo") - if isinstance(payload.get("selected_todo"), dict) - else {} - ) - todo_id = normalize_todo_id(selected.get("todo_id")) - todo_arg = f" --todo-id {todo_id}" if todo_id else "" - return ( - f"loopx quota spend-slot --goal-id {goal_id} --slots 1 " - f"--source heartbeat --execute{todo_arg}{scoped_cli_args}" - ) - - def _terminal_cli_actions( *, mode: str, goal_id: str, scoped_cli_args: str, - payload: dict[str, Any], settlement_plan: Mapping[str, Any] | None, + quota_spend_action: str, capability_resolution_actions: list[str], capability_reentry_actions: list[str], ) -> list[str]: @@ -676,12 +653,7 @@ def _terminal_cli_actions( *resolution_actions, typed_writeback or f"loopx refresh-state --goal-id {goal_id} --classification {scoped_cli_args}", - _quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ), + quota_spend_action, ] if mode in {"user_gate", "user_todo_blocker_push", "user_action_required"}: return [ @@ -757,6 +729,13 @@ def interaction_next_cli_actions( ) is SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT ) + quota_spend_action = build_quota_spend_action( + goal_id, + scoped_cli_args=scoped_cli_args, + payload=payload, + settlement_plan=settlement_plan, + scheduler_execution_context=scheduler_execution_context, + ) capability_resolution_actions = build_capability_resolution_writeback_actions( payload.get("capability_gate"), goal_id=goal_id, @@ -846,12 +825,7 @@ def interaction_next_cli_actions( f"loopx todo complete --goal-id {goal_id} --todo-id {monitor_todo_id}{lifecycle_actor_args} --evidence ''", f"loopx todo update --goal-id {goal_id} --todo-id {gated_todo_id}{lifecycle_actor_args} --note ''", f"loopx refresh-state --goal-id {goal_id} --classification standing_monitor_gate_repair_recorded --delivery-batch-scale single_surface --delivery-outcome outcome_progress{settlement_args}{scoped_cli_args}", - _quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ), + quota_spend_action, ] route_candidates = ( agent_scope_frontier.get("route_continuation_replan_candidates") @@ -862,12 +836,7 @@ def interaction_next_cli_actions( return [ f"loopx todo add --goal-id {goal_id} --role agent --text ''", f"loopx refresh-state --goal-id {goal_id} --classification route_continuation_replan_recorded --delivery-batch-scale single_surface --delivery-outcome outcome_progress{settlement_args}{scoped_cli_args}", - _quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ), + quota_spend_action, ] candidates = ( agent_scope_frontier.get("deferred_resume_candidates") @@ -883,12 +852,7 @@ def interaction_next_cli_actions( "--note ''" ), f"loopx refresh-state --goal-id {goal_id} --classification successor_replan_recorded --delivery-batch-scale single_surface --delivery-outcome outcome_progress{settlement_args}{scoped_cli_args}", - _quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ), + quota_spend_action, ] if ( mode == AgentScopeFrontierAction.AGENT_SCOPE_WAIT.value @@ -904,12 +868,6 @@ def interaction_next_cli_actions( typed_quota_guard, ] if mode == "external_evidence_observation": - spend_action = _quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ) return [ "read approved controller/job/marker/writeback surfaces only", ( @@ -921,7 +879,7 @@ def interaction_next_cli_actions( ), ( "after that accountable writeback receipt only: " - f"{spend_action}; otherwise do not spend for unchanged observation" + f"{quota_spend_action}; otherwise do not spend for unchanged observation" ), ] if mode == "agent_workspace_repair": @@ -941,12 +899,7 @@ def interaction_next_cli_actions( goal_id=goal_id, settlement_args=settlement_args, scoped_cli_args=scoped_cli_args, - quota_spend_action=_quota_spend_action( - goal_id, - scoped_cli_args=scoped_cli_args, - payload=payload, - settlement_plan=settlement_plan, - ), + quota_spend_action=quota_spend_action, replan_settlement_bound=bool( settlement_identity.get("replan_obligation_id") ), @@ -956,8 +909,8 @@ def interaction_next_cli_actions( mode=mode, goal_id=goal_id, scoped_cli_args=scoped_cli_args, - payload=payload, settlement_plan=settlement_plan, + quota_spend_action=quota_spend_action, capability_resolution_actions=capability_resolution_actions, capability_reentry_actions=capability_reentry_actions, ) diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 68de7f01f..0a8f73aca 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -16,6 +16,7 @@ teaches a reusable control-plane lesson. | `normal_no_user_todo_misflagged` | `action_required=false` and user open count is zero, but the agent reports a state projection fault. | quota payload and user todo summary. | Rule over-applied the "todo not projected" fallback. | Allow "无用户待办/无需通知"; update heartbeat/prompt smoke if needed. | | `todo_no_followup_global_terminal_overreach` | A completed scoped Todo records `done + no_followup`, but `refresh-state --repair-delta-kind no_followup` becomes `replan_noop` because unrelated advancement or long-running monitor Todos remain open. | rejected repair-delta claim, complete Agent Todo projection, scoped Todo metadata, and the latest local no-follow-up completion. | The repair-delta validator reused Goal-level terminal-frontier completeness to validate a Todo-local closure. | Keep Goal `terminal_no_followup` strict, but validate repair-delta no-follow-up against the latest scoped completed Todo in a complete parser projection; reject other-agent, continuation-required, malformed, or truncated evidence and keep the emitted Todo evidence bounded. Cover the real CLI path with an open monitor regression. | | `terminal_settlement_ordering_gap` | Final Todo completion succeeds, then the matching refresh or spend is rejected because the Goal is already terminal; different hosts may expose different closeout orders or retry the material effect. | Typed settlement plan and effect identity, Todo completion event, accountable refresh and spend receipts, Turn journal phase prefix, and host task-body projection. | Final `no_followup` was modeled like ordinary pre-settlement Todo completion even though it mutates Goal terminal state and activates the strict no-more-spend guard. | Keep Goal terminal guards strict. Treat ordinary successor completion as Todo lifecycle work, but make final `no_followup` a conditional post-spend terminal closeout under the same effect identity. Require matching writeback and spend receipts at the mutation boundary, prove the closeout event itself carries `no_followup`, and journal it so closeout-only retry cannot repeat writeback or spend. Cover CLI, visible Goal, Codex App, Turn driver, adapter order, failure short-circuit, and replay. | +| `host_settlement_source_projection_gap` | A visible Goal host packet requires `source=visible-goal` without an explicit Turn receipt, but the nested interaction contract tells the agent to spend as `heartbeat` and binds the currently selected Todo. The command can fail after Todo completion or misattribute interactive usage. | Host runtime profile, canonical host quota commands, interaction-contract CLI actions, selected Todo lifecycle order, and the failed spend preview. | The generic interaction projection hard-coded heartbeat accounting and selected-Todo binding instead of deriving the accounting route from the scheduler execution context. | Route spend actions from the typed runtime profile. Native Goal runtimes preserve `source=visible-goal` and host-level attribution; heartbeat automation keeps its Todo- and Turn-bound settlement plan. Cover the exact CLI actions for each native Goal profile and retain the heartbeat regression. | | `runtime_capability_user_gate_overreach` | A missing launcher/runtime capability such as `network` becomes `capability_gate.action=ask_owner`, `requires_user_action=true`, or a user notification even though no credential, protected access, or production authority is needed. | quota `capability_gate`, heartbeat `--available-capability` inputs, launcher/runtime observations, interaction contract user channel. | Capability availability and permission authority were collapsed into one owner-held class. | Keep runtime provisioning gaps in the agent repair lane, require truthful runtime observation before declaring availability, and reserve `ask_owner` for credentials or protected/production authority. Cover all-blocked and mixed-capability cases with capability-gate and monitor smokes. | | `visible_goal_dynamic_capability_projection_gap` | A visible Goal session proves a runtime capability such as `network` at a real callsite, but later continuations keep running the activation-time quota command without that capability and repeatedly enter `capability_bridge_repair`; a bounded host tool result truncates before the re-entry packet; or a same-turn re-entry selects a newly runnable Todo and conflicts with the heartbeat receipt already bound to the admitted Todo. | Activation-time capability list, later real-callsite observation, complete versus host-captured quota byte counts, byte offset of `runtime_capability_reentry_v0`, quota guard and follow-up argv, capability-gate result, heartbeat receipt settlement identity, and re-entry selected Todo. | The CLI decision surface lacked a typed re-entry packet, emitted it after diagnostics larger than the host capture budget, or recomputed Todo priority without treating the committed same-turn settlement identity as an immutable selection binding. | Keep the Goal prompt stable. Return the real-callsite-gated re-entry packet near the beginning of JSON CLI output; after verified re-entry, preserve observed capabilities in generated follow-up actions and prefer the receipt-bound Todo when it remains runnable. Fail closed on an unavailable bound Todo or an explicit conflicting Todo request. Never persist observations as grants. Cover blocked and bounded-capture packets, successful re-entry inheritance, owner-capability exclusion, receipt-bound replay, and explicit conflict. | | `blocked_priority_fallback_hidden` | A core P0 path is user-gated, safe P1/P2 fallback runs, but the user is not told what blocked the P0 path. | quota `safe_bypass_allowed`, active priority stack, final heartbeat text. | Fallback execution hid the higher-priority blocked item. | Report concrete blocker plus fallback progress; expose `blocked_priority_fallback` in state/payload if missing. | diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index 0d4864be1..2ec702d41 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -355,6 +355,40 @@ def test_standard_codex_app_actions_use_typed_settlement_before_turn_driver() -> assert '--turn-instance-id "${LOOPX_TURN:?}"' in command +@pytest.mark.parametrize( + "profile", + ( + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + ), +) +def test_native_goal_actions_preserve_visible_goal_spend_attribution( + profile: SchedulerRuntimeProfile, +) -> None: + todo_id = "todo_visible_goal" + actions = interaction_next_cli_actions( + { + "goal_id": GOAL_ID, + "agent_identity": {"agent_id": AGENT_ID}, + "selected_todo": {"todo_id": todo_id}, + }, + mode="bounded_delivery", + scheduler_execution_context=scheduler_execution_context_for_runtime_profile( + profile + ), + ) + + assert len(actions) == 2 + assert actions[0].startswith("loopx refresh-state") + assert actions[1] == ( + f"loopx quota spend-slot --goal-id {GOAL_ID} --slots 1 " + f"--source visible-goal --execute --agent-id {AGENT_ID}" + ) + assert all("--todo-id" not in command for command in actions) + assert all("--turn-instance-id" not in command for command in actions) + + def test_codex_app_external_observation_settles_only_substantive_writeback() -> None: todo_id = "todo_external_observation" actions = interaction_next_cli_actions(