diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 24fbf24b8..3e49896ba 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -18,6 +18,10 @@ from .chat_store import ChatSessionStore from .chat_todo_actions import ChatTodoActionMixin from .configure_goal import configure_goal +from .control_plane.goals.configure_goal_service import ( + bind_goal_agent_with_global_sync, + read_goal_agent_binding_with_source_route, +) from .control_plane.runtime.time import now_utc, parse_timestamp from .control_plane.scheduler.monitor_todo import monitor_next_due_at from .history import load_registry @@ -864,58 +868,44 @@ def _apply_goal_create( def _apply_agent_bind( self, proposal_id: str, proposal: dict[str, Any], parameters: dict[str, Any] ) -> dict[str, Any]: - current_fingerprint = self._registry_fingerprint() goal_id = str(parameters["goal_id"]) agent_id = str(parameters["agent_id"]) - existing = registered_agent_ids_for_goal(self._goal(goal_id)) - if agent_id in existing and current_fingerprint != proposal.get( - "expected_state_fingerprint" - ): - receipt = { - "receipt_id": _digest( - { - "proposal_id": proposal_id, - "goal_id": goal_id, - "agent_id": agent_id, - } - )[:32], - "outcome": "agent_already_bound", - "projection_verified": True, - "resource_ids": {"goal_id": goal_id, "agent_id": agent_id}, - } - stored = self.store.apply( - proposal_id, - current_state_fingerprint=str(proposal["expected_state_fingerprint"]), - receipt=receipt, - ) - return {"proposal": stored, "turn": None} - if current_fingerprint != proposal.get("expected_state_fingerprint"): - stale = self.store.apply( - proposal_id, current_state_fingerprint=current_fingerprint, receipt={} - ) - return {"proposal": stale, "turn": None} - registered = sorted({*existing, agent_id}) - result = configure_goal( + expected_revision = str(proposal["expected_state_fingerprint"]) + result = bind_goal_agent_with_global_sync( registry_path=self.registry_path, goal_id=goal_id, - registered_agents=registered, + agent_id=agent_id, execute=True, + expected_revision=expected_revision, ) - projected = registered_agent_ids_for_goal(self._goal(goal_id)) - if agent_id not in projected: - raise ValueError("Agent binding was not visible in the Goal projection") + if result.get("status") == "stale": + stale = self.store.apply( + proposal_id, + current_state_fingerprint=str(result["actual_revision"]), + receipt={}, + ) + return {"proposal": stale, "turn": None} + if not result.get("ok") or not result.get("projection_verified"): + raise ValueError( + str( + result.get("error") + or "Agent binding did not verify in source and shared registries" + ) + ) receipt = { "receipt_id": _digest( {"proposal_id": proposal_id, "goal_id": goal_id, "agent_id": agent_id} )[:32], "outcome": "agent_bound" - if result.get("changed") + if result.get("status") == "bound" else "agent_already_bound", "projection_verified": True, "resource_ids": {"goal_id": goal_id, "agent_id": agent_id}, } stored = self.store.apply( - proposal_id, current_state_fingerprint=current_fingerprint, receipt=receipt + proposal_id, + current_state_fingerprint=expected_revision, + receipt=receipt, ) return {"proposal": stored, "turn": None} @@ -1227,6 +1217,16 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: else "The monitor execution request is bound to the current Goal state." ] permission = "durable_write" + elif action_kind == "agent.bind": + binding = read_goal_agent_binding_with_source_route( + registry_path=self.registry_path, + goal_id=str(normalized["goal_id"]), + ) + fingerprint = str(binding["revision"]) + evidence = [ + "The Agent binding was validated against the canonical source Goal peer set." + ] + permission = "durable_write" else: fingerprint = self._registry_fingerprint() evidence = [ diff --git a/loopx/control_plane/goals/configure_goal_service.py b/loopx/control_plane/goals/configure_goal_service.py index b0136c075..975a0d332 100644 --- a/loopx/control_plane/goals/configure_goal_service.py +++ b/loopx/control_plane/goals/configure_goal_service.py @@ -6,7 +6,11 @@ from pathlib import Path from typing import Any -from ...configuration_transaction import goal_capability_configuration_revision +from ...agent_registry import registered_agent_ids_for_goal +from ...configuration_transaction import ( + configuration_payload_revision, + goal_capability_configuration_revision, +) from ...configure_goal import configure_goal from ...global_registry import ( sanitize_goal_for_global, @@ -98,6 +102,60 @@ def read_goal_configuration_with_source_route( ) +def _goal_agent_binding_revision( + *, + goal_id: str, + source_registry: Path, + registered_agents: list[str], +) -> str: + return configuration_payload_revision( + { + "goal_id": goal_id, + "source_registry": str(source_registry.expanduser().resolve()), + "registered_agents": registered_agents, + } + ) + + +def goal_agent_binding_revision( + goal_id: str, + goal: dict[str, Any], + *, + source_registry: Path, +) -> str: + """Revision the canonical source identity and its Agent peer set.""" + + return _goal_agent_binding_revision( + goal_id=goal_id, + source_registry=source_registry, + registered_agents=registered_agent_ids_for_goal(goal), + ) + + +def read_goal_agent_binding_with_source_route( + *, registry_path: Path, goal_id: str +) -> dict[str, Any]: + """Read the Agent binding state from the canonical source registry.""" + + source_registry_path = _resolve_authoritative_source_registry( + registry_path=registry_path, + goal_id=goal_id, + ) + source_goal = _goal(load_registry(source_registry_path), goal_id) + if source_goal is None: + raise ValueError(f"goal id not found in source registry: {goal_id}") + return { + "ok": True, + "goal_id": goal_id, + "registered_agents": registered_agent_ids_for_goal(source_goal), + "revision": goal_agent_binding_revision( + goal_id, + source_goal, + source_registry=source_registry_path, + ), + } + + def _digest(value: Any) -> str: return hashlib.sha256( json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8") @@ -286,6 +344,7 @@ def _configure_goal_with_global_sync_unlocked( runtime_root_override: str | None, execute: bool, registry_transaction: ProjectRegistryTransaction | None = None, + sync_if_unchanged: bool = False, **configure_options: Any, ) -> dict[str, Any]: """Configure one source goal and keep its authoritative shared read model current.""" @@ -298,24 +357,25 @@ def _configure_goal_with_global_sync_unlocked( **configure_options, ) changed = bool(preview.get("changed")) + sync_required = changed or sync_if_unchanged target_resolution = ( resolve_configure_goal_sync_target( registry_path=registry_path, goal_id=goal_id, runtime_root_override=runtime_root_override, ) - if changed + if sync_required else None ) preview["global_sync"] = _sync_plan( - changed=changed, + changed=sync_required, target_resolution=target_resolution, execute=execute, ) if not execute: return preview - if not changed: + if not sync_required: applied = configure_goal( registry_path=registry_path, goal_id=goal_id, @@ -360,7 +420,7 @@ def _configure_goal_with_global_sync_unlocked( _registry_transaction=registry_transaction, **configure_options, ) - if not applied.get("written"): + if not applied.get("written") and not sync_if_unchanged: applied["global_sync"] = _sync_plan( changed=False, target_resolution=target_resolution, @@ -640,3 +700,115 @@ def add_host_capacity( receipt=capacity_receipt, plan_before_apply=capacity_plan, ) + + +def bind_goal_agent_with_global_sync( + *, + registry_path: Path, + goal_id: str, + agent_id: str, + runtime_root_override: str | None = None, + execute: bool, + expected_revision: str | None = None, +) -> dict[str, Any]: + """Bind one Agent through source authority and verify its shared projection.""" + + source_registry_path = _resolve_authoritative_source_registry( + registry_path=registry_path, + goal_id=goal_id, + ) + if not execute: + state = read_goal_agent_binding_with_source_route( + registry_path=source_registry_path, + goal_id=goal_id, + ) + return { + **state, + "changed": agent_id not in state["registered_agents"], + "written": False, + "projection_verified": False, + } + + with project_registry_transaction( + source_registry_path, + operation="bind_goal_agent_with_global_sync", + ) as registry_transaction: + source_goal = _goal(registry_transaction.payload_copy(), goal_id) + if source_goal is None: + raise ValueError(f"goal id not found in source registry: {goal_id}") + registered_agents = registered_agent_ids_for_goal(source_goal) + actual_revision = goal_agent_binding_revision( + goal_id, + source_goal, + source_registry=source_registry_path, + ) + already_bound = agent_id in registered_agents + if expected_revision is not None and actual_revision != expected_revision: + retry_revision = _goal_agent_binding_revision( + goal_id=goal_id, + source_registry=source_registry_path, + registered_agents=[ + registered_agent + for registered_agent in registered_agents + if registered_agent != agent_id + ], + ) + if not already_bound or retry_revision != expected_revision: + return { + "ok": False, + "status": "stale", + "goal_id": goal_id, + "agent_id": agent_id, + "expected_revision": expected_revision, + "actual_revision": actual_revision, + "registered_agents": registered_agents, + "changed": False, + "written": False, + "projection_verified": False, + } + + if already_bound: + applied: dict[str, Any] = _configure_goal_with_global_sync_unlocked( + registry_path=source_registry_path, + goal_id=goal_id, + runtime_root_override=runtime_root_override, + execute=True, + registry_transaction=registry_transaction, + sync_if_unchanged=True, + registered_agents=registered_agents, + ) + else: + applied = _configure_goal_with_global_sync_unlocked( + registry_path=source_registry_path, + goal_id=goal_id, + runtime_root_override=runtime_root_override, + execute=True, + registry_transaction=registry_transaction, + registered_agents=sorted({*registered_agents, agent_id}), + ) + + source_after = _goal(load_registry(source_registry_path), goal_id) + source_registered_agents = registered_agent_ids_for_goal(source_after) + readback = (applied.get("global_sync") or {}).get("readback") or {} + projection_verified = bool( + agent_id in source_registered_agents and readback.get("verified") + ) + return { + **applied, + "ok": bool(applied.get("ok") and projection_verified), + "status": "already_bound" if already_bound else "bound", + "goal_id": goal_id, + "agent_id": agent_id, + "source_revision_before": actual_revision, + "source_revision_after": ( + goal_agent_binding_revision( + goal_id, + source_after, + source_registry=source_registry_path, + ) + if source_after is not None + else None + ), + "source_registered_agents": source_registered_agents, + "projection_verified": projection_verified, + } diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 4a51d08c9..3d09ed937 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -303,7 +303,7 @@ }, { "site": "loopx/chat_actions.py::.ChatActionService._registry::codec_read:load_registry#1", - "line": 241, + "line": 245, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -951,7 +951,7 @@ }, { "site": "loopx/control_plane/goals/configure_goal_service.py::._readback::codec_read:load_registry#1", - "line": 185, + "line": 243, "column": 22, "kind": "codec_read", "api": "load_registry", @@ -959,23 +959,47 @@ }, { "site": "loopx/control_plane/goals/configure_goal_service.py::._readback::codec_read:load_registry#2", - "line": 186, + "line": 244, "column": 22, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/control_plane/goals/configure_goal_service.py::.bind_goal_agent_with_global_sync::codec_transaction:project_registry_transaction#1", + "line": 732, + "column": 10, + "kind": "codec_transaction", + "api": "project_registry_transaction", + "classification": "codec_api" + }, + { + "site": "loopx/control_plane/goals/configure_goal_service.py::.bind_goal_agent_with_global_sync::codec_read:load_registry#1", + "line": 790, + "column": 30, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/control_plane/goals/configure_goal_service.py::.configure_goal_with_global_sync::codec_transaction:project_registry_transaction#1", - "line": 555, + "line": 615, "column": 10, "kind": "codec_transaction", "api": "project_registry_transaction", "classification": "codec_api" }, + { + "site": "loopx/control_plane/goals/configure_goal_service.py::.read_goal_agent_binding_with_source_route::codec_read:load_registry#1", + "line": 144, + "column": 25, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/control_plane/goals/configure_goal_service.py::.resolve_configure_goal_sync_target::codec_read:load_registry#1", - "line": 114, + "line": 172, "column": 22, "kind": "codec_read", "api": "load_registry", @@ -1535,7 +1559,7 @@ }, { "site": "loopx/history.py::.collect_history::codec_read:load_registry#1", - "line": 301, + "line": 327, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1543,7 +1567,7 @@ }, { "site": "loopx/history.py::.inspect_index_duplicates::codec_read:load_registry#1", - "line": 542, + "line": 571, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1551,7 +1575,7 @@ }, { "site": "loopx/history.py::.rebuild_index_artifact_collisions::codec_read:load_registry#1", - "line": 756, + "line": 785, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1559,7 +1583,7 @@ }, { "site": "loopx/history.py::.repair_index_duplicates::codec_read:load_registry#1", - "line": 646, + "line": 675, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index c25f74872..e7bb0bb64 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -101,6 +101,7 @@ teaches a reusable control-plane lesson. | `global_registry_write_boundary_gap` | `connect`, `sync-global`, or agent registration can write/read project-local state, but writing the shared global registry fails with permission or read-only host errors; the user sees an ambiguous "not connected" or a half-updated control plane. | command payload `global_sync`, `global_registry_writability`, project-local registry, global registry path, `loopx doctor`, filesystem write probe, recent source registry contents. | The shared global registry is a required projection for multi-project status/quota, but command flow treated it as best-effort or discovered write denial after mutating the source registry. | Probe global registry writability before source/local mutations when global sync is required; fail fast with `ok=false`, `global_registry_write_denied`, and recovery guidance. Keep explicit local-only mode behind `--no-global-sync`, and prefer `register-agent` through `source_registry` for agent onboarding. | | `cross_goal_health_contamination` | A registry or active-state error owned by one goal makes `status --goal-id` or `quota should-run --goal-id` unhealthy for an unrelated goal. | contract `error_diagnostics`, `global_errors`, `goal_errors`, selected-goal quota `status_health_ok`, filtered status output, and the original registry/active-state producer. | Error ownership was encoded only in message text, then aggregate `contract.ok` was reused as a per-goal execution guard. | Emit structured ownership at the validation source; keep registry identity ambiguity and registry/public-boundary failures global, attribute goal-entry and active-state todo failures to their goal or affected goal set, and derive legacy string lists from diagnostics. Per-goal decisions consume global plus selected-goal errors; broad inventory remains all-goal. Cover similar goal ids, unrelated-goal failures, shared-goal findings, and global fail-closed behavior. | | `runtime_projection_source_mirror_ambiguity` | A project-local material refresh appends its source run, but shared-runtime projection reports `ambiguous` when the source runtime's registry mirror and exactly one external runtime both declare the same source registry. | refresh-state route diagnostics, source and candidate runtime roots, each registry's `source_registry`, compact route match counts, and an immediate shared-runtime readback. | Route discovery counted the source runtime's local registry mirror as an independent projection target alongside the authoritative external runtime. | Prefer external matches when any exist, report the ignored source-mirror count separately, and preserve ambiguity when multiple external targets remain. Cover source mirror plus one external target and source mirror plus two external targets, then verify the real refresh route readback. | +| `chat_agent_binding_source_authority_gap` | Chat reports `agent_bound`, but the source Goal still lacks the Agent and the next project-to-global sync removes the binding; a source peer-set or canonical source route change after preview may also be accepted. | Chat proposal fingerprint and receipt, canonical source identity, source and global `registered_agents`, source route, post-sync readback, and concurrent source peer-set or route edits. | The preview and write targeted the shared registry read model or revisioned only the peer set, so the confirmation had no source-owned CAS boundary or could be replayed against an unreviewed source with identical peers. | Bind the preview to the canonical source identity plus peer-set revision, recheck and merge under that source registry transaction, preserve the selected source through shared projection sync, and issue success only after source plus global readback. Treat an already-present Agent as idempotent recovery only when removing that Agent reconstructs the confirmed revision for the same source; cover response loss and equal-peer A-to-B route changes. | | `agent_scoped_user_gate_overreach` | `quota should-run --agent-id ` returns `user_gate`, `requires_user_action=true`, or `delivery_allowed=false` because an open user todo exists, but that todo is scoped to `` through `blocks_agent` or `claimed_by`; agent A has an independent runnable todo or only needs its own workspace/scope repair. | quota payload with the same `--agent-id`, `user_todo_summary.first_open_items`, `blocks_agent`, `claimed_by`, `agent_lane_next_action`, active state user/agent todos, related interaction pattern. | User-gate projection treated agent-scoped routing metadata as diagnostic text instead of a hard scope boundary, so a target-agent gate became a global owner gate. | Filter other-agent `blocks_agent` / `claimed_by` user todos out of the current agent's blocking summary while preserving diagnostic visibility; if the raw state was `operator_gate` only because of that other-agent gate and the current agent has runnable work, project an eligible current-agent lane. Cover with `examples/control_plane/quota-agent-scoped-user-gate-smoke.py` and workspace-guard smoke coverage. | | `user_action_binding_projection_gap` | A non-blocking user action intended for one peer appears in another peer's heartbeat, or its scoped summary has `open_count=0` while inherited goal state still produces `operator_gate` / `NOTIFY`. | Active user-todo metadata, registered agents, agent-scoped quota summary and diagnostics, inherited status state, interaction contract user channel, todo mutation actor. | User actions had no first-class goal/agent binding, `claimed_by` was overloaded as routing, and quota filtered item details without clearing the upstream operator-gate state. | Require every multi-agent user todo to declare `bound_agent` or `goal_bound`; keep gate scope separate, make the bound agent the response-continuation lifecycle actor, filter other-lane details and counts, and override stale operator-gate state before interaction projection. Preserve `claimed_by` only as a read compatibility fallback and cover the no-current-work quiet lane. | | `same_agent_scoped_gate_due_monitor_gap` | A same-agent user gate has an exact `unblocks_todo_id` or narrow `decision_scope`, an unrelated capability-runnable continuous monitor is due, but quota returns `must_attempt=false` and blocks the whole agent lane. | quota payload `scoped_user_gate_fallback`, `agent_todo_summary.monitor_due_items`, capability gate, gate relation, linked todo, and current-agent monitor metadata. | Scoped-gate fallback considered only advancement candidates, so the separately projected due-monitor lane disappeared before dependency filtering. | Add the already scoped, due, capability-runnable monitor projection to scoped-gate dependency filtering and preserve normal todo priority ordering; keep linked work blocked and reject monitors with missing capabilities. Cover both positive and negative cases in `examples/control_plane/quota-agent-scoped-user-gate-smoke.py`. | diff --git a/tests/control_plane/test_configure_source_authority.py b/tests/control_plane/test_configure_source_authority.py index 5c627369f..f9cb7667e 100644 --- a/tests/control_plane/test_configure_source_authority.py +++ b/tests/control_plane/test_configure_source_authority.py @@ -6,6 +6,8 @@ import pytest +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService from loopx.configuration_transaction import goal_capability_configuration_revision from loopx.configure_goal import configure_goal from loopx.control_plane.goals.configure_goal_service import ( @@ -31,6 +33,9 @@ def mirrored_goal(tmp_path): "id": "example", "repo": str(tmp_path / "project"), "status": "active", + "coordination": { + "registered_agents": ["agent-a"], + }, "spawn_policy": { "mode": "default", "allowed": False, @@ -54,6 +59,32 @@ def policy(path): return json.loads(path.read_text())["goals"][0]["spawn_policy"] +def registered_agents(path): + return json.loads(path.read_text())["goals"][0]["coordination"][ + "registered_agents" + ] + + +def preview_agent_binding(tmp_path, mirror): + service = ChatActionService( + store=ChatActionStore(tmp_path / "actions"), + registry_path=mirror, + ) + proposal = service.preview( + { + "action_kind": "agent.bind", + "summary": "Bind agent-b to the Goal", + "normalized_parameters": { + "goal_id": "example", + "agent_id": "agent-b", + }, + "context": {}, + "idempotency_key": "bind-agent-b", + } + ) + return service, proposal + + def test_global_cli_change_survives_source_resync(mirrored_goal): source, mirror, runtime = mirrored_goal result = subprocess.run( @@ -216,3 +247,108 @@ def test_browser_settings_read_and_apply_use_source(mirrored_goal): server.shutdown() server.server_close() thread.join(timeout=5) + + +def test_chat_agent_binding_survives_source_resync(tmp_path, mirrored_goal): + source, mirror, runtime = mirrored_goal + service, proposal = preview_agent_binding(tmp_path, mirror) + + applied = service.apply(proposal["proposal_id"])["proposal"] + + assert applied["status"] == "applied" + assert applied["receipt"]["outcome"] == "agent_bound" + assert registered_agents(source) == ["agent-a", "agent-b"] + assert registered_agents(mirror) == ["agent-a", "agent-b"] + sync_project_registry_to_global( + registry_path=source, + runtime_root_override=str(runtime), + goal_id="example", + dry_run=False, + ) + assert registered_agents(mirror) == ["agent-a", "agent-b"] + + +def test_chat_agent_binding_rejects_stale_source_agents(tmp_path, mirrored_goal): + source, mirror, _runtime = mirrored_goal + service, proposal = preview_agent_binding(tmp_path, mirror) + payload = json.loads(source.read_text()) + payload["goals"][0]["coordination"]["registered_agents"].append("agent-c") + source.write_text(json.dumps(payload)) + before = source.read_bytes(), mirror.read_bytes() + + stale = service.apply(proposal["proposal_id"])["proposal"] + + assert stale["status"] == "stale" + assert stale["receipt"] is None + assert (source.read_bytes(), mirror.read_bytes()) == before + + +def test_chat_agent_binding_rejects_equal_peer_set_after_source_route_change( + tmp_path, mirrored_goal +): + source_a, mirror, _runtime = mirrored_goal + service, proposal = preview_agent_binding(tmp_path, mirror) + source_b = tmp_path / "project-b" / ".loopx" / "registry.json" + source_b.parent.mkdir(parents=True) + source_b.write_bytes(source_a.read_bytes()) + mirror_payload = json.loads(mirror.read_text()) + mirror_payload["goals"][0]["source_registry"] = str(source_b) + mirror.write_text(json.dumps(mirror_payload)) + before = source_a.read_bytes(), source_b.read_bytes(), mirror.read_bytes() + + stale = service.apply(proposal["proposal_id"])["proposal"] + + assert stale["status"] == "stale" + assert stale["receipt"] is None + assert (source_a.read_bytes(), source_b.read_bytes(), mirror.read_bytes()) == before + + +def test_chat_agent_binding_does_not_recover_across_source_route_change( + tmp_path, mirrored_goal +): + source_a, mirror, _runtime = mirrored_goal + service, proposal = preview_agent_binding(tmp_path, mirror) + source_b = tmp_path / "project-b" / ".loopx" / "registry.json" + source_b.parent.mkdir(parents=True) + source_b_payload = json.loads(source_a.read_text()) + source_b_payload["goals"][0]["coordination"]["registered_agents"].append("agent-b") + source_b.write_text(json.dumps(source_b_payload)) + mirror_payload = json.loads(mirror.read_text()) + mirror_payload["goals"][0]["source_registry"] = str(source_b) + mirror.write_text(json.dumps(mirror_payload)) + before = source_a.read_bytes(), source_b.read_bytes(), mirror.read_bytes() + + stale = service.apply(proposal["proposal_id"])["proposal"] + + assert stale["status"] == "stale" + assert stale["receipt"] is None + assert (source_a.read_bytes(), source_b.read_bytes(), mirror.read_bytes()) == before + + +def test_chat_agent_binding_recovers_after_receipt_loss( + tmp_path, mirrored_goal, monkeypatch +): + source, mirror, _runtime = mirrored_goal + service, proposal = preview_agent_binding(tmp_path, mirror) + persist_receipt = service.store.apply + + def lose_receipt(*args, **kwargs): + raise ConnectionError("simulated receipt loss") + + monkeypatch.setattr(service.store, "apply", lose_receipt) + + with pytest.raises(ConnectionError, match="receipt loss"): + service.apply(proposal["proposal_id"]) + assert registered_agents(source) == ["agent-a", "agent-b"] + assert registered_agents(mirror) == ["agent-a", "agent-b"] + + mirror_payload = json.loads(mirror.read_text()) + mirror_payload["goals"][0]["coordination"]["registered_agents"] = ["agent-a"] + mirror.write_text(json.dumps(mirror_payload)) + + monkeypatch.setattr(service.store, "apply", persist_receipt) + recovered = service.apply(proposal["proposal_id"])["proposal"] + + assert recovered["status"] == "applied" + assert recovered["receipt"]["outcome"] == "agent_already_bound" + assert registered_agents(mirror) == ["agent-a", "agent-b"]