From 6b35e196241e06357293468a4dcdfdf781dc5015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=9B=E7=91=9E=E5=8D=9A?= <912906590@qq.com> Date: Wed, 19 Aug 2026 15:24:34 +0800 Subject: [PATCH] fix(mcp): settle completion under the original todo identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve and persist one turn-scoped settlement identity before the MCP completion mutates the Todo frontier. The provider-neutral control-plane path now validates lifecycle completion, records accountable refresh-state writeback, and spends quota in order against the original Todo. Fail closed on malformed, terminal-without-selection, mismatched, and incomplete receipts. Replays recover the original binding from the heartbeat receipt so a successor Todo is never charged and quota spend remains idempotent. Add real CLI coverage for missing-writeback rejection, successful writeback/spend, successor selection, and replay, while preserving the declared completion-validation gate. Fixes #3341 Signed-off-by: 牛瑞博 <912906590@qq.com> --- loopx/cli_commands/project_lifecycle.py | 10 + .../control_plane/host_adapter_settlement.py | 454 ++++++++++++++++++ loopx/goal_mode_mcp.py | 47 +- tests/test_goal_mode_mcp_settlement.py | 312 ++++++++++++ tests/test_kunluncode_goal_mode.py | 271 ++++++++++- 5 files changed, 1052 insertions(+), 42 deletions(-) create mode 100644 loopx/control_plane/host_adapter_settlement.py create mode 100644 tests/test_goal_mode_mcp_settlement.py diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 3596b1db1..0dca086b5 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -298,6 +298,14 @@ def register_project_lifecycle_commands( "value on retries." ), ) + refresh_state_parser.add_argument( + "--completion-todo-id", + help=argparse.SUPPRESS, + ) + refresh_state_parser.add_argument( + "--completion-turn-key", + help=argparse.SUPPRESS, + ) refresh_state_parser.add_argument( "--autonomous-replan-recorded", action="store_true", @@ -651,6 +659,8 @@ def handle_project_lifecycle_command( replan_obligation_id=getattr( args, "replan_obligation_id", None ), + completion_todo_id=getattr(args, "completion_todo_id", None), + completion_turn_key=getattr(args, "completion_turn_key", None), agent_id=args.agent_id, agent_lane=args.agent_lane, progress_scope=args.progress_scope, diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py new file mode 100644 index 000000000..12d485207 --- /dev/null +++ b/loopx/control_plane/host_adapter_settlement.py @@ -0,0 +1,454 @@ +"""Provider-neutral Todo settlement orchestration for shipping host adapters.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, Protocol + +from .effect_program import SettlementIdentity +from .todos.contract import normalize_todo_id + + +HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = "host_adapter_todo_settlement_v0" + + +class HostGuardState(StrEnum): + SELECTED = "selected" + TERMINAL_NO_SELECTION = "terminal_no_selection" + INVALID = "invalid" + + +@dataclass(frozen=True, slots=True) +class HostGuardSelection: + state: HostGuardState + todo_id: str | None = None + reason: str | None = None + settlement_identity: SettlementIdentity | None = None + + +@dataclass(frozen=True, slots=True) +class HostTodoSettlementRequest: + goal_id: str + agent_id: str + todo_id: str + runtime_profile: str + legacy_host_surface: str + scheduler_owner: str + execution_mode: str + completion_args: tuple[str, ...] + no_follow_up: bool = False + + +class HostCliRunner(Protocol): + def __call__( + self, + args: list[str], + *, + legacy_args: list[str] | None = None, + ) -> str: ... + + +def host_adapter_turn_instance_id(request: HostTodoSettlementRequest) -> str: + """Return a retry-stable public-safe identity for one Todo completion.""" + + seed = "\0".join((request.goal_id, request.agent_id, request.todo_id)) + return "mcp-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32] + + +def classify_host_guard_snapshot(value: str) -> HostGuardSelection: + """Classify a guard without collapsing terminal and invalid snapshots.""" + + try: + payload = json.loads(value) + except json.JSONDecodeError: + return HostGuardSelection( + HostGuardState.INVALID, + reason="quota guard returned malformed JSON", + ) + if not isinstance(payload, Mapping) or payload.get("ok") is not True: + reason = ( + str(payload.get("error") or payload.get("reason") or "") + if isinstance(payload, Mapping) + else "" + ) + return HostGuardSelection( + HostGuardState.INVALID, + reason=reason or "quota guard did not return an ok object", + ) + heartbeat_receipt = payload.get("heartbeat_receipt") + if isinstance(heartbeat_receipt, Mapping): + raw_identity = heartbeat_receipt.get("settlement_identity") + if isinstance(raw_identity, Mapping): + goal_id = str(raw_identity.get("goal_id") or "").strip() + agent_id = str(raw_identity.get("agent_id") or "").strip() + todo_id = normalize_todo_id(raw_identity.get("todo_id")) + turn_instance_id = str( + raw_identity.get("turn_instance_id") or "" + ).strip() + if not (goal_id and agent_id and todo_id and turn_instance_id): + return HostGuardSelection( + HostGuardState.INVALID, + reason="quota guard returned an incomplete settlement identity", + ) + identity = SettlementIdentity( + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, + turn_instance_id=turn_instance_id, + ) + if raw_identity.get("effect_id") != identity.effect_id: + return HostGuardSelection( + HostGuardState.INVALID, + reason="quota guard returned a mismatched settlement effect id", + ) + return HostGuardSelection( + HostGuardState.SELECTED, + todo_id=todo_id, + settlement_identity=identity, + ) + selected = payload.get("selected_todo") + if isinstance(selected, Mapping): + todo_id = normalize_todo_id(selected.get("todo_id")) + if todo_id: + return HostGuardSelection(HostGuardState.SELECTED, todo_id=todo_id) + return HostGuardSelection( + HostGuardState.INVALID, + reason="quota guard selected_todo has no typed todo_id", + ) + if ( + payload.get("should_run") is False + and payload.get("effective_action") == "terminal_no_followup" + ): + return HostGuardSelection(HostGuardState.TERMINAL_NO_SELECTION) + return HostGuardSelection( + HostGuardState.INVALID, + reason="quota guard has no authoritative Todo selection", + ) + + +def _json_object(value: str) -> dict[str, Any] | None: + try: + payload = json.loads(value) + except json.JSONDecodeError: + return None + return dict(payload) if isinstance(payload, Mapping) else None + + +def _blocked_payload( + request: HostTodoSettlementRequest, + *, + stage: str, + reason: str, + guard_state: HostGuardState | None = None, + completion: Mapping[str, Any] | None = None, +) -> str: + payload: dict[str, Any] = { + "schema_version": HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, + "ok": False, + "completed": bool(completion and completion.get("completed") is True), + "goal_id": request.goal_id, + "todo_id": request.todo_id, + "settlement_blocked_completion": True, + "settlement": { + "ok": False, + "failed_stage": stage, + "reason": reason, + }, + } + if guard_state is not None: + payload["settlement"]["guard_state"] = guard_state.value + if completion is not None: + payload["completion"] = dict(completion) + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def _identity_matches( + payload: Mapping[str, Any], + expected: SettlementIdentity, +) -> bool: + identity = payload.get("settlement_identity") + if not isinstance(identity, Mapping): + return False + return ( + identity.get("goal_id") == expected.goal_id + and identity.get("agent_id") == expected.agent_id + and identity.get("todo_id") == expected.todo_id + and identity.get("turn_instance_id") == expected.turn_instance_id + and identity.get("effect_id") == expected.effect_id + ) + + +def settle_host_todo_completion( + request: HostTodoSettlementRequest, + *, + run_cli: HostCliRunner, +) -> str: + """Guard, complete, write back, and spend one Todo under one identity.""" + + turn_instance_id = host_adapter_turn_instance_id(request) + expected_identity = SettlementIdentity( + goal_id=request.goal_id, + agent_id=request.agent_id, + todo_id=request.todo_id, + turn_instance_id=turn_instance_id, + ) + guard_common = [ + "quota", + "should-run", + "--goal-id", + request.goal_id, + "--agent-id", + request.agent_id, + "--todo-id", + request.todo_id, + "--turn-instance-id", + turn_instance_id, + ] + guard_output = run_cli( + [*guard_common, "--runtime-profile", request.runtime_profile], + legacy_args=[ + *guard_common, + "--host-surface", + request.legacy_host_surface, + "--scheduler-owner", + request.scheduler_owner, + "--execution-mode", + request.execution_mode, + ], + ) + guard = classify_host_guard_snapshot(guard_output) + if guard.state is not HostGuardState.SELECTED: + return _blocked_payload( + request, + stage="guard", + reason=guard.reason or "quota guard has no selected Todo", + guard_state=guard.state, + ) + if guard.todo_id != request.todo_id: + return _blocked_payload( + request, + stage="guard", + reason=( + "quota guard selected a different Todo: expected " + f"{request.todo_id}, selected {guard.todo_id}" + ), + guard_state=guard.state, + ) + if ( + guard.settlement_identity is not None + and guard.settlement_identity != expected_identity + ): + return _blocked_payload( + request, + stage="guard", + reason="quota guard settlement identity does not match the request", + guard_state=HostGuardState.INVALID, + ) + + lifecycle_args = tuple( + arg for arg in request.completion_args if arg != "--no-follow-up" + ) + completion_output = run_cli( + [*lifecycle_args, "--turn-instance-id", turn_instance_id] + ) + completion = _json_object(completion_output) + if completion is None: + return _blocked_payload( + request, + stage="durable_writeback", + reason="todo completion returned malformed JSON", + ) + if not ( + completion.get("ok") is True + and completion.get("completed") is True + and completion.get("status") == "done" + ): + # Preserve the completion gate's typed failure payload unchanged. + return completion_output + + settlement_result = completion.get("settlement_result") + if ( + not _identity_matches(completion, expected_identity) + or not isinstance(settlement_result, Mapping) + or settlement_result.get("failure") is not None + ): + return _blocked_payload( + request, + stage="durable_writeback", + reason="todo completion did not prove the expected settlement identity", + completion=completion, + ) + + writeback_output = run_cli( + [ + "refresh-state", + "--goal-id", + request.goal_id, + "--agent-id", + request.agent_id, + "--classification", + "mcp_completed_turn_writeback", + "--delivery-batch-scale", + "single_surface", + "--delivery-outcome", + "outcome_progress", + "--todo-id", + request.todo_id, + "--turn-instance-id", + turn_instance_id, + "--completion-todo-id", + request.todo_id, + "--completion-turn-key", + expected_identity.effect_id, + "--no-global-sync", + "--suppress-external-sinks", + ] + ) + writeback = _json_object(writeback_output) + writeback_result = ( + writeback.get("settlement_result") + if isinstance(writeback, Mapping) + else None + ) + if not ( + isinstance(writeback, Mapping) + and writeback.get("ok") is True + and _identity_matches(writeback, expected_identity) + and isinstance(writeback_result, Mapping) + and writeback_result.get("failure") is None + ): + reason = ( + str(writeback.get("error") or writeback.get("reason") or "") + if isinstance(writeback, Mapping) + else "" + ) + return _blocked_payload( + request, + stage="durable_writeback", + reason=( + reason + or "refresh-state did not prove the expected settlement identity" + ), + completion=completion, + ) + + spend_output = run_cli( + [ + "quota", + "spend-slot", + "--goal-id", + request.goal_id, + "--slots", + "1", + "--source", + "heartbeat", + "--execute", + "--agent-id", + request.agent_id, + "--todo-id", + request.todo_id, + "--turn-instance-id", + turn_instance_id, + ] + ) + spend = _json_object(spend_output) + spend_result = spend.get("settlement_result") if spend is not None else None + spend_committed = bool( + isinstance(spend, Mapping) + and ( + spend.get("appended") is True + or spend.get("idempotent_replay") is True + or spend.get("receipt_repaired") is True + ) + ) + if not ( + isinstance(spend, Mapping) + and spend.get("ok") is True + and spend_committed + and _identity_matches(spend, expected_identity) + and isinstance(spend_result, Mapping) + and spend_result.get("failure") is None + ): + reason = ( + str(spend.get("error") or spend.get("reason") or "") + if spend is not None + else "" + ) + return _blocked_payload( + request, + stage="quota_spend", + reason=reason or "quota spend did not append a receipt", + completion=completion, + ) + + terminal_closeout = None + final_completion = completion + if request.no_follow_up: + terminal_output = run_cli( + [*request.completion_args, "--turn-instance-id", turn_instance_id] + ) + terminal_closeout = _json_object(terminal_output) + terminal_result = ( + terminal_closeout.get("settlement_result") + if terminal_closeout is not None + else None + ) + if not ( + isinstance(terminal_closeout, Mapping) + and terminal_closeout.get("ok") is True + and terminal_closeout.get("completed") is True + and terminal_closeout.get("status") == "done" + and terminal_closeout.get("completion_continuation") == "no_followup" + and _identity_matches(terminal_closeout, expected_identity) + and isinstance(terminal_result, Mapping) + and terminal_result.get("failure") is None + ): + reason = ( + str( + terminal_closeout.get("error") + or terminal_closeout.get("reason") + or "" + ) + if isinstance(terminal_closeout, Mapping) + else "" + ) + return _blocked_payload( + request, + stage="terminal_closeout", + reason=( + reason + or "Todo terminal closeout did not prove the expected identity" + ), + completion=completion, + ) + final_completion = terminal_closeout + + settlement_payload = { + "ok": True, + "guard_state": guard.state.value, + "durable_writeback": writeback, + "lifecycle_completion": completion, + "quota_spend": spend, + } + if terminal_closeout is not None: + settlement_payload["terminal_closeout"] = terminal_closeout + + return json.dumps( + { + "schema_version": HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, + "ok": True, + "completed": True, + "status": "done", + "goal_id": request.goal_id, + "todo_id": request.todo_id, + "settlement_identity": expected_identity.as_dict(), + "completion": final_completion, + "settlement": settlement_payload, + }, + ensure_ascii=False, + sort_keys=True, + ) diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index b0458f290..2bc71bcb2 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -9,6 +9,11 @@ from dataclasses import dataclass from typing import Any +from .control_plane.host_adapter_settlement import ( + HostTodoSettlementRequest, + settle_host_todo_completion, +) + ContextResolver = Callable[[], dict[str, Any] | None] @@ -193,35 +198,19 @@ def complete_task( args += ["--task-lease-idempotency-key", task_lease_idempotency_key] if no_follow_up: args.append("--no-follow-up") - output = self.run_cli(args) - if self._completion_succeeded(output): - spend = [ - "quota", - "spend-slot", - "--goal-id", - goal_id, - "--slots", - "1", - "--source", - "heartbeat", - "--execute", - "--agent-id", - agent_id, - ] - output += "\n--- spend-slot ---\n" + self.run_cli(spend) - return output - - @staticmethod - def _completion_succeeded(output: str) -> bool: - try: - payload = json.loads(output) - except json.JSONDecodeError: - return False - return bool( - isinstance(payload, dict) - and payload.get("ok") is True - and payload.get("completed") is True - and payload.get("status") == "done" + return settle_host_todo_completion( + HostTodoSettlementRequest( + goal_id=goal_id, + agent_id=agent_id, + todo_id=todo_id, + runtime_profile=self.config.runtime_profile, + legacy_host_surface=self.config.legacy_host_surface, + scheduler_owner=self.config.scheduler_owner, + execution_mode=self.config.execution_mode, + completion_args=tuple(args), + no_follow_up=no_follow_up, + ), + run_cli=self.run_cli, ) diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py new file mode 100644 index 000000000..d8dc7d4e0 --- /dev/null +++ b/tests/test_goal_mode_mcp_settlement.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from loopx.control_plane.host_adapter_settlement import ( + HostTodoSettlementRequest, + host_adapter_turn_instance_id, +) +from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane +from loopx.status import parse_active_state_todos +from loopx.todos import add_goal_todo + + +GOAL_ID = "mcp-settlement-fixture" +AGENT_ID = "claude" + + +def _write_fixture(tmp_path: Path) -> tuple[Path, Path]: + project = tmp_path / "project" + project.mkdir() + state_file = project / "ACTIVE_GOAL_STATE.md" + state_file.write_text( + "\n".join( + [ + "---", + f"goal_id: {GOAL_ID}", + "status: active", + "updated_at: 2026-08-21T00:00:00+00:00", + "---", + "", + "## Agent Todo", + "", + ] + ) + + "\n", + encoding="utf-8", + ) + registry = tmp_path / "registry.global.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(tmp_path / "runtime"), + "goals": [ + { + "id": GOAL_ID, + "domain": "harness_self_improvement", + "status": "active", + "repo": str(project), + "state_file": state_file.name, + "adapter": {"kind": "harness_self_improvement"}, + "quota": { + "compute": 1.0, + "window_hours": 24, + "slot_minutes": 1, + }, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": [AGENT_ID], + }, + } + ], + } + ), + encoding="utf-8", + ) + return registry, state_file + + +def _run_cli(registry: Path, *args: str) -> tuple[int, dict[str, object]]: + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--format", + "json", + *args, + ], + check=False, + capture_output=True, + text=True, + ) + return result.returncode, json.loads(result.stdout) + + +def _control(registry: Path) -> GoalModeMCPControlPlane: + control = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="loopx-test", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: { + "goal_id": GOAL_ID, + "registry": str(registry), + "agent_id": AGENT_ID, + }, + ) + control.command_prefix = lambda: [sys.executable, "-m", "loopx.cli"] + return control + + +def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identity( + tmp_path: Path, +) -> None: + registry, state_file = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Deliver the bounded fixture change.", + task_class="advancement_task", + claimed_by=AGENT_ID, + ) + todo_id = str(added["todo_id"]) + request = HostTodoSettlementRequest( + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=todo_id, + runtime_profile="claude_code", + legacy_host_surface="claude_code", + scheduler_owner="agent_cli_loop", + execution_mode="interactive", + completion_args=(), + ) + turn_instance_id = host_adapter_turn_instance_id(request) + + guard_rc, guard = _run_cli( + registry, + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--todo-id", + todo_id, + "--turn-instance-id", + turn_instance_id, + "--runtime-profile", + "claude_code", + ) + assert guard_rc == 0, guard + assert guard["selected_todo"]["todo_id"] == todo_id # type: ignore[index] + + premature_rc, premature = _run_cli( + registry, + "quota", + "spend-slot", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--todo-id", + todo_id, + "--turn-instance-id", + turn_instance_id, + "--slots", + "1", + "--source", + "heartbeat", + "--execute", + ) + assert premature_rc == 1 + assert premature["ok"] is False + assert "accountable refresh-state receipt is missing" in str( + premature.get("reason") or premature.get("error") + ) + + control = _control(registry) + output = control.complete_task( + todo_id, + AGENT_ID, + "focused fixture validation passed", + next_agent_todo="Run the successor fixture check.", + ) + result = json.loads(output) + + assert result["ok"] is True, json.dumps(result, indent=2, sort_keys=True) + identity = result["settlement_identity"] + assert identity["todo_id"] == todo_id + assert identity["turn_instance_id"] == turn_instance_id + settlement = result["settlement"] + assert settlement["durable_writeback"]["ok"] is True + assert settlement["lifecycle_completion"]["ok"] is True + assert settlement["quota_spend"]["ok"] is True + assert settlement["quota_spend"]["appended"] is True + + status_rc, status = _run_cli( + registry, + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--runtime-profile", + "claude_code", + ) + assert status_rc == 0, status + assert status["quota"]["spent_slots"] == 1 # type: ignore[index] + successor_id = status["selected_todo"]["todo_id"] # type: ignore[index] + assert successor_id != todo_id + assert settlement["quota_spend"]["settlement_identity"]["todo_id"] == todo_id + + todos = parse_active_state_todos(state_file.read_text(encoding="utf-8")) + todo_by_id = { + str(item["todo_id"]): item for item in todos["agent_todos"]["items"] + } + assert todo_by_id[todo_id]["status"] == "done" + assert todo_by_id[str(successor_id)]["status"] == "open" + + replay = json.loads( + control.complete_task( + todo_id, + AGENT_ID, + "focused fixture validation passed", + next_agent_todo="Run the successor fixture check.", + ) + ) + assert replay["ok"] is True, json.dumps(replay, indent=2, sort_keys=True) + assert replay["settlement_identity"] == identity + assert replay["settlement"]["quota_spend"]["appended"] is False + assert replay["settlement"]["quota_spend"]["idempotent_replay"] is True + + replay_status_rc, replay_status = _run_cli( + registry, + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--runtime-profile", + "claude_code", + ) + assert replay_status_rc == 0, replay_status + assert replay_status["quota"]["spent_slots"] == 1 # type: ignore[index] + + +def test_real_mcp_terminal_completion_closes_out_after_spend( + tmp_path: Path, +) -> None: + registry, state_file = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Finish the bounded non-delivery fixture.", + task_class="advancement_task", + continuation_policy="same_agent_non_delivery", + claimed_by=AGENT_ID, + ) + todo_id = str(added["todo_id"]) + + result = json.loads( + _control(registry).complete_task( + todo_id, + AGENT_ID, + "terminal fixture validation passed", + no_follow_up=True, + ) + ) + + assert result["ok"] is True, json.dumps(result, indent=2, sort_keys=True) + identity = result["settlement_identity"] + assert identity["todo_id"] == todo_id + settlement = result["settlement"] + assert settlement["lifecycle_completion"]["completion_continuation"] == ( + "active_goal" + ) + assert settlement["quota_spend"]["appended"] is True + terminal = settlement["terminal_closeout"] + assert terminal["completion_continuation"] == "no_followup" + assert terminal["completion_recovery"] == "same_turn_terminal_closeout" + assert [ + receipt["step_kind"] + for receipt in terminal["settlement_result"]["receipts"] + ] == [ + "validation", + "durable_writeback", + "quota_spend", + "terminal_closeout", + ] + + status_rc, status = _run_cli( + registry, + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--runtime-profile", + "claude_code", + ) + assert status_rc == 0, status + assert status["quota"]["spent_slots"] == 1 # type: ignore[index] + + todos = parse_active_state_todos(state_file.read_text(encoding="utf-8")) + completed = next( + item + for item in todos["agent_todos"]["items"] + if item["todo_id"] == todo_id + ) + assert completed["status"] == "done" + assert "no_followup=true" in state_file.read_text(encoding="utf-8") diff --git a/tests/test_kunluncode_goal_mode.py b/tests/test_kunluncode_goal_mode.py index bb13b0ac7..91634261f 100644 --- a/tests/test_kunluncode_goal_mode.py +++ b/tests/test_kunluncode_goal_mode.py @@ -16,6 +16,7 @@ GoalModeMCPControlPlane, create_fastmcp_server, ) +from loopx.control_plane.effect_program import SettlementIdentity from loopx.kunluncode_goal_mode import cli from loopx.kunluncode_goal_mode.app_server import ( NATIVE_GOAL_MODES, @@ -114,11 +115,48 @@ def test_mcp_uses_kunluncode_profile_and_rejects_agent_impersonation( def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: commands.append(command) - payload = ( - '{"ok": true, "completed": true, "status": "done"}' - if "complete" in command - else '{"ok": true}' - ) + if "--turn-instance-id" not in command: + payload = '{"ok": true}' + else: + args = command[command.index("json") + 1 :] + identity = SettlementIdentity( + goal_id="shared-goal", + agent_id="kunlun", + todo_id="todo_333333333333", + turn_instance_id=args[args.index("--turn-instance-id") + 1], + ) + if "should-run" in command: + payload = json.dumps( + { + "ok": True, + "should_run": True, + "selected_todo": {"todo_id": "todo_333333333333"}, + } + ) + elif "complete" in command: + payload = json.dumps( + { + "ok": True, + "completed": True, + "status": "done", + "completion_continuation": ( + "no_followup" + if "--no-follow-up" in command + else "active_goal" + ), + "settlement_identity": identity.as_dict(), + "settlement_result": {"failure": None}, + } + ) + else: + payload = json.dumps( + { + "ok": True, + "appended": True, + "settlement_identity": identity.as_dict(), + "settlement_result": {"failure": None}, + } + ) return subprocess.CompletedProcess(command, 0, payload, "") monkeypatch.setattr(goal_mode_mcp.subprocess, "run", capture) @@ -133,17 +171,27 @@ def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: assert json.loads(control.claim_task("todo-1", "kunlun"))["ok"] is True assert commands[-1][-4:] == ["--claimed-by", "kunlun", "--agent-id", "kunlun"] + command_count = len(commands) output = control.complete_task( - "todo-1", + "todo_333333333333", "kunlun", "focused check passed", task_lease_idempotency_key="lease-fixture", no_follow_up=True, ) - assert "spend-slot" in output - assert "--no-follow-up" in commands[-2] - assert "--task-lease-idempotency-key" in commands[-2] - assert commands[-1][-2:] == ["--agent-id", "kunlun"] + completed = json.loads(output) + assert completed["ok"] is True + settlement_commands = commands[command_count:] + assert len(settlement_commands) == 5 + first_completion = settlement_commands[1] + terminal_closeout = settlement_commands[-1] + assert "--no-follow-up" not in first_completion + assert "--no-follow-up" in terminal_closeout + assert "--task-lease-idempotency-key" in first_completion + assert "--task-lease-idempotency-key" in terminal_closeout + assert completed["settlement"]["terminal_closeout"][ + "completion_continuation" + ] == "no_followup" def test_mcp_spends_only_after_typed_completed_state( @@ -166,15 +214,212 @@ def test_mcp_spends_only_after_typed_completed_state( def incomplete(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: commands.append(command) - return subprocess.CompletedProcess(command, 0, '{"ok": true}', "") + payload = ( + json.dumps( + { + "ok": True, + "should_run": True, + "selected_todo": {"todo_id": "todo_111111111111"}, + } + ) + if "should-run" in command + else '{"ok": true}' + ) + return subprocess.CompletedProcess(command, 0, payload, "") monkeypatch.setattr(goal_mode_mcp.subprocess, "run", incomplete) - output = control.complete_task("todo-1", "claude", "not yet complete") + output = control.complete_task("todo_111111111111", "claude", "not yet complete") assert json.loads(output)["ok"] is True + assert len(commands) == 2 + assert not any("spend-slot" in command for command in commands) + + +def test_complete_task_spends_bound_to_selected_todo_and_refreshes_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + control = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="loopx-test", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: { + "goal_id": "shared-goal", + "registry": "/project/.loopx/registry.json", + "agent_id": "claude", + }, + ) + commands: list[list[str]] = [] + control.command_prefix = lambda: ["loopx"] + + def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: + commands.append(command) + args = command[command.index("json") + 1 :] + identity = SettlementIdentity( + goal_id="shared-goal", + agent_id="claude", + todo_id="todo_222222222222", + turn_instance_id=args[args.index("--turn-instance-id") + 1], + ) + if "should-run" in command: + payload = json.dumps( + { + "ok": True, + "should_run": True, + "selected_todo": { + "todo_id": "todo_222222222222", + "source": "agent_lane_next_action", + }, + } + ) + elif "complete" in command: + payload = json.dumps( + { + "ok": True, + "completed": True, + "status": "done", + "settlement_identity": identity.as_dict(), + "settlement_result": {"failure": None}, + } + ) + else: + payload = json.dumps( + { + "ok": True, + "appended": True, + "settlement_identity": identity.as_dict(), + "settlement_result": {"failure": None}, + } + ) + return subprocess.CompletedProcess(command, 0, payload, "") + + monkeypatch.setattr(goal_mode_mcp.subprocess, "run", capture) + + output = control.complete_task( + "todo_222222222222", "claude", "focused check passed" + ) + + result = json.loads(output) + assert result["ok"] is True + assert result["settlement"]["ok"] is True + should_run, complete, refresh, spend = commands[-4:] + assert should_run[should_run.index("json") + 1 :] == [ + "quota", + "should-run", + "--goal-id", + "shared-goal", + "--agent-id", + "claude", + "--todo-id", + "todo_222222222222", + "--turn-instance-id", + result["settlement_identity"]["turn_instance_id"], + "--runtime-profile", + "claude_code", + ] + assert complete[complete.index("--todo-id") + 1] == "todo_222222222222" + assert complete[complete.index("--turn-instance-id") + 1] == ( + result["settlement_identity"]["turn_instance_id"] + ) + assert refresh[refresh.index("json") + 1] == "refresh-state" + assert refresh[refresh.index("--goal-id") + 1] == "shared-goal" + assert refresh[refresh.index("--todo-id") + 1] == "todo_222222222222" + assert refresh[refresh.index("--turn-instance-id") + 1] == ( + result["settlement_identity"]["turn_instance_id"] + ) + assert refresh[refresh.index("--completion-todo-id") + 1] == ( + "todo_222222222222" + ) + assert refresh[refresh.index("--completion-turn-key") + 1] == ( + result["settlement_identity"]["effect_id"] + ) + assert spend[spend.index("json") + 1 : spend.index("json") + 3] == [ + "quota", + "spend-slot", + ] + assert spend[spend.index("--todo-id") + 1] == "todo_222222222222" + assert spend[spend.index("--source") + 1] == "heartbeat" + assert spend[spend.index("--turn-instance-id") + 1] == ( + result["settlement_identity"]["turn_instance_id"] + ) + + +def test_complete_task_classifies_terminal_no_selection_and_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + control = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="loopx-test", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: { + "goal_id": "shared-goal", + "registry": "/project/.loopx/registry.json", + "agent_id": "claude", + }, + ) + commands: list[list[str]] = [] + control.command_prefix = lambda: ["loopx"] + + def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: + commands.append(command) + payload = json.dumps( + { + "ok": True, + "should_run": False, + "effective_action": "terminal_no_followup", + "selected_todo": None, + } + ) + return subprocess.CompletedProcess(command, 0, payload, "") + + monkeypatch.setattr(goal_mode_mcp.subprocess, "run", capture) + + output = control.complete_task( + "todo_222222222222", "claude", "focused check passed" + ) + + payload = json.loads(output) + assert payload["ok"] is False + assert payload["settlement"]["guard_state"] == "terminal_no_selection" + assert len(commands) == 1 + + +def test_complete_task_fails_closed_on_unparseable_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + control = GoalModeMCPControlPlane( + GoalModeMCPConfig( + server_name="loopx-test", + runtime_profile="claude_code", + legacy_host_surface="claude_code", + ), + lambda: { + "goal_id": "shared-goal", + "registry": "/project/.loopx/registry.json", + "agent_id": "claude", + }, + ) + commands: list[list[str]] = [] + control.command_prefix = lambda: ["loopx"] + + def capture(command: list[str], **_kwargs) -> subprocess.CompletedProcess[str]: + commands.append(command) + return subprocess.CompletedProcess(command, 0, "not-json", "") + + monkeypatch.setattr(goal_mode_mcp.subprocess, "run", capture) + + output = control.complete_task( + "todo_222222222222", "claude", "focused check passed" + ) + + payload = json.loads(output) + assert payload["ok"] is False + assert payload["settlement"]["guard_state"] == "invalid" assert len(commands) == 1 - assert "spend-slot" not in commands[0] def test_installer_registers_only_the_owned_global_mcp_entry(