diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index fcdcc538b2..9df8bf596f 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -790,6 +790,35 @@ Approval needs a reviewed decision, not implementation progress or silence. exact expiry remains a release-owner decision. M1 is open. No module-count, loading-performance, split-package or R1 managed-Turn benefit is claimed. +### 2026-11-04 — M1 public status owner migration + +- **Delta:** the complete `ChatStatusRequestMixin` and `goal_read_runtime_root` + implementation moves to `loopx.chat.status_api`. Chat HTTP and Todo detail + import that owner directly. The characterization prerequisite + [#8001](https://github.com/loopx-project/loopx/pull/8001) is already merged. +- **Direct migration:** the historical top-level `loopx.chat_status_api` module is + removed rather than retained as an alias or warning shim. The repository callers + and published-surface audit found no external importer; all active callers and + tests now use `loopx.chat.status_api`. This intentional compatibility exemption + applies only to this internal-only relocation and creates no general precedent + for removing a supported public import. +- **Authority boundary:** retarget both exact owner-path entries in + `tests/architecture/test_source_session_registry_denial.py`, leaving every + denial assertion and the `_discovery_registry` exception unchanged. Real HTTP + coverage permits lifecycle-only registry discovery but refuses full status + before collector execution. Moving the presentation owner grants no new + runtime, source, provider or execution authority. +- **Evidence:** owner tests now require the canonical import and absence of the + top-level shim. Existing HTTP, File/SQLite source routing, source loss/recovery + and delivery-review tests protect the original behavior. Absolute-import- + resolved AST comparison covers the complete owner; the denial oracle differs + only in its two owner paths. +- **Remaining:** this is one public-owner M1 slice, not completion of the wider + public Chat migration, D3, split distributions or R1 managed minimal. The + removed internal-only top-level module is one representational cleanup, not + evidence of loading-performance or long-horizon benefit. Source and isolated- + wheel qualification do not establish an upgrade of an existing installed host. + ### 2026-11-04 — Capability discovery selected loading - **Baseline:** `79b781865`. `capability list` / `show` and command help now diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index 178229b8df..bbdd844eb1 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -573,6 +573,27 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, 版本,D3 精确退役时间仍由发布负责人决定。M1 未关闭,不声明模块数量、加载性能、 拆包或 R1 managed Turn 收益。 +### 2026-11-04 — M1 公开 status owner 迁移 + +- **变化:** 完整 `ChatStatusRequestMixin` 与 `goal_read_runtime_root` 实现迁入 + `loopx.chat.status_api`,Chat HTTP 与 Todo detail 直接导入该 owner。刻画前置 + [#8001](https://github.com/loopx-project/loopx/pull/8001) 已合入。 +- **直接迁移:** 历史顶层 `loopx.chat_status_api` 模块直接删除,不保留 alias 或 + warning shim。仓库调用方与公开面审计均未发现外部 importer;所有活跃调用方和 + 测试改用 `loopx.chat.status_api`。该兼容豁免仅适用于这个内部模块迁移,不构成 + 删除受支持公开 import 的通用先例。 +- **权限边界:** 同步重定向 + `tests/architecture/test_source_session_registry_denial.py` 的两个精确 owner + 路径项,全部拒绝断言及 `_discovery_registry` 例外不变。真实 HTTP 覆盖允许仅生命周期 + registry 的发现,但在 collector 执行前拒绝完整 status。移动 presentation owner + 不授予新的 runtime、来源、provider 或执行权威。 +- **证据:** owner 测试要求 canonical import,并断言顶层 shim 不存在。既有 HTTP、 + File/SQLite 源路由、源丢失/恢复和 delivery review 测试保护原行为。解析为绝对 + import 后的 AST 对比覆盖完整 owner;拒绝 oracle 仅有两个 owner 路径差异。 +- **剩余:** 这是 M1 的一个公开 owner 片,不关闭更广的公开 Chat 迁移、D3、发行物 + 拆分或 R1 managed minimal。删除内部顶层模块属于表示层整理,不代表加载性能或 + 长程成果收益。源码与隔离 wheel 验证不等于升级既有已安装宿主。 + ### 2026-11-04 — Capability 发现路径的所选加载 - **基线:** `79b781865`。`capability list` / `show` 及命令帮助复用既有 diff --git a/loopx/chat_status_api.py b/loopx/chat/status_api.py similarity index 93% rename from loopx/chat_status_api.py rename to loopx/chat/status_api.py index 0e1bbb96bc..86e03e339b 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat/status_api.py @@ -9,48 +9,48 @@ from typing import Any from urllib.parse import parse_qs, urlparse -from .chat import redact_local_paths -from .chat.goal_subagent_api import goal_subagent_configuration_enabled -from .chat.workspace_directory import workspace_goal_directory -from .codex_app_thread_activity import ( +from ..chat import redact_local_paths +from .goal_subagent_api import goal_subagent_configuration_enabled +from .workspace_directory import workspace_goal_directory +from ..codex_app_thread_activity import ( codex_delivery_expectations, codex_thread_observers, ) -from .control_plane.agents.host_thread_activity import ( +from ..control_plane.agents.host_thread_activity import ( attach_host_delivery_windows, attach_host_thread_activity, ) -from .control_plane.effect_runtime import ( +from ..control_plane.effect_runtime import ( EffectRuntimePermanentIOError, EffectRuntimeRemoteError, EffectRuntimeStartupError, effect_runtime_request_scope, ) -from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable -from .control_plane.runtime.public_safety import ( +from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable +from ..control_plane.runtime.public_safety import ( public_safe_compact_text, validate_public_safe_value, ) -from .control_plane.todos.contract import ( +from ..control_plane.todos.contract import ( TODO_STATUS_OPEN, TODO_TASK_CLASS_MONITOR, normalize_todo_status, todo_done_for_status, ) -from .control_plane.work_items.task_graph import build_goal_task_map -from .control_plane.projection_envelope_facts import seal_projection_envelope, source_fact -from .control_plane.runtime.time import now_utc_iso -from .feedback import validate_goal_id -from .history import load_registry -from .control_plane.projects.registry_codec import load_project_registry -from .status import collect_status -from .status_server import ( +from ..control_plane.work_items.task_graph import build_goal_task_map +from ..control_plane.projection_envelope_facts import seal_projection_envelope, source_fact +from ..control_plane.runtime.time import now_utc_iso +from ..feedback import validate_goal_id +from ..history import load_registry +from ..control_plane.projects.registry_codec import load_project_registry +from ..status import collect_status +from ..status_server import ( DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, DEFAULT_EXTENSION_PROJECTION_PATH, ExtensionPresentationRequestMixin, parse_goal_activation_filter, ) -from .control_plane.todos.list_readback import list_goal_todos +from ..control_plane.todos.list_readback import list_goal_todos def goal_read_runtime_root(server: Any) -> str | None: @@ -241,8 +241,8 @@ def _status(self, *, delivery_review: bool = False) -> None: return linked = None if goal_id and registry.get("goal_source_links"): - from .control_plane.status.goal_source import SourceScopeError - from .presentation.goal_source_routing import goal_source_catalog + from ..control_plane.status.goal_source import SourceScopeError + from ..presentation.goal_source_routing import goal_source_catalog catalog = goal_source_catalog(self.server) if delivery_review and catalog.resolve(goal_id, registry): self._send_error("Execution review belongs to the original Goal runtime.", status=403) diff --git a/loopx/chat/todo_detail.py b/loopx/chat/todo_detail.py index a84706f34d..08def79434 100644 --- a/loopx/chat/todo_detail.py +++ b/loopx/chat/todo_detail.py @@ -3,7 +3,7 @@ import hashlib from urllib.parse import parse_qs, urlparse -from ..chat_status_api import goal_read_runtime_root +from .status_api import goal_read_runtime_root from ..status_server import is_loopback_host from ..control_plane.todos.list_readback import list_goal_todos diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 009c96ba20..50929860f4 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -43,7 +43,7 @@ ) from .capabilities.native_chat.model_settings_api import ChatModelSettingsRequestMixin from .capabilities.native_chat.queue_api import ChatQueueRequestMixin -from .chat_status_api import ChatStatusRequestMixin +from .chat.status_api import ChatStatusRequestMixin from .chat_runtime import ( STEERING_NOT_DELIVERED_CODES, ChatRuntimeController, diff --git a/tests/architecture/test_source_session_registry_denial.py b/tests/architecture/test_source_session_registry_denial.py index 459380b096..0e05a858a8 100644 --- a/tests/architecture/test_source_session_registry_denial.py +++ b/tests/architecture/test_source_session_registry_denial.py @@ -13,7 +13,7 @@ "loopx/control_plane/coordination/new_goal_storage_transport.py", "loopx/chat_server.py", "loopx/presentation/goal_direction_api.py", - "loopx/chat_status_api.py", + "loopx/chat/status_api.py", "loopx/control_plane/goals/goal_amendment_runtime.py", "loopx/control_plane/goals/source_session_registration.py", "loopx/presentation/goal_amendment_api.py", @@ -139,7 +139,7 @@ def test_direct_project_registry_loaders_have_source_session_denial() -> None: "loopx/presentation/request_control_api.py": {"_request_control_source"}, "loopx/chat_server.py": {"serve_chat"}, "loopx/presentation/goal_direction_api.py": {"_direction_route"}, - "loopx/chat_status_api.py": {"_discovery_registry"}, + "loopx/chat/status_api.py": {"_discovery_registry"}, "loopx/control_plane/goals/direction_runtime.py": {"_source"}, "loopx/control_plane/coordination/new_goal_storage_transport.py": {"initialize_goal_storage_target"}, "loopx/presentation/goal_amendment_api.py": {"_amendment_inspect"}, diff --git a/tests/control_plane/test_chat_status_source_runtime.py b/tests/control_plane/test_chat_status_source_runtime.py index 31c5478d4b..0ec127e078 100644 --- a/tests/control_plane/test_chat_status_source_runtime.py +++ b/tests/control_plane/test_chat_status_source_runtime.py @@ -28,8 +28,8 @@ def status_api(routed, tmp_path, monkeypatch): server.limit = 80 server.verbose = False server.goal_subagent_configuration_enabled = False - monkeypatch.setattr("loopx.chat_status_api.attach_host_thread_activity", lambda *a, **k: None) - monkeypatch.setattr("loopx.chat_status_api.attach_host_delivery_windows", lambda *a, **k: None) + monkeypatch.setattr("loopx.chat.status_api.attach_host_thread_activity", lambda *a, **k: None) + monkeypatch.setattr("loopx.chat.status_api.attach_host_delivery_windows", lambda *a, **k: None) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/control_plane/test_chat_workbench_roots.py b/tests/control_plane/test_chat_workbench_roots.py index fd95c8bbe9..728294fea0 100644 --- a/tests/control_plane/test_chat_workbench_roots.py +++ b/tests/control_plane/test_chat_workbench_roots.py @@ -6,7 +6,7 @@ from canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture from loopx.chat_runtime import ChatRuntimeController -from loopx.chat_status_api import ChatStatusRequestMixin +from loopx.chat.status_api import ChatStatusRequestMixin from loopx.chat_store import ChatSessionStore @@ -28,8 +28,8 @@ def test_status_reads_registered_work_while_chat_keeps_its_own_store(tmp_path, m store.append_message(session["session_id"], role="agent", text="Keep the original private history.") before = {path: path.read_bytes() for path in store.root.rglob("*.json")} # External host activity is an optional observer, not the status authority. - monkeypatch.setattr("loopx.chat_status_api.attach_host_thread_activity", lambda *a, **k: None) - monkeypatch.setattr("loopx.chat_status_api.attach_host_delivery_windows", lambda *a, **k: None) + monkeypatch.setattr("loopx.chat.status_api.attach_host_thread_activity", lambda *a, **k: None) + monkeypatch.setattr("loopx.chat.status_api.attach_host_delivery_windows", lambda *a, **k: None) class Reader(ChatStatusRequestMixin): server = SimpleNamespace(registry_path=registry, runtime_root=store.root.parent, diff --git a/tests/test_chat_internal_module_layout.py b/tests/test_chat_internal_module_layout.py index ef0478b0a9..967aa71221 100644 --- a/tests/test_chat_internal_module_layout.py +++ b/tests/test_chat_internal_module_layout.py @@ -13,6 +13,7 @@ from loopx.chat.monitor_actions import ChatMonitorActionMixin from loopx.chat.session_open import open_chat_session from loopx.chat.ssh_source_api import SshSourceRequestMixin +from loopx.chat.status_api import ChatStatusRequestMixin, goal_read_runtime_root from loopx.chat.todo_actions import ChatTodoActionMixin from loopx.chat.todo_detail import TodoDetailRequestMixin from loopx.chat.usage_statistics_api import UsageStatisticsRequestMixin @@ -43,6 +44,7 @@ def test_chat_internal_helpers_load_below_the_public_chat_namespace() -> None: def test_chat_internal_helpers_have_no_top_level_compatibility_shims() -> None: for old_module in ( + "loopx.chat_status_api", "loopx.chat_event_cache", "loopx.chat_automation_cadence_api", "loopx.chat_goal_configuration_api", @@ -56,3 +58,9 @@ def test_chat_internal_helpers_have_no_top_level_compatibility_shims() -> None: "loopx.chat_workspace_directory", ): assert importlib.util.find_spec(old_module) is None + + +def test_public_status_owner_is_nested_without_a_top_level_shim() -> None: + assert ChatStatusRequestMixin.__module__ == "loopx.chat.status_api" + assert goal_read_runtime_root.__module__ == "loopx.chat.status_api" + assert importlib.util.find_spec("loopx.chat_status_api") is None diff --git a/tests/test_chat_path_redaction.py b/tests/test_chat_path_redaction.py index 0d43c9e337..c5f3dfecdf 100644 --- a/tests/test_chat_path_redaction.py +++ b/tests/test_chat_path_redaction.py @@ -18,7 +18,7 @@ redact_response_markdown, ) from loopx.chat_acp import ACPStdioAdapter -from loopx.chat_status_api import ChatStatusRequestMixin +from loopx.chat.status_api import ChatStatusRequestMixin def test_windows_near_root_finishes_without_exponential_case_backtracking(): @@ -571,7 +571,7 @@ def test_stream_holds_long_encoded_private_root_prefix_until_redacting(encoding) def test_status_http_hides_the_selected_custom_runtime_root(monkeypatch): root = Path("/custom-volume/private-state") monkeypatch.setattr( - "loopx.chat_status_api.collect_status", + "loopx.chat.status_api.collect_status", lambda **kwargs: { "ok": True, "runtime_root": str(root), @@ -579,7 +579,7 @@ def test_status_http_hides_the_selected_custom_runtime_root(monkeypatch): }, ) monkeypatch.setattr( - "loopx.chat_status_api.attach_host_thread_activity", + "loopx.chat.status_api.attach_host_thread_activity", lambda *args, **kwargs: None, ) diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 2585bb185a..ff7715a65d 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -478,7 +478,7 @@ def fake_collect_status(**kwargs): calls.append(kwargs) return {"ok": True, "scope": kwargs.get("activation_state_filter")} - monkeypatch.setattr("loopx.chat_status_api.collect_status", fake_collect_status) + monkeypatch.setattr("loopx.chat.status_api.collect_status", fake_collect_status) server, thread = _start_server() server.goal_subagent_configuration_enabled = True try: @@ -506,7 +506,7 @@ def fake_collect_status(**kwargs): def test_chat_status_rejects_invalid_goal_activation_scope(monkeypatch) -> None: monkeypatch.setattr( - "loopx.chat_status_api.collect_status", + "loopx.chat.status_api.collect_status", lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must fail before collection")), ) server, thread = _start_server() @@ -574,8 +574,8 @@ def test_workspace_directory_still_fails_on_an_unreadable_registry(tmp_path) -> def test_workspace_directory_skips_expensive_projection_and_respects_scope(monkeypatch) -> None: registry = {"goals": [{"id": "alpha", "display_name": "Alpha"}, {"id": "beta"}]} - monkeypatch.setattr("loopx.chat_status_api.load_project_registry", lambda _: registry) - monkeypatch.setattr("loopx.chat_status_api.collect_status", lambda **_: (_ for _ in ()).throw(AssertionError("directory must not collect status"))) + monkeypatch.setattr("loopx.chat.status_api.load_project_registry", lambda _: registry) + monkeypatch.setattr("loopx.chat.status_api.collect_status", lambda **_: (_ for _ in ()).throw(AssertionError("directory must not collect status"))) server, thread = _start_server() server.selected_goal_id = "alpha" try: @@ -596,12 +596,12 @@ def test_workspace_directory_skips_expensive_projection_and_respects_scope(monke def test_workspace_scoped_status_revision_and_membership_fences(monkeypatch) -> None: registry = {"goals": [{"id": "alpha"}, {"id": "beta"}]} - monkeypatch.setattr("loopx.chat_status_api.load_registry", lambda _: registry) + monkeypatch.setattr("loopx.chat.status_api.load_registry", lambda _: registry) calls = [] def collect(**kwargs): calls.append(kwargs["goal_id"]) return {"ok": True} - monkeypatch.setattr("loopx.chat_status_api.collect_status", collect) + monkeypatch.setattr("loopx.chat.status_api.collect_status", collect) server, thread = _start_server() try: response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?goal_id=alpha") @@ -616,7 +616,7 @@ def collect(**kwargs): def racing_collect(**kwargs): registry["goals"].pop() return {"ok": True} - monkeypatch.setattr("loopx.chat_status_api.collect_status", racing_collect) + monkeypatch.setattr("loopx.chat.status_api.collect_status", racing_collect) response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?goal_id=alpha") response.read() assert response.status == 409 @@ -689,13 +689,13 @@ def _status_failure(kind: str) -> BaseException: )], ) def test_workspace_status_access_error_contract(monkeypatch, kind: str, access: bool) -> None: - monkeypatch.setattr("loopx.chat_status_api.load_registry", lambda _: {"goals": [{"id": "alpha"}]}) + monkeypatch.setattr("loopx.chat.status_api.load_registry", lambda _: {"goals": [{"id": "alpha"}]}) failure = _status_failure(kind) def fail(**_kwargs): raise failure - monkeypatch.setattr("loopx.chat_status_api.collect_status", fail) + monkeypatch.setattr("loopx.chat.status_api.collect_status", fail) server, thread = _start_server() try: response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?goal_id=alpha") @@ -715,9 +715,9 @@ def test_workspace_access_code_does_not_change_other_status_routes(monkeypatch, def fail(*_args, **_kwargs): raise PermissionError("synthetic confidential diagnostic") - monkeypatch.setattr("loopx.chat_status_api.load_registry", fail) - monkeypatch.setattr("loopx.chat_status_api.load_project_registry", fail) - monkeypatch.setattr("loopx.chat_status_api.collect_status", fail) + monkeypatch.setattr("loopx.chat.status_api.load_registry", fail) + monkeypatch.setattr("loopx.chat.status_api.load_project_registry", fail) + monkeypatch.setattr("loopx.chat.status_api.collect_status", fail) server, thread = _start_server() try: response = _request(server.server_address[1], method="GET", origin=None, path="/status.json" + query) diff --git a/tests/test_chat_status_owner.py b/tests/test_chat_status_owner.py new file mode 100644 index 0000000000..aa7563d9b8 --- /dev/null +++ b/tests/test_chat_status_owner.py @@ -0,0 +1,165 @@ +"""The status owner is canonical and has no top-level compatibility shim.""" + +from __future__ import annotations + +import http.client +import importlib.util +import json +import subprocess +import sys +import threading +from types import SimpleNamespace + +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + + +def test_status_owner_imports_canonically_and_has_no_legacy_module(): + code = """ +import importlib.util +from loopx.chat.status_api import ChatStatusRequestMixin, goal_read_runtime_root +from loopx.chat.todo_detail import goal_read_runtime_root as todo_runtime_root +from loopx.chat_server import ChatRequestHandler + +assert issubclass(ChatRequestHandler, ChatStatusRequestMixin) +assert ChatStatusRequestMixin.__module__ == 'loopx.chat.status_api' +assert goal_read_runtime_root.__module__ == 'loopx.chat.status_api' +assert todo_runtime_root is goal_read_runtime_root +assert importlib.util.find_spec('loopx.chat_status_api') is None +""" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, text=True, timeout=60, check=False, + ) + assert result.returncode == 0, result.stderr + assert importlib.util.find_spec("loopx.chat_status_api") is None + + +@pytest.mark.parametrize("source_bound,controller_root,expected", [ + (True, "coordinator-decoy", None), + (False, "controller-runtime", "controller-runtime"), + (False, None, "chat-fallback"), +]) +def test_registry_patch_reaches_already_imported_todo_consumer( + monkeypatch, tmp_path, source_bound, controller_root, expected, +): + from loopx.chat.todo_detail import goal_read_runtime_root + + registry_path = tmp_path / "registry.json" + calls = [] + + def registry(path): + calls.append(path) + return {"common_runtime_root": "source-runtime"} if source_bound else {} + + monkeypatch.setattr("loopx.chat.status_api.load_project_registry", registry) + server = SimpleNamespace( + registry_path=registry_path, + runtime_root_override="chat-fallback", + runtime_controller=SimpleNamespace(coordination_runtime_root=controller_root), + ) + assert goal_read_runtime_root(server) == expected + assert calls == [registry_path] + + +@pytest.mark.parametrize("failure", [FileNotFoundError, PermissionError, ValueError]) +def test_discovery_patch_only_tolerates_missing_registry(monkeypatch, failure): + from loopx.chat import status_api + + def unreadable(_path): + raise failure("synthetic unavailable registry") + + monkeypatch.setattr("loopx.chat.status_api.load_project_registry", unreadable) + server = SimpleNamespace(registry_path=None, runtime_root_override=None) + if failure is FileNotFoundError: + assert status_api.goal_read_runtime_root(server) is None + else: + with pytest.raises(failure): + status_api.goal_read_runtime_root(server) + + +@pytest.fixture +def status_server(tmp_path): + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = tmp_path / "registry.json" + server.runtime_root = tmp_path / "runtime" + server.runtime_root_override = None + server.selected_goal_id = None + server.scan_roots = [] + server.limit = 20 + server.verbose = False + server.goal_subagent_configuration_enabled = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def get(query): + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + try: + connection.request("GET", "/status.json" + query) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + try: + yield server, get + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_canonical_status_patch_points_reach_the_real_http_handler(monkeypatch, status_server): + server, get = status_server + registry = {"goals": [{"id": "alpha"}]} + registry_reads, collected = [], [] + + def load_registry(path): + registry_reads.append(path) + return registry + + def collect_status(**kwargs): + collected.append(kwargs) + return {"ok": True, "owner_probe": "collector"} + + def attach_activity(projection, **_kwargs): + projection["owner_activity"] = True + + def attach_delivery(projection, **_kwargs): + projection["owner_delivery"] = True + + monkeypatch.setattr("loopx.chat.status_api.load_registry", load_registry) + monkeypatch.setattr("loopx.chat.status_api.load_project_registry", lambda _: {}) + monkeypatch.setattr("loopx.chat.status_api.collect_status", collect_status) + monkeypatch.setattr("loopx.chat.status_api.attach_host_thread_activity", attach_activity) + monkeypatch.setattr("loopx.chat.status_api.attach_host_delivery_windows", attach_delivery) + monkeypatch.setattr("loopx.chat.status_api.codex_thread_observers", lambda **_: []) + code, payload = get("?goal_id=alpha") + assert code == 200, payload + assert payload["owner_probe"] == "collector" + assert payload["owner_activity"] is True + assert payload["owner_delivery"] is True + assert len(collected) == 1 and collected[0]["goal_id"] == "alpha" + assert registry_reads == [server.registry_path, server.registry_path] + + +def test_lifecycle_registry_discovery_does_not_grant_status_execution(monkeypatch, status_server): + server, get = status_server + server.registry_path.write_text(json.dumps({ + "schema_version": 1, "profile_id": "source_session_v1", + "goals": [{"id": "alpha"}], + })) + collected = [] + + def forbidden(**kwargs): + collected.append(kwargs) + pytest.fail("lifecycle metadata must not reach the status collector") + + monkeypatch.setattr("loopx.chat.status_api.collect_status", forbidden) + code, directory = get("?view=workspace-directory") + assert code == 200, directory + assert [row["id"] for row in directory["goals"]] == ["alpha"] + code, refused = get("?goal_id=alpha") + assert code >= 400 and not refused["ok"] + assert collected == [] diff --git a/tests/test_chat_status_public_compatibility.py b/tests/test_chat_status_public_compatibility.py deleted file mode 100644 index bf3737cafe..0000000000 --- a/tests/test_chat_status_public_compatibility.py +++ /dev/null @@ -1,148 +0,0 @@ -"""I3 protects historical imports and their live patch points, not copied exports.""" - -from __future__ import annotations - -import http.client -import importlib -import json -import subprocess -import sys -import threading -from types import SimpleNamespace - -import pytest - -from loopx.chat_server import ChatHTTPServer, ChatRequestHandler - - -@pytest.mark.parametrize("first_import", [ - "loopx.chat_status_api", "loopx.chat_server", "loopx.chat.todo_detail", -]) -def test_historical_status_imports_survive_import_order_reload_and_pickle(first_import): - # A fresh process avoids an earlier test silently choosing the import order. - code = """ -import importlib -import pickle -import sys -from types import SimpleNamespace - -importlib.import_module(sys.argv[1]) -public = importlib.import_module('loopx.chat_status_api') -from loopx.chat_status_api import ChatStatusRequestMixin, goal_read_runtime_root -from loopx.chat_server import ChatRequestHandler -from loopx.chat.todo_detail import goal_read_runtime_root as todo_runtime_root - -assert issubclass(ChatRequestHandler, ChatStatusRequestMixin) -assert todo_runtime_root is goal_read_runtime_root -assert pickle.loads(b'cloopx.chat_status_api\\nChatStatusRequestMixin\\n.') is ChatStatusRequestMixin -assert pickle.loads(b'cloopx.chat_status_api\\ngoal_read_runtime_root\\n.') is goal_read_runtime_root - -# Reload replaces definitions, but already-imported functions keep live globals. -assert importlib.reload(public) is public -assert importlib.import_module('loopx.chat_status_api') is public -public.load_project_registry = lambda _: {'common_runtime_root': 'source-owned'} -server = SimpleNamespace(registry_path=None, runtime_root_override='chat-decoy') -assert goal_read_runtime_root(server) is None -assert todo_runtime_root(server) is None -""" - result = subprocess.run( - [sys.executable, "-c", code, first_import], - capture_output=True, text=True, timeout=60, check=False, - ) - assert result.returncode == 0, result.stderr - - -@pytest.mark.parametrize("source_bound,controller_root,expected", [ - (True, "coordinator-decoy", None), - (False, "controller-runtime", "controller-runtime"), - (False, None, "chat-fallback"), -]) -def test_historical_registry_patch_reaches_already_imported_todo_consumer( - monkeypatch, tmp_path, source_bound, controller_root, expected, -): - from loopx.chat.todo_detail import goal_read_runtime_root - - registry_path = tmp_path / "registry.json" - calls = [] - - def registry(path): - calls.append(path) - return {"common_runtime_root": "source-runtime"} if source_bound else {} - - monkeypatch.setattr("loopx.chat_status_api.load_project_registry", registry) - server = SimpleNamespace( - registry_path=registry_path, - runtime_root_override="chat-fallback", - runtime_controller=SimpleNamespace(coordination_runtime_root=controller_root), - ) - assert goal_read_runtime_root(server) == expected - assert calls == [registry_path] - - -@pytest.mark.parametrize("failure", [FileNotFoundError, PermissionError, ValueError]) -def test_historical_discovery_patch_only_tolerates_missing_registry(monkeypatch, failure): - public = importlib.import_module("loopx.chat_status_api") - - def unreadable(_path): - raise failure("synthetic unavailable registry") - - monkeypatch.setattr("loopx.chat_status_api.load_project_registry", unreadable) - server = SimpleNamespace(registry_path=None, runtime_root_override=None) - if failure is FileNotFoundError: - assert public.goal_read_runtime_root(server) is None - else: - with pytest.raises(failure): - public.goal_read_runtime_root(server) - - -def test_historical_status_patch_points_reach_the_real_http_handler(monkeypatch, tmp_path): - registry = {"goals": [{"id": "alpha"}]} - registry_reads, collected = [], [] - - def load_registry(path): - registry_reads.append(path) - return registry - - def collect_status(**kwargs): - collected.append(kwargs) - return {"ok": True, "compatibility_probe": "collector"} - - def attach_activity(projection, **_kwargs): - projection["compatibility_activity"] = True - - def attach_delivery(projection, **_kwargs): - projection["compatibility_delivery"] = True - - monkeypatch.setattr("loopx.chat_status_api.load_registry", load_registry) - monkeypatch.setattr("loopx.chat_status_api.load_project_registry", lambda _: {}) - monkeypatch.setattr("loopx.chat_status_api.collect_status", collect_status) - monkeypatch.setattr("loopx.chat_status_api.attach_host_thread_activity", attach_activity) - monkeypatch.setattr("loopx.chat_status_api.attach_host_delivery_windows", attach_delivery) - monkeypatch.setattr("loopx.chat_status_api.codex_thread_observers", lambda **_: []) - server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) - server.registry_path = tmp_path / "registry.json" - server.runtime_root = tmp_path / "runtime" - server.runtime_root_override = None - server.selected_goal_id = None - server.scan_roots = [] - server.limit = 20 - server.verbose = False - server.goal_subagent_configuration_enabled = False - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - connection = http.client.HTTPConnection(*server.server_address, timeout=5) - try: - connection.request("GET", "/status.json?goal_id=alpha") - response = connection.getresponse() - payload = json.loads(response.read()) - assert response.status == 200, payload - assert payload["compatibility_probe"] == "collector" - assert payload["compatibility_activity"] is True - assert payload["compatibility_delivery"] is True - assert len(collected) == 1 and collected[0]["goal_id"] == "alpha" - assert registry_reads == [server.registry_path, server.registry_path] - finally: - connection.close() - server.shutdown() - thread.join(timeout=5) - server.server_close() diff --git a/tests/test_delivery_review.py b/tests/test_delivery_review.py index ea8b9f7877..3d15c3f84c 100644 --- a/tests/test_delivery_review.py +++ b/tests/test_delivery_review.py @@ -118,7 +118,7 @@ def test_http_review_discloses_unreadable_map_without_hiding_other_components(wo def unreadable(**_kwargs): raise PermissionError("synthetic unavailable source") - monkeypatch.setattr("loopx.chat_status_api.list_goal_todos", unreadable) + monkeypatch.setattr("loopx.chat.status_api.list_goal_todos", unreadable) status, review = get(server) assert status == 200 and review["goal_map"] is None and review["graph"] is not None envelope = review["projection_envelope"] @@ -266,7 +266,7 @@ def test_review_is_cold_and_missing_observations_stay_unknown(workspace, monkeyp def collect(**kwargs): calls.append(kwargs) return {"ok": True, "attention_queue": {"items": []}, "run_history": {"goals": []}} - monkeypatch.setattr("loopx.chat_status_api.collect_status", collect) + monkeypatch.setattr("loopx.chat.status_api.collect_status", collect) assert get(server, "/status.json?goal_id=release-demo")[0] == 200 assert calls[-1]["include_task_graph"] is False _, result = get(server) @@ -290,14 +290,14 @@ def test_review_respects_origin_selected_goal_and_registry_drift(workspace, monk def collect(**kwargs): registry.write_text(json.dumps({"goals": []})) return {"ok": True} - monkeypatch.setattr("loopx.chat_status_api.collect_status", collect) + monkeypatch.setattr("loopx.chat.status_api.collect_status", collect) assert get(server)[0] == 409 def test_review_failure_does_not_expose_local_diagnostics(workspace, monkeypatch): def fail(**kwargs): raise OSError("private diagnostic and local filesystem details") - monkeypatch.setattr("loopx.chat_status_api.collect_status", fail) + monkeypatch.setattr("loopx.chat.status_api.collect_status", fail) status, payload = get(workspace[0]) assert status == 500 assert "private diagnostic" not in json.dumps(payload) @@ -308,7 +308,7 @@ def fail(**kwargs): {"ok": True, "attention_queue": {"items": [{"goal_id": "release-demo", "task_graph_projection": {"raw_log": "must not export"}}]}}, ]) def test_failed_or_unsafe_projection_cannot_be_exported(workspace, monkeypatch, projection): - monkeypatch.setattr("loopx.chat_status_api.collect_status", lambda **kwargs: projection) + monkeypatch.setattr("loopx.chat.status_api.collect_status", lambda **kwargs: projection) status, result = get(workspace[0]) assert status in {500, 503} assert result["ok"] is False @@ -385,7 +385,7 @@ def test_review_still_refuses_unsafe_acceptance_and_recovers(workspace, monkeypa projection = {"ok": True, "run_history": {"goals": [ {"id": "release-demo", "acceptance_observation": unsafe}, ]}} - monkeypatch.setattr("loopx.chat_status_api.collect_status", lambda **kwargs: projection) + monkeypatch.setattr("loopx.chat.status_api.collect_status", lambda **kwargs: projection) status, result = get(server) assert status == 500 and result["ok"] is False assert "synthetic private" not in json.dumps(result) diff --git a/tests/test_host_thread_activity.py b/tests/test_host_thread_activity.py index 4209a4aab7..620ebc792b 100644 --- a/tests/test_host_thread_activity.py +++ b/tests/test_host_thread_activity.py @@ -11,7 +11,7 @@ import pytest -import loopx.chat_status_api as chat_status_api +import loopx.chat.status_api as chat_status_api import loopx.codex_app_thread_activity as codex_activity from loopx.codex_app_thread_activity import ( _automation_rrule_interval_minutes,