diff --git a/loopx/capabilities/agent_turn_recall/cli.py b/loopx/capabilities/agent_turn_recall/cli.py index 7292cff21..1e5351c88 100644 --- a/loopx/capabilities/agent_turn_recall/cli.py +++ b/loopx/capabilities/agent_turn_recall/cli.py @@ -193,7 +193,8 @@ def handle_agent_turn_recall_command( payload: dict[str, Any] = { "ok": True, "schema_version": AGENT_TURN_RECALL_SCHEMA_VERSION, - "status": "disabled", + "status": str(experiment_status.get("status") or "not_available"), + "reason_code": experiment_status.get("reason_code"), "goal_id": args.goal_id, "agent_id": args.agent_id, "experiment": experiment_status, diff --git a/loopx/capabilities/reward_memory/README.md b/loopx/capabilities/reward_memory/README.md index 475d2c956..7bdac66de 100644 --- a/loopx/capabilities/reward_memory/README.md +++ b/loopx/capabilities/reward_memory/README.md @@ -53,6 +53,32 @@ OpenViking is the first provider used by the Issue Fix pilot, but it is not a global LoopX feature flag or mandatory dependency; another provider can satisfy the same binding contract. +### Recovering a changed binding + +Editing the ignored config invalidates its old enablement receipt. `enablement_stale` +and `enablement_unverified` now return a shared `repair` plan: reuse the invoked +registry and existing Agent allowlist with `configure-goal`, inspect the config +change, preview, apply within existing authorization, then verify `available`. +Commands are explicitly marked templates with a required `` +binding. Bind it to the exact invocation registry before execution; an omitted +registry must never silently select a default. The private registry path, config +pointer and provider scopes are not published. The config pointer is retained. Apply re-runs provider write/exact-readback and +synchronizes the source/global binding. Never repair drift by copying a digest +into an old receipt. Disabled capabilities offer no re-enable plan. + +The configuration catalog checks the live config digest and reuses runtime +admission validation rather than presenting a cached receipt as current verification. It separates `desired_automation` and +`recorded_verified_agents` from `binding_status`, `effective_available` and +effective automation. The existing settings summary renders these same fields. + +The same plan is carried through Turn recall, quota and agent status/Markdown; +the explicit recall CLI preserves the actual failure instead of labelling every +unavailable configuration `disabled`. The existing capability editor can preview +and reapply the retained pointer/Agent list through the same owner. No automatic +configuration acceptance or new provider permissions are introduced. Recovery of +enablement must still be followed by a real qualified experience write, exact +readback and destination recall before claiming useful memory is available. + ### OpenViking v0.4.19 identity boundary LoopX currently assumes one Agent belongs to exactly one Goal, while a Goal may diff --git a/loopx/capabilities/reward_memory/README.zh-CN.md b/loopx/capabilities/reward_memory/README.zh-CN.md index 3c3d0d96c..c7b82d2ad 100644 --- a/loopx/capabilities/reward_memory/README.zh-CN.md +++ b/loopx/capabilities/reward_memory/README.zh-CN.md @@ -612,3 +612,23 @@ publish、production 或跨项目 authority。 可选的[外发指导召回](OUTBOUND.zh-CN.md)会在真正绑定 Goal/Agent 的 Lark inbox send/reply 边界召回已经审阅过的偏好。它把指导交给 Agent 审视,但不会 授予发送权限。 + +## 配置变更后的恢复 + +直接修改忽略路径中的配置会使旧启用回执失效。`enablement_stale` 和 +`enablement_unverified` 现在返回共享 `repair` 计划:沿用本次 registry 和完整 +Agent 名单,通过 `configure-goal` 检查变更、预览、在既有授权内应用,再读回 +`available`。命令明确标为模板,执行前必须将 `` 绑定为本次调用的 +确切 registry,不能省略后悄悄采用默认值。模板不公开 registry 路径、配置指针或 +provider scope,配置指针沿用原值;应用时重新 +执行 provider 写入及精确读回,并同步源/全局绑定。不能把新摘要填进旧回执; +明确停用时不生成重新启用建议。 + +Turn recall、quota、Agent status/Markdown 传递同一计划;显式召回 CLI 保留 +真实故障类型,不再把所有不可用情况标成 disabled。现有 capability editor +可保留原指针/Agent 名单执行同一预览和应用流程,无须新增配置权威。恢复计划 +不自动接受配置变化、不增加 provider 权限。恢复后仍须用合格经验验证真实写入、 +精确读回和业务召回,才能宣称该经验可用。 + +配置目录现在核对当前文件摘要并复用运行时准入校验,将期望自动化和历史验证回执与当前绑定状态、 +可用性和有效自动化分开;现有设置页直接显示这些共享字段,避免缓存回执误报正常。 diff --git a/loopx/capabilities/reward_memory/configuration.py b/loopx/capabilities/reward_memory/configuration.py index d22741a3b..f36e9e0bd 100644 --- a/loopx/capabilities/reward_memory/configuration.py +++ b/loopx/capabilities/reward_memory/configuration.py @@ -1,18 +1,91 @@ from __future__ import annotations +import hashlib from collections.abc import Mapping, Sequence from copy import deepcopy from pathlib import Path from typing import Any -from ...control_plane.operator_inbox_binding import local_private_config_digest -from ...control_plane.reward_memory import reward_memory_goal_policy +from ...control_plane.operator_inbox_binding import ( + local_private_config_digest, + operator_inbox_binding, +) +from ...control_plane.reward_memory import ( + reward_memory_goal_policy, + reward_memory_host_coverage, +) from .experiment import ( load_reward_memory_experiment_config, preflight_reward_memory_experiment_config, + resolve_goal_reward_memory_experiment, ) +def reward_memory_goal_configuration_summary( + goal: Mapping[str, Any], +) -> dict[str, Any]: + """Reconcile the declared policy with live, read-only runtime admission. + + The configuration catalog and the settings summary need one projection that + separates the desired automation and historical receipts from the effective + binding. Effective availability reuses runtime admission (same digest, + isolation and receipt checks) so a drifted or unverified config cannot be + reported as currently verified. No provider is contacted and nothing is + written. + """ + + policy = reward_memory_goal_policy(goal) + binding_revision = "" + if policy["config_path"] and policy["config_digest"]: + binding_revision = "sha256:" + hashlib.sha256( + ( + f"{policy['config_path']}\0{policy['config_digest']}\0" + + "\0".join(policy["enabled_agents"]) + ).encode("utf-8") + ).hexdigest() + binding = operator_inbox_binding( + project=str(goal.get("repo") or ""), + config_path=policy["config_path"], + expected_digest=policy["config_digest"], + ) + recorded_verified_agents = sorted( + agent_id + for agent_id, receipt in policy["enablement_receipts"].items() + if receipt.get("status") == "verified" + and receipt.get("writability_verified") is True + and receipt.get("exact_readback_verified") is True + ) + effective_verified_agents: list[str] = [] + for agent_id in recorded_verified_agents: + try: + status, _ = resolve_goal_reward_memory_experiment( + goal=goal, agent_id=agent_id + ) + except ValueError: + continue + if status.get("available") is True: + effective_verified_agents.append(agent_id) + effective_available = bool(effective_verified_agents) + return { + "enabled": policy["enabled"], + "binding_status": binding["status"], + "effective_available": effective_available, + "desired_automation": dict(policy["automation"]), + "recorded_verified_agents": recorded_verified_agents, + "experimental": policy["experimental"], + "config_pointer_registered": bool(policy["config_path"]), + "binding_revision": binding_revision, + "automatic_ingest": effective_available + and policy["automation"].get("automatic_ingest") is True, + "automatic_recall": effective_available + and policy["automation"].get("automatic_recall") is True, + "automation_intent": dict(policy["automation_intent"]), + "host_coverage": reward_memory_host_coverage(), + "enabled_agents": list(policy["enabled_agents"]), + "enablement_verified_agents": effective_verified_agents, + } + + def plan_reward_memory_goal_configuration( *, goal: Mapping[str, Any], diff --git a/loopx/capabilities/reward_memory/experiment.py b/loopx/capabilities/reward_memory/experiment.py index f2c977234..49e9934df 100644 --- a/loopx/capabilities/reward_memory/experiment.py +++ b/loopx/capabilities/reward_memory/experiment.py @@ -3,6 +3,7 @@ import hashlib import json import re +import shlex import uuid from collections.abc import Mapping, Sequence from pathlib import Path @@ -787,6 +788,55 @@ def resolve_reward_memory_surface_config( } +def _enablement_repair( + *, goal_id: str, agent_ids: Sequence[str], agent_id: str +) -> dict[str, Any]: + """Reuse the configuration owner without publishing its private pointer. + + This is a recovery plan, never authorization to trust a changed binding. + Retain every enabled Agent when requalifying a shared corpus. + """ + argv = [ + "loopx", "--registry", "", + "configure-goal", "--goal-id", goal_id, + ] + for enabled_agent in agent_ids: + argv.extend(["--reward-memory-agent", enabled_agent]) + return { + "schema_version": "reward_memory_enablement_repair_v0", + "kind": "requalify_existing_binding", + "owner": "configure-goal", + "registry_context": "reuse_invoked_registry", + "commands_are_templates": True, + "required_bindings": {"": "invoked_registry_path"}, + "automatic_apply": False, + "instruction": ( + "Bind to the exact registry used for this invocation " + "before executing any command; never substitute the default registry. " + "Inspect the local configuration change and its existing authorization; " + "preview, then apply through configure-goal within that authorization. " + "Apply performs provider write/readback and synchronizes the binding. " + "Do not copy the new digest into an old receipt. Keep ordinary work running." + ), + "preview_command": shlex.join(argv), + "apply_command": shlex.join([*argv, "--execute"]), + "verify_command": shlex.join( + [ + "loopx", + "--registry", + "", + "reward-memory", + "experiment-status", + "--goal-id", + goal_id, + "--agent-id", + agent_id, + ] + ), + "success_status": "available", + } + + def resolve_reward_memory_experiment( *, registry_path: Path, goal_id: str, agent_id: str ) -> tuple[dict[str, Any], dict[str, Any] | None]: @@ -807,6 +857,24 @@ def resolve_reward_memory_experiment( ) if goal is None: raise ValueError(f"goal_id not found in registry: {goal_id}") + return resolve_goal_reward_memory_experiment( + goal=goal, + agent_id=normalized_agent, + registry_role=str(registry.get("registry_role") or "project-local"), + ) + + +def resolve_goal_reward_memory_experiment( + *, goal: Mapping[str, Any], agent_id: str, registry_role: str = "project-local" +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Resolve an already loaded Goal with the same checks as runtime admission. + + Configuration summaries reuse this read-only owner; no provider is contacted. + """ + goal_id = str(goal.get("id") or "") + normalized_agent = normalize_todo_claimed_by(agent_id) + if not normalized_agent: + raise ValueError("agent_id must be a public-safe registered agent id") registered_agents = normalize_registered_agents( (goal.get("coordination") or {}).get("registered_agents") if isinstance(goal.get("coordination"), Mapping) @@ -815,7 +883,7 @@ def resolve_reward_memory_experiment( if normalized_agent not in registered_agents: raise ValueError(f"agent_id is not registered for goal {goal_id}") policy = reward_memory_goal_policy(goal) - registry_role = str(registry.get("registry_role") or "project-local").strip() + registry_role = str(registry_role or "project-local").strip() config_runtime_route = { "schema_version": "reward_memory_config_runtime_route_v0", "registry_source": "invoked_registry", @@ -892,6 +960,11 @@ def resolve_reward_memory_experiment( "status": "enablement_stale", "available": False, "reason_code": "config_digest_missing_or_drifted", + "repair": _enablement_repair( + goal_id=goal_id, + agent_ids=policy["enabled_agents"], + agent_id=normalized_agent, + ), "isolation_mode": isolation["isolation_mode"], "actor_binding_verified": isolation["actor_binding_verified"], "writability_verified": False, @@ -918,6 +991,11 @@ def resolve_reward_memory_experiment( "status": "enablement_unverified", "available": False, "reason_code": "provider_write_preflight_missing_or_unverified", + "repair": _enablement_repair( + goal_id=goal_id, + agent_ids=policy["enabled_agents"], + agent_id=normalized_agent, + ), "isolation_mode": isolation["isolation_mode"], "actor_binding_verified": isolation["actor_binding_verified"], "writability_verified": False, diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index ce3018d0f..d5f642e5b 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -610,6 +610,8 @@ def _agent_reward_memory_projection( "configured_for_agent", "experiment_status", "experiment_available", + "reason_code", + "repair", "config_schema_version", "automatic_ingest", "automatic_recall", diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 124ecf1f1..88d280dbb 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -600,6 +600,15 @@ def build_goal_configuration_catalog( "availability": "experimental_opt_in", "default": {"enabled": False}, "current": { + "binding_status": reward_memory.get("binding_status"), + "effective_available": reward_memory.get("effective_available") + is True, + "desired_automation": dict( + reward_memory.get("desired_automation") or {} + ), + "recorded_verified_agents": list( + reward_memory.get("recorded_verified_agents") or [] + ), "enabled": reward_memory.get("enabled") is True, "experimental": reward_memory.get("experimental") is True, "config_pointer_registered": reward_memory.get( diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 1c124bf35..528de1e27 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -25,6 +25,7 @@ from .capabilities.reward_memory.configuration import ( apply_reward_memory_goal_configuration, plan_reward_memory_goal_configuration, + reward_memory_goal_configuration_summary, reward_memory_preflight_markdown_lines, ) from .configuration_catalog import ( @@ -49,9 +50,6 @@ from .control_plane.coordination import runtime_shadow as shadow from .control_plane.coordination.configuration import normalize_goal_write_scope from .control_plane.operator_inbox_binding import local_private_config_digest -from .control_plane.reward_memory import ( - reward_memory_goal_policy_summary, -) from .control_plane.todos.contract import normalize_todo_claimed_by from .control_plane.todos.mutation_authority import ( normalize_todo_lifecycle_authority, @@ -254,7 +252,7 @@ def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: "issue_fix_reviewer_notification": _reviewer_notification_config_summary(goal), "lark_event_inbox": _lark_event_inbox_config_summary(goal), "lark_kanban_heartbeat_sync": _lark_kanban_heartbeat_config_summary(goal), - "reward_memory": reward_memory_goal_policy_summary(goal), + "reward_memory": reward_memory_goal_configuration_summary(goal), "pull_request_review": pr_review_config.configuration_summary(goal), "change_quality_qualification": change_quality_goal_policy_summary(goal), "explore_graph": compact_explore_graph_policy(goal.get("explore_graph")), @@ -1257,7 +1255,7 @@ def configure_goal( "coordination_runtime_shadow": deepcopy(after["coordination_runtime_shadow"]), "lark_event_inbox": _lark_event_inbox_config_summary(goal), "lark_kanban_heartbeat_sync": _lark_kanban_heartbeat_config_summary(goal), - "reward_memory": reward_memory_goal_policy_summary(goal), + "reward_memory": reward_memory_goal_configuration_summary(goal), "pull_request_review": pr_review_config.configuration_summary(goal), "change_quality_qualification": change_quality_goal_policy_summary(goal), "default": "off", diff --git a/loopx/control_plane/quota/goal_boundary.py b/loopx/control_plane/quota/goal_boundary.py index 4946196aa..6d22c475f 100644 --- a/loopx/control_plane/quota/goal_boundary.py +++ b/loopx/control_plane/quota/goal_boundary.py @@ -198,6 +198,8 @@ def _reward_memory_enablement_projection( status: Mapping[str, Any], ) -> dict[str, Any]: fields = ( + "reason_code", + "repair", "isolation_mode", "enablement_receipt_status", "actor_binding_verified", diff --git a/loopx/control_plane/reward_memory.py b/loopx/control_plane/reward_memory.py index 9d5ff427d..be5ff109b 100644 --- a/loopx/control_plane/reward_memory.py +++ b/loopx/control_plane/reward_memory.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib from collections.abc import Mapping from typing import Any @@ -107,38 +106,7 @@ def reward_memory_goal_policy(goal: Mapping[str, Any]) -> dict[str, Any]: } -def reward_memory_goal_policy_summary(goal: Mapping[str, Any]) -> dict[str, Any]: - policy = reward_memory_goal_policy(goal) - binding_revision = "" - if policy["config_path"] and policy["config_digest"]: - binding_revision = "sha256:" + hashlib.sha256( - ( - f"{policy['config_path']}\0{policy['config_digest']}\0" - + "\0".join(policy["enabled_agents"]) - ).encode("utf-8") - ).hexdigest() - return { - "enabled": policy["enabled"], - "experimental": policy["experimental"], - "config_pointer_registered": bool(policy["config_path"]), - "binding_revision": binding_revision, - "automatic_ingest": policy["automation"].get("automatic_ingest"), - "automatic_recall": policy["automation"].get("automatic_recall"), - "automation_intent": dict(policy["automation_intent"]), - "host_coverage": reward_memory_host_coverage(), - "enabled_agents": list(policy["enabled_agents"]), - "enablement_verified_agents": sorted( - agent_id - for agent_id, receipt in policy["enablement_receipts"].items() - if receipt.get("status") == "verified" - and receipt.get("writability_verified") is True - and receipt.get("exact_readback_verified") is True - ), - } - - __all__ = [ "reward_memory_goal_policy", - "reward_memory_goal_policy_summary", "reward_memory_host_coverage", ] diff --git a/loopx/presentation/renderers/reward_memory_markdown.py b/loopx/presentation/renderers/reward_memory_markdown.py index 69bc533e8..2743dad3f 100644 --- a/loopx/presentation/renderers/reward_memory_markdown.py +++ b/loopx/presentation/renderers/reward_memory_markdown.py @@ -40,6 +40,13 @@ def append_agent_reward_memory_markdown( f"runtime_scope={markdown_scalar(config_runtime_route.get('runtime_scope') or '')} " f"exact_readback={config_runtime_route.get('exact_readback_verified')}" ) + repair = agent_reward_memory.get("repair") + if isinstance(repair, Mapping) and repair.get("preview_command"): + lines.append( + " - reward_memory_repair: " + + markdown_scalar(repair["preview_command"]) + + " (template: bind to the exact invoked registry; inspect changes before apply)" + ) host_coverage = agent_reward_memory.get("host_coverage") if not isinstance(host_coverage, list): return diff --git a/tests/capabilities/test_reward_memory_experiment.py b/tests/capabilities/test_reward_memory_experiment.py index 4686c8f0b..da25fec13 100644 --- a/tests/capabilities/test_reward_memory_experiment.py +++ b/tests/capabilities/test_reward_memory_experiment.py @@ -1423,3 +1423,225 @@ def test_issue_fix_before_send_recall_applies_structured_policy_and_fails_open( assert unavailable["before_send_gate"]["status"] == "fail_open" assert unavailable["decision"]["delivery_policy"] is None assert unavailable["provider_failure_is_user_gate"] is False + + +@pytest.mark.parametrize("failure", ["drift", "missing_receipt"]) +def test_enablement_repair_preserves_scope_and_cli_reason( + tmp_path: Path, capsys, failure: str +) -> None: + registry_path, event_path, _ = _experiment(tmp_path) + custom_registry = tmp_path / "custom registry" / "explicit.json" + custom_registry.parent.mkdir() + custom_registry.write_bytes(registry_path.read_bytes()) + registry_path = custom_registry + registry = json.loads(registry_path.read_text()) + policy = registry["goals"][0]["control_plane"]["reward_memory"] + policy["enabled_agents"] = ["pilot", "meta"] + if failure == "drift": + config = tmp_path / "project/.loopx/config/reward-memory/experiment.json" + config.write_text(config.read_text() + "\n") + else: + policy["enablement_receipts"] = { + "pilot": {"config_digest": policy["config_digest"]} + } + registry_path.write_text(json.dumps(registry)) + before = registry_path.read_bytes() + status, config = resolve_reward_memory_experiment( + registry_path=registry_path, goal_id="reward-memory-goal", agent_id="pilot" + ) + expected = "enablement_stale" if failure == "drift" else "enablement_unverified" + assert status["status"] == expected + assert config is None + repair = status["repair"] + assert repair["preview_command"] == ( + "loopx --registry '' configure-goal --goal-id reward-memory-goal " + "--reward-memory-agent pilot --reward-memory-agent meta" + ) + assert repair["apply_command"] == repair["preview_command"] + " --execute" + assert repair["automatic_apply"] is False + assert repair["registry_context"] == "reuse_invoked_registry" + assert repair["commands_are_templates"] is True + assert repair["required_bindings"] == {"": "invoked_registry_path"} + import shlex + + for key in ("preview_command", "apply_command", "verify_command"): + argv = shlex.split(repair[key]) + assert argv[1:3] == ["--registry", ""] + argv[2] = str(registry_path) + assert shlex.split(shlex.join(argv))[2] == str(registry_path) + assert policy["config_path"] not in json.dumps(repair) + assert "viking://" not in json.dumps(repair) + code, payload = _run( + capsys, + registry_path, + "agent-turn-recall", + "--goal-id", + "reward-memory-goal", + "--agent-id", + "pilot", + "--turn-instance-id", + "repair-probe", + "--quota-decision-json", + str(event_path), + "--execute", + ) + assert code == 0 + assert payload["status"] == expected + assert payload["reason_code"] == status["reason_code"] + assert payload["experiment"]["repair"] == repair + assert payload["provider_call_count"] == 0 + assert payload["external_writes_performed"] is False + assert registry_path.read_bytes() == before + verify_argv = shlex.split(repair["verify_command"]) + verify_argv[2] = str(registry_path) + assert main(["--format", "json", *verify_argv[1:]]) == 0 + verified = json.loads(capsys.readouterr().out) + assert verified["status"] == expected + assert registry_path.read_bytes() == before + + +def test_enablement_repair_not_offered_when_disabled(tmp_path: Path) -> None: + registry_path, _, _ = _experiment(tmp_path) + registry = json.loads(registry_path.read_text()) + registry["goals"][0]["control_plane"]["reward_memory"]["enabled"] = False + registry_path.write_text(json.dumps(registry)) + status, config = resolve_reward_memory_experiment( + registry_path=registry_path, goal_id="reward-memory-goal", agent_id="pilot" + ) + assert status["status"] == "disabled" + assert "repair" not in status + assert config is None + + +def test_enablement_repair_reaches_shared_status_projection() -> None: + from loopx.control_plane.quota.goal_boundary import goal_boundary + from loopx.cli_commands.status import _agent_reward_memory_projection + from loopx.presentation.renderers.reward_memory_markdown import ( + append_agent_reward_memory_markdown, + ) + + repair = { + "preview_command": "loopx configure-goal --goal-id goal --reward-memory-agent pilot" + } + status = { + "goal_id": "goal", + "agent_id": "pilot", + "status": "enablement_stale", + "available": False, + "reason_code": "config_digest_missing_or_drifted", + "repair": repair, + } + boundary = goal_boundary( + { + "id": "goal", + "control_plane": { + "reward_memory": { + "enabled": True, + "experimental": True, + "enabled_agents": ["pilot"], + "config_path": ".loopx/config/private.json", + } + }, + }, + agent_id="pilot", + reward_memory_experiment_status=status, + ) + assert boundary is not None + capability = boundary["capabilities"]["reward_memory"] + assert capability["repair"] == repair + projection = _agent_reward_memory_projection( + {"goal_boundary": boundary}, agent_id="pilot" + ) + assert projection["repair"] == repair + lines = [] + append_agent_reward_memory_markdown(lines, {"agent_reward_memory": projection}, {}) + assert any(repair["preview_command"] in line for line in lines) + + +def test_catalog_distinguishes_cached_receipt_from_live_config(tmp_path: Path) -> None: + from loopx.capabilities.reward_memory.configuration import ( + reward_memory_goal_configuration_summary, + ) + + registry_path, _, _ = _experiment(tmp_path) + goal = json.loads(registry_path.read_text())["goals"][0] + goal["control_plane"]["reward_memory"]["automation"] = { + "automatic_recall": True, + "automatic_ingest": True, + } + config = Path(goal["repo"]) / goal["control_plane"]["reward_memory"]["config_path"] + original = config.read_bytes() + assert ( + reward_memory_goal_configuration_summary(goal)["effective_available"] is True + ) + config.write_bytes(original + b"\n") + drifted = reward_memory_goal_configuration_summary(goal) + assert drifted["enabled"] is True + assert drifted["binding_status"] == "drifted" + assert drifted["recorded_verified_agents"] == ["pilot"] + assert drifted["enablement_verified_agents"] == [] + assert drifted["effective_available"] is False + assert drifted["automatic_recall"] is False + assert drifted["automatic_ingest"] is False + assert drifted["desired_automation"]["automatic_recall"] is True + assert str(config) not in json.dumps(drifted) + from loopx.configuration_catalog import build_goal_configuration_catalog + + catalog = build_goal_configuration_catalog( + goal_id=goal["id"], + settings={}, + feature_summary={"reward_memory": drifted}, + default_multi_subagent_max_children=4, + explore_harness_profiles=[], + ) + current = next( + f["current"] for f in catalog["features"] if f["feature_id"] == "reward_memory" + ) + assert current["binding_status"] == "drifted" + assert current["effective_available"] is False + assert current["desired_automation"]["automatic_recall"] is True + assert current["automatic_recall"] is False + config.write_bytes(original) + restored = reward_memory_goal_configuration_summary(goal) + assert restored["binding_status"] == "verified" + assert restored["effective_available"] is True + assert restored["automatic_recall"] is True + config.unlink() + assert ( + reward_memory_goal_configuration_summary(goal)["binding_status"] + == "unavailable" + ) + + +@pytest.mark.parametrize( + ("field", "invalid"), + [ + ("goal_id", "another-goal"), + ("agent_id", "meta"), + ("provider_id", "different-provider"), + ("isolation_mode", "invalid"), + ], +) +def test_catalog_availability_uses_runtime_receipt_checks( + tmp_path: Path, field: str, invalid: str +) -> None: + from loopx.capabilities.reward_memory.configuration import ( + reward_memory_goal_configuration_summary, + ) + + registry_path, _, _ = _experiment(tmp_path) + registry = json.loads(registry_path.read_text()) + goal = registry["goals"][0] + policy = goal["control_plane"]["reward_memory"] + policy["automation"] = {"automatic_recall": True, "automatic_ingest": True} + policy["enablement_receipts"]["pilot"][field] = invalid + registry_path.write_text(json.dumps(registry)) + status, _ = resolve_reward_memory_experiment( + registry_path=registry_path, goal_id=goal["id"], agent_id="pilot" + ) + summary = reward_memory_goal_configuration_summary(goal) + assert status["status"] == "enablement_unverified" + assert summary["binding_status"] == "verified" + assert summary["effective_available"] is False + assert summary["enablement_verified_agents"] == [] + assert summary["automatic_recall"] is False