From 347133c28ab138f3f6658e9507fbad60f778b9d3 Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:31:44 +0800 Subject: [PATCH 1/2] fix(codex): deduplicate aliased homes before observing cadence Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- loopx/codex_app_thread_activity.py | 5 ++- tests/test_host_thread_activity.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/loopx/codex_app_thread_activity.py b/loopx/codex_app_thread_activity.py index 92bc39d4a6..b8726db181 100644 --- a/loopx/codex_app_thread_activity.py +++ b/loopx/codex_app_thread_activity.py @@ -64,6 +64,7 @@ def codex_homes(env: Mapping[str, str] | None = None, user_home: Path | None = N ``LOOPX_CODEX_HOMES`` (``os.pathsep``-separated) is used verbatim when set. Otherwise ``CODEX_HOME``, ``~/.codex`` and sibling ``~/.codex-*`` homes are searched, because Codex App installs can each keep their own home. + Aliases of one directory retain only their first selected path. """ env = os.environ if env is None else env @@ -76,8 +77,10 @@ def codex_homes(env: Mapping[str, str] | None = None, user_home: Path | None = N candidates.append(user_home / ".codex") candidates.extend(sorted(user_home.glob(".codex-*"))) homes: list[Path] = [] + identities: set[Path] = set() for candidate in candidates: - if candidate.is_dir() and candidate not in homes: + if candidate.is_dir() and (identity := candidate.resolve()) not in identities: + identities.add(identity) homes.append(candidate) return homes diff --git a/tests/test_host_thread_activity.py b/tests/test_host_thread_activity.py index 389f67800e..f0b69d99da 100644 --- a/tests/test_host_thread_activity.py +++ b/tests/test_host_thread_activity.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import os import sqlite3 +import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -483,6 +485,57 @@ def _install_automation( ) +@pytest.mark.parametrize("selection", ["configured", "sibling", "explicit"]) +def test_aliased_homes_preserve_one_installed_heartbeat_cadence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, + selection: str, +) -> None: + home = CodexHome(tmp_path / ".codex") + alias = tmp_path / ".codex-alias" + if os.name == "nt": + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(home.root)], + capture_output=True, + ) + assert created.returncode == 0, created.stderr or created.stdout + request.addfinalizer(alias.rmdir) + else: + alias.symlink_to(home.root, target_is_directory=True) + other = CodexHome(tmp_path / ".codex-other") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("LOOPX_CODEX_HOMES", raising=False) + first = home.root + if selection == "configured": + monkeypatch.setenv("CODEX_HOME", str(alias)) + first = alias + elif selection == "explicit": + monkeypatch.setenv( + "LOOPX_CODEX_HOMES", os.pathsep.join(map(str, [alias, home.root, other.root])) + ) + first = alias + _install_automation( + home, automation_id="one", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=5", + ) + lane = HostDeliveryScope( + goal_id="bound", agent_id="a", host_surface="codex-app", thread_id="t-open" + ) + + assert codex_delivery_expectations([lane]) == { + lane: HostDeliveryExpectation( + expected_interval_minutes=5, source="codex_app_automation_rrule" + ) + } + assert codex_homes() == [first, other.root] + # Two distinct automations remain ambiguous even when their home has aliases. + _install_automation( + home, automation_id="two", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=9", + ) + assert codex_delivery_expectations([lane]) == {} + + def test_delivery_expectations_resolve_only_installed_active_app_automations( tmp_path: Path, ) -> None: From 78b266ec8471f179be5b382bc0d0cec90067e89c Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:16:32 +0800 Subject: [PATCH 2/2] fix(codex): preserve profile aliases during cadence deduplication Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- loopx/codex_app_thread_activity.py | 13 +++-- tests/test_chat_codex_defaults.py | 85 +++++++++++++++++++++++++++++- tests/test_host_thread_activity.py | 20 ++++++- 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/loopx/codex_app_thread_activity.py b/loopx/codex_app_thread_activity.py index b8726db181..b8630a1192 100644 --- a/loopx/codex_app_thread_activity.py +++ b/loopx/codex_app_thread_activity.py @@ -64,7 +64,7 @@ def codex_homes(env: Mapping[str, str] | None = None, user_home: Path | None = N ``LOOPX_CODEX_HOMES`` (``os.pathsep``-separated) is used verbatim when set. Otherwise ``CODEX_HOME``, ``~/.codex`` and sibling ``~/.codex-*`` homes are searched, because Codex App installs can each keep their own home. - Aliases of one directory retain only their first selected path. + Preserve distinct path names: callers also use them as profile aliases. """ env = os.environ if env is None else env @@ -77,10 +77,8 @@ def codex_homes(env: Mapping[str, str] | None = None, user_home: Path | None = N candidates.append(user_home / ".codex") candidates.extend(sorted(user_home.glob(".codex-*"))) homes: list[Path] = [] - identities: set[Path] = set() for candidate in candidates: - if candidate.is_dir() and (identity := candidate.resolve()) not in identities: - identities.add(identity) + if candidate.is_dir() and candidate not in homes: homes.append(candidate) return homes @@ -328,7 +326,14 @@ def _installed_automation_intervals( intervals: dict[tuple[str, str, str], int] = {} ambiguous: set[tuple[str, str, str]] = set() + identities: set[Path] = set() for home in codex_homes() if homes is None else homes: + # Discovery retains logical profile names; read each physical store only + # once here so an alias cannot masquerade as a second installed heartbeat. + identity = home.resolve() + if identity in identities: + continue + identities.add(identity) for path in sorted((home / "automations").glob("*/automation.toml")): try: item = tomllib.loads(path.read_text(encoding="utf-8")) diff --git a/tests/test_chat_codex_defaults.py b/tests/test_chat_codex_defaults.py index ad2eea458a..a31a2af4bc 100644 --- a/tests/test_chat_codex_defaults.py +++ b/tests/test_chat_codex_defaults.py @@ -1,11 +1,21 @@ """Configured Goal Chat defaults use native settings without retargeting work.""" +import os +import subprocess +import sys +from pathlib import Path from types import SimpleNamespace import pytest from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry -from loopx.capabilities.machine_configuration.store import configure_machine_configuration -from loopx.capabilities.native_chat.machine_defaults import CODEX_CHAT_DEFAULTS_SCHEMA_VERSION, normalize_codex_chat_defaults +from loopx.capabilities.configuration_ui import capability_configuration_editor +from loopx.capabilities.machine_configuration.store import configure_machine_configuration, read_machine_configuration +from loopx.capabilities.native_chat.machine_defaults import ( + CODEX_CHAT_DEFAULTS_SCHEMA_VERSION, + codex_profile_home, + codex_profile_options, + normalize_codex_chat_defaults, +) from loopx.chat_runtime import ChatRuntimeController, CodexAppServerAdapter from loopx.chat_store import ChatSessionStore @@ -18,6 +28,76 @@ def configure(root, **fields): return configure_machine_configuration(**kwargs, execute=True, expected_plan_revision=preview["plan_revision"]) +@pytest.mark.parametrize("selection", ["configured", "sibling", "explicit"]) +def test_saved_profile_alias_opens_ordinary_conversation(tmp_path, monkeypatch, request, selection): + """Physical scan dedup must preserve every supported profile's logical name. + + Run the real configuration transaction, editor and conversation path. Only + the final native adapter launch is replaced; no model or live store is used. + """ + home = tmp_path / ".codex" + home.mkdir() + native_config = 'model = "fixture-model"\n' + (home / "config.toml").write_text(native_config, encoding="utf-8") + alias = tmp_path / ".codex-alias" + if os.name == "nt": + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(home)], + capture_output=True, + ) + assert created.returncode == 0, created.stderr or created.stdout + request.addfinalizer(alias.rmdir) + else: + alias.symlink_to(home, target_is_directory=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("LOOPX_CHAT_CODEX_HOME", str(home)) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("LOOPX_CODEX_HOMES", raising=False) + if selection == "configured": + monkeypatch.setenv("CODEX_HOME", str(alias)) + elif selection == "explicit": + monkeypatch.setenv("LOOPX_CODEX_HOMES", os.pathsep.join(map(str, [alias, home]))) + profile = "codex-alias" if selection == "sibling" else "codex" + root = tmp_path / "runtime" + assert configure(root, codex_profile=profile)["status"] == "applied" + stored = read_machine_configuration(root, registry=build_builtin_machine_configuration_registry()) + defaults = stored["namespaces"]["codex_chat"] + assert defaults["codex_profile"] == profile + calls = [] + + def start(**kwargs): + calls.append(kwargs) + return CodexAppServerAdapter(SimpleNamespace( + model="fixture-model", resolved_model="fixture-model", resolved_reasoning_effort=None, + service_tier=None, read_only_review=False, thread_id="fixture-native-thread", + process=SimpleNamespace(poll=lambda: None), close=lambda: None, + )) + + monkeypatch.setattr(CodexAppServerAdapter, "start", start) + # An existing executable satisfies the real readiness probe without running it. + runtime = ChatRuntimeController(store=ChatSessionStore(root), codex_bin=sys.executable) + try: + session, reused = runtime.open_session( + goal_id="fixture", agent_id="codex", work_dir=tmp_path, objective="Inspect.", mode="new", + ) + assert not reused + assert codex_profile_options() == ["codex", "codex-alias"] + editor = capability_configuration_editor("codex_chat") + assert next(field for field in editor["fields"] if field["key"] == "codex_profile")["options"] == [ + "codex", "codex-alias", + ] + assert codex_profile_home(home, defaults) == home.resolve() + assert session["codex_home"] == str(home.resolve()) + assert session["codex_chat_configuration"]["codex_profile"] == profile + assert session["upstream_thread_id"] == "fixture-native-thread" + assert len(calls) == 1 and calls[0]["codex_home"] == home.resolve() + assert runtime.store.load_session(session["session_id"])["codex_home"] == str(home.resolve()) + assert (home / "config.toml").read_text(encoding="utf-8") == native_config + assert read_machine_configuration(root, registry=build_builtin_machine_configuration_registry()) == stored + finally: + runtime.close() + + def runtime_fixture(tmp_path, monkeypatch): base, gpt = tmp_path / ".codex-ds", tmp_path / ".codex-gpt" base.mkdir() @@ -38,6 +118,7 @@ def start(**kwargs): return CodexAppServerAdapter(SimpleNamespace(model=settings.get("model") or "inherited-model", resolved_model=settings.get("model") or "inherited-model", resolved_reasoning_effort=settings.get("reasoning_effort"), service_tier=settings.get("service_tier"), + read_only_review=False, thread_id=f"native-{len(calls)}", process=SimpleNamespace(poll=lambda: None), close=lambda: None)) monkeypatch.setattr(runtime, "_start_adapter", start) diff --git a/tests/test_host_thread_activity.py b/tests/test_host_thread_activity.py index f0b69d99da..e862ee95f8 100644 --- a/tests/test_host_thread_activity.py +++ b/tests/test_host_thread_activity.py @@ -521,19 +521,37 @@ def test_aliased_homes_preserve_one_installed_heartbeat_cadence( lane = HostDeliveryScope( goal_id="bound", agent_id="a", host_surface="codex-app", thread_id="t-open" ) + reads: list[Path] = [] + read_text = Path.read_text + + def record_read(path: Path, *args: Any, **kwargs: Any) -> str: + if path.name == "automation.toml": + reads.append(path) + return read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", record_read) assert codex_delivery_expectations([lane]) == { lane: HostDeliveryExpectation( expected_interval_minutes=5, source="codex_app_automation_rrule" ) } - assert codex_homes() == [first, other.root] + assert reads == [first / "automations" / "one" / "automation.toml"] + logical_homes = [home.root, alias, other.root] if selection == "sibling" else [alias, home.root, other.root] + assert codex_homes() == logical_homes + # Explicit reader inputs receive the same physical-store protection. + assert codex_delivery_expectations([lane], homes=logical_homes)[lane].expected_interval_minutes == 5 # Two distinct automations remain ambiguous even when their home has aliases. _install_automation( home, automation_id="two", goal_id="bound", agent_id="a", rrule="FREQ=MINUTELY;INTERVAL=9", ) assert codex_delivery_expectations([lane]) == {} + _install_automation( + home, automation_id="two", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=9", status="PAUSED", + ) + assert codex_delivery_expectations([lane])[lane].expected_interval_minutes == 5 def test_delivery_expectations_resolve_only_installed_active_app_automations(