From 42748fc82931c4ece1aa930b1b01802275205a69 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 23 Sep 2026 20:14:10 +0800 Subject: [PATCH 1/3] fix(chat): bind agents through source authority Chat agent binding wrote only the shared registry, so source resync discarded it and source peer changes did not stale reviewed proposals. Fingerprint the source peer set, merge under the source registry transaction, sync and verify the global projection, and let already-bound retries repair the mirror after response loss. Signed-off-by: duanjialing.777 --- loopx/chat_actions.py | 72 ++++----- .../goals/configure_goal_service.py | 142 +++++++++++++++++- .../references/repair-patterns.md | 1 + .../test_configure_source_authority.py | 94 ++++++++++++ 4 files changed, 268 insertions(+), 41 deletions(-) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 24fbf24b88..3e49896ba1 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 b0136c0756..f40f225c01 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,37 @@ def read_goal_configuration_with_source_route( ) +def goal_agent_binding_revision(goal_id: str, goal: dict[str, Any]) -> str: + """Revision the source-owned peer set used by an Agent binding preview.""" + + return configuration_payload_revision( + { + "goal_id": goal_id, + "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), + } + + def _digest(value: Any) -> str: return hashlib.sha256( json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8") @@ -286,6 +321,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 +334,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 +397,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 +677,98 @@ 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) + already_bound = agent_id in registered_agents + if expected_revision is not None and actual_revision != expected_revision: + if not already_bound: + 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) + if source_after is not None + else None + ), + "source_registered_agents": source_registered_agents, + "projection_verified": projection_verified, + } diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index c25f74872c..6412989c4c 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 change after preview may also be accepted. | Chat proposal fingerprint and receipt, source and global `registered_agents`, source route, post-sync readback, and a concurrent source peer-set edit. | The preview and write both targeted the shared registry read model instead of the source registry, so the confirmation had no source-owned CAS boundary. | Bind the preview to a source peer-set revision, recheck and merge under the source registry transaction, sync the shared read model, and issue success only after source plus global readback. Treat an already-present Agent as idempotent recovery and cover response loss. | | `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 5c627369f6..ed264a1d25 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,66 @@ 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_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"] From 18413c74805571a79f7306a3915c8f4e33a51206 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Thu, 24 Sep 2026 01:15:31 +0800 Subject: [PATCH 2/3] fix(chat): bind agent revisions to source route Include the canonical source registry in agent binding revisions so equal peer sets cannot authorize writes after a route change. Restrict already-bound recovery to the exact same source and pre-bind peer set, and cover both route-switch cases. Signed-off-by: duanjialing.777 --- .../goals/configure_goal_service.py | 56 ++++++++++++++++--- .../project_registry_io_manifest_v1.json | 56 +++++++++++++------ .../references/repair-patterns.md | 2 +- .../test_configure_source_authority.py | 42 ++++++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/loopx/control_plane/goals/configure_goal_service.py b/loopx/control_plane/goals/configure_goal_service.py index f40f225c01..975a0d3323 100644 --- a/loopx/control_plane/goals/configure_goal_service.py +++ b/loopx/control_plane/goals/configure_goal_service.py @@ -102,17 +102,36 @@ def read_goal_configuration_with_source_route( ) -def goal_agent_binding_revision(goal_id: str, goal: dict[str, Any]) -> str: - """Revision the source-owned peer set used by an Agent binding preview.""" - +def _goal_agent_binding_revision( + *, + goal_id: str, + source_registry: Path, + registered_agents: list[str], +) -> str: return configuration_payload_revision( { "goal_id": goal_id, - "registered_agents": registered_agent_ids_for_goal(goal), + "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]: @@ -129,7 +148,11 @@ def read_goal_agent_binding_with_source_route( "ok": True, "goal_id": goal_id, "registered_agents": registered_agent_ids_for_goal(source_goal), - "revision": goal_agent_binding_revision(goal_id, source_goal), + "revision": goal_agent_binding_revision( + goal_id, + source_goal, + source_registry=source_registry_path, + ), } @@ -714,10 +737,23 @@ def bind_goal_agent_with_global_sync( 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) + 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: - if not already_bound: + 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", @@ -765,7 +801,11 @@ def bind_goal_agent_with_global_sync( "agent_id": agent_id, "source_revision_before": actual_revision, "source_revision_after": ( - goal_agent_binding_revision(goal_id, source_after) + goal_agent_binding_revision( + goal_id, + source_after, + source_registry=source_registry_path, + ) if source_after is not None else None ), diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 371c61f4a9..67609a10b6 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", @@ -727,7 +727,7 @@ }, { "site": "loopx/cli_commands/todo.py::._validated_replan_successor_obligation::codec_read:load_registry#1", - "line": 120, + "line": 121, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -735,7 +735,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#1", - "line": 232, + "line": 233, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -743,7 +743,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#2", - "line": 412, + "line": 413, "column": 21, "kind": "codec_read", "api": "load_registry", @@ -751,7 +751,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#3", - "line": 594, + "line": 595, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -759,7 +759,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#4", - "line": 636, + "line": 637, "column": 38, "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", @@ -1071,7 +1095,7 @@ }, { "site": "loopx/control_plane/goals/goal_amendment_proposal.py::.admit_goal_amendment_proposal::codec_read:load_registry#1", - "line": 198, + "line": 199, "column": 28, "kind": "codec_read", "api": "load_registry", @@ -1495,7 +1519,7 @@ }, { "site": "loopx/feedback.py::.append_human_reward::codec_read:load_registry#1", - "line": 413, + "line": 414, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1535,7 +1559,7 @@ }, { "site": "loopx/history.py::.collect_history::codec_read:load_registry#1", - "line": 317, + "line": 301, "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": 558, + "line": 542, "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": 772, + "line": 756, "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": 662, + "line": 646, "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 6412989c4c..e7bb0bb647 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -101,7 +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 change after preview may also be accepted. | Chat proposal fingerprint and receipt, source and global `registered_agents`, source route, post-sync readback, and a concurrent source peer-set edit. | The preview and write both targeted the shared registry read model instead of the source registry, so the confirmation had no source-owned CAS boundary. | Bind the preview to a source peer-set revision, recheck and merge under the source registry transaction, sync the shared read model, and issue success only after source plus global readback. Treat an already-present Agent as idempotent recovery and cover response loss. | +| `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 ed264a1d25..f9cb7667e6 100644 --- a/tests/control_plane/test_configure_source_authority.py +++ b/tests/control_plane/test_configure_source_authority.py @@ -283,6 +283,48 @@ def test_chat_agent_binding_rejects_stale_source_agents(tmp_path, mirrored_goal) 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 ): From 8a71f9525a9aec4f6907cba7f3413f6f0b1b0d1a Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Thu, 24 Sep 2026 01:17:03 +0800 Subject: [PATCH 3/3] chore(semantics): refresh registry I/O metadata Regenerate source line metadata after merging the latest main history changes. Signed-off-by: duanjialing.777 --- loopx/semantics/project_registry_io_manifest_v1.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 4130d60ed8..3d09ed9372 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1559,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", @@ -1567,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", @@ -1575,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", @@ -1583,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",