From 616478cad4976d8a1600c186b8b327548abf4f23 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:47:13 +0800 Subject: [PATCH 1/2] fix(todos): validate in task repository worktree Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/project-agent-todo-contract.md | 22 ++ loopx/cli_commands/todo.py | 22 ++ loopx/cli_commands/turn.py | 14 ++ loopx/cli_commands/turn_todo_writeback.py | 7 +- .../todos/completion_transaction.py | 5 + .../todos/completion_transaction.ts | 5 + .../todos/completion_validation.py | 208 ++++++++++++++---- .../todos/provider_terminal_lifecycle.py | 10 + loopx/todos.py | 5 + .../test_todo_completion_validation.py | 198 +++++++++++++++++ .../todo_completion_transaction.test.ts | 2 + 11 files changed, 459 insertions(+), 39 deletions(-) diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 72b389f4ba..6187d8f60c 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -252,6 +252,28 @@ loopx todo update \ workspace isolation, not write authority; claim/lease, capabilities, the goal boundary, and repository policy continue to apply. +Completion validation follows the same routing. If `task_repository` differs +from the Goal repository, LoopX binds the caller-approved command to a +turn-bound delivery-workspace receipt (an existing writeback receipt or the +exact Turn's host-verified pre-completion snapshot) and executes it only from a +clean linked worktree whose canonical origin matches that identity. A missing receipt, +canonical checkout, dirty or deleted worktree, and repository mismatch all +fail closed before command execution with a path-free +`validation_blocked_completion` receipt. When no separate repository is +declared, validation keeps the Goal repository as its default workspace. The +CLI and managed Turn use this shared completion effect; frontend and Lark +consume the same receipt projection and do not own a second cwd setting. + +完成校验遵循同一套路由规则。若 `task_repository` 与 Goal 仓库不同,LoopX +会把调用方预先声明的校验命令绑定到当前 Turn 的 delivery-workspace receipt(已有 +writeback receipt,或同一 Turn 中 host 在完成前验证的 snapshot),且只在 +canonical origin 匹配、状态干净的 linked worktree 中执行。receipt 缺失、使用 +canonical checkout、worktree 脏或已删除、仓库身份不匹配时,系统都会在命令执行 +前 fail closed,并返回不泄露本地路径的 `validation_blocked_completion` receipt。 +未声明独立仓库时,仍以 Goal 仓库作为默认校验 workspace。CLI 与 managed Turn +共享同一个 completion effect;前端与 Lark 只消费同源 receipt 投影,不新增 cwd +配置源。 + `quota should-run --agent-id ` is the preflight for every peer. When the selected task writes repository state and the peer is in a non-git, unrelated, or non-isolated workspace, it returns `workspace_guard` and blocks diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 4a3135ae92..322b66ecc1 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -5,6 +5,7 @@ from pathlib import Path from ..control_plane.coordination.local_authority import read_canonical_todo_fields_if_promoted +from ..control_plane.agents.workspace_guard import capture_delivery_workspace from ..control_plane.todos.contract import ( replan_successor_semantic_binding, ) @@ -392,6 +393,7 @@ def handle_todo_command( completion_error = None completion_turn_key = None completion_identity_source = None + completion_delivery_workspace = None if getattr(args, "turn_instance_id", None): runtime_root = resolve_runtime_root( load_registry(registry_path), @@ -458,6 +460,24 @@ def handle_todo_command( } completion_turn_key = identity.effect_id completion_identity_source = "turn_settlement" + writeback_run = settlement_readback.writeback_run + if isinstance(writeback_run, dict) and isinstance( + writeback_run.get("delivery_workspace"), dict + ): + completion_delivery_workspace = dict( + writeback_run["delivery_workspace"] + ) + elif todo.get("task_repository"): + # Completion validation precedes accountable refresh, so + # the exact Turn can legitimately have no writeback row + # yet. Bind a freshly verified current-worktree snapshot + # to this already-read settlement identity rather than + # introducing an arbitrary cwd option or a circular gate. + completion_delivery_workspace = capture_delivery_workspace( + Path.cwd(), + peer_independent_worktree_required=True, + repository_source="todo.complete.turn_settlement", + ) elif getattr(args, "completion_identity_key", None): completion_turn_key = str(args.completion_identity_key) completion_identity_source = "lifecycle_reentry" @@ -472,6 +492,8 @@ def handle_todo_command( evidence=args.evidence, completion_turn_key=completion_turn_key, completion_identity_source=completion_identity_source, + completion_delivery_workspace=completion_delivery_workspace, + completion_validation_workspace_path=Path.cwd(), task_lease_idempotency_key=args.task_lease_idempotency_key, task_lease_expected_version=args.task_lease_expected_version, note=args.note, diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index a8d2fbe003..a07209d970 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -20,6 +20,7 @@ from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch from ..capabilities.periodic_report.pending_intent import periodic_report_pending_intent_interaction_hook from ..control_plane.quota.live_decision import build_live_quota_should_run_decision +from ..control_plane.agents.workspace_guard import capture_delivery_workspace from ..control_plane.quota.heartbeat_receipt import ( ensure_turn_heartbeat_settlement_receipt, ) @@ -473,6 +474,17 @@ def append_settlement_event( ) else None ) + completion_delivery_workspace = ( + capture_delivery_workspace( + delivery_workspace_path, + peer_independent_worktree_required=bool( + selected_todo.get("task_repository") + ), + repository_source="turn.delivery_workspace", + ) + if delivery_workspace_path is not None + else None + ) def writeback( result: dict[str, object], @@ -590,6 +602,8 @@ def todo_completion( ), note=str(result["next_action"]), agent_id=args.agent_id, + completion_delivery_workspace=completion_delivery_workspace, + completion_validation_workspace_path=delivery_workspace_path, ) # Project the continuation the Todo lifecycle durably recorded, # never a host-normalized continuation. Contradictory or diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 7763807a92..975fff2544 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Mapping from ..todos import complete_goal_todo, update_goal_todo @@ -49,6 +49,8 @@ def write_turn_validated_completion( evidence: str, note: str, agent_id: str | None, + completion_delivery_workspace: Mapping[str, Any] | None = None, + completion_validation_workspace_path: Path | None = None, ) -> dict[str, Any]: """Complete one validated Todo under the effective runtime root.""" @@ -58,6 +60,9 @@ def write_turn_validated_completion( todo_id=todo_id, role="agent", completion_turn_key=completion_turn_key, + completion_identity_source="turn_settlement", + completion_delivery_workspace=completion_delivery_workspace, + completion_validation_workspace_path=completion_validation_workspace_path, evidence=evidence, note=note, agent_id=agent_id, diff --git a/loopx/control_plane/todos/completion_transaction.py b/loopx/control_plane/todos/completion_transaction.py index 4a57d532c9..603062b46e 100644 --- a/loopx/control_plane/todos/completion_transaction.py +++ b/loopx/control_plane/todos/completion_transaction.py @@ -31,6 +31,7 @@ "completion_continuation", "completion_turn_key", "successor_todo_ids", + "task_repository", "validation_command", "validation_command_argv", "validation_label", @@ -384,6 +385,10 @@ def _valid_execute_validation_result(result: Mapping[str, Any]) -> bool: and 1 <= int(effect["validation_timeout_seconds"]) <= 29 ) ) + and ( + effect.get("task_repository") is None + or isinstance(effect.get("task_repository"), str) + ) ) diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index d4c7652da0..27ec506623 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -82,6 +82,7 @@ export interface TodoCompletionValidationEffect extends JsonObject { validation_argv: readonly string[] | null; validation_label: string | null; validation_timeout_seconds: number | null; + task_repository: string | null; } export interface TodoCompletionExecuteValidation @@ -410,6 +411,10 @@ export function reduceTodoCompletionTransaction( validation_label: validationPlan.validation_label, validation_timeout_seconds: validationPlan.validation_timeout_seconds, + task_repository: optionalNonEmptyString( + request.todo.task_repository, + "todo.task_repository", + ), }, }; } diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index b28325915c..ea64d4ef9c 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -8,6 +8,11 @@ from ...history import load_registry from ...materials import find_registry_goal, goal_repo +from ..agents.delivery_workspace import normalize_delivery_workspace_snapshot +from ..agents.workspace_guard import ( + capture_delivery_workspace, + delivery_workspace_repository, +) from ..runtime.validation_command import ( CALLER_VALIDATION_RECEIPT_SCHEMA_VERSION, run_caller_validation, @@ -72,6 +77,142 @@ def _resolve_goal_repo_workspace(registry_path: Path, goal_id: str) -> Path | No return cast(Path, repo) +def _workspace_failure(label: str, *, status: str, summary: str) -> dict[str, Any]: + """Return a path-free completion-validation workspace receipt.""" + + return { + "schema_version": CALLER_VALIDATION_RECEIPT_SCHEMA_VERSION, + "command_label": label, + "exit_code": None, + "passed": False, + "status": status, + "summary": summary, + "stdout_captured": False, + "stderr_captured": False, + "local_path_captured": False, + } + + +def _git_workspace_is_clean(path: Path) -> bool | None: + """Return clean/dirty without exposing local paths or command output.""" + + try: + result = subprocess.run( + ["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=normal"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return not bool(result.stdout.strip()) + + +def _resolve_completion_validation_workspace( + *, + registry_path: Path, + goal_id: str, + task_repository: str | None, + delivery_workspace: Mapping[str, Any] | None, + validation_workspace_path: Path | None, + label: str, +) -> tuple[Path | None, dict[str, Any] | None]: + """Resolve the authority-bound workspace for one validation effect. + + The Goal repository remains the default when a Todo does not select a + separate repository. A cross-repository Todo must instead present the + path-free, turn-bound delivery-workspace snapshot and run from a clean + independent worktree with the same canonical repository identity. The + snapshot may come from prior writeback or from the host's exact-Turn + pre-completion check because completion itself precedes refresh. The + candidate path is host execution context, never a public CLI cwd override. + """ + + goal_workspace = _resolve_goal_repo_workspace(registry_path, goal_id) + if goal_workspace is None: + return None, _workspace_failure( + label, + status="workspace_unavailable", + summary=( + "validation_command is declared but the goal has no repository " + "workspace to run it in" + ), + ) + expected_repository = str(task_repository or "").strip() + if not expected_repository: + return goal_workspace, None + + goal_snapshot = capture_delivery_workspace(goal_workspace) + if delivery_workspace_repository(goal_snapshot) == expected_repository: + return goal_workspace, None + + recorded = normalize_delivery_workspace_snapshot(delivery_workspace) + if recorded is None: + return None, _workspace_failure( + label, + status="workspace_receipt_unavailable", + summary=( + "cross-repository validation requires a verified delivery " + "workspace receipt for the selected Todo" + ), + ) + if ( + delivery_workspace_repository(recorded) != expected_repository + or recorded.get("workspace_kind") != "independent_git_worktree" + ): + return None, _workspace_failure( + label, + status="workspace_receipt_mismatch", + summary=( + "the recorded delivery workspace does not match the selected " + "Todo repository and isolation contract" + ), + ) + + candidate = validation_workspace_path or Path.cwd() + current = capture_delivery_workspace( + candidate, + peer_independent_worktree_required=True, + ) + if current is None: + return None, _workspace_failure( + label, + status="workspace_unverified", + summary=( + "cross-repository validation requires a verifiable independent " + "Git worktree" + ), + ) + if ( + delivery_workspace_repository(current) != expected_repository + or current.get("workspace_kind") != "independent_git_worktree" + or current.get("workspace_identity") != recorded.get("workspace_identity") + ): + return None, _workspace_failure( + label, + status="workspace_repository_mismatch", + summary=( + "the current validation worktree does not match the recorded " + "delivery repository" + ), + ) + clean = _git_workspace_is_clean(candidate) + if clean is not True: + return None, _workspace_failure( + label, + status=("workspace_dirty" if clean is False else "workspace_unverified"), + summary=( + "cross-repository validation requires a clean, verifiable " + "delivery worktree" + ), + ) + return candidate, None + + def _materialized_todo_item( *, state_file: Path, todo_id: str, role: str | None ) -> dict[str, Any] | None: @@ -96,6 +237,9 @@ def _run_declared_completion_validation( validation_timeout_seconds: int | None, registry_path: Path, goal_id: str, + task_repository: str | None = None, + delivery_workspace: Mapping[str, Any] | None = None, + validation_workspace_path: Path | None = None, ) -> dict[str, Any] | None: """Run a todo's declared caller-approved validation command. @@ -118,22 +262,17 @@ def _run_declared_completion_validation( else _COMPLETION_VALIDATION_TIMEOUT_SECONDS ) label = validation_label or "todo completion validation" - workspace = _resolve_goal_repo_workspace(registry_path, goal_id) - if workspace is None: - return { - "schema_version": CALLER_VALIDATION_RECEIPT_SCHEMA_VERSION, - "command_label": label, - "exit_code": None, - "passed": False, - "status": "workspace_unavailable", - "summary": ( - "validation_command is declared but the goal has no " - "repository workspace to run it in" - ), - "stdout_captured": False, - "stderr_captured": False, - "local_path_captured": False, - } + workspace, workspace_failure = _resolve_completion_validation_workspace( + registry_path=registry_path, + goal_id=goal_id, + task_repository=task_repository, + delivery_workspace=delivery_workspace, + validation_workspace_path=validation_workspace_path, + label=label, + ) + if workspace_failure is not None: + return workspace_failure + assert workspace is not None try: if validation_argv is not None: return cast( @@ -202,6 +341,8 @@ def run_declared_completion_validation_effect( effect: Mapping[str, Any], registry_path: Path, goal_id: str, + delivery_workspace: Mapping[str, Any] | None = None, + validation_workspace_path: Path | None = None, ) -> dict[str, Any]: """Execute exactly one TypeScript-authorized validation effect. @@ -238,6 +379,13 @@ def run_declared_completion_validation_effect( ), registry_path=registry_path, goal_id=goal_id, + task_repository=( + str(effect["task_repository"]) + if effect.get("task_repository") is not None + else None + ), + delivery_workspace=delivery_workspace, + validation_workspace_path=validation_workspace_path, ) if receipt is None: raise RuntimeError("authorized validation effect produced no receipt") @@ -333,6 +481,8 @@ def run_completion_validation_gate_with_source( requested_has_successor: bool = False, completion_policy_facts: Mapping[str, Any] | None = None, requested_successor_todo_ids: list[str] | None = None, + completion_delivery_workspace: Mapping[str, Any] | None = None, + completion_validation_workspace_path: Path | None = None, ) -> dict[str, Any]: """Run the caller-approved completion validation gate, OUTSIDE the mutation lock. @@ -403,30 +553,12 @@ def run_completion_validation_gate_with_source( completion_validation = None if transaction["decision"] == "execute_validation": effect = transaction["validation_effect"] - validation_argv = effect.get("validation_argv") - completion_validation = _run_declared_completion_validation( - validation_command=( - str(effect["validation_command"]) - if effect.get("validation_command") is not None - else None - ), - validation_argv=( - list(validation_argv) - if isinstance(validation_argv, list) - else None - ), - validation_label=( - str(effect["validation_label"]) - if effect.get("validation_label") is not None - else None - ), - validation_timeout_seconds=( - int(effect["validation_timeout_seconds"]) - if effect.get("validation_timeout_seconds") is not None - else None - ), + completion_validation = run_declared_completion_validation_effect( + effect=effect, registry_path=registry_path, goal_id=goal_id, + delivery_workspace=completion_delivery_workspace, + validation_workspace_path=completion_validation_workspace_path, ) if completion_validation is None: raise RuntimeError("Todo completion validation effect produced no receipt") diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index f6b9556cb8..ce941765f4 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -119,6 +119,12 @@ def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any completion_identity_source=( call.get("completion_identity_source") if complete else None ), + completion_delivery_workspace=( + call.get("completion_delivery_workspace") if complete else None + ), + completion_validation_workspace_path=( + call.get("completion_validation_workspace_path") if complete else None + ), task_lease_idempotency_key=call.get("task_lease_idempotency_key"), task_lease_expected_version=_non_negative_integer( call.get("task_lease_expected_version"), @@ -280,6 +286,8 @@ def terminal_canonical_todo_if_promoted( reason: str | None, completion_turn_key: str | None, completion_identity_source: str | None, + completion_delivery_workspace: Mapping[str, Any] | None, + completion_validation_workspace_path: Path | None, task_lease_idempotency_key: str | None, task_lease_expected_version: int | None, no_followup: bool, @@ -425,6 +433,8 @@ def terminal_canonical_todo_if_promoted( effect=effect, registry_path=registry_path, goal_id=goal_id, + delivery_workspace=completion_delivery_workspace, + validation_workspace_path=completion_validation_workspace_path, ) result = effect_runtime_result( "coordination.local_authority.todo_terminal", request diff --git a/loopx/todos.py b/loopx/todos.py index 913d4f9cd4..8b3f37d1d9 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import ExitStack from json import dumps as json_dumps from pathlib import Path @@ -1419,6 +1420,8 @@ def complete_goal_todo( evidence: str | None = None, completion_turn_key: str | None = None, completion_identity_source: str | None = None, + completion_delivery_workspace: Mapping[str, Any] | None = None, + completion_validation_workspace_path: Path | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, note: str | None = None, @@ -1484,6 +1487,8 @@ def complete_goal_todo( ), completion_policy_facts=completion_policy_facts, requested_successor_todo_ids=normalized_successor_todo_ids, + completion_delivery_workspace=completion_delivery_workspace, + completion_validation_workspace_path=completion_validation_workspace_path, ) validation_failure = validation_gate.get("failure") if validation_failure is not None: diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index f793342926..b75fdfbe89 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -3,6 +3,7 @@ import json import re import shlex +import subprocess import sys from pathlib import Path from typing import Any @@ -18,6 +19,7 @@ from loopx.control_plane.todos.completion_validation import ( resolve_private_completion_validation_declaration, ) +from loopx.control_plane.agents.workspace_guard import capture_delivery_workspace from loopx.control_plane.todos.completion_validation_store import ( completion_validation_declaration_path, persist_completion_validation_declaration, @@ -105,6 +107,7 @@ def _add_todo( validation_command_json: str | None = None, validation_label: str | None = None, validation_timeout_seconds: int | None = None, + task_repository: str | None = None, ) -> dict: return add_goal_todo( registry_path=registry, @@ -117,9 +120,48 @@ def _add_todo( validation_command_json=validation_command_json, validation_label=validation_label, validation_timeout_seconds=validation_timeout_seconds, + task_repository=task_repository, ) +def _git(*args: str, cwd: Path) -> None: + subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def _cross_repository_worktree( + tmp_path: Path, + *, + name: str, +) -> tuple[str, Path, dict[str, Any]]: + repository = tmp_path / f"{name}-repository" + repository.mkdir() + _git("init", "-b", "main", cwd=repository) + _git("config", "user.name", "LoopX Test", cwd=repository) + _git("config", "user.email", "loopx-test@example.invalid", cwd=repository) + remote = f"https://github.com/example/{name}.git" + _git("remote", "add", "origin", remote, cwd=repository) + (repository / f"{name}-only").write_text("ok\n", encoding="utf-8") + _git("add", f"{name}-only", cwd=repository) + _git("commit", "-m", "fixture", cwd=repository) + worktree = tmp_path / f"{name}-worktree" + _git("worktree", "add", "-b", f"test-{name}", str(worktree), cwd=repository) + snapshot = capture_delivery_workspace( + worktree, + peer_independent_worktree_required=True, + repository_source="test_settlement", + ) + assert snapshot is not None + task_repository = str(snapshot["task_repository"]) + return task_repository, worktree, snapshot + + def _record_completion_runtime_calls( monkeypatch: pytest.MonkeyPatch, ) -> list[str]: @@ -179,6 +221,162 @@ def counting_runner(*args, **kwargs): # type: ignore[no-untyped-def] assert _agent_todo(state, str(todo["todo_id"]))["status"] == "done" +def test_cross_repository_validation_runs_in_recorded_clean_worktree( + tmp_path: Path, +) -> None: + registry, state = _write_fixture(tmp_path) + task_repository, worktree, workspace_receipt = _cross_repository_worktree( + tmp_path, + name="repo-b", + ) + todo = _add_todo( + registry, + validation_command_json=json.dumps( + [ + sys.executable, + "-c", + "from pathlib import Path; assert Path('repo-b-only').is_file()", + ] + ), + validation_label="repository B smoke", + task_repository=task_repository, + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + agent_id=AGENT, + evidence="validated in repository B", + completion_delivery_workspace=workspace_receipt, + completion_validation_workspace_path=worktree, + ) + + assert result["ok"] is True + assert result["changed"] is True + assert _agent_todo(state, str(todo["todo_id"]))["status"] == "done" + + +def test_cross_repository_validation_without_recorded_workspace_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, state = _write_fixture(tmp_path) + task_repository, worktree, _workspace_receipt = _cross_repository_worktree( + tmp_path, + name="repo-b", + ) + todo = _add_todo( + registry, + validation_command_json=json.dumps([sys.executable, "-c", "pass"]), + task_repository=task_repository, + ) + calls = {"count": 0} + + def forbidden_runner(*args, **kwargs): # type: ignore[no-untyped-def] + calls["count"] += 1 + raise AssertionError("validation command must not run") + + monkeypatch.setattr(completion_validation_module, "run_caller_validation", forbidden_runner) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + agent_id=AGENT, + evidence="unverified claim", + completion_validation_workspace_path=worktree, + ) + + assert calls["count"] == 0 + assert result["validation_blocked_completion"] is True + assert result["validation"]["status"] == "workspace_receipt_unavailable" + assert str(tmp_path) not in json.dumps(result["validation"]) + assert _agent_todo(state, str(todo["todo_id"]))["status"] == "open" + + +def test_cross_repository_validation_rejects_foreign_worktree_without_running( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, state = _write_fixture(tmp_path) + task_repository, _worktree, workspace_receipt = _cross_repository_worktree( + tmp_path, + name="repo-b", + ) + _foreign_repository, foreign_worktree, _foreign_receipt = ( + _cross_repository_worktree(tmp_path, name="repo-c") + ) + todo = _add_todo( + registry, + validation_command_json=json.dumps([sys.executable, "-c", "pass"]), + task_repository=task_repository, + ) + calls = {"count": 0} + + def forbidden_runner(*args, **kwargs): # type: ignore[no-untyped-def] + calls["count"] += 1 + raise AssertionError("validation command must not run") + + monkeypatch.setattr(completion_validation_module, "run_caller_validation", forbidden_runner) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + agent_id=AGENT, + evidence="wrong repository claim", + completion_delivery_workspace=workspace_receipt, + completion_validation_workspace_path=foreign_worktree, + ) + + assert calls["count"] == 0 + assert result["validation_blocked_completion"] is True + assert result["validation"]["status"] == "workspace_repository_mismatch" + assert str(tmp_path) not in json.dumps(result["validation"]) + assert _agent_todo(state, str(todo["todo_id"]))["status"] == "open" + + +def test_cross_repository_validation_rejects_dirty_worktree_without_running( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, state = _write_fixture(tmp_path) + task_repository, worktree, workspace_receipt = _cross_repository_worktree( + tmp_path, + name="repo-b", + ) + (worktree / "uncommitted-artifact").write_text("dirty\n", encoding="utf-8") + todo = _add_todo( + registry, + validation_command_json=json.dumps([sys.executable, "-c", "pass"]), + task_repository=task_repository, + ) + calls = {"count": 0} + + def forbidden_runner(*args, **kwargs): # type: ignore[no-untyped-def] + calls["count"] += 1 + raise AssertionError("validation command must not run") + + monkeypatch.setattr(completion_validation_module, "run_caller_validation", forbidden_runner) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + agent_id=AGENT, + evidence="dirty worktree claim", + completion_delivery_workspace=workspace_receipt, + completion_validation_workspace_path=worktree, + ) + + assert calls["count"] == 0 + assert result["validation_blocked_completion"] is True + assert result["validation"]["status"] == "workspace_dirty" + assert str(tmp_path) not in json.dumps(result["validation"]) + assert _agent_todo(state, str(todo["todo_id"]))["status"] == "open" + + def test_missing_validation_executable_returns_typed_receipt( tmp_path: Path, ) -> None: diff --git a/tests/control_plane_ts/todo_completion_transaction.test.ts b/tests/control_plane_ts/todo_completion_transaction.test.ts index e8e720f8a8..02b44fa02e 100644 --- a/tests/control_plane_ts/todo_completion_transaction.test.ts +++ b/tests/control_plane_ts/todo_completion_transaction.test.ts @@ -84,6 +84,7 @@ test("declared validation is one external effect between two reductions", () => validation_command_argv: ["python", "-c", "pass"], validation_label: "focused smoke", validation_timeout_seconds: "5", + task_repository: "git:github.com/example/repo-b", }, }), ); @@ -100,6 +101,7 @@ test("declared validation is one external effect between two reductions", () => validation_argv: ["python", "-c", "pass"], validation_label: "focused smoke", validation_timeout_seconds: 5, + task_repository: "git:github.com/example/repo-b", }, }); From 69d19988b2aa237e4bcf7f781a715f4a05fa3c3b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:38:59 +0800 Subject: [PATCH 2/2] fix(todos): normalize malformed workspace receipts Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../todos/completion_validation.py | 15 +++++- .../test_todo_completion_validation.py | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index ea64d4ef9c..9434a9e465 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -150,7 +150,20 @@ def _resolve_completion_validation_workspace( if delivery_workspace_repository(goal_snapshot) == expected_repository: return goal_workspace, None - recorded = normalize_delivery_workspace_snapshot(delivery_workspace) + try: + recorded = normalize_delivery_workspace_snapshot(delivery_workspace) + except (RuntimeError, TypeError, ValueError): + # Decoder rejection is an input/receipt failure, not an adapter crash. + # Keep it inside the path-free completion state model and never expose + # the TypeScript decoder's internal error text at the CLI/Turn boundary. + return None, _workspace_failure( + label, + status="workspace_receipt_invalid", + summary=( + "the recorded delivery workspace receipt is invalid and cannot " + "authorize cross-repository validation" + ), + ) if recorded is None: return None, _workspace_failure( label, diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index b75fdfbe89..bdc136d3c0 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -377,6 +377,53 @@ def forbidden_runner(*args, **kwargs): # type: ignore[no-untyped-def] assert _agent_todo(state, str(todo["todo_id"]))["status"] == "open" +def test_malformed_delivery_workspace_receipt_returns_typed_failure_without_running_validator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, state = _write_fixture(tmp_path) + todo = _add_todo( + registry, + validation_command=_PASS_COMMAND, + task_repository="git:github.com/example/delivery", + ) + calls = {"count": 0} + + def unexpected_validation(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + calls["count"] += 1 + raise AssertionError("malformed workspace receipts must block before validation") + + monkeypatch.setattr( + completion_validation_module, + "run_caller_validation", + unexpected_validation, + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + agent_id=AGENT, + evidence="malformed workspace receipt", + completion_delivery_workspace={ + "schema_version": "delivery_workspace_v1", + "workspace_identity": "git:github.com/example/delivery", + "identity_kind": "unsupported_kind", + "task_repository": "git:github.com/example/delivery", + "repository_source": "turn.delivery_workspace", + "workspace_kind": "independent_git_worktree", + "peer_independent_worktree_required": True, + }, + ) + + assert calls["count"] == 0 + assert result["ok"] is False + assert result["validation_blocked_completion"] is True + assert result["validation"]["status"] == "workspace_receipt_invalid" + assert result["validation"]["local_path_captured"] is False + assert _agent_todo(state, str(todo["todo_id"]))["status"] == "open" + + def test_missing_validation_executable_returns_typed_receipt( tmp_path: Path, ) -> None: