diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 30d8eeeea9..583e154bd6 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1465,13 +1465,20 @@ Post-turn accounting protocol: `--delivery-workspace-path `; the path is validated locally and omitted from persisted history. Do not point this option at the canonical checkout for peer work. -- delivery attribution is not synonymous with Git. A registered single-agent - goal whose project has no Git origin records a path-free `local_goal` +- delivery attribution is not synonymous with Git. A registered non-Git + project records a path-free `local_goal` workspace identity (`loopx:`) when refresh runs inside that registered project root. This lets validated non-repository work settle - without inventing a repository. It does not weaken peer isolation: a peer - repository write still requires an `independent_git_worktree`, and a local - goal workspace is rejected when that requirement is active. + without inventing a repository, including peer research and material work. + The existing Todo claim/lease and completion validator still apply; local + delivery is not `same_agent_non_delivery`. A Git peer delivery still requires + an `independent_git_worktree`. An explicit Git task repository or an explicit + owner isolation requirement rejects a local Goal receipt. An outside-root + workspace cannot produce that local receipt. +- `todo complete --evidence ` can record a validated local artifact. + `--result-file` additionally requires approved Goal acceptance criteria bound + to that Todo. A standalone Todo validator does not establish Goal acceptance; + an unsupported result binding is rejected before executing the validator. - autonomous replans follow the same accountable-outcome rule: spend after a concrete successor, blocker, or `outcome_progress`/`primary_goal_outcome` writeback, but do not spend for a `surface_only` watch-lane continuation or diff --git a/docs/reference/protocols/peer-agent-runtime-v1.md b/docs/reference/protocols/peer-agent-runtime-v1.md index 28a65cfa26..8aa77ee8f2 100644 --- a/docs/reference/protocols/peer-agent-runtime-v1.md +++ b/docs/reference/protocols/peer-agent-runtime-v1.md @@ -72,6 +72,15 @@ workspace isolation only: it is not an agent scope, write scope, permission grant, or replacement for claim/lease and goal-boundary checks. Without the field, the goal repository remains authoritative. +Accountable refresh applies Git isolation to Git delivery. A registered non-Git +Goal can instead record the existing path-free `local_goal_workspace` receipt +for peer research or material work. Capture must occur inside that Goal root; +an explicit Git task repository or owner isolation requirement cannot use this +route. Completion validation remains independent, and in-flight writeback and +spend leave the Todo open. Do not reclassify a local deliverable as +`same_agent_non_delivery` to settle it. `--evidence` records a local pointer; +`--result-file` requires approved Goal acceptance criteria bound to the Todo. + ## Task-Scoped Coordination When bounded multi-agent orchestration is enabled, LoopX hashes the canonical diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 1a23b7db37..0ed9f32ed3 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -106,7 +106,7 @@ def register_todo_command( todo_parser.add_argument("--status", choices=["open", "done", "blocked", "deferred"], help="For todo add/update, set the lifecycle status.") todo_parser.add_argument("--note", help="Public-safe note to attach to a lifecycle transition.") todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") - todo_parser.add_argument("--result-file", help="For todo complete, bind a bounded local .json, .md or .txt result to the independently accepted completion.") + todo_parser.add_argument("--result-file", help="For todo complete with bound Goal acceptance criteria, bind a bounded local .json, .md or .txt result. A Todo validator alone is insufficient; use --evidence for a local artifact pointer.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/control_plane/agents/delivery_workspace.py b/loopx/control_plane/agents/delivery_workspace.py index 9bcb06cf43..113d7045ca 100644 --- a/loopx/control_plane/agents/delivery_workspace.py +++ b/loopx/control_plane/agents/delivery_workspace.py @@ -124,3 +124,22 @@ def normalize_delivery_workspace_snapshot(value: Any) -> dict[str, Any] | None: ), } return _workspace_result(_runtime_result("normalize", workspace=prepared)) + + +def qualify_delivery_workspace_isolation( + workspace: dict[str, Any] | None, + *, + multi_agent_goal: bool, + explicit_peer_worktree_requirement: bool | None, + task_repository: str | None = None, +) -> tuple[dict[str, Any] | None, bool]: + """Delegate Git/local isolation policy to the existing typed owner.""" + result = _runtime_result( + "isolation", workspace=workspace, multi_agent_goal=multi_agent_goal, + explicit_peer_worktree_requirement=explicit_peer_worktree_requirement, + task_repository=task_repository, + ) + required = result.get("peer_independent_worktree_required") + if not isinstance(required, bool): + raise RuntimeError("TypeScript delivery workspace isolation result shape mismatch") + return _workspace_result(result), required diff --git a/loopx/control_plane/agents/delivery_workspace.ts b/loopx/control_plane/agents/delivery_workspace.ts index 588074b20c..0277025e5b 100644 --- a/loopx/control_plane/agents/delivery_workspace.ts +++ b/loopx/control_plane/agents/delivery_workspace.ts @@ -49,7 +49,7 @@ export interface DeliveryWorkspaceSnapshot extends JsonObject { peer_independent_worktree_required: boolean; } -type DeliveryWorkspaceOperation = "build" | "normalize"; +type DeliveryWorkspaceOperation = "build" | "normalize" | "isolation"; const GIT_IDENTITY_PATTERN = /^git:[a-z0-9.-]+(?::[0-9]{1,5})?\/[A-Za-z0-9._~+/-]+$/i; @@ -60,7 +60,7 @@ const GIT_REVISION_DIGEST_PATTERN = /^[0-9a-f]{64}$/i; function operation(value: unknown): DeliveryWorkspaceOperation { return requireStringLiteral( value, - ["build", "normalize"] as const, + ["build", "normalize", "isolation"] as const, "delivery workspace operation", "delivery workspace operation is unsupported", ); @@ -249,6 +249,34 @@ export function normalizeDeliveryWorkspaceSnapshot( export function evaluateDeliveryWorkspace(value: unknown): JsonObject { const request = requestObject(value); const selectedOperation = operation(request.operation); + if (selectedOperation === "isolation") { + const workspace = normalizeDeliveryWorkspaceSnapshot(request.workspace); + const multiAgent = requireBoolean(request.multi_agent_goal, "multi_agent_goal"); + const explicit = request.explicit_peer_worktree_requirement == null + ? null + : requireBoolean(request.explicit_peer_worktree_requirement, "explicit_peer_worktree_requirement"); + const repository = optionalNonEmptyString(request.task_repository, "task_repository"); + if (repository !== null && canonicalGitIdentity(repository, "task_repository") === null) { + throw new EffectRuntimeRequestError("task_repository must identify a Git repository"); + } + // A local Goal receipt proves the registered non-Git workspace. It is a + // delivery, so keep independent Todo acceptance; do not relabel it as + // non-delivery just because there is no Git worktree to isolate. + const required = multiAgent && explicit !== false && ( + explicit === true || repository !== null || workspace?.identity_kind !== "local_goal" + ); + return { + schema_version: DELIVERY_WORKSPACE_RESULT_SCHEMA, + peer_independent_worktree_required: required, + workspace: workspace === null || ( + repository !== null && workspace.task_repository !== canonicalGitIdentity(repository, "task_repository") + ) ? null : snapshot( + workspace.workspace_identity, workspace.identity_kind, + workspace.workspace_revision_digest ?? null, workspace.repository_source, + workspace.workspace_kind, required, + ), + }; + } return { schema_version: DELIVERY_WORKSPACE_RESULT_SCHEMA, workspace: selectedOperation === "build" diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 1a1188e867..f77a034a4a 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1229,6 +1229,13 @@ export async function executeCoordinationTodoTerminalLifecycle( {goal_acceptance_guard: acceptance}, "decision_rejection"); } const acceptanceRequirements = acceptanceCompletionRequirements(completionHead, input.goal_id, input.todo_id); + if (input.completion_result != null && acceptanceRequirements === null) { + return terminalFailure("completion_result_rejected", + "--result-file requires Goal acceptance criteria bound to this Todo; a Todo validator alone does not establish Goal acceptance. " + + "Use --evidence for a local artifact pointer, or bind approved Goal acceptance criteria before retrying --result-file.", + {next_action: "Keep the same Todo/Turn and complete with --evidence, or configure approved bound Goal acceptance criteria."}, + "decision_rejection"); + } const acceptanceBinding = acceptanceRequirements === null ? null : acceptanceSourceBinding(input, acceptanceRequirements, head.provider_revision); let acceptanceEvidence: JsonObject | null = null; diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index fb7da5fe63..388751fcf5 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -1487,10 +1487,14 @@ def _build_interaction_cli_channel( if isinstance(payload.get("selected_todo"), Mapping) else {} ) - if spend_after_selection and selected_todo.get("task_repository"): + if spend_after_selection and selected_todo.get("todo_id"): channel["delivery_workspace_causality"] = { "schema_version": "delivery_workspace_causality_v0", - "refresh": "delivery_workspace; otherwise --delivery-workspace-path", + "refresh": ( + "delivery_workspace; otherwise --delivery-workspace-path" + if selected_todo.get("task_repository") + else "registered local Goal workspace; Git peer delivery requires an independent worktree" + ), "spend": "recorded_delivery_workspace", "mismatch": "fail_closed", } diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 0cee37f45d..709413835b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2127,7 +2127,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 802, + "line": 803, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 209763a968..7366dc89fc 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -27,6 +27,7 @@ from .control_plane.agents.workspace_guard import ( capture_delivery_workspace, ) +from .control_plane.agents.delivery_workspace import qualify_delivery_workspace_isolation from .control_plane.quota.refresh_external_delivery import ( finish_external_delivery_refresh, refresh_recovery_payload, ) @@ -943,10 +944,6 @@ def refresh_state_run( explicit_peer_worktree_requirement = workspace_guard_policy.get( "peer_independent_worktree_required" ) - peer_independent_worktree_required = multi_agent_goal and ( - explicit_peer_worktree_requirement is None - or explicit_peer_worktree_requirement is True - ) if normalized_agent_id and known_agents and normalized_agent_id not in known_agents: raise ValueError( f"agent_id {normalized_agent_id!r} is not registered for goal {safe_goal_id!r}" @@ -1160,7 +1157,7 @@ def refresh_state_run( ): delivery_workspace = capture_delivery_workspace( current_path=delivery_workspace_path, - peer_independent_worktree_required=peer_independent_worktree_required, + peer_independent_worktree_required=False, local_goal_id=safe_goal_id, local_project_root=resolved_project, repository_source=( @@ -1169,6 +1166,22 @@ def refresh_state_run( else None ), ) + workspace_todo_fields = todo_fields + if workspace_todo_fields is None: + workspace_todo_fields = parse_active_state_todos( + state_text, goal=registry_goal, state_path=resolved_state_file, + preferred_todo_ids={settlement_identity.todo_id or ""}, + rollout_events=planning_events, item_limit=None, + ) + selected_contract = next(( + item for item in workspace_todo_fields.get("agent_todos", {}).get("items", []) + if item.get("todo_id") == settlement_identity.todo_id + ), {}) + delivery_workspace, peer_independent_worktree_required = qualify_delivery_workspace_isolation( + delivery_workspace, multi_agent_goal=multi_agent_goal, + explicit_peer_worktree_requirement=explicit_peer_worktree_requirement, + task_repository=selected_contract.get("task_repository"), + ) if ( peer_independent_worktree_required and ( @@ -1182,10 +1195,11 @@ def refresh_state_run( "git worktree that produced it, or name that worktree with " "--delivery-workspace-path" ) - if delivery_workspace_path is not None and delivery_workspace is None: + if delivery_workspace is None: raise ValueError( - "--delivery-workspace-path must identify the registered local goal " - "workspace or a git checkout with a credential-free origin repository" + "delivery workspace could not be verified; run from the registered " + "local Goal workspace or the selected repository worktree, or name " + "that workspace with --delivery-workspace-path" ) if checkpoint_supplement: # The supplemental row must not reattribute the original delivery to diff --git a/tests/control_plane/test_interaction_contract_workspace_causality.py b/tests/control_plane/test_interaction_contract_workspace_causality.py index ed0d552b4f..73db5b6b67 100644 --- a/tests/control_plane/test_interaction_contract_workspace_causality.py +++ b/tests/control_plane/test_interaction_contract_workspace_causality.py @@ -38,13 +38,16 @@ def test_non_delivery_contract_omits_workspace_causality() -> None: assert "delivery_workspace_causality" not in contract["cli_channel"] -def test_delivery_without_task_repository_keeps_default_hot_path_compact() -> None: +def test_local_delivery_packet_explains_workspace_without_non_delivery_relabel() -> None: payload = _payload(should_run=True) payload["selected_todo"].pop("task_repository") contract = build_interaction_contract(payload) - assert "delivery_workspace_causality" not in contract["cli_channel"] + causality = contract["cli_channel"]["delivery_workspace_causality"] + assert causality["refresh"] == "registered local Goal workspace; Git peer delivery requires an independent worktree" + assert causality["spend"] == "recorded_delivery_workspace" + assert causality["mismatch"] == "fail_closed" def test_turn_envelope_preserves_workspace_causality() -> None: diff --git a/tests/control_plane/test_local_peer_delivery_journey.py b/tests/control_plane/test_local_peer_delivery_journey.py new file mode 100644 index 0000000000..8ecc7545de --- /dev/null +++ b/tests/control_plane/test_local_peer_delivery_journey.py @@ -0,0 +1,254 @@ +"""Local research is independently accepted delivery, not non-delivery work.""" +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import datetime, timedelta, timezone + +import pytest +import test_quota_authority_settlement_journey as journey +import test_quota_settlement_cli as cli +from canonical_authority_fixture import isolate_sqlite_runtime + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_bound_repository_is_checked_from_the_complete_todo_source(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, _, _ = journey._source( + tmp_path, provider=provider, + extra=f"claimed_by={cli.AGENT_ID} task_repository=git:github.com/example/right", + ) + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("peer-researcher") + registry.write_text(json.dumps(data)) + + def worktree(name): + repository, checkout = tmp_path / name, tmp_path / f"{name}-worktree" + repository.mkdir() + + def git(*args): + subprocess.run(["git", "-C", str(repository), *args], check=True, capture_output=True) + + git("init", "--quiet") + git("config", "remote.origin.url", f"https://github.com/example/{name}.git") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "commit", "--quiet", "--allow-empty", "--signoff", "-m", "Synthetic fixture") + git("worktree", "add", "--quiet", "-b", "codex/fixture", str(checkout)) + return checkout + + wrong, right = worktree("wrong"), worktree("right") + code, guard = journey._guard(project, runtime, registry) + assert code == 0 and guard["selected_todo"]["task_repository"] == "git:github.com/example/right", guard + + def refresh(checkout): + return cli._run_cli( + registry, runtime, "refresh-state", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID, "--delivery-boundary", "in_flight_continuation", + "--delivery-outcome", "outcome_progress", "--delivery-workspace-path", str(checkout), + "--no-global-sync", "--suppress-external-sinks", cwd=checkout, + ) + + code, rejected = refresh(wrong) + assert code != 0 and not rejected["ok"] and not rejected["appended"], rejected + assert cli._spend_run_count(runtime) == 0 + code, recovered = refresh(right) + assert code == 0 and recovered["ok"], recovered + assert recovered["delivery_workspace"]["task_repository"] == "git:github.com/example/right" + code, spent = journey._execute(recovered["settlement_owed"]["command"], right, runtime, registry) + assert code == 0 and spent["settlement_progress"]["state"] == "settled", spent + code, retry = journey._execute(recovered["settlement_owed"]["command"], right, runtime, registry) + assert code == 0 and not retry["appended"], retry + assert cli._spend_run_count(runtime) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_local_peer_delivery_completes_and_settles_without_git(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, _, _ = journey._source( + tmp_path, provider=provider, handoff_mode="hard_lease", + extra=f"claimed_by={cli.AGENT_ID}", + ) + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("peer-researcher") + registry.write_text(json.dumps(data)) + + def run(*args, cwd=project): + return cli._run_cli(registry, runtime, *args, "--goal-id", cli.GOAL_ID, cwd=cwd) + + code, guard = journey._guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + key = "local-result-execution" + code, claimed = run("todo", "claim", "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--claimed-by", cli.AGENT_ID, + "--task-lease-idempotency-key", key) + assert code == 0 and claimed["ok"], claimed + proof = ("--task-lease-idempotency-key", key, + "--task-lease-expected-version", str(claimed["lease"]["version"])) + # A successful caller preflight cannot substitute for controller validation. + # The approved validator independently checks the actual result artifact. + result = project / "review.md" + command = [sys.executable, "-c", "from pathlib import Path; Path('validation-ran').touch(); assert Path('review.md').read_text() == 'accepted local research\\n'"] + code, listed = run("todo", "list", "--todo-id", cli.TODO_ID) + assert code == 0, listed + code, revised = run("todo", "update", "--todo-id", cli.TODO_ID, "--agent-id", cli.AGENT_ID, + "--update-operation-id", "local-result-validator", + "--update-expected-provider-revision", listed["authority_read"]["provider_revision"], + *proof, "--validation-command-json", json.dumps(command), + "--validation-timeout-seconds", "20") + assert code == 0 and revised["ok"], revised + complete = ("todo", "complete", "--todo-id", cli.TODO_ID, "--agent-id", cli.AGENT_ID, + "--turn-instance-id", cli.TURN_ID, *proof, + "--evidence", "review.md") + result.write_text("accepted local research\n") + code, unbound_result = run(*complete, "--result-file", str(result)) + assert not unbound_result["ok"] and unbound_result["reason_code"] == "completion_result_rejected", unbound_result + assert "Goal acceptance criteria" in unbound_result["reason"] + assert not (project / "validation-ran").exists() + result.write_text("unvalidated local research\n") + code, rejected = run(*complete) + assert not rejected["ok"] and rejected.get("validation_blocked_completion"), rejected + assert rejected["validation_failure"]["validation_receipt"]["exit_code"] == 1 + assert run("todo", "list", "--todo-id", cli.TODO_ID)[1]["todo"]["status"] == "open" + + result.write_text("accepted local research\n") + code, completed = run(*complete) + assert code == 0 and completed["ok"], completed + current = run("todo", "list", "--todo-id", cli.TODO_ID)[1]["todo"] + assert current["status"] == "done" and current["completion_validation_required"] + assert current["evidence"] == "review.md" and not current.get("completion_result") + + outside = tmp_path / "outside" + outside.mkdir() + code, wrong_workspace = run("refresh-state", "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--delivery-outcome", "outcome_progress", "--no-global-sync", cwd=outside) + assert not wrong_workspace["ok"] and not wrong_workspace["appended"], wrong_workspace + code, refreshed = journey._refresh(project, runtime, registry) + assert code == 0 and refreshed["ok"], json.dumps(refreshed) + assert refreshed["delivery_workspace"]["identity_kind"] == "local_goal" + assert refreshed["delivery_workspace"]["workspace_identity"] == f"loopx:{cli.GOAL_ID}" + assert refreshed["delivery_workspace"]["peer_independent_worktree_required"] is False + spend = refreshed["settlement_owed"]["command"] + code, spent = journey._execute(spend, project, runtime, registry) + assert code == 0 and spent["settlement_progress"]["state"] == "settled", spent + code, retry = journey._execute(spend, project, runtime, registry) + assert code == 0 and not retry["appended"], retry + assert cli._spend_run_count(runtime) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("release_execution_lease", [False, True]) +def test_local_in_flight_progress_keeps_completion_independent(tmp_path, monkeypatch, provider, release_execution_lease): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, _, _ = journey._source( + tmp_path, provider=provider, handoff_mode="hard_lease", + extra=f"claimed_by={cli.AGENT_ID}", + ) + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("peer-researcher") + registry.write_text(json.dumps(data)) + code, guard = journey._guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + code, claimed = cli._run_cli(registry, runtime, "todo", "claim", + "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--claimed-by", cli.AGENT_ID, + "--task-lease-idempotency-key", "local-in-flight", cwd=project) + assert code == 0 and claimed["ok"], claimed + if release_execution_lease: + code, released = cli._run_cli( + registry, runtime, "task-lease", "release", "--goal-id", cli.GOAL_ID, + "--todo-id", cli.TODO_ID, "--owner", cli.AGENT_ID, + "--idempotency-key", "local-in-flight", + "--expected-version", str(claimed["lease"]["version"]), cwd=project, + ) + assert code == 0 and released["ok"], released + code, refreshed = cli._run_cli( + registry, runtime, "refresh-state", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID, "--delivery-boundary", "in_flight_continuation", + "--delivery-outcome", "outcome_progress", "--delivery-batch-scale", "multi_surface", + "--no-global-sync", "--suppress-external-sinks", cwd=project, + ) + assert code == 0 and refreshed["ok"], json.dumps(refreshed) + assert refreshed["delivery_workspace"]["workspace_kind"] == "local_goal_workspace" + code, spent = journey._execute(refreshed["settlement_owed"]["command"], project, runtime, registry) + assert code == 0 and spent["settlement_progress"]["state"] == "settled", spent + code, listed = cli._run_cli(registry, runtime, "todo", "list", "--goal-id", cli.GOAL_ID, + "--todo-id", cli.TODO_ID, cwd=project) + assert code == 0 and listed["todo"]["status"] == "open", listed + assert not listed["todo"].get("completion_result") + code, next_guard = journey._guard(project, runtime, registry, turn_id="next-local-in-flight") + assert code == 0 and next_guard["selected_todo"]["todo_id"] == cli.TODO_ID, next_guard + assert cli._spend_run_count(runtime) == 1 + + +def test_local_receipt_cannot_satisfy_an_explicit_git_task(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, _, _ = journey._source( + tmp_path, provider="sqlite", handoff_mode="hard_lease", + extra=f"claimed_by={cli.AGENT_ID} task_repository=git:github.com/example/delivery", + ) + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("peer-researcher") + registry.write_text(json.dumps(data)) + code, guard = journey._guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + code, rejected = cli._run_cli( + registry, runtime, "refresh-state", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID, "--delivery-boundary", "in_flight_continuation", + "--delivery-outcome", "outcome_progress", "--no-global-sync", cwd=project, + ) + assert not rejected["ok"] and not rejected["appended"], rejected + assert "independent git worktree" in rejected["error"] + assert cli._spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_local_blocked_writeback_preserves_retry_and_spends_nothing(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, _, _ = journey._source( + tmp_path, provider=provider, handoff_mode="hard_lease", + extra=f"claimed_by={cli.AGENT_ID}", + ) + data = json.loads(registry.read_text()) + data["goals"][0]["coordination"]["registered_agents"].append("peer-researcher") + registry.write_text(json.dumps(data)) + + def run(*args): + return cli._run_cli(registry, runtime, *args, "--goal-id", cli.GOAL_ID, cwd=project) + + code, guard = journey._guard(project, runtime, registry) + assert code == 0 and guard["decision"] == "run", guard + code, claimed = run("todo", "claim", "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--claimed-by", cli.AGENT_ID, + "--task-lease-idempotency-key", "local-blocked") + assert code == 0 and claimed["ok"], claimed + due = (datetime.now(timezone.utc) + timedelta(minutes=5)).replace(microsecond=0).isoformat().replace("+00:00", "Z") + code, listed = run("todo", "list", "--todo-id", cli.TODO_ID) + assert code == 0, listed + code, wait = run("todo", "update", "--todo-id", cli.TODO_ID, "--agent-id", cli.AGENT_ID, + "--update-operation-id", "local-blocked-retry", + "--update-expected-provider-revision", listed["authority_read"]["provider_revision"], + "--task-lease-idempotency-key", "local-blocked", + "--task-lease-expected-version", str(claimed["lease"]["version"]), + "--resume-when", f"resume_at:{due}") + assert code == 0 and wait["ok"], wait + code, refreshed = run("refresh-state", "--agent-id", cli.AGENT_ID, + "--todo-id", cli.TODO_ID, "--turn-instance-id", cli.TURN_ID, + "--delivery-outcome", "outcome_gap", "--progress-result-class", "blocked", + "--progress-blocker-id", "blocker:runtime-boundary", + "--progress-evidence-id", "evidence:runtime-boundary", + "--no-global-sync", "--suppress-external-sinks") + assert code == 0 and refreshed["ok"], json.dumps(refreshed) + assert refreshed["delivery_workspace"]["identity_kind"] == "local_goal" + assert refreshed["settlement_progress"]["state"] == "settled" + assert refreshed["settlement_progress"]["closeout_kind"] == "typed_blocked_writeback_no_spend" + assert refreshed.get("settlement_owed") is None + assert refreshed["blocked_retry"]["resume_when"] == f"resume_at:{due}" + todo = run("todo", "list", "--todo-id", cli.TODO_ID)[1]["todo"] + assert todo["status"] == "open" and not todo["resume_ready"] + assert not todo.get("completion_result") + assert cli._spend_run_count(runtime) == 0 diff --git a/tests/control_plane_ts/delivery_workspace.test.ts b/tests/control_plane_ts/delivery_workspace.test.ts index 0d5d80331d..640bf25c93 100644 --- a/tests/control_plane_ts/delivery_workspace.test.ts +++ b/tests/control_plane_ts/delivery_workspace.test.ts @@ -118,3 +118,38 @@ test("workspace normalization is immutable and fails closed on contradictions", /peer_independent_worktree_required must be a boolean/, ); }); + +test("peer isolation preserves local delivery and repository/owner boundaries", () => { + const local = { + schema_version: "delivery_workspace_v1", workspace_identity: "loopx:research", + identity_kind: "local_goal", task_repository: null, + repository_source: "goal_id_fallback", workspace_kind: "local_goal_workspace", + peer_independent_worktree_required: false, + }; + const git = { + ...local, workspace_identity: "git:github.com/example/project", + identity_kind: "git_repository", task_repository: "git:github.com/example/project", + workspace_kind: "canonical_checkout", + }; + for (const [workspace, explicit, repository, required, accepted] of [ + [local, null, null, false, true], + [local, true, null, true, false], + [local, null, "git:github.com/example/project", true, false], + [null, null, null, true, false], + [git, null, null, true, true], + [git, false, null, false, true], + [git, null, "git:github.com/example/other", true, false], + ] as const) { + const result = evaluateDeliveryWorkspace({ + schema_version: DELIVERY_WORKSPACE_REQUEST_SCHEMA, operation: "isolation", + workspace, multi_agent_goal: true, explicit_peer_worktree_requirement: explicit, + task_repository: repository, + }); + assert.equal(result.peer_independent_worktree_required, required); + assert.equal(result.workspace !== null, accepted); + if (result.workspace) { + assert.equal((result.workspace as Record).peer_independent_worktree_required, required); + } + } + assert.equal(local.peer_independent_worktree_required, false); +});